Compare commits

..

No commits in common. "edge_redacted" and "main" have entirely different histories.

688 changed files with 40852 additions and 39624 deletions

View file

@ -1,9 +0,0 @@
**/node_modules
**/dist
apps/users-ui/app
apps/users-ui/src
apps/admin-ui/app
apps/users-ui/src
**/package-lock.json
test/

4
.gitignore vendored
View file

@ -7,14 +7,10 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
*.apk
**/appBinaries
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
test/appBinaries
# Runtime data # Runtime data
pids pids
*.pid *.pid

View file

@ -1,4 +0,0 @@
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
- remove slots from products cache
- remove redundant product details like name, description etc from the slots api

View file

@ -1,36 +1,32 @@
# Optimized Dockerfile for backend and fallback-ui services (project root) # Optimized Dockerfile for backend and fallback-ui services (project root)
# 1. ---- Base Bun image # 1. ---- Base Node image
FROM oven/bun:1.3.10 AS base FROM node:20-slim AS base
WORKDIR /app WORKDIR /app
# 2. ---- Pruner ---- # 2. ---- Pruner ----
FROM base AS pruner FROM base AS pruner
WORKDIR /app WORKDIR /app
# Copy config files first for better caching # Copy config files first for better caching
COPY package.json turbo.json ./ COPY package.json package-lock.json turbo.json ./
COPY apps/backend/package.json ./apps/backend/ COPY apps/backend/package.json ./apps/backend/
COPY apps/fallback-ui/package.json ./apps/fallback-ui/ COPY apps/fallback-ui/package.json ./apps/fallback-ui/
COPY packages/shared/ ./packages/shared
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
RUN bun install -g turbo RUN npm install -g turbo
COPY . . COPY . .
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
# RUN find . -path "./node_modules" -prune -o -print
# 3. ---- Builder ---- # 3. ---- Builder ----
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
# Copy package files first to cache bun install # Copy package files first to cache npm install
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
#COPY --from=pruner /app/out/bun.lock ./bun.lock COPY --from=pruner /app/out/package-lock.json ./package-lock.json
#RUN cat ./bun.lock
COPY --from=pruner /app/turbo.json . COPY --from=pruner /app/turbo.json .
RUN bun install RUN npm ci
# Copy source code after dependencies are installed # Copy source code after dependencies are installed
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN bunx turbo run build --filter=fallback-ui... --filter=backend... RUN npx turbo run build --filter=fallback-ui... --filter=backend...
RUN find . -path "./node_modules" -prune -o -print
# 4. ---- Runner ---- # 4. ---- Runner ----
FROM base AS runner FROM base AS runner
@ -38,15 +34,12 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Copy package files and install production deps # Copy package files and install production deps
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
#COPY --from=pruner /app/out/bun.lock ./bun.lock COPY --from=pruner /app/out/package-lock.json ./package-lock.json
RUN bun install --production RUN npm ci --production --omit=dev
# Copy built applications # Copy built applications
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
COPY --from=builder /app/packages/shared ./packages/shared
# RUN ls -R
RUN find . -path "./node_modules" -prune -o -print
EXPOSE 4000 EXPOSE 4000
CMD ["bun", "apps/backend/dist/apps/backend/index.js"] RUN npm i -g bun
CMD ["bun", "apps/backend/dist/index.js"]
# CMD ["node", "apps/backend/dist/index.js"]

File diff suppressed because one or more lines are too long

View file

@ -227,7 +227,8 @@ export default function Layout() {
<Drawer.Screen name="slots" options={{ title: "Slots" }} /> <Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} /> <Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} /> <Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} /> <Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} /> <Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
<Drawer.Screen name="user-management" options={{ title: "User Management" }} /> <Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} /> <Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />

View file

@ -0,0 +1,108 @@
import React, { useState } from 'react'
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
import { BottomDialog , tw } from 'common-ui'
import { trpc } from '@/src/trpc-client'
import AddressZoneForm from '@/components/AddressZoneForm'
import AddressPlaceForm from '@/components/AddressPlaceForm'
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
const AddressManagement: React.FC = () => {
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
const createZone = trpc.admin.address.createZone.useMutation({
onSuccess: () => {
refetchZones()
setDialogOpen(false)
},
})
const createArea = trpc.admin.address.createArea.useMutation({
onSuccess: () => {
refetchAreas()
setDialogOpen(false)
},
})
const handleAddZone = () => {
setDialogType('zone')
setDialogOpen(true)
}
const handleAddPlace = () => {
setDialogType('place')
setDialogOpen(true)
}
const toggleZone = (zoneId: number) => {
setExpandedZones(prev => {
const newSet = new Set(prev)
if (newSet.has(zoneId)) {
newSet.delete(zoneId)
} else {
newSet.add(zoneId)
}
return newSet
})
}
const groupedAreas = areas?.reduce((acc, area) => {
if (area.zoneId) {
if (!acc[area.zoneId]) acc[area.zoneId] = []
acc[area.zoneId].push(area)
}
return acc
}, {} as Record<number, typeof areas[0][]>) || {}
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
return (
<View style={tw`flex-1 bg-white`}>
<View style={tw`flex-row justify-between p-4`}>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
<Text style={tw`text-white`}>Add Zone</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
<Text style={tw`text-white`}>Add Place</Text>
</TouchableOpacity>
</View>
<ScrollView style={tw`flex-1 p-4`}>
{zones?.map(zone => (
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
</TouchableOpacity>
{expandedZones.has(zone.id) && (
<View style={tw`p-3`}>
{groupedAreas[zone.id]?.map(area => (
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
</View>
)}
</View>
))}
<View style={tw`mt-6`}>
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
{unzonedAreas.map(area => (
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
))}
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
</View>
</ScrollView>
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
</BottomDialog>
</View>
)
}
export default AddressManagement

View file

@ -74,7 +74,7 @@ export default function Dashboard() {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
title: 'Manage Orderss', title: 'Manage Orders',
icon: 'shopping-bag', icon: 'shopping-bag',
description: 'View and manage customer orders', description: 'View and manage customer orders',
route: '/(drawer)/manage-orders', route: '/(drawer)/manage-orders',
@ -158,15 +158,6 @@ export default function Dashboard() {
iconColor: '#8B5CF6', iconColor: '#8B5CF6',
iconBg: '#F3E8FF', iconBg: '#F3E8FF',
}, },
{
title: 'Stocking Schedules',
icon: 'schedule',
description: 'Manage product stocking schedules',
route: '/(drawer)/stocking-schedules',
category: 'products',
iconColor: '#0EA5E9',
iconBg: '#E0F2FE',
},
{ {
title: 'Stores', title: 'Stores',
icon: 'store', icon: 'store',
@ -185,6 +176,15 @@ export default function Dashboard() {
iconColor: '#F97316', iconColor: '#F97316',
iconBg: '#FFEDD5', iconBg: '#FFEDD5',
}, },
{
title: 'Address Management',
icon: 'location-on',
description: 'Manage service areas',
route: '/(drawer)/address-management',
category: 'settings',
iconColor: '#EAB308',
iconBg: '#FEF9C3',
},
{ {
title: 'App Constants', title: 'App Constants',
icon: 'settings-applications', icon: 'settings-applications',
@ -294,4 +294,4 @@ export default function Dashboard() {
</ScrollView> </ScrollView>
</View> </View>
); );
} }

View file

@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui'; import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm'; import TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
interface TagFormData { interface TagFormData {
@ -14,17 +15,36 @@ interface TagFormData {
export default function AddTag() { export default function AddTag() {
const router = useRouter(); const router = useRouter();
const createTag = trpc.admin.tag.createTag.useMutation(); const { mutate: createTag, isPending: isCreating } = useCreateTag();
const { data: storesData } = trpc.admin.store.getStores.useQuery(); const { data: storesData } = trpc.admin.store.getStores.useQuery();
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => { const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
createTag.mutate({ const formData = new FormData();
tagName: values.tagName,
tagDescription: values.tagDescription, // Add text fields
isDashboardTag: values.isDashboardTag, formData.append('tagName', values.tagName);
relatedStores: values.relatedStores, if (values.tagDescription) {
imageKey: imageKey, formData.append('tagDescription', values.tagDescription);
}, { }
formData.append('isDashboardTag', values.isDashboardTag.toString());
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
createTag(formData, {
onSuccess: (data) => { onSuccess: (data) => {
Alert.alert('Success', 'Tag created successfully', [ Alert.alert('Success', 'Tag created successfully', [
{ {
@ -56,10 +76,10 @@ export default function AddTag() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={createTag.isPending} isLoading={isCreating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui'; import { AppContainer, MyText, tw } from 'common-ui';
import TagForm from '@/src/components/TagForm'; import TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
interface TagFormData { interface TagFormData {
@ -10,6 +11,7 @@ interface TagFormData {
tagDescription: string; tagDescription: string;
isDashboardTag: boolean; isDashboardTag: boolean;
relatedStores: number[]; relatedStores: number[];
existingImageUrl?: string;
} }
export default function EditTag() { export default function EditTag() {
@ -17,25 +19,39 @@ export default function EditTag() {
const { tagId } = useLocalSearchParams<{ tagId: string }>(); const { tagId } = useLocalSearchParams<{ tagId: string }>();
const tagIdNum = tagId ? parseInt(tagId) : null; const tagIdNum = tagId ? parseInt(tagId) : null;
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.tag.getTagById.useQuery( const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
{ id: tagIdNum! }, const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
{ enabled: !!tagIdNum }
);
const updateTag = trpc.admin.tag.updateTag.useMutation();
const { data: storesData } = trpc.admin.store.getStores.useQuery(); const { data: storesData } = trpc.admin.store.getStores.useQuery();
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => { const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
if (!tagIdNum) return; if (!tagIdNum) return;
updateTag.mutate({ const formData = new FormData();
id: tagIdNum,
tagName: values.tagName, // Add text fields
tagDescription: values.tagDescription, formData.append('tagName', values.tagName);
isDashboardTag: values.isDashboardTag, if (values.tagDescription) {
relatedStores: values.relatedStores, formData.append('tagDescription', values.tagDescription);
imageKey: imageKey, }
deleteExistingImage: deleteExistingImage, formData.append('isDashboardTag', values.isDashboardTag.toString());
}, {
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
updateTag({ id: tagIdNum, formData }, {
onSuccess: (data) => { onSuccess: (data) => {
Alert.alert('Success', 'Tag updated successfully', [ Alert.alert('Success', 'Tag updated successfully', [
{ {
@ -76,7 +92,8 @@ export default function EditTag() {
tagName: tag.tagName, tagName: tag.tagName,
tagDescription: tag.tagDescription || '', tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [], relatedStores: tag.relatedStores || [],
existingImageUrl: tag.imageUrl || undefined,
}; };
return ( return (
@ -89,10 +106,10 @@ export default function EditTag() {
initialValues={initialValues} initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined} existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={updateTag.isPending} isLoading={isUpdating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -5,17 +5,7 @@ import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui'; import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { TagMenu } from '@/src/components/TagMenu'; import { TagMenu } from '@/src/components/TagMenu';
import { trpc } from '@/src/trpc-client'; import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores?: any;
createdAt?: string;
}
interface TagItemProps { interface TagItemProps {
item: Tag; item: Tag;
@ -70,7 +60,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
export default function ProductTags() { export default function ProductTags() {
const router = useRouter(); const router = useRouter();
const { data: tagsData, isLoading, error, refetch } = trpc.admin.tag.getTags.useQuery(); const { data: tagsData, isLoading, error, refetch } = useGetTags();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const tags = tagsData?.tags || []; const tags = tagsData?.tags || [];

View file

@ -2,28 +2,53 @@ import React from 'react';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { AppContainer } from 'common-ui'; import { AppContainer } from 'common-ui';
import ProductForm from '@/src/components/ProductForm'; import ProductForm from '@/src/components/ProductForm';
import { trpc } from '@/src/trpc-client'; import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
export default function AddProduct() { export default function AddProduct() {
const createProduct = trpc.admin.product.createProduct.useMutation(); const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
const handleSubmit = (values: any, imageKeys?: string[]) => { const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
createProduct.mutate({ const payload: CreateProductPayload = {
name: values.name, name: values.name,
shortDescription: values.shortDescription, shortDescription: values.shortDescription,
longDescription: values.longDescription, longDescription: values.longDescription,
unitId: parseInt(values.unitId), unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId), storeId: parseInt(values.storeId),
price: parseFloat(values.price), price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1, incrementStep: 1,
productQuantity: values.productQuantity || 1, productQuantity: values.productQuantity || 1,
isSuspended: values.isSuspended || false, };
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined, const formData = new FormData();
tagIds: values.tagIds || [], Object.entries(payload).forEach(([key, value]) => {
imageKeys: imageKeys || [], if (value !== undefined && value !== null) {
}, { formData.append(key, value as string);
}
});
// Append tag IDs
if (values.tagIds && values.tagIds.length > 0) {
values.tagIds.forEach((tagId: number) => {
formData.append('tagIds', tagId.toString());
});
}
// Append images
if (images) {
images.forEach((image, index) => {
if (image.uri) {
formData.append('images', {
uri: image.uri,
name: `image-${index}.jpg`,
// type: 'image/jpeg',
type: image.mimeType as any,
} as any);
}
});
}
createProduct(formData, {
onSuccess: (data) => { onSuccess: (data) => {
Alert.alert('Success', 'Product created successfully!'); Alert.alert('Success', 'Product created successfully!');
// Reset form or navigate // Reset form or navigate
@ -48,7 +73,7 @@ export default function AddProduct() {
isFlashAvailable: false, isFlashAvailable: false,
flashPrice: '', flashPrice: '',
productQuantity: 1, productQuantity: 1,
}; };
return ( return (
<AppContainer> <AppContainer>
@ -56,9 +81,9 @@ export default function AddProduct() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={createProduct.isPending} isLoading={isCreating}
existingImages={[]} existingImages={[]}
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -6,7 +6,6 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
@ -27,7 +26,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const [uploadUrls, setUploadUrls] = useState<string[]>([]); const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.useMutation(); const respondToReview = trpc.admin.product.respondToReview.useMutation();
const { upload, isUploading } = useUploadToObjectStorage(); const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -63,16 +62,30 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => { const handleSubmit = async (adminResponse: string) => {
try { try {
let keys: string[] = []; const mimeTypes = selectedImages.map(s => s.mimeType);
let generatedUrls: string[] = []; const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
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);
if (selectedImages.length > 0) { for (let i = 0; i < generatedUrls.length; i++) {
const result = await upload({ const uploadUrl = generatedUrls[i];
images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })), const { blob, mimeType } = selectedImages[i];
contextString: 'review', const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
}); });
keys = result.keys; if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
generatedUrls = result.presignedUrls;
} }
await respondToReview.mutateAsync({ await respondToReview.mutateAsync({
@ -88,7 +101,8 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setSelectedImages([]); setSelectedImages([]);
setDisplayImages([]); setDisplayImages([]);
setUploadUrls([]); setUploadUrls([]);
} catch (error: any) { } catch (error:any) {
Alert.alert('Error', error.message || 'Failed to submit response.'); Alert.alert('Error', error.message || 'Failed to submit response.');
} }
}; };
@ -123,7 +137,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
<TouchableOpacity <TouchableOpacity
onPress={() => formikSubmit()} onPress={() => formikSubmit()}
activeOpacity={0.8} activeOpacity={0.8}
disabled={respondToReview.isPending || isUploading} disabled={respondToReview.isPending}
> >
<LinearGradient <LinearGradient
colors={['#2563EB', '#1D4ED8']} colors={['#2563EB', '#1D4ED8']}
@ -131,9 +145,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={tw`py-4 rounded-2xl items-center shadow-lg`} style={tw`py-4 rounded-2xl items-center shadow-lg`}
> >
{isUploading ? ( {respondToReview.isPending ? (
<ActivityIndicator color="white" />
) : respondToReview.isPending ? (
<ActivityIndicator color="white" /> <ActivityIndicator color="white" />
) : ( ) : (
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText> <MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>

View file

@ -3,6 +3,7 @@ import { View, Text, Alert } from 'react-native';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui'; import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm'; import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
import { useUpdateProduct } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
export default function EditProduct() { export default function EditProduct() {
@ -10,52 +11,85 @@ export default function EditProduct() {
const productId = Number(id); const productId = Number(id);
const productFormRef = useRef<ProductFormRef>(null); const productFormRef = useRef<ProductFormRef>(null);
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery( const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
{ id: productId }, { id: productId },
{ enabled: !!productId } { enabled: !!productId }
); );
//
const updateProduct = trpc.admin.product.updateProduct.useMutation(); const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
useManualRefresh(() => refetch()); useManualRefresh(() => refetch());
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => { const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
updateProduct.mutate({ const payload = {
id: productId, name: values.name,
name: values.name, shortDescription: values.shortDescription,
shortDescription: values.shortDescription, longDescription: values.longDescription,
longDescription: values.longDescription, unitId: parseInt(values.unitId),
unitId: parseInt(values.unitId), storeId: parseInt(values.storeId),
storeId: parseInt(values.storeId), price: parseFloat(values.price),
price: parseFloat(values.price), marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, incrementStep: 1,
incrementStep: 1, productQuantity: values.productQuantity || 1,
productQuantity: values.productQuantity || 1, deals: values.deals?.filter((deal: any) =>
isSuspended: values.isSuspended,
isFlashAvailable: values.isFlashAvailable,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
deals: values.deals?.filter((deal: any) =>
deal.quantity && deal.price && deal.validTill deal.quantity && deal.price && deal.validTill
).map((deal: any) => ({ ).map((deal: any) => ({
quantity: parseInt(deal.quantity), quantity: parseInt(deal.quantity),
price: parseFloat(deal.price), price: parseFloat(deal.price),
validTill: deal.validTill instanceof Date validTill: deal.validTill instanceof Date
? deal.validTill.toISOString().split('T')[0] ? deal.validTill.toISOString().split('T')[0]
: deal.validTill, : deal.validTill, // Convert Date to YYYY-MM-DD string
})), })),
tagIds: values.tagIds, tagIds: values.tagIds,
newImageKeys: newImageKeys || [], };
imagesToDelete: imagesToDelete || [],
}, {
onSuccess: (data) => { const formData = new FormData();
Alert.alert('Success', 'Product updated successfully!'); Object.entries(payload).forEach(([key, value]) => {
// Clear newly added images after successful update if (key === 'deals' && Array.isArray(value)) {
productFormRef.current?.clearImages(); formData.append(key, JSON.stringify(value));
}, } else if (key === 'tagIds' && Array.isArray(value)) {
onError: (error: any) => { value.forEach(tagId => {
Alert.alert('Error', error.message || 'Failed to update product'); formData.append('tagIds', tagId.toString());
}, });
} else if (value !== undefined && value !== null) {
formData.append(key, value as string);
}
}); });
// Add new images
if (newImages && newImages.length > 0) {
newImages.forEach((image, index) => {
if (image.uri) {
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
formData.append('images', {
uri: image.uri,
name: fileName,
type: 'image/jpeg',
} as any);
}
});
}
// Add images to delete
if (imagesToDelete && imagesToDelete.length > 0) {
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
}
updateProduct(
{ id: productId, formData },
{
onSuccess: (data) => {
Alert.alert('Success', 'Product updated successfully!');
// Clear newly added images after successful update
productFormRef.current?.clearImages();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update product');
},
}
);
}; };
if (isFetching) { if (isFetching) {
@ -91,7 +125,7 @@ export default function EditProduct() {
deals: productData.deals?.map(deal => ({ deals: productData.deals?.map(deal => ({
quantity: deal.quantity, quantity: deal.quantity,
price: deal.price, price: deal.price,
validTill: deal.validTill ? new Date(deal.validTill) : null, validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
})) || [{ quantity: '', price: '', validTill: null }], })) || [{ quantity: '', price: '', validTill: null }],
tagIds: productData.tags?.map((tag: any) => tag.id) || [], tagIds: productData.tags?.map((tag: any) => tag.id) || [],
isSuspended: productData.isSuspended || false, isSuspended: productData.isSuspended || false,
@ -107,9 +141,9 @@ export default function EditProduct() {
mode="edit" mode="edit"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={updateProduct.isPending} isLoading={isUpdating}
existingImages={productData.images || []} existingImages={productData.images || []}
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -18,7 +18,6 @@ import {
} from 'common-ui'; } from 'common-ui';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../../../hooks/useUploadToObjectStorage';
interface User { interface User {
id: number; id: number;
@ -27,6 +26,12 @@ interface User {
isEligibleForNotif: boolean; isEligibleForNotif: boolean;
} }
const extractKeyFromUrl = (url: string): string => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, '');
return decodeURIComponent(rawKey);
};
export default function SendNotifications() { export default function SendNotifications() {
const router = useRouter(); const router = useRouter();
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
@ -41,7 +46,8 @@ export default function SendNotifications() {
search: searchQuery, search: searchQuery,
}); });
const { uploadSingle, isUploading } = useUploadToObjectStorage(); // Generate upload URLs mutation
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
// Send notification mutation // Send notification mutation
const sendNotification = trpc.admin.user.sendNotification.useMutation({ const sendNotification = trpc.admin.user.sendNotification.useMutation({
@ -121,8 +127,28 @@ export default function SendNotifications() {
// Upload image if selected // Upload image if selected
if (selectedImage) { if (selectedImage) {
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification'); const { uploadUrls } = await generateUploadUrls.mutateAsync({
imageUrl = key; contextString: 'notification',
mimeTypes: [selectedImage.mimeType],
});
if (uploadUrls.length > 0) {
const uploadUrl = uploadUrls[0];
imageUrl = extractKeyFromUrl(uploadUrl);
// Upload image
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: selectedImage.blob,
headers: {
'Content-Type': selectedImage.mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
}
} }
// Send notification // Send notification
@ -230,15 +256,15 @@ export default function SendNotifications() {
{/* Submit Button */} {/* Submit Button */}
<TouchableOpacity <TouchableOpacity
onPress={handleSend} onPress={handleSend}
disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0} disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
style={tw`${ style={tw`${
sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0 sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
? 'bg-gray-300' ? 'bg-gray-300'
: 'bg-blue-600' : 'bg-blue-600'
} rounded-xl py-4 items-center shadow-sm`} } rounded-xl py-4 items-center shadow-sm`}
> >
<MyText style={tw`text-white font-bold text-base`}> <MyText style={tw`text-white font-bold text-base`}>
{isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'} {sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>

View file

@ -1,443 +0,0 @@
import React, { useState } from 'react';
import { View, ScrollView, Alert, FlatList, TouchableOpacity } from 'react-native';
import {
theme,
AppContainer,
MyText,
tw,
useManualRefresh,
useMarkDataFetchers,
MyTouchableOpacity,
RawBottomDialog,
BottomDialog,
} from 'common-ui';
import { trpc } from '../../../src/trpc-client';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Ionicons, Entypo } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import AvailabilityScheduleForm from '../../../components/AvailabilityScheduleForm';
interface Schedule {
id: number;
scheduleName: string;
time: string;
action: 'in' | 'out';
createdAt: string;
lastUpdated: string;
productIds: number[];
groupIds: number[];
productCount: number;
groupCount: number;
}
const ScheduleItem = ({
schedule,
onDelete,
index,
onViewProducts,
onViewGroups,
onReplicate,
}: {
schedule: Schedule;
onDelete: (id: number) => void;
index: number;
onViewProducts: (productIds: number[]) => void;
onViewGroups: (groupIds: number[]) => void;
onReplicate: (schedule: Schedule) => void;
}) => {
const isIn = schedule.action === 'in';
const [menuOpen, setMenuOpen] = useState(false);
return (
<View style={tw``}>
<View style={tw`p-6`}>
{/* Top Header: Name & Action Badge */}
<View style={tw`flex-row justify-between items-start mb-4`}>
<View style={tw`flex-row items-center flex-1`}>
<View
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
>
<MaterialIcons
name="schedule"
size={24}
color={theme.colors.brand600}
/>
</View>
<View style={tw`flex-1`}>
<MyText
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
>
Schedule Name
</MyText>
<MyText
style={tw`text-xl font-black text-slate-900`}
numberOfLines={1}
>
{schedule.scheduleName}
</MyText>
</View>
</View>
<View style={tw`flex-row items-center`}>
<View
style={[
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
{ backgroundColor: isIn ? '#F0FDF4' : '#FFF1F2' },
]}
>
<View
style={[
tw`w-1.5 h-1.5 rounded-full mr-2`,
{ backgroundColor: isIn ? '#10B981' : '#E11D48' },
]}
/>
<MyText
style={[
tw`text-[10px] font-black uppercase tracking-tighter`,
{ color: isIn ? '#10B981' : '#E11D48' },
]}
>
{isIn ? 'In Stock' : 'Out of Stock'}
</MyText>
</View>
<TouchableOpacity
onPress={() => setMenuOpen(true)}
style={tw`p-1`}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Entypo name="dots-three-vertical" size={20} color="#9CA3AF" />
</TouchableOpacity>
</View>
</View>
{/* Menu Dialog */}
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
<View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-4`}>{schedule.scheduleName}</MyText>
<TouchableOpacity
onPress={() => {
setMenuOpen(false);
onReplicate(schedule);
}}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
<MyText style={tw`text-base text-gray-800`}>Replicate items</MyText>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setMenuOpen(false);
Alert.alert('Coming Soon', 'Edit functionality will be available soon');
}}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="edit" size={20} color="#4B5563" style={tw`mr-3`} />
<MyText style={tw`text-base text-gray-800`}>Edit</MyText>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setMenuOpen(false);
onDelete(schedule.id);
}}
style={tw`py-4 border-b border-gray-200`}
>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="delete" size={20} color="#E11D48" style={tw`mr-3`} />
<MyText style={tw`text-base text-red-500`}>Delete</MyText>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMenuOpen(false)}
style={tw`py-4 mt-2`}
>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="close" size={20} color="#6B7280" style={tw`mr-3`} />
<MyText style={tw`text-base text-gray-600`}>Cancel</MyText>
</View>
</TouchableOpacity>
</View>
</BottomDialog>
{/* Middle: Time Banner */}
<View
style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-4 border border-slate-100`}
>
<View
style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}
>
<MaterialIcons name="access-time" size={20} color="#64748B" />
</View>
<View style={tw`ml-4 flex-1`}>
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
{schedule.time}
</MyText>
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
Daily at this time
</MyText>
</View>
</View>
{/* Stats & Actions */}
<View style={tw`flex-row items-center justify-between`}>
<View style={tw`flex-row items-center`}>
<MyTouchableOpacity
onPress={() => onViewProducts(schedule.productIds)}
style={tw`flex-row items-center mr-4`}
>
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
{schedule.productCount} Products
</MyText>
</MyTouchableOpacity>
{schedule.groupCount > 0 && (
<MyTouchableOpacity
onPress={() => onViewGroups(schedule.groupIds)}
style={tw`flex-row items-center`}
>
<MaterialIcons name="category" size={14} color="#94A3B8" />
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
{schedule.groupCount} Groups
</MyText>
</MyTouchableOpacity>
)}
</View>
</View>
</View>
</View>
);
};
export default function StockingSchedules() {
const {
data: schedules,
isLoading,
error,
refetch,
} = trpc.admin.productAvailabilitySchedules.getAll.useQuery();
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
const deleteSchedule = trpc.admin.productAvailabilitySchedules.delete.useMutation();
const [showCreateForm, setShowCreateForm] = useState(false);
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<'products' | 'groups'>('products');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Replication state
const [replicatingSchedule, setReplicatingSchedule] = useState<Schedule | null>(null);
useManualRefresh(refetch);
useMarkDataFetchers(() => {
refetch();
});
const handleCreate = () => {
setShowCreateForm(true);
};
const handleDelete = (id: number) => {
Alert.alert(
'Delete Schedule',
'Are you sure you want to delete this schedule? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
deleteSchedule.mutate(
{ id },
{
onSuccess: () => {
refetch();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to delete schedule');
},
},
);
},
},
],
);
};
const handleViewProducts = (productIds: number[]) => {
setDialogType('products');
setSelectedIds(productIds);
setDialogOpen(true);
};
const handleViewGroups = (groupIds: number[]) => {
setDialogType('groups');
setSelectedIds(groupIds);
setDialogOpen(true);
};
const handleReplicate = (schedule: Schedule) => {
setReplicatingSchedule(schedule);
setShowCreateForm(true);
};
const handleCloseForm = () => {
setShowCreateForm(false);
setReplicatingSchedule(null);
};
// Get product/group names from IDs
const getProductNames = () => {
const allProducts = productsData?.products || [];
return selectedIds.map(id => {
const product = allProducts.find(p => p.id === id);
return product?.name || `Product #${id}`;
});
};
const getGroupNames = () => {
const allGroups = groupsData?.groups || [];
return selectedIds.map(id => {
const group = allGroups.find(g => g.id === id);
return group?.groupName || `Group #${id}`;
});
};
if (showCreateForm) {
return (
<AvailabilityScheduleForm
onClose={handleCloseForm}
onSuccess={() => {
refetch();
handleCloseForm();
}}
initialProductIds={replicatingSchedule?.productIds}
initialGroupIds={replicatingSchedule?.groupIds}
/>
);
}
if (isLoading) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-gray-600`}>Loading schedules...</MyText>
</View>
</AppContainer>
);
}
if (error) {
return (
<AppContainer>
<View style={tw`flex-1 justify-center items-center`}>
<MyText style={tw`text-red-600`}>Error loading schedules</MyText>
</View>
</AppContainer>
);
}
return (
<>
<AppContainer>
<View style={tw`flex-1 bg-white h-full`}>
<ScrollView
style={tw`flex-1`}
contentContainerStyle={tw`pt-2 pb-32`}
showsVerticalScrollIndicator={false}
>
{schedules && schedules.length === 0 ? (
<View style={tw`flex-1 justify-center items-center py-20`}>
<View
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
>
<Ionicons name="time-outline" size={48} color="#94A3B8" />
</View>
<MyText
style={tw`text-slate-900 text-xl font-black tracking-tight`}
>
No Schedules Yet
</MyText>
<MyText
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
>
Start by creating your first availability schedule using the
button below.
</MyText>
</View>
) : (
schedules?.map((schedule, index) => (
<React.Fragment key={schedule.id}>
<ScheduleItem
schedule={schedule}
index={index}
onDelete={handleDelete}
onViewProducts={handleViewProducts}
onViewGroups={handleViewGroups}
onReplicate={handleReplicate}
/>
{index < schedules.length - 1 && (
<View style={tw`h-px bg-slate-200 w-full`} />
)}
</React.Fragment>
))
)}
</ScrollView>
</View>
</AppContainer>
<MyTouchableOpacity
onPress={handleCreate}
activeOpacity={0.95}
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
>
<LinearGradient
colors={['#1570EF', '#194185']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
>
<MaterialIcons name="add" size={32} color="white" />
</LinearGradient>
</MyTouchableOpacity>
{/* Products/Groups Dialog */}
<RawBottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-4`}>
{dialogType === 'products' ? 'Products' : 'Groups'}
</MyText>
<FlatList
data={dialogType === 'products' ? getProductNames() : getGroupNames()}
keyExtractor={(item, index) => index.toString()}
renderItem={({ item }) => (
<View style={tw`py-3 border-b border-gray-100`}>
<MyText style={tw`text-base text-gray-800`}>{item}</MyText>
</View>
)}
showsVerticalScrollIndicator={false}
style={tw`max-h-80`}
ListEmptyComponent={
<View style={tw`py-8 items-center`}>
<MyText style={tw`text-gray-500`}>
No {dialogType} found
</MyText>
</View>
}
/>
</View>
</RawBottomDialog>
</>
);
}

View file

@ -0,0 +1,64 @@
import React from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { View, Text, TouchableOpacity } from 'react-native'
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
import { trpc } from '@/src/trpc-client'
interface AddressPlaceFormProps {
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
onClose: () => void
}
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
const { data: zones } = trpc.admin.address.getZones.useQuery()
const validationSchema = Yup.object({
placeName: Yup.string().required('Place name is required'),
zoneId: Yup.number().optional(),
})
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
return (
<View style={tw`p-4`}>
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
<Formik
initialValues={{ placeName: '', zoneId: null as number | null }}
validationSchema={validationSchema}
onSubmit={(values) => {
onSubmit(values)
onClose()
}}
>
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
<View>
<MyTextInput
label="Place Name"
value={values.placeName}
onChangeText={handleChange('placeName')}
error={!!(touched.placeName && errors.placeName)}
/>
<BottomDropdown
label="Zone (Optional)"
value={values.zoneId as any}
options={zoneOptions}
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
placeholder="Select Zone"
/>
<View style={tw`flex-row justify-between mt-4`}>
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
<Text style={tw`text-gray-900`}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
<Text style={tw`text-white`}>Create</Text>
</TouchableOpacity>
</View>
</View>
)}
</Formik>
</View>
)
}
export default AddressPlaceForm

View file

@ -0,0 +1,51 @@
import React from 'react'
import { Formik } from 'formik'
import * as Yup from 'yup'
import { View, Text, TouchableOpacity } from 'react-native'
import { MyTextInput, tw } from 'common-ui'
interface AddressZoneFormProps {
onSubmit: (values: { zoneName: string }) => void
onClose: () => void
}
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
const validationSchema = Yup.object({
zoneName: Yup.string().required('Zone name is required'),
})
return (
<View style={tw`p-4`}>
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
<Formik
initialValues={{ zoneName: '' }}
validationSchema={validationSchema}
onSubmit={(values) => {
onSubmit(values)
onClose()
}}
>
{({ handleChange, handleSubmit, values, errors, touched }) => (
<View>
<MyTextInput
label="Zone Name"
value={values.zoneName}
onChangeText={handleChange('zoneName')}
error={!!(touched.zoneName && errors.zoneName)}
/>
<View style={tw`flex-row justify-between mt-4`}>
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
<Text style={tw`text-gray-900`}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
<Text style={tw`text-white`}>Create</Text>
</TouchableOpacity>
</View>
</View>
)}
</Formik>
</View>
)
}
export default AddressZoneForm

View file

@ -1,237 +0,0 @@
import React, { useState } from 'react';
import { View, TouchableOpacity, Alert, ScrollView } from 'react-native';
import { useFormik } from 'formik';
import { MyText, tw, MyTextInput, MyTouchableOpacity, DateTimePickerMod } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
interface AvailabilityScheduleFormProps {
onClose: () => void;
onSuccess: () => void;
initialProductIds?: number[];
initialGroupIds?: number[];
}
const AvailabilityScheduleForm: React.FC<AvailabilityScheduleFormProps> = ({
onClose,
onSuccess,
initialProductIds,
initialGroupIds,
}) => {
const createSchedule = trpc.admin.productAvailabilitySchedules.create.useMutation();
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
// Map groups data to match ProductsSelector types (convert price from string to number)
const groups = (groupsData?.groups || []).map(group => ({
...group,
products: group.products.map(product => ({
...product,
price: parseFloat(product.price as unknown as string) || 0,
})),
}));
const formik = useFormik({
initialValues: {
scheduleName: '',
timeDate: null as Date | null,
action: 'in' as 'in' | 'out',
productIds: initialProductIds || ([] as number[]),
groupIds: initialGroupIds || ([] as number[]),
},
validate: (values) => {
const errors: {[key: string]: string} = {};
if (!values.scheduleName.trim()) {
errors.scheduleName = 'Schedule name is required';
}
if (!values.timeDate) {
errors.timeDate = 'Time is required';
}
if (!values.action) {
errors.action = 'Action is required';
}
if (values.productIds.length === 0) {
errors.productIds = 'At least one product must be selected';
}
return errors;
},
onSubmit: async (values) => {
try {
// Convert Date to HH:MM string
const hours = values.timeDate!.getHours().toString().padStart(2, '0');
const minutes = values.timeDate!.getMinutes().toString().padStart(2, '0');
const timeString = `${hours}:${minutes}`;
await createSchedule.mutateAsync({
scheduleName: values.scheduleName,
time: timeString,
action: values.action,
productIds: values.productIds,
groupIds: values.groupIds,
});
Alert.alert('Success', 'Schedule created successfully');
onSuccess();
onClose();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to create schedule');
}
},
});
const actionOptions = [
{ label: 'In Stock', value: 'in' },
{ label: 'Out of Stock', value: 'out' },
];
return (
<View style={tw`flex-1 bg-white`}>
{/* Header */}
<View style={tw`flex-row items-center justify-between p-4 border-b border-gray-200 bg-white`}>
<MyText style={tw`text-xl font-bold text-gray-900`}>
Create Availability Schedule
</MyText>
<MyTouchableOpacity onPress={onClose}>
<MaterialIcons name="close" size={24} color="#6B7280" />
</MyTouchableOpacity>
</View>
<ScrollView style={tw`flex-1 p-4`} showsVerticalScrollIndicator={false}>
{/* Schedule Name */}
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
Schedule Name
</MyText>
<MyTextInput
placeholder="Enter schedule name"
value={formik.values.scheduleName}
onChangeText={formik.handleChange('scheduleName')}
onBlur={formik.handleBlur('scheduleName')}
style={tw`border rounded-lg p-3 ${
formik.touched.scheduleName && formik.errors.scheduleName
? 'border-red-500'
: 'border-gray-300'
}`}
/>
{formik.touched.scheduleName && formik.errors.scheduleName && (
<MyText style={tw`text-red-500 text-xs mt-1`}>
{formik.errors.scheduleName}
</MyText>
)}
</View>
{/* Time */}
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
Time
</MyText>
<DateTimePickerMod
value={formik.values.timeDate}
setValue={(date) => formik.setFieldValue('timeDate', date)}
timeOnly={true}
showLabels={false}
/>
{formik.touched.timeDate && formik.errors.timeDate && (
<MyText style={tw`text-red-500 text-xs mt-1`}>
{formik.errors.timeDate}
</MyText>
)}
</View>
{/* Action */}
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
Action
</MyText>
<View style={tw`flex-row gap-3`}>
{actionOptions.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => formik.setFieldValue('action', option.value)}
style={tw`flex-1 flex-row items-center p-4 rounded-lg border ${
formik.values.action === option.value
? 'bg-blue-50 border-blue-500'
: 'bg-white border-gray-300'
}`}
>
<View
style={tw`w-5 h-5 rounded-full border-2 mr-3 items-center justify-center ${
formik.values.action === option.value
? 'border-blue-500'
: 'border-gray-300'
}`}
>
{formik.values.action === option.value && (
<View style={tw`w-3 h-3 rounded-full bg-blue-500`} />
)}
</View>
<MyText
style={tw`font-medium ${
formik.values.action === option.value
? 'text-blue-700'
: 'text-gray-700'
}`}
>
{option.label}
</MyText>
</TouchableOpacity>
))}
</View>
</View>
{/* Products and Groups */}
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
Products & Groups
</MyText>
<ProductsSelector
value={formik.values.productIds}
onChange={(value) => formik.setFieldValue('productIds', value)}
groups={groups}
selectedGroupIds={formik.values.groupIds}
onGroupChange={(groupIds) => formik.setFieldValue('groupIds', groupIds)}
showGroups={true}
label="Select Products"
placeholder="Select products for this schedule"
/>
{formik.touched.productIds && formik.errors.productIds && (
<MyText style={tw`text-red-500 text-xs mt-1`}>
{formik.errors.productIds}
</MyText>
)}
</View>
{/* Spacer for bottom padding */}
<View style={tw`h-24`} />
</ScrollView>
{/* Footer Buttons */}
<View style={tw`p-4 border-t border-gray-200 bg-white flex-row gap-3`}>
<MyTouchableOpacity
onPress={onClose}
style={tw`flex-1 py-3 px-4 rounded-lg border border-gray-300 items-center`}
>
<MyText style={tw`font-medium text-gray-700`}>Cancel</MyText>
</MyTouchableOpacity>
<MyTouchableOpacity
onPress={() => formik.handleSubmit()}
disabled={formik.isSubmitting}
style={tw`flex-1 py-3 px-4 rounded-lg bg-blue-600 items-center ${
formik.isSubmitting ? 'opacity-50' : ''
}`}
>
<MyText style={tw`font-medium text-white`}>
{formik.isSubmitting ? 'Creating...' : 'Create Schedule'}
</MyText>
</MyTouchableOpacity>
</View>
</View>
);
};
export default AvailabilityScheduleForm;

View file

@ -8,7 +8,6 @@ import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client'; import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
export interface BannerFormData { export interface BannerFormData {
name: string; name: string;
@ -53,7 +52,14 @@ export default function BannerForm({
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]); const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]); const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const { uploadSingle, isUploading } = useUploadToObjectStorage(); const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
// Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
const products = productsData?.products || [];
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -91,15 +97,37 @@ export default function BannerForm({
let imageUrl: string | undefined; let imageUrl: string | undefined;
if (selectedImages.length > 0) { if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store', // Using 'store' for now
mimeTypes,
});
// Upload image
const uploadUrl = uploadUrls[0];
const { blob, mimeType } = selectedImages[0]; const { blob, mimeType } = selectedImages[0];
const { key, presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl; const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
imageUrl = uploadUrl;
} }
// Call onSubmit with form values and imageUrl
await onSubmit(values, imageUrl); await onSubmit(values, imageUrl);
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image'); Alert.alert('Error', 'Failed to upload image');
} }
}; };
@ -211,15 +239,15 @@ export default function BannerForm({
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
disabled={isSubmitting || isUploading || !isValid || !dirty} disabled={isSubmitting || !isValid || !dirty}
style={tw`flex-1 rounded-lg py-4 items-center ${ style={tw`flex-1 rounded-lg py-4 items-center ${
isSubmitting || isUploading || !isValid || !dirty isSubmitting || !isValid || !dirty
? 'bg-blue-400' ? 'bg-blue-400'
: 'bg-blue-600' : 'bg-blue-600'
}`} }`}
> >
<MyText style={tw`text-white font-semibold`}> <MyText style={tw`text-white font-semibold`}>
{isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText} {isSubmitting ? 'Saving...' : submitButtonText}
</MyText> </MyText>
</MyTouchableOpacity> </MyTouchableOpacity>
</View> </View>
@ -228,4 +256,4 @@ export default function BannerForm({
)} )}
</Formik> </Formik>
); );
} }

View file

@ -2,11 +2,10 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native'; import { View, TouchableOpacity, Alert } from 'react-native';
import { Formik } from 'formik'; import { Formik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui'; import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
import ProductsSelector from './ProductsSelector'; import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client'; import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
export interface StoreFormData { export interface StoreFormData {
name: string; name: string;
@ -16,12 +15,6 @@ export interface StoreFormData {
products: number[]; products: number[];
} }
interface StoreImage {
uri: string;
mimeType: string;
isExisting: boolean;
}
export interface StoreFormRef { export interface StoreFormRef {
// Add methods if needed // Add methods if needed
} }
@ -34,11 +27,6 @@ interface StoreFormProps {
storeId?: number; storeId?: number;
} }
// Extend Formik values with images array
interface FormikStoreValues extends StoreFormData {
images: StoreImage[];
}
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
name: Yup.string().required('Name is required'), name: Yup.string().required('Name is required'),
description: Yup.string(), description: Yup.string(),
@ -52,23 +40,9 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery(); const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
const { data: productsData } = trpc.admin.product.getProducts.useQuery(); const { data: productsData } = trpc.admin.product.getProducts.useQuery();
// Build initial form values with images array const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
const buildInitialValues = (): FormikStoreValues => { const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const images: StoreImage[] = []; const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
if (initialValues.imageUrl) {
images.push({
uri: initialValues.imageUrl,
mimeType: 'image/jpeg',
isExisting: true,
});
}
return {
...initialValues,
images,
};
};
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
// For edit mode, pre-select products belonging to this store // For edit mode, pre-select products belonging to this store
const initialSelectedProducts = useMemo(() => { const initialSelectedProducts = useMemo(() => {
@ -80,7 +54,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
useEffect(() => { useEffect(() => {
setFormInitialValues({ setFormInitialValues({
...buildInitialValues(), ...initialValues,
products: initialSelectedProducts, products: initialSelectedProducts,
}); });
}, [initialValues, initialSelectedProducts]); }, [initialValues, initialSelectedProducts]);
@ -90,8 +64,42 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
value: staff.id, value: staff.id,
})) || []; })) || [];
const { uploadSingle, isUploading } = useUploadToObjectStorage();
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setSelectedImages([]);
setDisplayImages([]);
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const blobPromises = files.map(async (asset) => {
const response = await fetch(asset.uri);
const blob = await response.blob();
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
});
const blobArray = await Promise.all(blobPromises);
setSelectedImages(blobArray);
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
},
multiple: false, // Single image for stores
});
const handleRemoveImage = (uri: string) => {
const index = displayImages.findIndex(img => img.uri === uri);
if (index !== -1) {
const newDisplay = displayImages.filter((_, i) => i !== index);
const newFiles = selectedImages.filter((_, i) => i !== index);
setDisplayImages(newDisplay);
setSelectedImages(newFiles);
}
};
return ( return (
<Formik <Formik
initialValues={formInitialValues} initialValues={formInitialValues}
@ -100,78 +108,51 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => { {({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
// Image picker that adds to Formik field
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const newImages: StoreImage[] = files.map((asset) => ({
uri: asset.uri,
mimeType: asset.mimeType || 'image/jpeg',
isExisting: false,
}));
// Add to Formik images field
const currentImages = values.images || [];
setFieldValue('images', [...currentImages, ...newImages]);
},
multiple: false,
});
// Remove image - works for both existing and new
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
const currentImages = values.images || [];
const removedImage = currentImages.find(img => img.uri === image.uri);
const newImages = currentImages.filter(img => img.uri !== image.uri);
setFieldValue('images', newImages);
// If we removed an existing image, also clear the imageUrl
if (removedImage?.isExisting) {
setFieldValue('imageUrl', undefined);
}
};
const submit = async () => { const submit = async () => {
try { try {
let imageUrl: string | undefined; let imageUrl: string | undefined;
// Get new images that need to be uploaded if (selectedImages.length > 0) {
const newImages = values.images.filter(img => !img.isExisting); // Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store',
mimeTypes,
});
if (newImages.length > 0) { // Upload images
// Upload the first new image (single image for stores) for (let i = 0; i < uploadUrls.length; i++) {
const image = newImages[0]; const uploadUrl = uploadUrls[i];
const response = await fetch(image.uri); const { blob, mimeType } = selectedImages[i];
const imageBlob = await response.blob();
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store'); const uploadResponse = await fetch(uploadUrl, {
imageUrl = key; method: 'PUT',
} else { body: blob,
// Check if there's an existing image remaining headers: {
const existingImage = values.images.find(img => img.isExisting); 'Content-Type': mimeType,
if (existingImage) { },
imageUrl = existingImage.uri; });
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
} }
// Extract key from first upload URL
// const u = new URL(uploadUrls[0]);
// const rawKey = u.pathname.replace(/^\/+/, "");
// imageUrl = decodeURIComponent(rawKey);
imageUrl = uploadUrls[0];
} }
// Submit form with imageUrl (without images array) // Submit form with imageUrl
const { images, ...submitValues } = values; onSubmit({ ...values, imageUrl });
onSubmit({ ...submitValues, imageUrl });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image'); Alert.alert('Error', 'Failed to upload image');
} }
}; };
// Prepare images for ImageUploaderNeo (convert to expected format)
const imagesForUploader = (values.images || []).map(img => ({
uri: img.uri,
mimeType: img.mimeType,
}));
return ( return (
<View> <View>
<MyTextInput <MyTextInput
@ -212,21 +193,22 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
/> />
<View style={tw`mb-6`}> <View style={tw`mb-6`}>
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText> <MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader
<ImageUploaderNeo images={displayImages}
images={imagesForUploader} existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
onUploadImage={handleImagePick} onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
allowMultiple={false} allowMultiple={false}
/> />
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading || isUploading} disabled={isLoading || generateUploadUrls.isPending}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')} {generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -238,4 +220,4 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
StoreForm.displayName = 'StoreForm'; StoreForm.displayName = 'StoreForm';
export default StoreForm; export default StoreForm;

View file

@ -5,8 +5,8 @@
}, },
"build": { "build": {
"development": { "development": {
"distribution": "internal", "developmentClient": true,
"autoIncrement": true "distribution": "internal"
}, },
"preview": { "preview": {
"distribution": "internal", "distribution": "internal",

View file

@ -1,118 +0,0 @@
import { useState } from 'react';
import { trpc } from '../src/trpc-client';
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
interface UploadInput {
blob: Blob;
mimeType: string;
}
interface UploadBatchInput {
images: UploadInput[];
contextString: ContextString;
}
interface UploadResult {
keys: string[];
presignedUrls: string[];
}
export function useUploadToObjectStorage() {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
setIsUploading(true);
setError(null);
setProgress({ completed: 0, total: input.images.length });
try {
const { images, contextString } = input;
if (images.length === 0) {
return { keys: [], presignedUrls: [] };
}
// 1. Get presigned URLs from backend (one call for all images)
const mimeTypes = images.map(img => img.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString,
mimeTypes,
});
if (uploadUrls.length !== images.length) {
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
}
// 2. Upload all images in parallel
const uploadPromises = images.map(async (image, index) => {
const presignedUrl = uploadUrls[index];
const { blob, mimeType } = image;
const response = await fetch(presignedUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!response.ok) {
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
}
// Update progress
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
return {
key: extractKeyFromPresignedUrl(presignedUrl),
presignedUrl,
};
});
// Use Promise.all - if any fails, entire batch fails
const results = await Promise.all(uploadPromises);
return {
keys: results.map(r => r.key),
presignedUrls: results.map(r => r.presignedUrl),
};
} catch (err) {
const uploadError = err instanceof Error ? err : new Error('Upload failed');
setError(uploadError);
throw uploadError;
} finally {
setIsUploading(false);
setProgress(null);
}
};
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
const result = await upload({
images: [{ blob, mimeType }],
contextString,
});
return {
key: result.keys[0],
presignedUrl: result.presignedUrls[0],
};
};
return {
upload,
uploadSingle,
isUploading,
error,
progress,
isPending: generateUploadUrls.isPending
};
}
function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url);
let rawKey = u.pathname.replace(/^\/+/, '');
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
return decodeURIComponent(rawKey);
}

View file

@ -0,0 +1,111 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface UpdateProductPayload {
name: string;
shortDescription?: string;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
incrementStep?: number;
productQuantity?: number;
isOutOfStock?: boolean;
deals?: {
quantity: number;
price: number;
validTill: string;
}[];
}
export interface Product {
id: number;
name: string;
shortDescription?: string | null;
longDescription?: string;
unitId: number;
storeId: number;
price: number;
marketPrice?: number;
productQuantity?: number;
isOutOfStock?: boolean;
images?: string[];
createdAt: string;
unit?: {
id: number;
shortNotation: string;
fullName: string;
};
deals?: {
id: number;
quantity: string;
price: string;
validTill: string;
}[];
}
export interface CreateProductResponse {
product: Product;
deals?: any[];
message: string;
}
// API functions
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
const response = await axios.post('/av/products', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
const response = await axios.put(`/av/products/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
// Hooks
export const useCreateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProductApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
};
export const useUpdateProduct = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProductApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
};

View file

@ -0,0 +1,119 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from '../../services/axios-admin-ui';
// Types
export interface CreateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface UpdateTagPayload {
tagName: string;
tagDescription?: string;
imageUrl?: string;
isDashboardTag: boolean;
relatedStores?: number[];
}
export interface Tag {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores: number[];
createdAt?: string;
}
export interface CreateTagResponse {
tag: Tag;
message: string;
}
export interface GetTagsResponse {
tags: Tag[];
message: string;
}
// API functions
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
const response = await axios.post('/av/product-tags', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
const response = await axios.put(`/av/product-tags/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
const response = await axios.delete(`/av/product-tags/${id}`);
return response.data;
};
const getTagsApi = async (): Promise<GetTagsResponse> => {
const response = await axios.get('/av/product-tags');
return response.data;
};
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
const response = await axios.get(`/av/product-tags/${id}`);
return response.data;
};
// Hooks
export const useCreateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useDeleteTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTagApi,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
},
});
};
export const useGetTags = () => {
return useQuery({
queryKey: ['tags'],
queryFn: getTagsApi,
});
};
export const useGetTag = (id: number) => {
return useQuery({
queryKey: ['tags', id],
queryFn: () => getTagApi(id),
enabled: !!id,
});
};

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Formik, FieldArray } from 'formik'; import { Formik, FieldArray } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
@ -7,7 +7,7 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client'; import { trpc } from '../trpc-client';
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage'; import { useGetTags } from '../api-hooks/tag.api';
interface ProductFormData { interface ProductFormData {
name: string; name: string;
@ -38,7 +38,7 @@ export interface ProductFormRef {
interface ProductFormProps { interface ProductFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: ProductFormData; initialValues: ProductFormData;
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void; onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
isLoading: boolean; isLoading: boolean;
existingImages?: string[]; existingImages?: string[];
} }
@ -60,9 +60,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
existingImages = [] existingImages = []
}, ref) => { }, ref) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]); const [images, setImages] = useState<{ uri?: string }[]>([]);
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages); const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
const { upload, isUploading } = useUploadToObjectStorage();
const { data: storesData } = trpc.common.getStoresSummary.useQuery(); const { data: storesData } = trpc.common.getStoresSummary.useQuery();
const storeOptions = storesData?.stores.map(store => ({ const storeOptions = storesData?.stores.map(store => ({
@ -70,8 +69,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id, value: store.id,
})) || []; })) || [];
const { data: tagsData } = trpc.admin.tag.getTags.useQuery(); const { data: tagsData } = useGetTags();
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({ const tagOptions = tagsData?.tags.map(tag => ({
label: tag.tagName, label: tag.tagName,
value: tag.id.toString(), value: tag.id.toString(),
})) || []; })) || [];
@ -84,62 +83,23 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
}, [existingImages]); }, [existingImages]);
const pickImage = usePickImage({ const pickImage = usePickImage({
setFile: async (assets: any) => { setFile: (files) => setImages(prev => [...prev, ...files]),
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
return;
}
const files = Array.isArray(assets) ? assets : [assets];
const imageData = await Promise.all(
files.map(async (asset) => {
const response = await fetch(asset.uri);
const blob = await response.blob();
return {
blob,
mimeType: asset.mimeType || 'image/jpeg',
uri: asset.uri
};
})
);
setNewImages(prev => [...prev, ...imageData]);
},
multiple: true, multiple: true,
}); });
// Calculate which existing images were deleted // Calculate which existing images were deleted
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img)); const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
// Display images for ImageUploader component
const displayImages = newImages.map(img => ({ uri: img.uri }));
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={async (values) => { onSubmit={(values) => onSubmit(values, images, deletedImages)}
try {
let imageKeys: string[] = [];
// Upload new images if any
if (newImages.length > 0) {
const result = await upload({
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
contextString: 'product_info',
});
imageKeys = result.keys;
}
onSubmit(values, imageKeys, deletedImages);
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
}
}}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
// Clear form when screen comes into focus // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setNewImages([]); setImages([]);
setExistingImagesState([]); setExistingImagesState([]);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
@ -183,9 +143,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
{mode === 'create' && ( {mode === 'create' && (
<ImageUploader <ImageUploader
images={displayImages} images={images}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))} onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/> />
)} )}
@ -206,9 +166,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<View style={{ marginBottom: 16 }}> <View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText> <MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
<ImageUploader <ImageUploader
images={displayImages} images={images}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))} onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/> />
</View> </View>
)} )}
@ -395,11 +355,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading || isUploading} disabled={isLoading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')} {isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -411,4 +371,4 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
ProductForm.displayName = 'ProductForm'; ProductForm.displayName = 'ProductForm';
export default ProductForm; export default ProductForm;

View file

@ -1,12 +1,11 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Formik } from 'formik'; import { Formik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui'; import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
interface StoreOption { interface StoreOption {
id: number; id: number;
@ -24,7 +23,7 @@ interface TagFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: TagFormData; initialValues: TagFormData;
existingImageUrl?: string; existingImageUrl?: string;
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void; onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
isLoading: boolean; isLoading: boolean;
stores?: StoreOption[]; stores?: StoreOption[];
} }
@ -37,34 +36,23 @@ const TagForm = forwardRef<any, TagFormProps>(({
isLoading, isLoading,
stores = [], stores = [],
}, ref) => { }, ref) => {
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null); const [image, setImage] = useState<{ uri?: string } | null>(null);
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag)); const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const { uploadSingle, isUploading } = useUploadToObjectStorage();
// Update checkbox when initial values change // Update checkbox when initial values change
useEffect(() => { useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl})
}, [initialValues.isDashboardTag]); }, [initialValues.isDashboardTag]);
const pickImage = usePickImage({ const pickImage = usePickImage({
setFile: async (assets: any) => { setFile: (files) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setNewImage(null);
return;
}
const asset = Array.isArray(assets) ? assets[0] : assets;
const response = await fetch(asset.uri);
const blob = await response.blob();
setNewImage({ setImage(files || null)
blob,
mimeType: asset.mimeType || 'image/jpeg',
uri: asset.uri
});
}, },
multiple: false, multiple: false,
}); });
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
tagName: Yup.string() tagName: Yup.string()
@ -75,44 +63,18 @@ const TagForm = forwardRef<any, TagFormProps>(({
.max(500, 'Description must be less than 500 characters'), .max(500, 'Description must be less than 500 characters'),
}); });
// Display images for ImageUploader
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
const existingImages = existingImageUrl ? [existingImageUrl] : [];
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={async (values) => { onSubmit={(values) => onSubmit(values, image || undefined)}
try {
let imageKey: string | undefined;
let deleteExistingImage = false;
// Handle image upload
if (newImage) {
const result = await uploadSingle(newImage.blob, newImage.mimeType, 'product_info');
imageKey = result.key;
// If we're uploading a new image and there's an existing one, mark it for deletion
if (existingImageUrl) {
deleteExistingImage = true;
}
} else if (mode === 'edit' && !newImage && existingImageUrl) {
// In edit mode, if no new image and existing was removed
// This would need UI to explicitly remove image
// For now, we don't support explicit deletion without replacement
}
onSubmit(values, imageKey, deleteExistingImage);
} catch (error) {
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
}
}}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setNewImage(null); setImage(null);
setIsDashboardTagChecked(false); setIsDashboardTagChecked(false);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
@ -145,15 +107,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'} Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
</MyText> </MyText>
<ImageUploader <ImageUploader
images={displayImages} images={image ? [image] : []}
existingImageUrls={mode === 'edit' ? existingImages : []}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={() => setNewImage(null)} onRemoveImage={() => setImage(null)}
onRemoveExistingImage={mode === 'edit' ? () => {
// In edit mode, this would trigger deletion of existing image
// But we need to implement this logic in the parent
} : undefined}
/> />
</View> </View>
@ -164,7 +122,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
onPress={() => { onPress={() => {
const newValue = !isDashboardTagChecked; const newValue = !isDashboardTagChecked;
setIsDashboardTagChecked(newValue); setIsDashboardTagChecked(newValue);
setFieldValue('isDashboardTag', newValue); formikSetFieldValue('isDashboardTag', newValue);
}} }}
/> />
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText> <MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
@ -185,7 +143,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
}))} }))}
onValueChange={(selectedValues) => { onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v)); const numericValues = (selectedValues as string[]).map(v => parseInt(v));
setFieldValue('relatedStores', numericValues); formikSetFieldValue('relatedStores', numericValues);
}} }}
multiple={true} multiple={true}
/> />
@ -193,11 +151,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
<TouchableOpacity <TouchableOpacity
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
disabled={isLoading || isUploading} disabled={isLoading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')} {isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -209,4 +167,4 @@ const TagForm = forwardRef<any, TagFormProps>(({
TagForm.displayName = 'TagForm'; TagForm.displayName = 'TagForm';
export default TagForm; export default TagForm;

View file

@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
import { Entypo } from '@expo/vector-icons'; import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui'; import { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { trpc } from '../trpc-client'; import { useDeleteTag } from '../api-hooks/tag.api';
export interface TagMenuProps { export interface TagMenuProps {
tagId: number; tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const deleteTag = trpc.admin.tag.deleteTag.useMutation(); const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
const handleOpenMenu = () => { const handleOpenMenu = () => {
setIsOpen(true); setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}; };
const performDelete = () => { const performDelete = () => {
deleteTag.mutate({ id: tagId }, { deleteTag(tagId, {
onSuccess: () => { onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully'); Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.(); onDeleteSuccess?.();

View file

@ -1,10 +1,7 @@
ENV_MODE=PROD ENV_MODE=PROD
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy # DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
SQLITE_DB_PATH='./sqlite.db'
DB_DIALECT='sqlite'
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/ PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090 PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
PHONE_PE_CLIENT_VERSION=1 PHONE_PE_CLIENT_VERSION=1
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
@ -20,14 +17,10 @@ S3_REGION=apac
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7 S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950 S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer-dev S3_BUCKET_NAME=meatfarmer
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK- EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
JWT_SECRET=my_meatfarmer_jwt_secret_key JWT_SECRET=my_meatfarmer_jwt_secret_key
ASSETS_DOMAIN=https://assets2.freshyo.in/ ASSETS_DOMAIN=https://assets.freshyo.in/
API_CACHE_KEY=api-cache-dev
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379 # REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379 REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
APP_URL=http://localhost:4000 APP_URL=http://localhost:4000

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,11 @@
import postgresConfig from '../db-helper-postgres/drizzle.config' import 'dotenv/config';
import sqliteConfig from '../db-helper-sqlite/drizzle.config' import { defineConfig } from 'drizzle-kit';
export default process.env.DB_DIALECT === 'sqlite' export default defineConfig({
? sqliteConfig out: './drizzle',
: postgresConfig schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -1,14 +0,0 @@
CREATE TYPE "public"."product_availability_action" AS ENUM('in', 'out');--> statement-breakpoint
CREATE TABLE "mf"."product_availability_schedules" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."product_availability_schedules_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"time" varchar(10) NOT NULL,
"schedule_name" varchar(255) NOT NULL,
"action" "product_availability_action" NOT NULL,
"product_ids" integer[] DEFAULT '{}' NOT NULL,
"group_ids" integer[] DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_updated" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "product_availability_schedules_schedule_name_unique" UNIQUE("schedule_name")
);
--> statement-breakpoint
ALTER TABLE "mf"."product_info" ADD COLUMN "scheduled_availability" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -540,13 +540,6 @@
"when": 1772637259874, "when": 1772637259874,
"tag": "0076_sturdy_wolverine", "tag": "0076_sturdy_wolverine",
"breakpoints": true "breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1773927855512,
"tag": "0077_wakeful_norrin_radd",
"breakpoints": true
} }
] ]
} }

View file

@ -1,515 +0,0 @@
CREATE TABLE `address_areas` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`place_name` text NOT NULL,
`zone_id` integer,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `address_zones` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`zone_name` text NOT NULL,
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `addresses` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`phone` text NOT NULL,
`address_line1` text NOT NULL,
`address_line2` text,
`city` text NOT NULL,
`state` text NOT NULL,
`pincode` text NOT NULL,
`is_default` integer DEFAULT false NOT NULL,
`latitude` real,
`longitude` real,
`google_maps_url` text,
`admin_latitude` real,
`admin_longitude` real,
`zone_id` integer,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `cart_items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`product_id` integer NOT NULL,
`quantity` text NOT NULL,
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_user_product` ON `cart_items` (`user_id`,`product_id`);--> statement-breakpoint
CREATE TABLE `complaints` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`order_id` integer,
`complaint_body` text NOT NULL,
`images` text,
`response` text,
`is_resolved` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `coupon_applicable_products` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`coupon_id` integer NOT NULL,
`product_id` integer NOT NULL,
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_coupon_product` ON `coupon_applicable_products` (`coupon_id`,`product_id`);--> statement-breakpoint
CREATE TABLE `coupon_applicable_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`coupon_id` integer NOT NULL,
`user_id` integer NOT NULL,
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_coupon_user` ON `coupon_applicable_users` (`coupon_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `coupon_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`coupon_id` integer NOT NULL,
`order_id` integer,
`order_item_id` integer,
`used_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`order_item_id`) REFERENCES `order_items`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `coupons` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`coupon_code` text NOT NULL,
`is_user_based` integer DEFAULT false NOT NULL,
`discount_percent` text,
`flat_discount` text,
`min_order` text,
`product_ids` text,
`created_by` integer,
`max_value` text,
`is_apply_for_all` integer DEFAULT false NOT NULL,
`valid_till` integer,
`max_limit_for_user` integer,
`is_invalidated` integer DEFAULT false NOT NULL,
`exclusive_apply` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_coupon_code` ON `coupons` (`coupon_code`);--> statement-breakpoint
CREATE TABLE `delivery_slot_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`delivery_time` integer NOT NULL,
`freeze_time` integer NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`is_flash` integer DEFAULT false NOT NULL,
`is_capacity_full` integer DEFAULT false NOT NULL,
`delivery_sequence` text DEFAULT '{}',
`group_ids` text DEFAULT '[]'
);
--> statement-breakpoint
CREATE TABLE `home_banners` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`image_url` text NOT NULL,
`description` text,
`product_ids` text,
`redirect_url` text,
`serial_num` integer,
`is_active` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `key_val_store` (
`key` text PRIMARY KEY NOT NULL,
`value` text
);
--> statement-breakpoint
CREATE TABLE `notif_creds` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`token` text NOT NULL,
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`user_id` integer NOT NULL,
`last_verified` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notif_creds_token_unique` ON `notif_creds` (`token`);--> statement-breakpoint
CREATE TABLE `notifications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`title` text NOT NULL,
`body` text NOT NULL,
`type` text,
`is_read` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `order_items` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`order_id` integer NOT NULL,
`product_id` integer NOT NULL,
`quantity` text NOT NULL,
`price` text NOT NULL,
`discounted_price` text,
`is_packaged` integer DEFAULT false NOT NULL,
`is_package_verified` integer DEFAULT false NOT NULL,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `order_status` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`order_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
`user_id` integer NOT NULL,
`order_id` integer NOT NULL,
`is_packaged` integer DEFAULT false NOT NULL,
`is_delivered` integer DEFAULT false NOT NULL,
`is_cancelled` integer DEFAULT false NOT NULL,
`cancel_reason` text,
`is_cancelled_by_admin` integer,
`payment_state` text DEFAULT 'pending' NOT NULL,
`cancellation_user_notes` text,
`cancellation_admin_notes` text,
`cancellation_reviewed` integer DEFAULT false NOT NULL,
`cancellation_reviewed_at` integer,
`refund_coupon_id` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`refund_coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `orders` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`address_id` integer NOT NULL,
`slot_id` integer,
`is_cod` integer DEFAULT false NOT NULL,
`is_online_payment` integer DEFAULT false NOT NULL,
`payment_info_id` integer,
`total_amount` text NOT NULL,
`delivery_charge` text DEFAULT '0' NOT NULL,
`readable_id` integer NOT NULL,
`admin_notes` text,
`user_notes` text,
`order_group_id` text,
`order_group_proportion` text,
`is_flash_delivery` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`address_id`) REFERENCES `addresses`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`payment_info_id`) REFERENCES `payment_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `payment_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`status` text NOT NULL,
`gateway` text NOT NULL,
`order_id` text,
`token` text,
`merchant_order_id` text NOT NULL,
`payload` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `payment_info_merchant_order_id_unique` ON `payment_info` (`merchant_order_id`);--> statement-breakpoint
CREATE TABLE `payments` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`status` text NOT NULL,
`gateway` text NOT NULL,
`order_id` integer NOT NULL,
`token` text,
`merchant_order_id` text NOT NULL,
`payload` text,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `payments_merchant_order_id_unique` ON `payments` (`merchant_order_id`);--> statement-breakpoint
CREATE TABLE `product_availability_schedules` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`time` text NOT NULL,
`schedule_name` text NOT NULL,
`action` text NOT NULL,
`product_ids` text DEFAULT '[]' NOT NULL,
`group_ids` text DEFAULT '[]' NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `product_availability_schedules_schedule_name_unique` ON `product_availability_schedules` (`schedule_name`);--> statement-breakpoint
CREATE TABLE `product_categories` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text
);
--> statement-breakpoint
CREATE TABLE `product_group_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`group_name` text NOT NULL,
`description` text,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `product_group_membership` (
`product_id` integer NOT NULL,
`group_id` integer NOT NULL,
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`group_id`) REFERENCES `product_group_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `product_group_membership_pk` ON `product_group_membership` (`product_id`,`group_id`);--> statement-breakpoint
CREATE TABLE `product_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`short_description` text,
`long_description` text,
`unit_id` integer NOT NULL,
`price` text NOT NULL,
`market_price` text,
`images` text,
`is_out_of_stock` integer DEFAULT false NOT NULL,
`is_suspended` integer DEFAULT false NOT NULL,
`is_flash_available` integer DEFAULT false NOT NULL,
`flash_price` text,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`increment_step` real DEFAULT 1 NOT NULL,
`product_quantity` real DEFAULT 1 NOT NULL,
`store_id` integer,
`scheduled_availability` integer DEFAULT true NOT NULL,
FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`store_id`) REFERENCES `store_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `product_reviews` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`product_id` integer NOT NULL,
`review_body` text NOT NULL,
`image_urls` text DEFAULT '[]',
`review_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
`ratings` real NOT NULL,
`admin_response` text,
`admin_response_images` text DEFAULT '[]',
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
CONSTRAINT "rating_check" CHECK("product_reviews"."ratings" >= 1 AND "product_reviews"."ratings" <= 5)
);
--> statement-breakpoint
CREATE TABLE `product_slots` (
`product_id` integer NOT NULL,
`slot_id` integer NOT NULL,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `product_slot_pk` ON `product_slots` (`product_id`,`slot_id`);--> statement-breakpoint
CREATE TABLE `product_tag_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`tag_name` text NOT NULL,
`tag_description` text,
`image_url` text,
`is_dashboard_tag` integer DEFAULT false NOT NULL,
`related_stores` text DEFAULT '[]',
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `product_tag_info_tag_name_unique` ON `product_tag_info` (`tag_name`);--> statement-breakpoint
CREATE TABLE `product_tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`product_id` integer NOT NULL,
`tag_id` integer NOT NULL,
`assigned_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`tag_id`) REFERENCES `product_tag_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_product_tag` ON `product_tags` (`product_id`,`tag_id`);--> statement-breakpoint
CREATE TABLE `refunds` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`order_id` integer NOT NULL,
`refund_amount` text,
`refund_status` text DEFAULT 'none',
`merchant_refund_id` text,
`refund_processed_at` integer,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `reserved_coupons` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`secret_code` text NOT NULL,
`coupon_code` text NOT NULL,
`discount_percent` text,
`flat_discount` text,
`min_order` text,
`product_ids` text,
`max_value` text,
`valid_till` integer,
`max_limit_for_user` integer,
`exclusive_apply` integer DEFAULT false NOT NULL,
`is_redeemed` integer DEFAULT false NOT NULL,
`redeemed_by` integer,
`redeemed_at` integer,
`created_by` integer NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`redeemed_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `reserved_coupons_secret_code_unique` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
CREATE UNIQUE INDEX `unique_secret_code` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
CREATE TABLE `special_deals` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`product_id` integer NOT NULL,
`quantity` text NOT NULL,
`price` text NOT NULL,
`valid_till` integer NOT NULL,
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `staff_permissions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`permission_name` text NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_permission_name` ON `staff_permissions` (`permission_name`);--> statement-breakpoint
CREATE TABLE `staff_role_permissions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`staff_role_id` integer NOT NULL,
`staff_permission_id` integer NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`staff_permission_id`) REFERENCES `staff_permissions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_role_permission` ON `staff_role_permissions` (`staff_role_id`,`staff_permission_id`);--> statement-breakpoint
CREATE TABLE `staff_roles` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`role_name` text NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_role_name` ON `staff_roles` (`role_name`);--> statement-breakpoint
CREATE TABLE `staff_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`password` text NOT NULL,
`staff_role_id` integer,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `store_info` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`image_url` text,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`owner` integer NOT NULL,
FOREIGN KEY (`owner`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `units` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`short_notation` text NOT NULL,
`full_name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_short_notation` ON `units` (`short_notation`);--> statement-breakpoint
CREATE TABLE `unlogged_user_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`token` text NOT NULL,
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`last_verified` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unlogged_user_tokens_token_unique` ON `unlogged_user_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `upload_url_status` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`key` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL
);
--> statement-breakpoint
CREATE TABLE `user_creds` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`user_password` text NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_details` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`bio` text,
`date_of_birth` integer,
`gender` text,
`occupation` text,
`profile_image` text,
`is_suspended` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`updated_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_details_user_id_unique` ON `user_details` (`user_id`);--> statement-breakpoint
CREATE TABLE `user_incidents` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`order_id` integer,
`date_added` integer DEFAULT (strftime('%s','now')) NOT NULL,
`admin_comment` text,
`added_by` integer,
`negativity_score` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`added_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_notifications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`image_url` text,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`body` text NOT NULL,
`applicable_users` text
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text,
`email` text,
`mobile` text,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `unique_email` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `vendor_snippets` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snippet_code` text NOT NULL,
`slot_id` integer,
`is_permanent` integer DEFAULT false NOT NULL,
`product_ids` text NOT NULL,
`valid_till` integer,
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `vendor_snippets_snippet_code_unique` ON `vendor_snippets` (`snippet_code`);

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1774244805277,
"tag": "0000_goofy_oracle",
"breakpoints": true
}
]
}

View file

@ -1,189 +0,0 @@
import 'dotenv/config';
import express, { NextFunction, Request, Response } from "express";
import cors from "cors";
// import bodyParser from "body-parser";
import path from "path";
import fs from "fs";
import { db } from '@/src/db/db_index';
import { staffUsers, userDetails } from '@/src/db/schema';
import { eq } from 'drizzle-orm';
import mainRouter from '@/src/main-router';
import initFunc from '@/src/lib/init';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from '@/src/trpc/router';
import { TRPCError } from '@trpc/server';
import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/db/seed';
import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils';
seed()
initFunc()
startAutomatedJobs()
const app = express();
app.use(cors({
origin: 'http://localhost:5174'
}));
signedUrlCache.loadFromDisk();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Middleware to log all request URLs
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
next();
});
//cors middleware
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
// Allow requests from any origin (for production, replace * with your domain)
res.header('Access-Control-Allow-Origin', '*');
// Allow specific headers clients can send
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
);
// Allow specific HTTP methods
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
// Allow credentials if needed (optional)
// res.header('Access-Control-Allow-Credentials', 'true');
// Handle preflight (OPTIONS) requests quickly
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
}
app.use('/api/trpc', createExpressMiddleware({
router: appRouter,
createContext: async ({ req, res }) => {
let user = null;
let staffUser = null;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = await verifyToken(token);
// Check if this is a staff token (has staffId)
if ('staffId' in decoded) {
const staffPayload = decoded as StaffJWTPayload;
// This is a staff token, verify staff exists
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, staffPayload.staffId),
});
if (staff) {
staffUser = {
id: staff.id,
name: staff.name,
};
}
} else {
const userPayload = decoded as UserJWTPayload;
// This is a regular user token
user = {
userId: userPayload.userId,
name: userPayload.name,
email: userPayload.email,
mobile: userPayload.mobile,
};
// Check if user is suspended
const details = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, userPayload.userId),
});
if (details?.isSuspended) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account suspended',
});
}
}
} catch (err) {
// Invalid token, both user and staffUser remain null
}
}
return { req, res, user, staffUser };
},
onError({ error, path, type, ctx }) {
console.error('🚨 tRPC Error :', {
path,
type,
code: error.code,
message: error.message,
userId: ctx?.user?.userId,
stack: error.stack,
});
},
}));
app.use('/api', mainRouter)
const fallbackUiDirCandidates = [
path.resolve(__dirname, '../fallback-ui/dist'),
path.resolve(__dirname, '../../fallback-ui/dist'),
path.resolve(process.cwd(), '../fallback-ui/dist'),
path.resolve(process.cwd(), './apps/fallback-ui/dist')
]
const fallbackUiDir =
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
fallbackUiDirCandidates[0]
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
// const fallbackUiMountPath = '/admin-web'
const fallbackUiMountPath = '/';
if (fs.existsSync(fallbackUiIndex)) {
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
const fallbackUiRegex = new RegExp(
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
)
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
res.sendFile(fallbackUiIndex)
})
app.get(/.*/, (req,res) => {
res.sendFile(fallbackUiIndex)
})
} else {
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
}
// Serve /assets/public folder at /assets route
const assetsPublicDir = path.resolve(__dirname, './assets/public');
if (fs.existsSync(assetsPublicDir)) {
app.use('/assets', express.static(assetsPublicDir));
console.log('Serving /assets from', assetsPublicDir);
} else {
console.warn('Assets public folder not found at', assetsPublicDir);
}
// Global error handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err);
const status = err.statusCode || err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({ message });
});
app.listen(4000, () => {
console.log("Server is running on http://localhost:4000/api/mobile/");
});

316
apps/backend/index.ts Normal file → Executable file
View file

@ -1,167 +1,185 @@
import { Hono } from 'hono' import 'dotenv/config';
import { cors } from 'hono/cors' import express, { NextFunction, Request, Response } from "express";
import { logger } from 'hono/logger' import cors from "cors";
import { serve } from 'bun' // import bodyParser from "body-parser";
import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import multer from "multer";
import { appRouter } from '@/src/trpc/router' import path from "path";
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils' import fs from "fs";
import { db } from '@/src/db/db_index' import { db } from '@/src/db/db_index';
import { staffUsers, userDetails } from '@/src/db/schema' import { staffUsers, userDetails } from '@/src/db/schema';
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm';
import { TRPCError } from '@trpc/server' import mainRouter from '@/src/main-router';
import signedUrlCache from '@/src/lib/signed-url-cache' import initFunc from '@/src/lib/init';
import { seed } from '@/src/db/seed' import { createExpressMiddleware } from '@trpc/server/adapters/express';
import initFunc from '@/src/lib/init' import { appRouter } from '@/src/trpc/router';
import '@/src/jobs/jobs-index' import { TRPCError } from '@trpc/server';
import { startAutomatedJobs } from '@/src/lib/automatedJobs' import jwt from 'jsonwebtoken'
import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/db/seed';
import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
// Initialize
seed() seed()
initFunc() initFunc()
startAutomatedJobs() startAutomatedJobs()
const app = new Hono() const app = express();
// CORS middleware app.use(cors({
app.use('*', cors({ origin: 'http://localhost:5174'
origin: ['http://localhost:5174', 'http://localhost:5173'], }));
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
credentials: true
}))
// Request logging
app.use('*', logger())
// Health check signedUrlCache.loadFromDisk();
app.get('/health', (c) => {
return c.json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
message: 'Hello world'
})
})
// tRPC handler with context app.use(express.json());
app.use('/api/trpc/*', async (c) => { app.use(express.urlencoded({ extended: true }));
const response = await fetchRequestHandler({
endpoint: '/api/trpc', // Middleware to log all request URLs
req: c.req.raw, app.use((req, res, next) => {
router: appRouter, const timestamp = new Date().toISOString();
createContext: async ({ req }) => { console.log(`[${timestamp}] ${req.method} ${req.url}`);
let user = null next();
let staffUser = null });
const authHeader = req.headers.get('authorization')
//cors middleware
if (authHeader?.startsWith('Bearer ')) { export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
const token = authHeader.substring(7) // Allow requests from any origin (for production, replace * with your domain)
try { res.header('Access-Control-Allow-Origin', '*');
const decoded = await verifyToken(token)
// Allow specific headers clients can send
if ('staffId' in decoded) { res.header(
const staffPayload = decoded as StaffJWTPayload 'Access-Control-Allow-Headers',
const staff = await db.query.staffUsers.findFirst({ 'Origin, X-Requested-With, Content-Type, Accept, Authorization'
where: eq(staffUsers.id, staffPayload.staffId) );
})
if (staff) { // Allow specific HTTP methods
staffUser = { id: staff.id, name: staff.name } res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
}
} else { // Allow credentials if needed (optional)
const userPayload = decoded as UserJWTPayload // res.header('Access-Control-Allow-Credentials', 'true');
user = {
userId: userPayload.userId, // Handle preflight (OPTIONS) requests quickly
name: userPayload.name, if (req.method === 'OPTIONS') {
email: userPayload.email, return res.sendStatus(204);
mobile: userPayload.mobile }
}
next();
const details = await db.query.userDetails.findFirst({ }
where: eq(userDetails.userId, userPayload.userId)
})
app.use('/api/trpc', createExpressMiddleware({
if (details?.isSuspended) { router: appRouter,
throw new TRPCError({ createContext: async ({ req, res }) => {
code: 'FORBIDDEN', let user = null;
message: 'Account suspended' let staffUser = null;
}) const authHeader = req.headers.authorization;
}
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
// Check if this is a staff token (has staffId)
if (decoded.staffId) {
// This is a staff token, verify staff exists
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, decoded.staffId),
});
if (staff) {
user=staffUser
staffUser = {
id: staff.id,
name: staff.name,
};
}
} else {
// This is a regular user token
user = decoded;
// Check if user is suspended
const details = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, user.userId),
});
if (details?.isSuspended) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account suspended',
});
} }
} catch {
// Invalid token
} }
} catch (err) {
// Invalid token, both user and staffUser remain null
} }
return { req, res: c.res, user, staffUser }
},
onError: ({ error, path, ctx }) => {
console.error('🚨 tRPC Error:', {
path,
code: error.code,
message: error.message,
userId: ctx?.user?.userId
})
} }
return { req, res, user, staffUser };
},
onError({ error, path, type, ctx }) {
console.error('🚨 tRPC Error :', {
path,
type,
code: error.code,
message: error.message,
userId: ctx?.user?.userId,
stack: error.stack,
});
},
}));
app.use('/api', mainRouter)
const fallbackUiDirCandidates = [
path.resolve(__dirname, '../fallback-ui/dist'),
path.resolve(__dirname, '../../fallback-ui/dist'),
path.resolve(process.cwd(), '../fallback-ui/dist'),
path.resolve(process.cwd(), './apps/fallback-ui/dist')
]
const fallbackUiDir =
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
fallbackUiDirCandidates[0]
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
// const fallbackUiMountPath = '/admin-web'
const fallbackUiMountPath = '/';
if (fs.existsSync(fallbackUiIndex)) {
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
const fallbackUiRegex = new RegExp(
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
)
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
res.sendFile(fallbackUiIndex)
}) })
app.get(/.*/, (req,res) => {
return response res.sendFile(fallbackUiIndex)
}) })
} else {
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
}
// Static files - Fallback UI // Serve /assets/public folder at /assets route
app.use('/*', async (c) => { const assetsPublicDir = path.resolve(__dirname, './assets/public');
const url = new URL(c.req.url) if (fs.existsSync(assetsPublicDir)) {
let filePath = url.pathname app.use('/assets', express.static(assetsPublicDir));
console.log('Serving /assets from', assetsPublicDir);
// Default to index.html for root } else {
if (filePath === '/') { console.warn('Assets public folder not found at', assetsPublicDir);
filePath = '/index.html' }
}
// Try to serve the file
const file = Bun.file(`./fallback-ui/dist${filePath}`)
if (await file.exists()) {
return new Response(file)
}
// SPA fallback - serve index.html for any unmatched routes
const indexFile = Bun.file('./fallback-ui/dist/index.html')
if (await indexFile.exists()) {
return new Response(indexFile)
}
return c.notFound()
})
// Static files - Assets
app.use('/assets/*', async (c) => {
const path = c.req.path.replace('/assets/', '')
const file = Bun.file(`./assets/public/${path}`)
if (await file.exists()) {
return new Response(file)
}
return c.notFound()
})
// Global error handler // Global error handler
app.onError((err, c) => { app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err) console.error(err);
const status = err.statusCode || err.status || 500;
const status = err instanceof TRPCError const message = err.message || 'Internal Server Error';
? (err.code === 'UNAUTHORIZED' ? 401 : 500) res.status(status).json({ message });
: 500 });
const message = err.message || 'Internal Server Error'
return c.json({ message }, status)
})
// Start server app.listen(4000, '::', () => {
serve({ console.log("Server is running on http://localhost:4000/api/mobile/");
fetch: app.fetch, });
port: 4000,
hostname: '0.0.0.0'
})
console.log('🚀 Server running on http://localhost:4000')

View file

@ -4,21 +4,14 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"migrate": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts", "migrate": "drizzle-kit generate:pg",
"migrate:pg": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
"migrate:sqlite": "drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
"generate:pg": "bunx drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
"generate:sqlite": "bunx drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json", "build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"build2": "rimraf ./dist && tsc", "build2": "rimraf ./dist && tsc",
"db:push": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts", "db:push": "drizzle-kit push:pg",
"db:push:pg": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
"db:push:sqlite": "drizzle-kit push --config ../db-helper-sqlite/drizzle.config.ts",
"db:seed": "tsx src/db/seed.ts", "db:seed": "tsx src/db/seed.ts",
"dev:express": "bun --watch index-express.ts", "dev2": "tsx watch index.ts",
"dev:hono": "bun --watch index.ts", "dev_node": "tsx watch index.ts",
"dev": "bun --watch index.ts", "dev": "bun --watch index.ts",
"start": "bun index.ts",
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .", "docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
"docker:push": "docker push mohdshafiuddin54/health_petal:latest" "docker:push": "docker push mohdshafiuddin54/health_petal:latest"
}, },
@ -33,6 +26,8 @@
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bullmq": "^5.63.0", "bullmq": "^5.63.0",
@ -41,16 +36,18 @@
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0", "expo-server-sdk": "^4.0.0",
"express": "^5.1.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"hono": "^4.6.3", "jsonwebtoken": "^9.0.2",
"jose": "^5.10.0", "multer": "^2.0.2",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"razorpay": "^2.9.6",
"redis": "^5.9.0", "redis": "^5.9.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.10", "@types/express": "^5.0.3",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",

Binary file not shown.

View file

@ -0,0 +1,19 @@
import { Router } from "express";
import { authenticateStaff } from "@/src/middleware/staff-auth";
import productRouter from "@/src/apis/admin-apis/apis/product.router"
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
const router = Router();
// Apply staff authentication to all admin routes
router.use(authenticateStaff);
// Product routes
router.use("/products", productRouter);
// Tag routes
router.use("/product-tags", tagRouter);
const avRouter = router;
export default avRouter;

View file

@ -0,0 +1,222 @@
import { Request, Response } from "express";
import { db } from "@/src/db/db_index";
import { productTagInfo } from "@/src/db/schema";
import { eq } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
import { deleteS3Image } from "@/src/lib/delete-image";
import { initializeAllStores } from '@/src/stores/store-initializer';
/**
* Create a new product tag
*/
export const createTag = async (req: Request, res: Response) => {
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
if (!tagName) {
throw new ApiError("Tag name is required", 400);
}
// Check for duplicate tag name
const existingTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.tagName, tagName.trim()),
});
if (existingTag) {
throw new ApiError("A tag with this name already exists", 400);
}
let imageUrl: string | null = null;
// Handle image upload if file is provided
if (req.file) {
const key = `tags/${Date.now()}-${req.file.originalname}`;
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
// Parse relatedStores if it's a string (from FormData)
let parsedRelatedStores: number[] = [];
if (relatedStores) {
try {
parsedRelatedStores = typeof relatedStores === 'string'
? JSON.parse(relatedStores)
: relatedStores;
} catch (e) {
parsedRelatedStores = [];
}
}
const [newTag] = await db
.insert(productTagInfo)
.values({
tagName: tagName.trim(),
tagDescription,
imageUrl,
isDashboardTag: isDashboardTag || false,
relatedStores: parsedRelatedStores,
})
.returning();
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(201).json({
tag: newTag,
message: "Tag created successfully",
});
};
/**
* Get all product tags
*/
export const getAllTags = async (req: Request, res: Response) => {
const tags = await db
.select()
.from(productTagInfo)
.orderBy(productTagInfo.tagName);
// Generate signed URLs for tag images
const tagsWithSignedUrls = await Promise.all(
tags.map(async (tag) => ({
...tag,
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
}))
);
return res.status(200).json({
tags: tagsWithSignedUrls,
message: "Tags retrieved successfully",
});
};
/**
* Get a single product tag by ID
*/
export const getTagById = async (req: Request, res: Response) => {
const { id } = req.params;
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, parseInt(id)),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Generate signed URL for tag image
const tagWithSignedUrl = {
...tag,
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
};
return res.status(200).json({
tag: tagWithSignedUrl,
message: "Tag retrieved successfully",
});
};
/**
* Update a product tag
*/
export const updateTag = async (req: Request, res: Response) => {
const { id } = req.params;
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
// Get the current tag to check for existing image
const currentTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, parseInt(id)),
});
if (!currentTag) {
throw new ApiError("Tag not found", 404);
}
let imageUrl = currentTag.imageUrl;
// Handle image upload if new file is provided
if (req.file) {
// Delete old image if it exists
if (currentTag.imageUrl) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (error) {
console.error("Failed to delete old image:", error);
// Continue with update even if delete fails
}
}
// Upload new image
const key = `tags/${Date.now()}-${req.file.originalname}`;
console.log('file', key)
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
}
// Parse relatedStores if it's a string (from FormData)
let parsedRelatedStores: number[] | undefined;
if (relatedStores !== undefined) {
try {
parsedRelatedStores = typeof relatedStores === 'string'
? JSON.parse(relatedStores)
: relatedStores;
} catch (e) {
parsedRelatedStores = [];
}
}
const [updatedTag] = await db
.update(productTagInfo)
.set({
tagName: tagName?.trim(),
tagDescription,
imageUrl,
isDashboardTag,
relatedStores: parsedRelatedStores,
})
.where(eq(productTagInfo.id, parseInt(id)))
.returning();
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(200).json({
tag: updatedTag,
message: "Tag updated successfully",
});
};
/**
* Delete a product tag
*/
export const deleteTag = async (req: Request, res: Response) => {
const { id } = req.params;
// Check if tag exists
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, parseInt(id)),
});
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Delete image from S3 if it exists
if (tag.imageUrl) {
try {
await deleteS3Image(tag.imageUrl);
} catch (error) {
console.error("Failed to delete image from S3:", error);
// Continue with deletion even if image delete fails
}
}
// Note: This will fail if tag is still assigned to products due to foreign key constraint
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
// Reinitialize stores to reflect changes in cache
await initializeAllStores();
return res.status(200).json({
message: "Tag deleted successfully",
});
};

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import { Router } from "express";
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
import uploadHandler from '@/src/lib/upload-handler';
const router = Router();
// Tag routes
router.post("/", uploadHandler.single('image'), createTag);
router.get("/", getAllTags);
router.get("/:id", getTagById);
router.put("/:id", uploadHandler.single('image'), updateTag);
router.delete("/:id", deleteTag);
export default router;

View file

@ -1,37 +1,105 @@
import { Context } from 'hono' import { eq, gt, and, sql, inArray } from "drizzle-orm";
import { Request, Response } from "express";
import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
import { getProductsSummaryData } from '@/src/db/common-product' /**
import { scaffoldAssetUrl } from '@/src/lib/s3-client' * Get next delivery date for a product
*/
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
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),
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
)
)
.orderBy(deliverySlotInfo.deliveryTime)
.limit(1);
return result[0]?.deliveryTime || null;
};
/** /**
* Get all products summary for dropdown * Get all products summary for dropdown
*/ */
export const getAllProductsSummary = async (c: Context) => { export const getAllProductsSummary = async (req: Request, res: Response) => {
try { try {
const tagId = c.req.query('tagId') const { tagId } = req.query;
const tagIdNum = tagId ? parseInt(tagId) : null const tagIdNum = tagId ? parseInt(tagId as string) : null;
const productsWithUnits = await getProductsSummaryData(tagIdNum) let productIds: number[] | null = null;
const formattedProducts = productsWithUnits.map((product) => ({ // If tagId is provided, get products that have this tag
id: product.id, if (tagIdNum) {
name: product.name, const taggedProducts = await db
shortDescription: product.shortDescription, .select({ productId: productTags.productId })
price: product.price, .from(productTags)
marketPrice: product.marketPrice, .where(eq(productTags.tagId, tagIdNum));
unit: product.unitShortNotation,
productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
images: scaffoldAssetUrl((product.images as string[]) || []),
}))
return c.json({ 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);
} else if (tagIdNum) {
// If tagId was provided but no products found, return empty array
return res.status(200).json({
products: [],
count: 0,
});
}
const productsWithUnits = 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);
// Generate signed URLs for product images
const formattedProducts = await Promise.all(
productsWithUnits.map(async (product) => {
const nextDeliveryDate = await getNextDeliveryDate(product.id);
return {
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
price: product.price,
marketPrice: product.marketPrice,
unit: product.unitShortNotation,
productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: scaffoldAssetUrl((product.images as string[]) || []),
};
})
);
return res.status(200).json({
products: formattedProducts, products: formattedProducts,
count: formattedProducts.length, count: formattedProducts.length,
}) });
} catch (error) { } catch (error) {
console.error('Get products summary error:', error) console.error("Get products summary error:", error);
return c.json({ error: 'Failed to fetch products summary' }, 500) return res.status(500).json({ error: "Failed to fetch products summary" });
} }
} };

View file

@ -1,9 +1,10 @@
import { Hono } from 'hono' import { Router } from "express";
import { getAllProductsSummary } from '@/src/apis/common-apis/apis/common-product.controller' import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
const app = new Hono() const router = Router();
// GET /summary - Get all products summary router.get("/summary", getAllProductsSummary);
app.get('/summary', getAllProductsSummary)
export default app
const commonProductsRouter= router;
export default commonProductsRouter;

View file

@ -1,9 +1,10 @@
import { Hono } from 'hono' import { Router } from "express";
import commonProductsRouter from '@/src/apis/common-apis/apis/common-product.router' import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
const app = new Hono() const router = Router();
// Mount product routes at /products router.use('/products', commonProductsRouter)
app.route('/products', commonProductsRouter)
export default app const commonRouter = router;
export default commonRouter;

View file

@ -1,10 +0,0 @@
import { getProductsSummaryData as getProductsSummaryDataPostgres } from '@db-helper-postgres/apis/common-apis/common-product'
import { getProductsSummaryData as getProductsSummaryDataSqlite } from '@db-helper-sqlite/apis/common-apis/common-product'
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
const getProductsSummaryData = dialect === 'sqlite'
? getProductsSummaryDataSqlite
: getProductsSummaryDataPostgres
export { getProductsSummaryData }

14
apps/backend/src/db/db_index.ts Normal file → Executable file
View file

@ -1,10 +1,8 @@
import { db as postgresDb } from '@db-helper-postgres/db/db_index' import { drizzle } from "drizzle-orm/node-postgres"
import { db as sqliteDb } from '@db-helper-sqlite/db/db_index' import { migrate } from "drizzle-orm/node-postgres/migrator"
import path from "path"
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE import * as schema from "@/src/db/schema"
type Db = typeof DB_DIALECT_TYPE extends 'sqlite' ? typeof sqliteDb : typeof postgresDb
const db = (dialect === 'sqlite' ? sqliteDb : postgresDb) as Db
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
export { db } export { db }

View file

@ -2,13 +2,13 @@
* This was a one time script to change the composition of the signed urls * This was a one time script to change the composition of the signed urls
*/ */
import { db } from './db_index' import { db } from '@/src/db/db_index'
import { import {
userDetails, userDetails,
productInfo, productInfo,
productTagInfo, productTagInfo,
complaints complaints
} from './schema'; } from '@/src/db/schema';
import { eq, not, isNull } from 'drizzle-orm'; import { eq, not, isNull } from 'drizzle-orm';
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net'; const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';

View file

@ -1 +0,0 @@
export * from '@/db-helper-postgres/db/schema'

View file

@ -1 +0,0 @@
export * from '@/db-helper-sqlite/db/schema'

690
apps/backend/src/db/schema.ts Normal file → Executable file
View file

@ -1 +1,689 @@
export * from './schema-sqlite' import { pgTable, pgSchema, integer, varchar, date, boolean, timestamp, numeric, jsonb, pgEnum, unique, real, text, check, decimal } from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
const mf = pgSchema('mf');
export const users = mf.table('users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }),
email: varchar({ length: 255 }),
mobile: varchar({ length: 255 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (t) => ({
unq_email: unique('unique_email').on(t.email),
}));
export const userDetails = mf.table('user_details', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id).unique(),
bio: varchar('bio', { length: 500 }),
dateOfBirth: date('date_of_birth'),
gender: varchar('gender', { length: 20 }),
occupation: varchar('occupation', { length: 100 }),
profileImage: varchar('profile_image', { length: 500 }),
isSuspended: boolean('is_suspended').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
export const userCreds = mf.table('user_creds', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
userPassword: varchar('user_password', { length: 255 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const addresses = mf.table('addresses', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
name: varchar('name', { length: 255 }).notNull(),
phone: varchar('phone', { length: 15 }).notNull(),
addressLine1: varchar('address_line1', { length: 255 }).notNull(),
addressLine2: varchar('address_line2', { length: 255 }),
city: varchar('city', { length: 100 }).notNull(),
state: varchar('state', { length: 100 }).notNull(),
pincode: varchar('pincode', { length: 10 }).notNull(),
isDefault: boolean('is_default').notNull().default(false),
latitude: real('latitude'),
longitude: real('longitude'),
googleMapsUrl: varchar('google_maps_url', { length: 500 }),
adminLatitude: real('admin_latitude'),
adminLongitude: real('admin_longitude'),
zoneId: integer('zone_id').references(() => addressZones.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const addressZones = mf.table('address_zones', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
zoneName: varchar('zone_name', { length: 255 }).notNull(),
addedAt: timestamp('added_at').notNull().defaultNow(),
});
export const addressAreas = mf.table('address_areas', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
placeName: varchar('place_name', { length: 255 }).notNull(),
zoneId: integer('zone_id').references(() => addressZones.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const staffUsers = mf.table('staff_users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
password: varchar({ length: 255 }).notNull(),
staffRoleId: integer('staff_role_id').references(() => staffRoles.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const storeInfo = mf.table('store_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
description: varchar({ length: 500 }),
imageUrl: varchar('image_url', { length: 500 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
owner: integer('owner').notNull().references(() => staffUsers.id),
});
export const units = mf.table('units', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
shortNotation: varchar('short_notation', { length: 50 }).notNull(),
fullName: varchar('full_name', { length: 100 }).notNull(),
}, (t) => ({
unq_short_notation: unique('unique_short_notation').on(t.shortNotation),
}));
export const productInfo = mf.table('product_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
shortDescription: varchar('short_description', { length: 500 }),
longDescription: varchar('long_description', { length: 1000 }),
unitId: integer('unit_id').notNull().references(() => units.id),
price: numeric({ precision: 10, scale: 2 }).notNull(),
marketPrice: numeric('market_price', { precision: 10, scale: 2 }),
images: jsonb('images'),
isOutOfStock: boolean('is_out_of_stock').notNull().default(false),
isSuspended: boolean('is_suspended').notNull().default(false),
isFlashAvailable: boolean('is_flash_available').notNull().default(false),
flashPrice: numeric('flash_price', { precision: 10, scale: 2 }),
createdAt: timestamp('created_at').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 = mf.table('product_group_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
groupName: varchar('group_name', { length: 255 }).notNull(),
description: varchar({ length: 500 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const productGroupMembership = mf.table('product_group_membership', {
productId: integer('product_id').notNull().references(() => productInfo.id),
groupId: integer('group_id').notNull().references(() => productGroupInfo.id),
addedAt: timestamp('added_at').notNull().defaultNow(),
}, (t) => ({
pk: unique('product_group_membership_pk').on(t.productId, t.groupId),
}));
export const homeBanners = mf.table('home_banners', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar('name', { length: 255 }).notNull(),
imageUrl: varchar('image_url', { length: 500 }).notNull(),
description: varchar('description', { length: 500 }),
productIds: integer('product_ids').array(),
redirectUrl: varchar('redirect_url', { length: 500 }),
serialNum: integer('serial_num'),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
lastUpdated: timestamp('last_updated').notNull().defaultNow(),
});
export const productReviews = mf.table('product_reviews', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
productId: integer('product_id').notNull().references(() => productInfo.id),
reviewBody: text('review_body').notNull(),
imageUrls: jsonb('image_urls').$defaultFn(() => []),
reviewTime: timestamp('review_time').notNull().defaultNow(),
ratings: real('ratings').notNull(),
adminResponse: text('admin_response'),
adminResponseImages: jsonb('admin_response_images').$defaultFn(() => []),
}, (t) => ({
ratingCheck: check('rating_check', sql`${t.ratings} >= 1 AND ${t.ratings} <= 5`),
}));
export const uploadStatusEnum = pgEnum('upload_status', ['pending', 'claimed']);
export const staffRoleEnum = pgEnum('staff_role', ['super_admin', 'admin', 'marketer', 'delivery_staff']);
export const staffPermissionEnum = pgEnum('staff_permission', ['crud_product', 'make_coupon', 'crud_staff_users']);
export const uploadUrlStatus = mf.table('upload_url_status', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
createdAt: timestamp('created_at').notNull().defaultNow(),
key: varchar('key', { length: 500 }).notNull(),
status: uploadStatusEnum('status').notNull().default('pending'),
});
export const productTagInfo = mf.table('product_tag_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
tagName: varchar('tag_name', { length: 100 }).notNull().unique(),
tagDescription: varchar('tag_description', { length: 500 }),
imageUrl: varchar('image_url', { length: 500 }),
isDashboardTag: boolean('is_dashboard_tag').notNull().default(false),
relatedStores: jsonb('related_stores').$defaultFn(() => []),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const productTags = mf.table('product_tags', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
productId: integer('product_id').notNull().references(() => productInfo.id),
tagId: integer('tag_id').notNull().references(() => productTagInfo.id),
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
}, (t) => ({
unq_product_tag: unique('unique_product_tag').on(t.productId, t.tagId),
}));
export const deliverySlotInfo = mf.table('delivery_slot_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
deliveryTime: timestamp('delivery_time').notNull(),
freezeTime: timestamp('freeze_time').notNull(),
isActive: boolean('is_active').notNull().default(true),
isFlash: boolean('is_flash').notNull().default(false),
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
groupIds: jsonb('group_ids').$defaultFn(() => []),
});
export const vendorSnippets = mf.table('vendor_snippets', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
isPermanent: boolean('is_permanent').notNull().default(false),
productIds: integer('product_ids').array().notNull(),
validTill: timestamp('valid_till'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
}));
export const productSlots = mf.table('product_slots', {
productId: integer('product_id').notNull().references(() => productInfo.id),
slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id),
}, (t) => ({
pk: unique('product_slot_pk').on(t.productId, t.slotId),
}));
export const specialDeals = mf.table('special_deals', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
productId: integer('product_id').notNull().references(() => productInfo.id),
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
price: numeric({ precision: 10, scale: 2 }).notNull(),
validTill: timestamp('valid_till').notNull(),
});
export const orders = mf.table('orders', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
addressId: integer('address_id').notNull().references(() => addresses.id),
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
isCod: boolean('is_cod').notNull().default(false),
isOnlinePayment: boolean('is_online_payment').notNull().default(false),
paymentInfoId: integer('payment_info_id').references(() => paymentInfoTable.id),
totalAmount: numeric('total_amount', { precision: 10, scale: 2 }).notNull(),
deliveryCharge: numeric('delivery_charge', { precision: 10, scale: 2 }).notNull().default('0'),
readableId: integer('readable_id').notNull(),
adminNotes: text('admin_notes'),
userNotes: text('user_notes'),
orderGroupId: varchar('order_group_id', { length: 255 }),
orderGroupProportion: decimal('order_group_proportion', { precision: 10, scale: 4 }),
isFlashDelivery: boolean('is_flash_delivery').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const orderItems = mf.table('order_items', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
orderId: integer('order_id').notNull().references(() => orders.id),
productId: integer('product_id').notNull().references(() => productInfo.id),
quantity: varchar('quantity', { length: 50 }).notNull(),
price: numeric({ precision: 10, scale: 2 }).notNull(),
discountedPrice: numeric('discounted_price', { precision: 10, scale: 2 }),
is_packaged: boolean('is_packaged').notNull().default(false),
is_package_verified: boolean('is_package_verified').notNull().default(false),
});
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'success', 'cod', 'failed']);
export const orderStatus = mf.table('order_status', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
orderTime: timestamp('order_time').notNull().defaultNow(),
userId: integer('user_id').notNull().references(() => users.id),
orderId: integer('order_id').notNull().references(() => orders.id),
isPackaged: boolean('is_packaged').notNull().default(false),
isDelivered: boolean('is_delivered').notNull().default(false),
isCancelled: boolean('is_cancelled').notNull().default(false),
cancelReason: varchar('cancel_reason', { length: 255 }),
isCancelledByAdmin: boolean('is_cancelled_by_admin'),
paymentStatus: paymentStatusEnum('payment_state').notNull().default('pending'),
cancellationUserNotes: text('cancellation_user_notes'),
cancellationAdminNotes: text('cancellation_admin_notes'),
cancellationReviewed: boolean('cancellation_reviewed').notNull().default(false),
cancellationReviewedAt: timestamp('cancellation_reviewed_at'),
refundCouponId: integer('refund_coupon_id').references(() => coupons.id),
});
export const paymentInfoTable = mf.table('payment_info', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
status: varchar({ length: 50 }).notNull(),
gateway: varchar({ length: 50 }).notNull(),
orderId: varchar('order_id', { length: 500 }),
token: varchar({ length: 500 }),
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
payload: jsonb('payload'),
});
export const payments = mf.table('payments', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
status: varchar({ length: 50 }).notNull(),
gateway: varchar({ length: 50 }).notNull(),
orderId: integer('order_id').notNull().references(() => orders.id),
token: varchar({ length: 500 }),
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
payload: jsonb('payload'),
});
export const refunds = mf.table('refunds', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
orderId: integer('order_id').notNull().references(() => orders.id),
refundAmount: numeric('refund_amount', { precision: 10, scale: 2 }),
refundStatus: varchar('refund_status', { length: 50 }).default('none'),
merchantRefundId: varchar('merchant_refund_id', { length: 255 }),
refundProcessedAt: timestamp('refund_processed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const keyValStore = mf.table('key_val_store', {
key: varchar('key', { length: 255 }).primaryKey(),
value: jsonb('value'),
});
export const notifications = mf.table('notifications', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
title: varchar({ length: 255 }).notNull(),
body: varchar({ length: 512 }).notNull(),
type: varchar({ length: 50 }),
isRead: boolean('is_read').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const productCategories = mf.table('product_categories', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
description: varchar({ length: 500 }),
});
export const cartItems = mf.table('cart_items', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
productId: integer('product_id').notNull().references(() => productInfo.id),
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
addedAt: timestamp('added_at').notNull().defaultNow(),
}, (t) => ({
unq_user_product: unique('unique_user_product').on(t.userId, t.productId),
}));
export const complaints = mf.table('complaints', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
orderId: integer('order_id').references(() => orders.id),
complaintBody: varchar('complaint_body', { length: 1000 }).notNull(),
images: jsonb('images'),
response: varchar('response', { length: 1000 }),
isResolved: boolean('is_resolved').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const coupons = mf.table('coupons', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
couponCode: varchar('coupon_code', { length: 50 }).notNull().unique('unique_coupon_code'),
isUserBased: boolean('is_user_based').notNull().default(false),
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
productIds: jsonb('product_ids'),
createdBy: integer('created_by').references(() => staffUsers.id),
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
isApplyForAll: boolean('is_apply_for_all').notNull().default(false),
validTill: timestamp('valid_till'),
maxLimitForUser: integer('max_limit_for_user'),
isInvalidated: boolean('is_invalidated').notNull().default(false),
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const couponUsage = mf.table('coupon_usage', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
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: timestamp('used_at').notNull().defaultNow(),
});
export const couponApplicableUsers = mf.table('coupon_applicable_users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
couponId: integer('coupon_id').notNull().references(() => coupons.id),
userId: integer('user_id').notNull().references(() => users.id),
}, (t) => ({
unq_coupon_user: unique('unique_coupon_user').on(t.couponId, t.userId),
}));
export const couponApplicableProducts = mf.table('coupon_applicable_products', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
couponId: integer('coupon_id').notNull().references(() => coupons.id),
productId: integer('product_id').notNull().references(() => productInfo.id),
}, (t) => ({
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
}));
export const userIncidents = mf.table('user_incidents', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => users.id),
orderId: integer('order_id').references(() => orders.id),
dateAdded: timestamp('date_added').notNull().defaultNow(),
adminComment: text('admin_comment'),
addedBy: integer('added_by').references(() => staffUsers.id),
negativityScore: integer('negativity_score'),
});
export const reservedCoupons = mf.table('reserved_coupons', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
couponCode: varchar('coupon_code', { length: 50 }).notNull(),
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
productIds: jsonb('product_ids'),
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
validTill: timestamp('valid_till'),
maxLimitForUser: integer('max_limit_for_user'),
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
isRedeemed: boolean('is_redeemed').notNull().default(false),
redeemedBy: integer('redeemed_by').references(() => users.id),
redeemedAt: timestamp('redeemed_at'),
createdBy: integer('created_by').notNull().references(() => staffUsers.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (t) => ({
unq_secret_code: unique('unique_secret_code').on(t.secretCode),
}));
export const notifCreds = mf.table('notif_creds', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
token: varchar({ length: 500 }).notNull().unique(),
addedAt: timestamp('added_at').notNull().defaultNow(),
userId: integer('user_id').notNull().references(() => users.id),
lastVerified: timestamp('last_verified'),
});
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
token: varchar({ length: 500 }).notNull().unique(),
addedAt: timestamp('added_at').notNull().defaultNow(),
lastVerified: timestamp('last_verified'),
});
export const userNotifications = mf.table('user_notifications', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar('title', { length: 255 }).notNull(),
imageUrl: varchar('image_url', { length: 500 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
body: text('body').notNull(),
applicableUsers: jsonb('applicable_users'),
});
export const staffRoles = mf.table('staff_roles', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
roleName: staffRoleEnum('role_name').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (t) => ({
unq_role_name: unique('unique_role_name').on(t.roleName),
}));
export const staffPermissions = mf.table('staff_permissions', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
permissionName: staffPermissionEnum('permission_name').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (t) => ({
unq_permission_name: unique('unique_permission_name').on(t.permissionName),
}));
export const staffRolePermissions = mf.table('staff_role_permissions', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
staffRoleId: integer('staff_role_id').notNull().references(() => staffRoles.id),
staffPermissionId: integer('staff_permission_id').notNull().references(() => staffPermissions.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (t) => ({
unq_role_permission: unique('unique_role_permission').on(t.staffRoleId, t.staffPermissionId),
}));
// 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] }),
}));

View file

@ -1,8 +1,138 @@
import { seed as seedPostgres } from '@db-helper-postgres/db/seed' import { db } from "@/src/db/db_index"
import { seed as seedSqlite } from '@db-helper-sqlite/db/seed' 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'
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE export async function seed() {
console.log("Seeding database...");
const seedImpl = dialect === 'sqlite' ? seedSqlite : seedPostgres // Seed units individually
const unitsToSeed = [
{ shortNotation: "Kg", fullName: "Kilogram" },
{ shortNotation: "L", fullName: "Litre" },
{ shortNotation: "Dz", fullName: "Dozen" },
{ shortNotation: "Pc", fullName: "Unit Piece" },
];
export const seed = async () => seedImpl() 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.");
}

View file

@ -1 +0,0 @@
export * from '@/db-helper-sqlite/db/sqlite-casts'

View file

@ -1,58 +1,47 @@
import type { InferSelectModel } from "drizzle-orm";
import type { import type {
User as PostgresUser, users,
Address as PostgresAddress, addresses,
Unit as PostgresUnit, units,
ProductInfo as PostgresProductInfo, productInfo,
DeliverySlotInfo as PostgresDeliverySlotInfo, deliverySlotInfo,
ProductSlot as PostgresProductSlot, productSlots,
SpecialDeal as PostgresSpecialDeal, specialDeals,
Order as PostgresOrder, orders,
OrderItem as PostgresOrderItem, orderItems,
Payment as PostgresPayment, payments,
Notification as PostgresNotification, notifications,
ProductCategory as PostgresProductCategory, productCategories,
CartItem as PostgresCartItem, cartItems,
Coupon as PostgresCoupon, coupons,
ProductWithUnit as PostgresProductWithUnit, } from "@/src/db/schema";
OrderWithItems as PostgresOrderWithItems,
CartItemWithProduct as PostgresCartItemWithProduct,
} from '@db-helper-postgres/db/types'
import type {
User as SqliteUser,
Address as SqliteAddress,
Unit as SqliteUnit,
ProductInfo as SqliteProductInfo,
DeliverySlotInfo as SqliteDeliverySlotInfo,
ProductSlot as SqliteProductSlot,
SpecialDeal as SqliteSpecialDeal,
Order as SqliteOrder,
OrderItem as SqliteOrderItem,
Payment as SqlitePayment,
Notification as SqliteNotification,
ProductCategory as SqliteProductCategory,
CartItem as SqliteCartItem,
Coupon as SqliteCoupon,
ProductWithUnit as SqliteProductWithUnit,
OrderWithItems as SqliteOrderWithItems,
CartItemWithProduct as SqliteCartItemWithProduct,
} from '@db-helper-sqlite/db/types'
type UseSqlite = typeof DB_DIALECT_TYPE extends 'sqlite' ? true : false export type User = InferSelectModel<typeof users>;
export type Address = InferSelectModel<typeof addresses>;
export type Unit = InferSelectModel<typeof units>;
export type ProductInfo = InferSelectModel<typeof productInfo>;
export type DeliverySlotInfo = InferSelectModel<typeof deliverySlotInfo>;
export type ProductSlot = InferSelectModel<typeof productSlots>;
export type SpecialDeal = InferSelectModel<typeof specialDeals>;
export type Order = InferSelectModel<typeof orders>;
export type OrderItem = InferSelectModel<typeof orderItems>;
export type Payment = InferSelectModel<typeof payments>;
export type Notification = InferSelectModel<typeof notifications>;
export type ProductCategory = InferSelectModel<typeof productCategories>;
export type CartItem = InferSelectModel<typeof cartItems>;
export type Coupon = InferSelectModel<typeof coupons>;
export type User = UseSqlite extends true ? SqliteUser : PostgresUser // Combined types
export type Address = UseSqlite extends true ? SqliteAddress : PostgresAddress export type ProductWithUnit = ProductInfo & {
export type Unit = UseSqlite extends true ? SqliteUnit : PostgresUnit unit: Unit;
export type ProductInfo = UseSqlite extends true ? SqliteProductInfo : PostgresProductInfo };
export type DeliverySlotInfo = UseSqlite extends true ? SqliteDeliverySlotInfo : PostgresDeliverySlotInfo
export type ProductSlot = UseSqlite extends true ? SqliteProductSlot : PostgresProductSlot export type OrderWithItems = Order & {
export type SpecialDeal = UseSqlite extends true ? SqliteSpecialDeal : PostgresSpecialDeal items: (OrderItem & { product: ProductInfo })[];
export type Order = UseSqlite extends true ? SqliteOrder : PostgresOrder address: Address;
export type OrderItem = UseSqlite extends true ? SqliteOrderItem : PostgresOrderItem slot: DeliverySlotInfo;
export type Payment = UseSqlite extends true ? SqlitePayment : PostgresPayment };
export type Notification = UseSqlite extends true ? SqliteNotification : PostgresNotification
export type ProductCategory = UseSqlite extends true ? SqliteProductCategory : PostgresProductCategory export type CartItemWithProduct = CartItem & {
export type CartItem = UseSqlite extends true ? SqliteCartItem : PostgresCartItem product: ProductInfo;
export type Coupon = UseSqlite extends true ? SqliteCoupon : PostgresCoupon };
export type ProductWithUnit = UseSqlite extends true ? SqliteProductWithUnit : PostgresProductWithUnit
export type OrderWithItems = UseSqlite extends true ? SqliteOrderWithItems : PostgresOrderWithItems
export type CartItemWithProduct = UseSqlite extends true ? SqliteCartItemWithProduct : PostgresCartItemWithProduct

View file

@ -1,14 +0,0 @@
import { claimUploadUrlStatus as claimUploadUrlStatusPostgres, createUploadUrlStatus as createUploadUrlStatusPostgres } from '@db-helper-postgres/lib/upload-url'
import { claimUploadUrlStatus as claimUploadUrlStatusSqlite, createUploadUrlStatus as createUploadUrlStatusSqlite } from '@db-helper-sqlite/lib/upload-url'
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
const createUploadUrlStatus = dialect === 'sqlite'
? createUploadUrlStatusSqlite
: createUploadUrlStatusPostgres
const claimUploadUrlStatus = dialect === 'sqlite'
? claimUploadUrlStatusSqlite
: claimUploadUrlStatusPostgres
export { claimUploadUrlStatus, createUploadUrlStatus }

View file

@ -1,9 +1,17 @@
import * as cron from 'node-cron'; import * as cron from 'node-cron';
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
const runCombinedJob = async () => { const runCombinedJob = async () => {
const start = Date.now(); const start = Date.now();
try { try {
console.log('Starting combined job'); console.log('Starting combined job: payments and refunds check');
// Run payment check
// await checkPendingPayments();
// Run refund check
// await checkRefundStatuses();
console.log('Combined job completed successfully'); console.log('Combined job completed successfully');
} catch (error) { } catch (error) {
console.error('Error in combined job:', error); console.error('Error in combined job:', error);

View file

@ -0,0 +1,79 @@
import * as cron from 'node-cron';
import { db } from '@/src/db/db_index'
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
import { eq, and, gt, isNotNull } from 'drizzle-orm';
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
interface PendingPaymentRecord {
payment: typeof payments.$inferSelect;
order: typeof orders.$inferSelect;
slot: typeof deliverySlotInfo.$inferSelect;
}
export const createPaymentNotification = (record: PendingPaymentRecord) => {
// Construct message from record data
const message = `Payment pending for order ORD${record.order.id}. Please complete before orders close time.`;
// TODO: Implement notification sending logic using record.order.userId, record.order.id, message
console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`);
};
export const checkRefundStatuses = async () => {
try {
const initiatedRefunds = await db
.select()
.from(refunds)
.where(and(
eq(refunds.refundStatus, 'initiated'),
isNotNull(refunds.merchantRefundId)
));
// Process refunds concurrently using Promise.allSettled
const promises = initiatedRefunds.map(async (refund) => {
if (!refund.merchantRefundId) return;
try {
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
if (razorpayRefund.status === 'processed') {
await db
.update(refunds)
.set({ refundStatus: 'success', refundProcessedAt: new Date() })
.where(eq(refunds.id, refund.id));
}
} catch (error) {
console.error(`Error checking refund ${refund.id}:`, error);
}
});
// Wait for all promises to complete
await Promise.allSettled(promises);
} catch (error) {
console.error('Error in checkRefundStatuses:', error);
}
};
export const checkPendingPayments = async () => {
try {
const pendingPayments = await db
.select({
payment: payments,
order: orders,
slot: deliverySlotInfo,
})
.from(payments)
.innerJoin(orders, eq(payments.orderId, orders.id))
.innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id))
.where(and(
eq(payments.status, 'pending'),
gt(deliverySlotInfo.freezeTime, new Date()) // Freeze time not passed
));
for (const record of pendingPayments) {
createPaymentNotification(record);
}
} catch (error) {
console.error('Error checking pending payments:', error);
}
};

View file

@ -0,0 +1,85 @@
import * as cron from 'node-cron';
import { db } from '@/src/db/db_index'
import { productInfo, keyValStore } from '@/src/db/schema'
import { inArray, eq } from 'drizzle-orm';
import { CONST_KEYS } from '@/src/lib/const-keys'
import { computeConstants } from '@/src/lib/const-store'
const MUTTON_ITEMS = [
12, //Lamb mutton
14, // Mutton Boti
35, //Mutton Kheema
84, //Mutton Brain
4, //Mutton
86, //Mutton Chops
87, //Mutton Soup bones
85 //Mutton paya
];
export const startAutomatedJobs = () => {
// Job to disable flash delivery for mutton at 12 PM daily
cron.schedule('0 12 * * *', async () => {
try {
console.log('Disabling flash delivery for products at 12 PM');
await db
.update(productInfo)
.set({ isFlashAvailable: false })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery disabled successfully');
} catch (error) {
console.error('Error disabling flash delivery:', error);
}
});
// Job to enable flash delivery for mutton at 6 AM daily
cron.schedule('0 6 * * *', async () => {
try {
console.log('Enabling flash delivery for products at 5 AM');
await db
.update(productInfo)
.set({ isFlashAvailable: true })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery enabled successfully');
} catch (error) {
console.error('Error enabling flash delivery:', error);
}
});
// Job to disable flash delivery feature at 9 PM daily
cron.schedule('0 21 * * *', async () => {
try {
console.log('Disabling flash delivery feature at 9 PM');
await db
.update(keyValStore)
.set({ value: false })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature disabled successfully');
} catch (error) {
console.error('Error disabling flash delivery feature:', error);
}
});
// Job to enable flash delivery feature at 6 AM daily
cron.schedule('0 6 * * *', async () => {
try {
console.log('Enabling flash delivery feature at 6 AM');
await db
.update(keyValStore)
.set({ value: true })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature enabled successfully');
} catch (error) {
console.error('Error enabling flash delivery feature:', error);
}
});
console.log('Automated jobs scheduled');
};
// Optional: Call on import if desired, or export and call in main app
// startAutomatedJobs();

View file

@ -0,0 +1,6 @@
import express from 'express';
const catchAsync =
(fn: express.RequestHandler) =>
(req: express.Request, res: express.Response, next: express.NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);
export default catchAsync;

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { keyValStore } from '../db/schema' import { keyValStore } from '@/src/db/schema'
import redisClient from '@/src/lib/redis-client' import redisClient from '@/src/lib/redis-client'
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys' import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'

View file

@ -1,54 +1,46 @@
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from '@/src/lib/s3-client' import { eq } from "drizzle-orm";
import { assetsDomain, s3Url } from '@/src/lib/env-exporter' import { db } from "@/src/db/db_index"
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
import { s3Url } from "@/src/lib/env-exporter"
function extractS3Key(url: string): string | null { function extractS3Key(url: string): string | null {
try { try {
// Check if this is a signed URL first and get the original if it is // Check if this is a signed URL first and get the original if it is
const originalUrl = getOriginalUrlFromSignedUrl(url) || url const originalUrl = getOriginalUrlFromSignedUrl(url) || url;
// Find the index of '.com/' in the URL // Find the index of '.com/' in the URL
// const comIndex = originalUrl.indexOf(".com/"); // const comIndex = originalUrl.indexOf(".com/");
const baseUrlIndex = originalUrl.indexOf(s3Url) const baseUrlIndex = originalUrl.indexOf(s3Url);
// If '.com/' is found, return everything after it // If '.com/' is found, return everything after it
if (baseUrlIndex !== -1) { if (baseUrlIndex !== -1) {
return originalUrl.substring(baseUrlIndex + s3Url.length) // +5 to skip '.com/' return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/'
} }
} catch (error) { } catch (error) {
console.error('Error extracting key from URL:', error) console.error("Error extracting key from URL:", error);
} }
// Return null if the pattern isn't found or there was an error // Return null if the pattern isn't found or there was an error
return null return null;
} }
export async function deleteS3Image(imageUrl: string) { export async function deleteS3Image(imageUrl: string) {
try { try {
// First check if this is a signed URL and get the original if it is
let key: string | null = '' const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
const key = extractS3Key(originalUrl || "");
if (imageUrl.includes(assetsDomain)) {
key = imageUrl.replace(assetsDomain, '')
}
else if (imageUrl.startsWith('http')) {
// First check if this is a signed URL and get the original if it is
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl
key = extractS3Key(originalUrl || '')
}
else {
key = imageUrl
}
if (!key) { if (!key) {
throw new Error('Invalid image URL format') throw new Error("Invalid image URL format");
} }
const deleteS3 = await deleteImageUtil({ keys: [key] }) const deleteS3 = await deleteImageUtil({keys: [key] });
if (!deleteS3) { if (!deleteS3) {
throw new Error('Failed to delete image from S3') throw new Error("Failed to delete image from S3");
} }
} catch (error) { } catch (error) {
console.error('Error deleting image from S3:', error) console.error("Error deleting image from S3:", error);
} }
} }

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema' import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
/** /**

View file

@ -17,12 +17,6 @@ export const s3Region = process.env.S3_REGION as string
export const assetsDomain = process.env.ASSETS_DOMAIN 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 s3Url = process.env.S3_URL as string
export const redisUrl = process.env.REDIS_URL as string export const redisUrl = process.env.REDIS_URL as string

View file

@ -3,8 +3,6 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store' import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler' import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
import { deleteOrders } from '@/src/lib/delete-orders' import { deleteOrders } from '@/src/lib/delete-orders'
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
import { verifyProductsAvailabilityBySchedule } from './manage-scheduled-availability'
/** /**
* Initialize all application services * Initialize all application services
@ -19,8 +17,7 @@ import { verifyProductsAvailabilityBySchedule } from './manage-scheduled-availab
export const initFunc = async (): Promise<void> => { export const initFunc = async (): Promise<void> => {
try { try {
console.log('Starting application initialization...'); console.log('Starting application initialization...');
await verifyProductsAvailabilityBySchedule(false);
await Promise.all([ await Promise.all([
initializeAllStores(), initializeAllStores(),
initializeUserNegativityStore(), initializeUserNegativityStore(),
@ -28,10 +25,6 @@ export const initFunc = async (): Promise<void> => {
startCancellationHandler(), startCancellationHandler(),
]); ]);
// Create all cache files after stores are initialized
await createAllCacheFiles();
console.log('Cache files created successfully');
console.log('Application initialization completed successfully'); console.log('Application initialization completed successfully');
} catch (error) { } catch (error) {
console.error('Application initialization failed:', error); console.error('Application initialization failed:', error);

View file

@ -1,72 +0,0 @@
import { SignJWT, jwtVerify, errors, JWTPayload } from 'jose';
import { jwtSecret } from './env-exporter';
// JWT Payload Types
export interface UserJWTPayload extends JWTPayload {
userId: number;
name?: string;
email?: string;
mobile?: string;
roles?: string[];
}
export interface StaffJWTPayload extends JWTPayload {
staffId: number;
name: string;
}
// Convert string secret to Uint8Array
const getSecret = () => {
if (!jwtSecret) {
throw new Error('JWT secret not configured');
}
return new TextEncoder().encode(jwtSecret);
};
/**
* Sign a JWT token
* Compatible with tokens signed by jsonwebtoken library
*/
export const signToken = async (
payload: Record<string, unknown>,
expiresIn: string = '7d'
): Promise<string> => {
const secret = getSecret();
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(secret);
};
/**
* Verify a JWT token
* Compatible with tokens signed by jsonwebtoken library
*/
export const verifyToken = async (token: string): Promise<JWTPayload> => {
try {
const secret = getSecret();
const { payload } = await jwtVerify(token, secret);
return payload;
} catch (error) {
if (error instanceof errors.JWTExpired) {
throw new Error('Token expired');
}
if (error instanceof errors.JWTInvalid) {
throw new Error('Invalid token');
}
throw error;
}
};
/**
* Check if an error is a JWT-related error
*/
export const isJWTError = (error: unknown): boolean => {
return error instanceof errors.JOSEError ||
(error instanceof Error && (
error.message.includes('token') ||
error.message.includes('JWT')
));
};

View file

@ -1,7 +1,7 @@
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { Expo } from 'expo-server-sdk'; import { Expo } from 'expo-server-sdk';
import { redisUrl } from '@/src/lib/env-exporter' import { redisUrl } from '@/src/lib/env-exporter'
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
import { import {
NOTIFS_QUEUE, NOTIFS_QUEUE,

View file

@ -1,5 +1,7 @@
import { sendPushNotificationsMany } from '@/src/lib/expo-service' import { db } from "@/src/db/db_index"
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema"; // import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
// Core notification dispatch methods (renamed for clarity) // Core notification dispatch methods (renamed for clarity)
export async function dispatchBulkNotification({ export async function dispatchBulkNotification({

View file

@ -0,0 +1,59 @@
import Razorpay from "razorpay";
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
import { db } from "@/src/db/db_index"
import { payments } from "@/src/db/schema"
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export class RazorpayPaymentService {
private static instance = new Razorpay({
key_id: razorpayId,
key_secret: razorpaySecret,
});
static async createOrder(orderId: number, amount: string) {
// Create Razorpay order
const razorpayOrder = await this.instance.orders.create({
amount: parseFloat(amount) * 100, // Convert to paisa
currency: 'INR',
receipt: `order_${orderId}`,
notes: {
customerOrderId: orderId.toString(),
},
});
return razorpayOrder;
}
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
// Use transaction if provided, otherwise use db
const dbInstance = tx || db;
// Insert payment record
const [payment] = await dbInstance
.insert(payments)
.values({
status: 'pending',
gateway: 'razorpay',
orderId,
token: orderId.toString(),
merchantOrderId: razorpayOrder.id,
payload: razorpayOrder,
})
.returning();
return payment;
}
static async initiateRefund(paymentId: string, amount: number) {
const refund = await this.instance.payments.refund(paymentId, {
amount,
});
return refund;
}
static async fetchRefund(refundId: string) {
const refund = await this.instance.refunds.fetch(refundId);
return refund;
}
}

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { orders, orderStatus } from '../db/schema' import { orders, orderStatus } from '@/src/db/schema'
import redisClient from '@/src/lib/redis-client' import redisClient from '@/src/lib/redis-client'
import { sendTelegramMessage } from '@/src/lib/telegram-service' import { sendTelegramMessage } from '@/src/lib/telegram-service'
import { inArray, eq } from 'drizzle-orm'; import { inArray, eq } from 'drizzle-orm';

View file

@ -1,23 +0,0 @@
export async function retryWithExponentialBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < maxRetries) {
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
await new Promise(resolve => setTimeout(resolve, delayMs))
delayMs *= 2
}
}
}
throw lastError
}

View file

@ -1,3 +1,5 @@
import { db } from "@/src/db/db_index"
/** /**
* Constants for role names to avoid hardcoding and typos * Constants for role names to avoid hardcoding and typos
*/ */

View file

@ -3,7 +3,9 @@ import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client,
import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import signedUrlCache from "@/src/lib/signed-url-cache" import signedUrlCache from "@/src/lib/signed-url-cache"
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter" import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/db/upload-url' import { db } from "@/src/db/db_index"; // Adjust path if needed
import { uploadUrlStatus } from "@/src/db/schema"
import { and, eq } from 'drizzle-orm';
const s3Client = new S3Client({ const s3Client = new S3Client({
region: s3Region, region: s3Region,
@ -159,7 +161,10 @@ export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expi
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> { export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
try { try {
// Insert record into upload_url_status // Insert record into upload_url_status
await createUploadUrlStatus(key) await db.insert(uploadUrlStatus).values({
key: key,
status: 'pending',
});
// Generate signed upload URL // Generate signed upload URL
const command = new PutObjectCommand({ const command = new PutObjectCommand({
@ -196,13 +201,19 @@ export function extractKeyFromPresignedUrl(url: string): string {
export async function claimUploadUrl(url: string): Promise<void> { export async function claimUploadUrl(url: string): Promise<void> {
try { try {
let semiKey:string = '' const semiKey = extractKeyFromPresignedUrl(url);
const key = s3BucketName+'/'+ semiKey
// Update status to 'claimed' if currently 'pending'
const result = await db
.update(uploadUrlStatus)
.set({ status: 'claimed' })
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
.returning();
if(url.startsWith('http')) if (result.length === 0) {
semiKey = extractKeyFromPresignedUrl(url); throw new Error('Upload URL not found or already claimed');
else }
semiKey = url
await claimUploadUrlStatus(semiKey)
} catch (error) { } catch (error) {
console.error('Error claiming upload URL:', error); console.error('Error claiming upload URL:', error);
throw new Error('Failed to claim upload URL'); throw new Error('Failed to claim upload URL');

136
apps/backend/src/lib/signed-url-cache.ts Normal file → Executable file
View file

@ -1,3 +1,8 @@
import fs from 'fs';
import path from 'path';
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
// Interface for cache entries with TTL // Interface for cache entries with TTL
interface CacheEntry { interface CacheEntry {
value: string; value: string;
@ -11,7 +16,18 @@ class SignedURLCache {
constructor() { constructor() {
this.originalToSignedCache = new Map(); this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map(); this.signedToOriginalCache = new Map();
console.log('SignedURLCache: Initialized (in-memory only)');
// Create cache directory if it doesn't exist
const cacheDir = path.dirname(CACHE_FILE_PATH);
if (!fs.existsSync(cacheDir)) {
console.log('creating the directory')
fs.mkdirSync(cacheDir, { recursive: true });
}
else {
console.log('the directory is already present')
}
} }
/** /**
@ -94,7 +110,7 @@ class SignedURLCache {
clear(): void { clear(): void {
this.originalToSignedCache.clear(); this.originalToSignedCache.clear();
this.signedToOriginalCache.clear(); this.signedToOriginalCache.clear();
console.log('SignedURLCache: Cleared all entries'); this.saveToDisk();
} }
/** /**
@ -129,27 +145,119 @@ class SignedURLCache {
} }
/** /**
* Get cache statistics * Save the cache to disk
*/ */
getStats(): { totalEntries: number } { saveToDisk(): void {
return { try {
totalEntries: this.originalToSignedCache.size // Remove expired entries before saving
}; const removedCount = this.clearExpired();
// Convert Maps to serializable objects
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
serializedOriginalToSigned[originalUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
serializedSignedToOriginal[signedUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
const serializedCache = {
originalToSigned: serializedOriginalToSigned,
signedToOriginal: serializedSignedToOriginal
};
// Write to file
fs.writeFileSync(
CACHE_FILE_PATH,
JSON.stringify(serializedCache),
'utf8'
);
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
} catch (error) {
console.error('Error saving SignedURLCache to disk:', error);
}
} }
/** /**
* Stub methods for backward compatibility - do nothing in in-memory mode * Load the cache from disk
*/ */
saveToDisk(): void {
// No-op: In-memory cache only
}
loadFromDisk(): void { loadFromDisk(): void {
// No-op: In-memory cache only try {
if (fs.existsSync(CACHE_FILE_PATH)) {
// Read from file
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
// Parse the data
const parsedData = JSON.parse(data) as {
originalToSigned: Record<string, { value: string; expiresAt: number }>,
signedToOriginal: Record<string, { value: string; expiresAt: number }>
};
// Only load entries that haven't expired yet
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
// Load original to signed mappings
if (parsedData.originalToSigned) {
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
if (now <= entry.expiresAt) {
this.originalToSignedCache.set(originalUrl, entry);
loadedCount++;
} else {
expiredCount++;
}
}
}
// Load signed to original mappings
if (parsedData.signedToOriginal) {
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
if (now <= entry.expiresAt) {
this.signedToOriginalCache.set(signedUrl, entry);
// Don't increment loadedCount as these are pairs of what we already counted
} else {
// Don't increment expiredCount as these are pairs of what we already counted
}
}
}
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
} else {
console.log('SignedURLCache: No cache file found, starting with empty cache');
}
} catch (error) {
console.error('Error loading SignedURLCache from disk:', error);
// Start with empty caches if loading fails
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
}
} }
} }
// Create a singleton instance to be used throughout the application // Create a singleton instance to be used throughout the application
const signedUrlCache = new SignedURLCache(); const signedUrlCache = new SignedURLCache();
export default signedUrlCache; process.on('SIGINT', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
export default signedUrlCache;

View file

@ -1,263 +0,0 @@
import fs from 'fs';
import path from 'path';
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
// Interface for cache entries with TTL
interface CacheEntry {
value: string;
expiresAt: number; // Timestamp when this entry expires
}
class SignedURLCache {
private originalToSignedCache: Map<string, CacheEntry>;
private signedToOriginalCache: Map<string, CacheEntry>;
constructor() {
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
// Create cache directory if it doesn't exist
const cacheDir = path.dirname(CACHE_FILE_PATH);
if (!fs.existsSync(cacheDir)) {
console.log('creating the directory')
fs.mkdirSync(cacheDir, { recursive: true });
}
else {
console.log('the directory is already present')
}
}
/**
* Get a signed URL from the cache using an original URL as the key
*/
get(originalUrl: string): string | undefined {
const entry = this.originalToSignedCache.get(originalUrl);
// If no entry or entry has expired, return undefined
if (!entry || Date.now() > entry.expiresAt) {
if (entry) {
// Remove expired entry
this.originalToSignedCache.delete(originalUrl);
// Also remove from reverse mapping if it exists
this.signedToOriginalCache.delete(entry.value);
}
return undefined;
}
return entry.value;
}
/**
* Get the original URL from the cache using a signed URL as the key
*/
getOriginalUrl(signedUrl: string): string | undefined {
const entry = this.signedToOriginalCache.get(signedUrl);
// If no entry or entry has expired, return undefined
if (!entry || Date.now() > entry.expiresAt) {
if (entry) {
// Remove expired entry
this.signedToOriginalCache.delete(signedUrl);
// Also remove from primary mapping if it exists
this.originalToSignedCache.delete(entry.value);
}
return undefined;
}
return entry.value;
}
/**
* Set a value in the cache with a TTL (Time To Live)
* @param originalUrl The original S3 URL
* @param signedUrl The signed URL
* @param ttlMs Time to live in milliseconds (default: 3 days)
*/
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
const expiresAt = Date.now() + ttlMs;
const entry: CacheEntry = {
value: signedUrl,
expiresAt
};
const reverseEntry: CacheEntry = {
value: originalUrl,
expiresAt
};
this.originalToSignedCache.set(originalUrl, entry);
this.signedToOriginalCache.set(signedUrl, reverseEntry);
}
has(originalUrl: string): boolean {
const entry = this.originalToSignedCache.get(originalUrl);
// Entry exists and hasn't expired
return !!entry && Date.now() <= entry.expiresAt;
}
hasSignedUrl(signedUrl: string): boolean {
const entry = this.signedToOriginalCache.get(signedUrl);
// Entry exists and hasn't expired
return !!entry && Date.now() <= entry.expiresAt;
}
clear(): void {
this.originalToSignedCache.clear();
this.signedToOriginalCache.clear();
this.saveToDisk();
}
/**
* Remove all expired entries from the cache
* @returns The number of expired entries that were removed
*/
clearExpired(): number {
const now = Date.now();
let removedCount = 0;
// Clear expired entries from original to signed cache
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
if (now > entry.expiresAt) {
this.originalToSignedCache.delete(originalUrl);
removedCount++;
}
}
// Clear expired entries from signed to original cache
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
if (now > entry.expiresAt) {
this.signedToOriginalCache.delete(signedUrl);
// No need to increment removedCount as we've already counted these in the first loop
}
}
if (removedCount > 0) {
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
}
return removedCount;
}
/**
* Save the cache to disk
*/
saveToDisk(): void {
try {
// Remove expired entries before saving
const removedCount = this.clearExpired();
// Convert Maps to serializable objects
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
serializedOriginalToSigned[originalUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
serializedSignedToOriginal[signedUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
const serializedCache = {
originalToSigned: serializedOriginalToSigned,
signedToOriginal: serializedSignedToOriginal
};
// Write to file
fs.writeFileSync(
CACHE_FILE_PATH,
JSON.stringify(serializedCache),
'utf8'
);
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
} catch (error) {
console.error('Error saving SignedURLCache to disk:', error);
}
}
/**
* Load the cache from disk
*/
loadFromDisk(): void {
try {
if (fs.existsSync(CACHE_FILE_PATH)) {
// Read from file
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
// Parse the data
const parsedData = JSON.parse(data) as {
originalToSigned: Record<string, { value: string; expiresAt: number }>,
signedToOriginal: Record<string, { value: string; expiresAt: number }>
};
// Only load entries that haven't expired yet
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
// Load original to signed mappings
if (parsedData.originalToSigned) {
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
if (now <= entry.expiresAt) {
this.originalToSignedCache.set(originalUrl, entry);
loadedCount++;
} else {
expiredCount++;
}
}
}
// Load signed to original mappings
if (parsedData.signedToOriginal) {
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
if (now <= entry.expiresAt) {
this.signedToOriginalCache.set(signedUrl, entry);
// Don't increment loadedCount as these are pairs of what we already counted
} else {
// Don't increment expiredCount as these are pairs of what we already counted
}
}
}
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
} else {
console.log('SignedURLCache: No cache file found, starting with empty cache');
}
} catch (error) {
console.error('Error loading SignedURLCache from disk:', error);
// Start with empty caches if loading fails
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
}
}
}
// Create a singleton instance to be used throughout the application
const signedUrlCache = new SignedURLCache();
process.on('SIGINT', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
export default signedUrlCache;

View file

@ -0,0 +1,8 @@
import multerParent from 'multer';
const uploadHandler = multerParent({
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB
}
});
export default uploadHandler

View file

@ -1,34 +1,65 @@
import { Hono } from 'hono' import { Router, Request, Response, NextFunction } from "express";
import { authenticateUser } from '@/src/middleware/auth.middleware' import avRouter from "@/src/apis/admin-apis/apis/av-router"
import v1Router from '@/src/v1-router' 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"
// Note: This router is kept for compatibility during migration
// Most routes have been moved to tRPC const router = Router();
const router = new Hono()
// Health check endpoints (no auth required) // Health check endpoints (no auth required)
// Note: These are also defined in index.ts, keeping for compatibility router.get('/health', (req: Request, res: Response) => {
router.get('/health', (c) => { res.status(200).json({
return c.json({
status: 'OK', status: 'OK',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
message: 'Hello world' message: 'Hello world'
}) });
}) });
router.get('/seed', (req:Request, res: Response) => {
router.get('/seed', (c) => { res.status(200).json({
return c.json({
status: 'OK', status: 'OK',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime() uptime: process.uptime()
}) });
}) })
// Mount v1 routes (REST API)
router.route('/v1', v1Router)
// Apply authentication middleware to all subsequent routes // Apply authentication middleware to all subsequent routes
router.use('*', authenticateUser) router.use(authenticateUser);
export default router 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);
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: err.message,
details: err.details,
statusCode: err.statusCode
});
}
// Handle unknown errors
return res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
statusCode: 500
});
});
const mainRouter = router;
export default mainRouter;

View file

@ -0,0 +1,67 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '@/src/db/db_index'
import { staffUsers, userDetails } from '@/src/db/schema'
import { eq } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
interface AuthenticatedRequest extends Request {
user?: {
userId: number;
name?: string;
email?: string;
mobile?: string;
};
staffUser?: {
id: number;
name: string;
};
}
export const authenticateUser = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new ApiError('Authorization token required', 401);
}
const token = authHeader.substring(7);
console.log(req.headers)
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
// Check if this is a staff token (has staffId)
if (decoded.staffId) {
// This is a staff token, verify staff exists
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, decoded.staffId),
});
if (!staff) {
throw new ApiError('Invalid staff token', 401);
}
req.staffUser = {
id: staff.id,
name: staff.name,
};
} else {
// This is a regular user token
req.user = decoded;
// Check if user is suspended
const details = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, decoded.userId),
});
if (details?.isSuspended) {
throw new ApiError('Account suspended', 403);
}
}
next();
} catch (error) {
next(error);
}
};

View file

@ -1,67 +1,67 @@
import { createMiddleware } from 'hono/factory' import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { verifyToken, isJWTError, UserJWTPayload } from '@/src/lib/jwt-utils'
// Type for Hono context variables // Extend the Request interface to include user property
type Variables = { declare global {
user: UserJWTPayload namespace Express {
interface Request {
user?: any;
}
}
} }
/** export const verifyToken = (req: Request, res: Response, next: NextFunction) => {
* Hono middleware to verify JWT token and attach user to context
*/
export const verifyTokenMiddleware = createMiddleware<{ Variables: Variables }>(async (c, next) => {
try { try {
// Get token from Authorization header // Get token from Authorization header
const authHeader = c.req.header('authorization') const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new ApiError('Access denied. No token provided', 401) throw new ApiError('Access denied. No token provided', 401);
} }
const token = authHeader.split(' ')[1] const token = authHeader.split(' ')[1];
if (!token) { if (!token) {
throw new ApiError('Access denied. Invalid token format', 401) throw new ApiError('Access denied. Invalid token format', 401);
} }
// Verify token // Verify token
const decoded = await verifyToken(token) as UserJWTPayload const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
// Add user info to context
c.set('user', decoded)
await next() // Add user info to request
req.user = decoded;
next();
} catch (error) { } catch (error) {
if (isJWTError(error)) { if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError('Invalid Auth Credentials', 401) next(new ApiError('Invalid Auth Credentials', 401));
} else {
next(error);
} }
throw error
} }
}) };
// Keep old name for backward compatibility
export { verifyTokenMiddleware as verifyToken }
/**
* Hono middleware to require specific roles
*/
export const requireRole = (roles: string[]) => { export const requireRole = (roles: string[]) => {
return createMiddleware<{ Variables: Variables }>(async (c, next) => { return (req: Request, res: Response, next: NextFunction) => {
const user = c.get('user') try {
if (!req.user) {
if (!user) { throw new ApiError('Authentication required', 401);
throw new ApiError('Authentication required', 401) }
// Check if user has any of the required roles
const userRoles = req.user.roles || [];
const hasPermission = roles.some(role => userRoles.includes(role));
if (!hasPermission) {
throw new ApiError('Access denied. Insufficient permissions', 403);
}
next();
} catch (error) {
next(error);
} }
};
// Check if user has any of the required roles };
const userRoles = user.roles || []
const hasPermission = roles.some(role => userRoles.includes(role))
if (!hasPermission) {
throw new ApiError('Access denied. Insufficient permissions', 403)
}
await next()
})
}

View file

@ -1,37 +1,40 @@
import { createMiddleware } from 'hono/factory' import { Request, Response, NextFunction } from 'express';
import { db } from '../db/db_index' import jwt from 'jsonwebtoken';
import { staffUsers } from '../db/schema' import { db } from '@/src/db/db_index'
import { staffUsers } from '@/src/db/schema'
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { verifyToken, StaffJWTPayload } from '@/src/lib/jwt-utils'
// Type for Hono context variables // Extend Request interface to include staffUser
type Variables = { declare global {
staffUser?: { namespace Express {
id: number; interface Request {
name: string; staffUser?: {
}; id: number;
name: string;
};
}
}
} }
/** /**
* Verify JWT token and extract payload * Verify JWT token and extract payload
*/ */
const verifyStaffToken = async (token: string): Promise<StaffJWTPayload> => { const verifyStaffToken = (token: string) => {
try { try {
const payload = await verifyToken(token); return jwt.verify(token, process.env.JWT_SECRET || 'default-secret');
return payload as StaffJWTPayload;
} catch (error) { } catch (error) {
throw new ApiError('Access denied. Invalid auth credentials', 401); throw new ApiError('Access denied. Invalid auth credentials', 401);
} }
}; };
/** /**
* Hono middleware to authenticate staff users and attach staffUser to context * Middleware to authenticate staff users and attach staffUser to request
*/ */
export const authenticateStaff = createMiddleware<{ Variables: Variables }>(async (c, next) => { export const authenticateStaff = async (req: Request, res: Response, next: NextFunction) => {
try { try {
// Extract token from Authorization header // Extract token from Authorization header
const authHeader = c.req.header('authorization'); const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new ApiError('Staff authentication required', 401); throw new ApiError('Staff authentication required', 401);
@ -44,7 +47,7 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
} }
// Verify token and extract payload // Verify token and extract payload
const decoded = await verifyStaffToken(token); const decoded = verifyStaffToken(token) as any;
// Verify staffId exists in token // Verify staffId exists in token
if (!decoded.staffId) { if (!decoded.staffId) {
@ -60,14 +63,14 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
throw new ApiError('Staff user not found', 401); throw new ApiError('Staff user not found', 401);
} }
// Attach staff user to context // Attach staff user to request
c.set('staffUser', { req.staffUser = {
id: staff.id, id: staff.id,
name: staff.name, name: staff.name,
}); };
await next(); next();
} catch (error) { } catch (error) {
throw error; next(error);
} }
}); };

View file

@ -0,0 +1,405 @@
import { db } from '@/src/db/db_index'
import {
orders,
orderItems,
orderStatus,
addresses,
productInfo,
paymentInfoTable,
coupons,
couponUsage,
payments,
cartItems,
refunds,
units,
userDetails,
} from '@/src/db/schema'
import { eq, and, inArray, desc, gte } from 'drizzle-orm'
// ============ User/Auth Queries ============
/**
* Get user details by user ID
*/
export async function getUserDetails(userId: number) {
return db.query.userDetails.findFirst({
where: eq(userDetails.userId, userId),
})
}
// ============ Address Queries ============
/**
* Get user address by ID
*/
export async function getUserAddress(userId: number, addressId: number) {
return db.query.addresses.findFirst({
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
})
}
// ============ Product Queries ============
/**
* Get product by ID
*/
export async function getProductById(productId: number) {
return db.query.productInfo.findFirst({
where: eq(productInfo.id, productId),
})
}
/**
* Get multiple products by IDs with unit info
*/
export async function getProductsByIdsWithUnits(productIds: number[]) {
return 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))
}
// ============ Coupon Queries ============
/**
* Get coupon with usages for user
*/
export async function getCouponWithUsages(couponId: number, userId: number) {
return db.query.coupons.findFirst({
where: eq(coupons.id, couponId),
with: {
usages: { where: eq(couponUsage.userId, userId) },
},
})
}
/**
* Insert coupon usage
*/
export async function insertCouponUsage(data: {
userId: number
couponId: number
orderId: number
orderItemId: number | null
usedAt: Date
}) {
return db.insert(couponUsage).values(data)
}
/**
* Get coupon usages for order
*/
export async function getCouponUsagesForOrder(orderId: number) {
return db.query.couponUsage.findMany({
where: eq(couponUsage.orderId, orderId),
with: {
coupon: true,
},
})
}
// ============ Cart Queries ============
/**
* Delete cart items for user by product IDs
*/
export async function deleteCartItems(userId: number, productIds: number[]) {
return db.delete(cartItems).where(
and(
eq(cartItems.userId, userId),
inArray(cartItems.productId, productIds)
)
)
}
// ============ Payment Info Queries ============
/**
* Create payment info
*/
export async function createPaymentInfo(data: {
status: string
gateway: string
merchantOrderId: string
}) {
return db.insert(paymentInfoTable).values(data).returning()
}
// ============ Order Queries ============
/**
* Insert multiple orders
*/
export async function insertOrders(ordersData: any[]) {
return db.insert(orders).values(ordersData).returning()
}
/**
* Insert multiple order items
*/
export async function insertOrderItems(itemsData: any[]) {
return db.insert(orderItems).values(itemsData)
}
/**
* Insert multiple order statuses
*/
export async function insertOrderStatuses(statusesData: any[]) {
return db.insert(orderStatus).values(statusesData)
}
/**
* Get user orders with all relations
*/
export async function getUserOrdersWithRelations(userId: number, limit: number, offset: number) {
return db.query.orders.findMany({
where: eq(orders.userId, userId),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: true,
refunds: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
limit: limit,
offset: offset,
})
}
/**
* Count user orders
*/
export async function countUserOrders(userId: number) {
return db.$count(orders, eq(orders.userId, userId))
}
/**
* Get order by ID with all relations
*/
export async function getOrderByIdWithRelations(orderId: number) {
return db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: {
with: {
refundCoupon: true,
},
},
refunds: true,
},
})
}
/**
* Get order by ID with order status
*/
export async function getOrderWithStatus(orderId: number) {
return db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
orderStatus: true,
},
})
}
/**
* Update order status to cancelled
*/
export async function updateOrderStatusToCancelled(
statusId: number,
data: {
isCancelled: boolean
cancelReason: string
cancellationUserNotes: string
cancellationReviewed: boolean
}
) {
return db
.update(orderStatus)
.set(data)
.where(eq(orderStatus.id, statusId))
}
/**
* Insert refund record
*/
export async function insertRefund(data: { orderId: number; refundStatus: string }) {
return db.insert(refunds).values(data)
}
/**
* Update order notes
*/
export async function updateOrderNotes(orderId: number, userNotes: string | null) {
return db
.update(orders)
.set({ userNotes })
.where(eq(orders.id, orderId))
}
/**
* Get recent delivered orders for user
*/
export async function getRecentDeliveredOrders(
userId: number,
since: Date,
limit: number
) {
return 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)
}
/**
* Get order items by order IDs
*/
export async function getOrderItemsByOrderIds(orderIds: number[]) {
return db
.select({ productId: orderItems.productId })
.from(orderItems)
.where(inArray(orderItems.orderId, orderIds))
}
// ============ Transaction Helper ============
/**
* Execute function within a database transaction
*/
export async function withTransaction<T>(fn: (tx: any) => Promise<T>): Promise<T> {
return db.transaction(fn)
}
/**
* Cancel order with refund record in a transaction
*/
export async function cancelOrderWithRefund(
statusId: number,
orderId: number,
isCod: boolean,
reason: string
): Promise<{ orderId: number }> {
return db.transaction(async (tx) => {
// Update order status
await tx
.update(orderStatus)
.set({
isCancelled: true,
cancelReason: reason,
cancellationUserNotes: reason,
cancellationReviewed: false,
})
.where(eq(orderStatus.id, statusId))
// Insert refund record
const refundStatus = isCod ? "na" : "pending"
await tx.insert(refunds).values({
orderId,
refundStatus,
})
return { orderId }
})
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* Create orders with payment info in a transaction
*/
export async function createOrdersWithPayment(
ordersData: any[],
paymentMethod: "online" | "cod",
totalWithDelivery: number,
razorpayOrderCreator?: (paymentInfoId: number, amount: string) => Promise<any>,
paymentRecordInserter?: (paymentInfoId: number, razorpayOrder: any, tx: Tx) => Promise<any>
): Promise<typeof orders.$inferSelect[]> {
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<typeof orders.$inferInsert, "id">[] = ordersData.map(
(od) => ({
...od.order,
paymentInfoId: sharedPaymentInfoId,
})
)
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning()
const allOrderItems: Omit<typeof orderItems.$inferInsert, "id">[] = []
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, "id">[] = []
insertedOrders.forEach((order: typeof orders.$inferSelect, index: number) => {
const od = ordersData[index]
od.orderItems.forEach((item: any) => {
allOrderItems.push({ ...item, orderId: order.id as number })
})
allOrderStatuses.push({
...od.orderStatus,
orderId: order.id as number,
})
})
await tx.insert(orderItems).values(allOrderItems)
await tx.insert(orderStatus).values(allOrderStatuses)
if (paymentMethod === "online" && sharedPaymentInfoId && razorpayOrderCreator && paymentRecordInserter) {
const razorpayOrder = await razorpayOrderCreator(
sharedPaymentInfoId,
totalWithDelivery.toString()
)
await paymentRecordInserter(
sharedPaymentInfoId,
razorpayOrder,
tx
)
}
return insertedOrders
})
}

View file

@ -1,5 +1,5 @@
import { db } from '../../db/db_index' import { db } from '@/src/db/db_index'
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '../../db/schema' import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '@/src/db/schema'
import { eq, and, gt, sql, desc } from 'drizzle-orm' import { eq, and, gt, sql, desc } from 'drizzle-orm'
/** /**
@ -55,8 +55,8 @@ export async function getProductDeliverySlots(productId: number) {
and( and(
eq(productSlots.productId, productId), eq(productSlots.productId, productId),
eq(deliverySlotInfo.isActive, true), eq(deliverySlotInfo.isActive, true),
gt(deliverySlotInfo.deliveryTime, new Date()), gt(deliverySlotInfo.deliveryTime, sql`NOW()`),
gt(deliverySlotInfo.freezeTime, new Date()) gt(deliverySlotInfo.freezeTime, sql`NOW()`)
) )
) )
.orderBy(deliverySlotInfo.deliveryTime) .orderBy(deliverySlotInfo.deliveryTime)
@ -76,7 +76,7 @@ export async function getProductSpecialDeals(productId: number) {
.where( .where(
and( and(
eq(specialDeals.productId, productId), eq(specialDeals.productId, productId),
gt(specialDeals.validTill, new Date()) gt(specialDeals.validTill, sql`NOW()`)
) )
) )
.orderBy(specialDeals.quantity) .orderBy(specialDeals.quantity)

View file

@ -1,7 +1,7 @@
// import redisClient from '@/src/stores/redis-client'; // import redisClient from '@/src/stores/redis-client';
import redisClient from '@/src/lib/redis-client'; import redisClient from '@/src/lib/redis-client';
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { homeBanners } from '../db/schema' import { homeBanners } from '@/src/db/schema'
import { isNotNull, asc } from 'drizzle-orm'; import { isNotNull, asc } from 'drizzle-orm';
import { scaffoldAssetUrl } from '@/src/lib/s3-client'; import { scaffoldAssetUrl } from '@/src/lib/s3-client';

View file

@ -1,7 +1,7 @@
// import redisClient from '@/src/stores/redis-client'; // import redisClient from '@/src/stores/redis-client';
import redisClient from '@/src/lib/redis-client'; import redisClient from '@/src/lib/redis-client';
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '../db/schema' import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '@/src/db/schema'
import { eq, and, gt, sql } from 'drizzle-orm'; import { eq, and, gt, sql } from 'drizzle-orm';
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client'; import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
@ -72,7 +72,7 @@ export async function initializeProducts(): Promise<void> {
and( and(
eq(deliverySlotInfo.isActive, true), eq(deliverySlotInfo.isActive, true),
eq(deliverySlotInfo.isCapacityFull, false), eq(deliverySlotInfo.isCapacityFull, false),
gt(deliverySlotInfo.deliveryTime, new Date()) gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
) )
); );
const deliverySlotsMap = new Map<number, typeof allDeliverySlots>(); const deliverySlotsMap = new Map<number, typeof allDeliverySlots>();
@ -90,7 +90,7 @@ export async function initializeProducts(): Promise<void> {
validTill: specialDeals.validTill, validTill: specialDeals.validTill,
}) })
.from(specialDeals) .from(specialDeals)
.where(gt(specialDeals.validTill, new Date())); .where(gt(specialDeals.validTill, sql`NOW()`));
const specialDealsMap = new Map<number, typeof allSpecialDeals>(); const specialDealsMap = new Map<number, typeof allSpecialDeals>();
for (const deal of allSpecialDeals) { for (const deal of allSpecialDeals) {
if (!specialDealsMap.has(deal.productId)) specialDealsMap.set(deal.productId, []); if (!specialDealsMap.has(deal.productId)) specialDealsMap.set(deal.productId, []);

View file

@ -1,7 +1,7 @@
// import redisClient from '@/src/stores/redis-client'; // import redisClient from '@/src/stores/redis-client';
import redisClient from '@/src/lib/redis-client'; import redisClient from '@/src/lib/redis-client';
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { productTagInfo, productTags } from '../db/schema' import { productTagInfo, productTags } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';

View file

@ -1,6 +1,6 @@
import redisClient from '@/src/lib/redis-client'; import redisClient from '@/src/lib/redis-client';
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { deliverySlotInfo, productSlots, productInfo, units } from '../db/schema' import { deliverySlotInfo, productSlots, productInfo, units } from '@/src/db/schema'
import { eq, and, gt, asc } from 'drizzle-orm'; import { eq, and, gt, asc } from 'drizzle-orm';
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client'; import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
import dayjs from 'dayjs'; import dayjs from 'dayjs';

View file

@ -4,11 +4,6 @@ import { initializeProducts } from '@/src/stores/product-store'
import { initializeProductTagStore } from '@/src/stores/product-tag-store' import { initializeProductTagStore } from '@/src/stores/product-tag-store'
import { initializeSlotStore } from '@/src/stores/slot-store' import { initializeSlotStore } from '@/src/stores/slot-store'
import { initializeBannerStore } from '@/src/stores/banner-store' import { initializeBannerStore } from '@/src/stores/banner-store'
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
// const STORE_INIT_DELAY_MS = 3 * 60 * 1000
const STORE_INIT_DELAY_MS = 0.5 * 60 * 1000
let storeInitializationTimeout: NodeJS.Timeout | null = null
/** /**
* Initialize all application stores * Initialize all application stores
@ -34,27 +29,8 @@ export const initializeAllStores = async (): Promise<void> => {
]); ]);
console.log('All application stores initialized successfully'); console.log('All application stores initialized successfully');
// Regenerate all cache files (fire-and-forget)
createAllCacheFiles().catch(error => {
console.error('Failed to regenerate cache files during store initialization:', error)
})
} catch (error) { } catch (error) {
console.error('Application stores initialization failed:', error); console.error('Application stores initialization failed:', error);
throw error; throw error;
} }
}; };
export const scheduleStoreInitialization = (): void => {
if (storeInitializationTimeout) {
clearTimeout(storeInitializationTimeout)
storeInitializationTimeout = null
}
storeInitializationTimeout = setTimeout(() => {
storeInitializationTimeout = null
initializeAllStores().catch(error => {
console.error('Scheduled store initialization failed:', error)
})
}, STORE_INIT_DELAY_MS)
}

View file

@ -1,6 +1,6 @@
import redisClient from '@/src/lib/redis-client'; import redisClient from '@/src/lib/redis-client';
import { db } from '../db/db_index' import { db } from '@/src/db/db_index'
import { userIncidents } from '../db/schema' import { userIncidents } from '@/src/db/schema'
import { eq, sum } from 'drizzle-orm'; import { eq, sum } from 'drizzle-orm';
export async function initializeUserNegativityStore(): Promise<void> { export async function initializeUserNegativityStore(): Promise<void> {

View file

@ -0,0 +1,13 @@
import { Router, Request, Response } from 'express';
const router = Router();
router.get('/', (req: Request, res: Response) => {
res.json({
status: 'ok',
message: 'Health check passed',
timestamp: new Date().toISOString(),
});
});
export default router;

View file

@ -0,0 +1,32 @@
import { z } from 'zod';
import { addressZones, addressAreas } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
import { db } from '@/src/db/db_index'
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
const addressRouter = router({
getZones: protectedProcedure.query(async () => {
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
return zones
}),
getAreas: protectedProcedure.query(async () => {
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
return areas
}),
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
return {zone: zone};
}),
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
return {area};
}),
// TODO: Add update and delete mutations if needed
});
export default addressRouter;

View file

@ -2,6 +2,7 @@
import { router } from '@/src/trpc/trpc-index' import { router } from '@/src/trpc/trpc-index'
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint' import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon' import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order' import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets' import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots' import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
@ -9,15 +10,15 @@ import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user' import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store' import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments' import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner' import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user' import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const' import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
export const adminRouter = router({ export const adminRouter = router({
complaint: complaintRouter, complaint: complaintRouter,
coupon: couponRouter, coupon: couponRouter,
cancelledOrders: cancelledOrdersRouter,
order: orderRouter, order: orderRouter,
vendorSnippets: vendorSnippetsRouter, vendorSnippets: vendorSnippetsRouter,
slots: slotsRouter, slots: slotsRouter,
@ -25,11 +26,10 @@ export const adminRouter = router({
staffUser: staffUserRouter, staffUser: staffUserRouter,
store: storeRouter, store: storeRouter,
payments: adminPaymentsRouter, payments: adminPaymentsRouter,
address: addressRouter,
banner: bannerRouter, banner: bannerRouter,
user: userRouter, user: userRouter,
const: constRouter, const: constRouter,
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
tag: tagRouter,
}); });
export type AdminRouter = typeof adminRouter; export type AdminRouter = typeof adminRouter;

View file

@ -1,42 +1,52 @@
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { homeBanners } from '@/src/db/schema'
import { eq, and, desc, sql } from 'drizzle-orm';
import { protectedProcedure, router } from '@/src/trpc/trpc-index' import { protectedProcedure, router } from '@/src/trpc/trpc-index'
import { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client' import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error'; import { ApiError } from '@/src/lib/api-error';
import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { initializeAllStores } from '@/src/stores/store-initializer'
import { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const bannerRouter = router({ export const bannerRouter = router({
// Get all banners // Get all banners
getBanners: protectedProcedure getBanners: protectedProcedure
.query(async () => { .query(async () => {
try { try {
const banners = await bannerDbService.getAllBanners()
// Convert S3 keys to signed URLs for client const banners = await db.query.homeBanners.findMany({
const bannersWithSignedUrls = await Promise.all( orderBy: desc(homeBanners.createdAt), // Order by creation date instead
banners.map(async (banner) => { // Removed product relationship since we now use productIds array
try { });
return {
...banner,
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
productIds: banner.productIds || [],
};
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
return {
...banner,
imageUrl: banner.imageUrl,
productIds: banner.productIds || [],
};
}
})
);
return { // Convert S3 keys to signed URLs for client
banners: bannersWithSignedUrls, const bannersWithSignedUrls = await Promise.all(
}; banners.map(async (banner) => {
} catch (e: any) { try {
return {
...banner,
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl,
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
} catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
return {
...banner,
imageUrl: banner.imageUrl, // Keep original on error
// Ensure productIds is always an array
productIds: banner.productIds || [],
};
}
})
);
return {
banners: bannersWithSignedUrls,
};
}
catch(e:any) {
console.log(e) console.log(e)
throw new ApiError(e.message); throw new ApiError(e.message);
} }
}), }),
@ -45,17 +55,23 @@ export const bannerRouter = router({
getBanner: protectedProcedure getBanner: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input }) => { .query(async ({ input }) => {
const banner = await bannerDbService.getBannerById(input.id) const banner = await db.query.homeBanners.findFirst({
where: eq(homeBanners.id, input.id),
// Removed product relationship since we now use productIds array
});
if (banner) { if (banner) {
try { try {
// Convert S3 key to signed URL for client
if (banner.imageUrl) { if (banner.imageUrl) {
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl); banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl);
} }
} catch (error) { } catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
// Keep original imageUrl on error
} }
// Ensure productIds is always an array (handle migration compatibility)
if (!banner.productIds) { if (!banner.productIds) {
banner.productIds = []; banner.productIds = [];
} }
@ -68,31 +84,32 @@ export const bannerRouter = router({
createBanner: protectedProcedure createBanner: protectedProcedure
.input(z.object({ .input(z.object({
name: z.string().min(1), name: z.string().min(1),
imageUrl: z.string(), imageUrl: z.string().url(),
description: z.string().optional(), description: z.string().optional(),
productIds: z.array(z.number()).optional(), productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(), redirectUrl: z.string().url().optional(),
// serialNum removed completely
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl) const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
const [banner] = await db.insert(homeBanners).values({
const banner = await bannerDbService.createBanner({
name: input.name, name: input.name,
imageUrl: imageUrl, imageUrl: imageUrl,
description: input.description, description: input.description,
productIds: input.productIds || [], productIds: input.productIds || [],
redirectUrl: input.redirectUrl, redirectUrl: input.redirectUrl,
serialNum: 999, serialNum: 999, // Default value, not used
isActive: false, isActive: false, // Default to inactive
}) }).returning();
scheduleStoreInitialization() // Reinitialize stores to reflect changes
await initializeAllStores();
return banner; return banner;
} catch (error) { } catch (error) {
console.error('Error creating banner:', error); console.error('Error creating banner:', error);
throw error; throw error; // Re-throw to maintain tRPC error handling
} }
}), }),
@ -110,21 +127,31 @@ export const bannerRouter = router({
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const { id, ...updateData } = input; const { id, ...updateData } = input;
const incomingProductIds = input.productIds;
const processedData: any = { ...updateData } // Extract S3 key from presigned URL if imageUrl is provided
const processedData = {
if (updateData.imageUrl) { ...updateData,
processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl) ...(updateData.imageUrl && {
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
}),
};
// Handle serialNum null case
const finalData: any = { ...processedData };
if ('serialNum' in finalData && finalData.serialNum === null) {
// Set to null explicitly
finalData.serialNum = null;
} }
if ('serialNum' in processedData && processedData.serialNum === null) { const [banner] = await db.update(homeBanners)
processedData.serialNum = null; .set({ ...finalData, lastUpdated: new Date(), })
} .where(eq(homeBanners.id, id))
.returning();
const banner = await bannerDbService.updateBannerById(id, processedData) // Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return banner; return banner;
} catch (error) { } catch (error) {
@ -137,9 +164,10 @@ export const bannerRouter = router({
deleteBanner: protectedProcedure deleteBanner: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await bannerDbService.deleteBannerById(input.id) await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
scheduleStoreInitialization() // Reinitialize stores to reflect changes
await initializeAllStores();
return { success: true }; return { success: true };
}), }),

View file

@ -0,0 +1,179 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
import { eq, desc } from 'drizzle-orm';
const updateCancellationReviewSchema = z.object({
orderId: z.number(),
cancellationReviewed: z.boolean(),
adminNotes: z.string().optional(),
});
const updateRefundSchema = z.object({
orderId: z.number(),
isRefundDone: z.boolean(),
});
export const cancelledOrdersRouter = router({
getAll: protectedProcedure
.query(async () => {
// First get cancelled order statuses with order details
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
where: eq(orderStatus.isCancelled, true),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
refunds: true,
},
},
},
orderBy: [desc(orderStatus.orderTime)],
});
const filteredStatuses = cancelledOrderStatuses.filter(status => {
return status.order.isCod || status.paymentStatus === 'success';
});
return filteredStatuses.map(status => {
const refund = status.order.refunds[0];
return {
id: status.order.id,
readableId: status.order.id,
customerName: `${status.order.user.name}`,
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
totalAmount: status.order.totalAmount,
cancellationReviewed: status.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: status.order.adminNotes,
cancelReason: status.cancelReason,
paymentMode: status.order.isCod ? 'COD' : 'Online',
paymentStatus: status.paymentStatus || 'pending',
items: status.order.orderItems.map(item => ({
name: item.product.name,
quantity: item.quantity,
price: item.price,
unit: item.product.unit?.shortNotation,
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
})),
createdAt: status.order.createdAt,
};
});
}),
updateReview: protectedProcedure
.input(updateCancellationReviewSchema)
.mutation(async ({ input }) => {
const { orderId, cancellationReviewed, adminNotes } = input;
const result = await db.update(orderStatus)
.set({
cancellationReviewed,
cancellationAdminNotes: adminNotes || null,
cancellationReviewedAt: new Date(),
})
.where(eq(orderStatus.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const { id } = input;
// Get cancelled order with full details
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
where: eq(orderStatus.id, id),
with: {
order: {
with: {
user: true,
address: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
},
},
});
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
throw new Error("Cancelled order not found");
}
// Get refund details separately
const refund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
});
const order = cancelledOrderStatus.order;
// Format the response similar to the getAll method
const formattedOrder = {
id: order.id,
readableId: order.id,
customerName: order.user.name,
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
totalAmount: order.totalAmount,
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
isRefundDone: refund?.refundStatus === 'processed' || false,
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
cancelReason: cancelledOrderStatus.cancelReason || null,
items: order.orderItems.map((item: any) => ({
name: item.product.name,
quantity: item.quantity,
price: parseFloat(item.price.toString()),
unit: item.product.unit?.shortNotation || 'unit',
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: item.product.images?.[0] || null,
})),
createdAt: order.createdAt.toISOString(),
};
return { order: formattedOrder };
}),
updateRefund: protectedProcedure
.input(updateRefundSchema)
.mutation(async ({ input }) => {
const { orderId, isRefundDone } = input;
const refundStatus = isRefundDone ? 'processed' : 'none';
const result = await db.update(refunds)
.set({
refundStatus,
refundProcessedAt: isRefundDone ? new Date() : null,
})
.where(eq(refunds.orderId, orderId))
.returning();
if (result.length === 0) {
throw new Error("Cancellation record not found");
}
return result[0];
}),
});

View file

@ -1,7 +1,9 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { db } from '@/src/db/db_index'
import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' import { complaints, users } from '@/src/db/schema'
import { eq, desc, lt, and } from 'drizzle-orm';
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
export const complaintRouter = router({ export const complaintRouter = router({
getAll: protectedProcedure getAll: protectedProcedure
@ -12,7 +14,27 @@ export const complaintRouter = router({
.query(async ({ input }) => { .query(async ({ input }) => {
const { cursor, limit } = input; const { cursor, limit } = input;
const complaintsData = await complaintDbService.getComplaints(cursor, limit); let 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,
createdAt: complaints.createdAt,
userName: users.name,
userMobile: users.mobile,
images: complaints.images,
})
.from(complaints)
.leftJoin(users, eq(complaints.userId, users.id))
.where(whereCondition)
.orderBy(desc(complaints.id))
.limit(limit + 1);
const hasMore = complaintsData.length > limit; const hasMore = complaintsData.length > limit;
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData; const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
@ -20,7 +42,7 @@ export const complaintRouter = router({
const complaintsWithSignedImages = await Promise.all( const complaintsWithSignedImages = await Promise.all(
complaintsToReturn.map(async (c) => { complaintsToReturn.map(async (c) => {
const signedImages = c.images const signedImages = c.images
? scaffoldAssetUrl(c.images as string[]) ? await generateSignedUrlsFromS3Urls(c.images as string[])
: []; : [];
return { return {
@ -48,8 +70,11 @@ export const complaintRouter = router({
resolve: protectedProcedure resolve: protectedProcedure
.input(z.object({ id: z.string(), response: z.string().optional() })) .input(z.object({ id: z.string(), response: z.string().optional() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await complaintDbService.resolveComplaint(parseInt(input.id), input.response); await db
.update(complaints)
.set({ isResolved: true, response: input.response })
.where(eq(complaints.id, parseInt(input.id)));
return { message: 'Complaint resolved successfully' }; return { message: 'Complaint resolved successfully' };
}), }),
}); });

View file

@ -1,13 +1,15 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { keyValStore } from '@/src/db/schema'
import { computeConstants } from '@/src/lib/const-store' import { computeConstants } from '@/src/lib/const-store'
import { CONST_KEYS } from '@/src/lib/const-keys' import { CONST_KEYS } from '@/src/lib/const-keys'
import { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const constRouter = router({ export const constRouter = router({
getConstants: protectedProcedure getConstants: protectedProcedure
.query(async () => { .query(async () => {
const constants = await constantDbService.getAllConstants();
const constants = await db.select().from(keyValStore);
const resp = constants.map(c => ({ const resp = constants.map(c => ({
key: c.key, key: c.key,
@ -36,15 +38,24 @@ export const constRouter = router({
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`); throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
} }
const updatedCount = await constantDbService.upsertConstants(constants); await db.transaction(async (tx) => {
for (const { key, value } of constants) {
await tx.insert(keyValStore)
.values({ key, value })
.onConflictDoUpdate({
target: keyValStore.key,
set: { value },
});
}
});
// Refresh all constants in Redis after database update // Refresh all constants in Redis after database update
await computeConstants(); await computeConstants();
return { return {
success: true, success: true,
updatedCount, updatedCount: constants.length,
keys: constants.map(c => c.key), keys: constants.map(c => c.key),
}; };
}), }),
}); });

View file

@ -1,7 +1,9 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
const createCouponBodySchema = z.object({ const createCouponBodySchema = z.object({
couponCode: z.string().optional(), couponCode: z.string().optional(),
@ -49,7 +51,10 @@ export const couponRouter = router({
// If applicableUsers is provided, verify users exist // If applicableUsers is provided, verify users exist
if (applicableUsers && applicableUsers.length > 0) { if (applicableUsers && applicableUsers.length > 0) {
const existingUsers = await couponDbService.getUsersByIds(applicableUsers); const existingUsers = await db.query.users.findMany({
where: inArray(users.id, applicableUsers),
columns: { id: true },
});
if (existingUsers.length !== applicableUsers.length) { if (existingUsers.length !== applicableUsers.length) {
throw new Error("Some applicable users not found"); throw new Error("Some applicable users not found");
} }
@ -64,40 +69,56 @@ export const couponRouter = router({
// Generate coupon code if not provided // Generate coupon code if not provided
let finalCouponCode = couponCode; let finalCouponCode = couponCode;
if (!finalCouponCode) { if (!finalCouponCode) {
// Generate a unique coupon code
const timestamp = Date.now().toString().slice(-6); const timestamp = Date.now().toString().slice(-6);
const random = Math.random().toString(36).substring(2, 8).toUpperCase(); const random = Math.random().toString(36).substring(2, 8).toUpperCase();
finalCouponCode = `MF${timestamp}${random}`; finalCouponCode = `MF${timestamp}${random}`;
} }
// Check if coupon code already exists // Check if coupon code already exists
const existingCoupon = await couponDbService.getCouponByCode(finalCouponCode); const existingCoupon = await db.query.coupons.findFirst({
where: eq(coupons.couponCode, finalCouponCode),
});
if (existingCoupon) { if (existingCoupon) {
throw new Error("Coupon code already exists"); throw new Error("Coupon code already exists");
} }
const coupon = await couponDbService.createCoupon({ const result = await db.insert(coupons).values({
couponCode: finalCouponCode, couponCode: finalCouponCode,
isUserBased: isUserBased || false, isUserBased: isUserBased || false,
discountPercent: discountPercent?.toString() || null, discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString() || null, flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString() || null, minOrder: minOrder?.toString(),
productIds: productIds || null, productIds: productIds || null,
createdBy: staffUserId, createdBy: staffUserId,
maxValue: maxValue?.toString() || null, maxValue: maxValue?.toString(),
isApplyForAll: isApplyForAll || false, isApplyForAll: isApplyForAll || false,
validTill: validTill ? dayjs(validTill).toDate() : null, validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser: maxLimitForUser || null, maxLimitForUser: maxLimitForUser,
exclusiveApply: exclusiveApply || false, exclusiveApply: exclusiveApply || false,
}); }).returning();
const coupon = result[0];
// Insert applicable users // Insert applicable users
if (applicableUsers && applicableUsers.length > 0) { if (applicableUsers && applicableUsers.length > 0) {
await couponDbService.addApplicableUsers(coupon.id, applicableUsers); await db.insert(couponApplicableUsers).values(
applicableUsers.map(userId => ({
couponId: coupon.id,
userId,
}))
);
} }
// Insert applicable products // Insert applicable products
if (applicableProducts && applicableProducts.length > 0) { if (applicableProducts && applicableProducts.length > 0) {
await couponDbService.addApplicableProducts(coupon.id, applicableProducts); await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
} }
return coupon; return coupon;
@ -112,7 +133,39 @@ export const couponRouter = router({
.query(async ({ input }) => { .query(async ({ input }) => {
const { cursor, limit, search } = input; const { cursor, limit, search } = input;
const result = await couponDbService.getAllCoupons({ cursor, limit, search }); 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: (coupons, { desc }) => [desc(coupons.createdAt)],
limit: limit + 1,
});
const hasMore = result.length > limit; const hasMore = result.length > limit;
const couponsList = hasMore ? result.slice(0, limit) : result; const couponsList = hasMore ? result.slice(0, limit) : result;
@ -124,7 +177,24 @@ export const couponRouter = router({
getById: protectedProcedure getById: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input }) => { .query(async ({ input }) => {
const result = await couponDbService.getCouponById(input.id); const couponId = input.id;
const result = await db.query.coupons.findFirst({
where: eq(coupons.id, couponId),
with: {
creator: true,
applicableUsers: {
with: {
user: true,
},
},
applicableProducts: {
with: {
product: true,
},
},
},
});
if (!result) { if (!result) {
throw new Error("Coupon not found"); throw new Error("Coupon not found");
@ -155,24 +225,27 @@ export const couponRouter = router({
} }
} }
// If updating to user-based, applicableUsers is required // If updating to user-based, applicableUsers is required
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
const existingCount = await couponDbService.countApplicableUsers(id); const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
if (existingCount === 0) { if (existingCount === 0) {
throw new Error("applicableUsers is required for user-based coupons"); throw new Error("applicableUsers is required for user-based coupons");
}
} }
}
// If applicableUsers is provided, verify users exist // If applicableUsers is provided, verify users exist
if (updates.applicableUsers && updates.applicableUsers.length > 0) { if (updates.applicableUsers && updates.applicableUsers.length > 0) {
const existingUsers = await couponDbService.getUsersByIds(updates.applicableUsers); const existingUsers = await db.query.users.findMany({
if (existingUsers.length !== updates.applicableUsers.length) { where: inArray(users.id, updates.applicableUsers),
throw new Error("Some applicable users not found"); columns: { id: true },
} });
} if (existingUsers.length !== updates.applicableUsers.length) {
throw new Error("Some applicable users not found");
}
}
const updateData: any = { ...updates }; const updateData: any = { ...updates };
delete updateData.applicableUsers; delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
if (updates.discountPercent !== undefined) { if (updates.discountPercent !== undefined) {
updateData.discountPercent = updates.discountPercent?.toString(); updateData.discountPercent = updates.discountPercent?.toString();
} }
@ -182,38 +255,67 @@ export const couponRouter = router({
if (updates.minOrder !== undefined) { if (updates.minOrder !== undefined) {
updateData.minOrder = updates.minOrder?.toString(); updateData.minOrder = updates.minOrder?.toString();
} }
if (updates.maxValue !== undefined) { if (updates.maxValue !== undefined) {
updateData.maxValue = updates.maxValue?.toString(); updateData.maxValue = updates.maxValue?.toString();
} }
if (updates.validTill !== undefined) { if (updates.validTill !== undefined) {
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
} }
const result = await couponDbService.updateCoupon(id, updateData); const result = await db.update(coupons)
.set(updateData)
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
console.log('updated coupon successfully')
// Update applicable users: delete existing and insert new // Update applicable users: delete existing and insert new
if (updates.applicableUsers !== undefined) { if (updates.applicableUsers !== undefined) {
await couponDbService.removeAllApplicableUsers(id); await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
if (updates.applicableUsers.length > 0) { if (updates.applicableUsers.length > 0) {
await couponDbService.addApplicableUsers(id, updates.applicableUsers); await db.insert(couponApplicableUsers).values(
updates.applicableUsers.map(userId => ({
couponId: id,
userId,
}))
);
} }
} }
// Update applicable products: delete existing and insert new // Update applicable products: delete existing and insert new
if (updates.applicableProducts !== undefined) { if (updates.applicableProducts !== undefined) {
await couponDbService.removeAllApplicableProducts(id); await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
if (updates.applicableProducts.length > 0) { if (updates.applicableProducts.length > 0) {
await couponDbService.addApplicableProducts(id, updates.applicableProducts); await db.insert(couponApplicableProducts).values(
updates.applicableProducts.map(productId => ({
couponId: id,
productId,
}))
);
} }
} }
return result; return result[0];
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await couponDbService.invalidateCoupon(input.id); const { id } = input;
const result = await db.update(coupons)
.set({ isInvalidated: true })
.where(eq(coupons.id, id))
.returning();
if (result.length === 0) {
throw new Error("Coupon not found");
}
return { message: "Coupon invalidated successfully" }; return { message: "Coupon invalidated successfully" };
}), }),
@ -226,9 +328,14 @@ export const couponRouter = router({
return { valid: false, message: "Invalid coupon code" }; return { valid: false, message: "Invalid coupon code" };
} }
const coupon = await couponDbService.getCouponByCode(code.toUpperCase()); const coupon = await db.query.coupons.findFirst({
where: and(
eq(coupons.couponCode, code.toUpperCase()),
eq(coupons.isInvalidated, false)
),
});
if (!coupon || coupon.isInvalidated) { if (!coupon) {
return { valid: false, message: "Coupon not found or invalidated" }; return { valid: false, message: "Coupon not found or invalidated" };
} }
@ -263,71 +370,115 @@ export const couponRouter = router({
discountAmount = maxValueLimit; discountAmount = maxValueLimit;
} }
return { return {
valid: true, valid: true,
discountAmount, discountAmount,
coupon: { coupon: {
id: coupon.id, id: coupon.id,
discountPercent: coupon.discountPercent, discountPercent: coupon.discountPercent,
flatDiscount: coupon.flatDiscount, flatDiscount: coupon.flatDiscount,
maxValue: coupon.maxValue, maxValue: coupon.maxValue,
} }
}; };
}), }),
generateCancellationCoupon: protectedProcedure generateCancellationCoupon: protectedProcedure
.input(z.object({ orderId: z.number() })) .input(
.mutation(async ({ input, ctx }) => { z.object({
const { orderId } = input; orderId: z.number(),
})
)
.mutation(async ({ input, ctx }) => {
const { orderId } = input;
const staffUserId = ctx.staffUser?.id; // Get staff user ID from auth middleware
if (!staffUserId) { const staffUserId = ctx.staffUser?.id;
throw new Error("Unauthorized"); if (!staffUserId) {
} throw new Error("Unauthorized");
}
const order = await couponDbService.getOrderByIdWithUserAndStatus(orderId); // Find the order with user and order status information
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
user: true,
orderStatus: true,
},
});
if (!order) { if (!order) {
throw new Error("Order not found"); throw new Error("Order not found");
} }
if (!order.user) { // Check if order is cancelled (check if any status entry has isCancelled: true)
throw new Error("User not found for this order"); // const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
} // if (!isOrderCancelled) {
// throw new Error("Order is not cancelled");
// }
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); // // Check if payment method is COD
const couponCode = `${userNamePrefix}${orderId}`; // if (order.isCod) {
// throw new Error("Can't generate refund coupon for CoD Order");
// }
const existingCoupon = await couponDbService.getCouponByCode(couponCode); // Verify user exists
if (existingCoupon) { if (!order.user) {
throw new Error("Coupon code already exists"); throw new Error("User not found for this order");
} }
const orderAmount = parseFloat(order.totalAmount); // Generate coupon code: first 3 letters of user name or mobile + orderId
const expiryDate = new Date(); const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
expiryDate.setDate(expiryDate.getDate() + 30); const couponCode = `${userNamePrefix}${orderId}`;
const coupon = await couponDbService.withTransaction(async (tx) => { // Check if coupon code already exists
const newCoupon = await couponDbService.createCoupon({ const existingCoupon = await db.query.coupons.findFirst({
couponCode, where: eq(coupons.couponCode, couponCode),
isUserBased: true, });
flatDiscount: orderAmount.toString(),
minOrder: orderAmount.toString(),
maxValue: orderAmount.toString(),
validTill: expiryDate,
maxLimitForUser: 1,
createdBy: staffUserId,
isApplyForAll: false,
});
await couponDbService.addApplicableUsers(newCoupon.id, [order.userId]); if (existingCoupon) {
await couponDbService.updateOrderStatusRefundCoupon(orderId, newCoupon.id); throw new Error("Coupon code already exists");
}
return newCoupon; // Get order total amount
}); const orderAmount = parseFloat(order.totalAmount);
return coupon; // Calculate expiry date (30 days from now)
}), const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 30);
// Create the coupon and update order status in a transaction
const coupon = await db.transaction(async (tx) => {
// Create the coupon
const result = 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();
const coupon = result[0];
// Insert applicable users
await tx.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: order.userId,
});
// Update order_status with refund coupon ID
await tx.update(orderStatus)
.set({ refundCouponId: coupon.id })
.where(eq(orderStatus.orderId, orderId));
return coupon;
});
return coupon;
}),
getReservedCoupons: protectedProcedure getReservedCoupons: protectedProcedure
.input(z.object({ .input(z.object({
@ -336,52 +487,100 @@ export const couponRouter = router({
search: z.string().optional(), search: z.string().optional(),
})) }))
.query(async ({ input }) => { .query(async ({ input }) => {
const result = await couponDbService.getReservedCoupons(input); const { cursor, limit, search } = input;
let whereCondition = undefined;
const conditions = [];
const hasMore = result.length > input.limit; if (cursor) {
const coupons = hasMore ? result.slice(0, input.limit) : result; 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: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
limit: limit + 1, // Fetch one extra to check if there's more
});
const hasMore = result.length > limit;
const coupons = hasMore ? result.slice(0, limit) : result;
const nextCursor = hasMore ? result[result.length - 1].id : undefined; const nextCursor = hasMore ? result[result.length - 1].id : undefined;
return { coupons, nextCursor }; return {
coupons,
nextCursor,
};
}), }),
createReservedCoupon: protectedProcedure createReservedCoupon: protectedProcedure
.input(createCouponBodySchema) .input(createCouponBodySchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { couponCode, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
// Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
} }
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id; const staffUserId = ctx.staffUser?.id;
if (!staffUserId) { if (!staffUserId) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
// Generate secret code if not provided (use couponCode as base)
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
const existing = await couponDbService.getCouponByCode(secretCode); // Check if secret code already exists
const existing = await db.query.reservedCoupons.findFirst({
where: eq(reservedCoupons.secretCode, secretCode),
});
if (existing) { if (existing) {
throw new Error("Secret code already exists"); throw new Error("Secret code already exists");
} }
const coupon = await couponDbService.createReservedCoupon({ const result = await db.insert(reservedCoupons).values({
secretCode, secretCode,
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
discountPercent: discountPercent?.toString() || null, discountPercent: discountPercent?.toString(),
flatDiscount: flatDiscount?.toString() || null, flatDiscount: flatDiscount?.toString(),
minOrder: minOrder?.toString() || null, minOrder: minOrder?.toString(),
productIds: productIds || null, productIds,
maxValue: maxValue?.toString() || null, maxValue: maxValue?.toString(),
validTill: validTill ? dayjs(validTill).toDate() : null, validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser: maxLimitForUser || null, maxLimitForUser,
exclusiveApply: exclusiveApply || false, exclusiveApply: exclusiveApply || false,
createdBy: staffUserId, createdBy: staffUserId,
}); }).returning();
const coupon = result[0];
// Insert applicable products if provided
if (applicableProducts && applicableProducts.length > 0) { if (applicableProducts && applicableProducts.length > 0) {
await couponDbService.addApplicableProducts(coupon.id, applicableProducts); await db.insert(couponApplicableProducts).values(
applicableProducts.map(productId => ({
couponId: coupon.id,
productId,
}))
);
} }
return coupon; return coupon;
@ -394,11 +593,27 @@ export const couponRouter = router({
offset: z.number().min(0).default(0), offset: z.number().min(0).default(0),
})) }))
.query(async ({ input }) => { .query(async ({ input }) => {
const { search, limit, offset } = input; const { search, limit } = input;
const userList = search let whereCondition = undefined;
? await couponDbService.getUsersBySearch(search, limit, offset) if (search && search.trim()) {
: await couponDbService.getUsersByIds([]); 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: input.offset,
orderBy: (users, { asc }) => [asc(users.name)],
});
return { return {
users: userList.map(user => ({ users: userList.map(user => ({
@ -409,68 +624,88 @@ export const couponRouter = router({
}; };
}), }),
createCoupon: protectedProcedure createCoupon: protectedProcedure
.input(z.object({ mobile: z.string().min(1, 'Mobile number is required') })) .input(z.object({
.mutation(async ({ input, ctx }) => { mobile: z.string().min(1, 'Mobile number is required'),
const { mobile } = input; }))
.mutation(async ({ input, ctx }) => {
const { mobile } = input;
const staffUserId = ctx.staffUser?.id; // Get staff user ID from auth middleware
if (!staffUserId) { const staffUserId = ctx.staffUser?.id;
throw new Error("Unauthorized"); if (!staffUserId) {
} throw new Error("Unauthorized");
}
const cleanMobile = mobile.replace(/\D/g, ''); // Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, '');
if (cleanMobile.length !== 10) { // Validate: exactly 10 digits
throw new Error("Mobile number must be exactly 10 digits"); if (cleanMobile.length !== 10) {
} throw new Error("Mobile number must be exactly 10 digits");
}
let user = await couponDbService.getUserByMobile(cleanMobile); // Check if user exists, create if not
let user = await db.query.users.findFirst({
if (!user) { where: eq(users.mobile, cleanMobile),
user = await couponDbService.createUser({
name: null,
email: null,
mobile: cleanMobile,
}); });
}
const timestamp = Date.now().toString().slice(-6); if (!user) {
const random = Math.random().toString(36).substring(2, 6).toUpperCase(); // Create new user
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; const [newUser] = await db.insert(users).values({
name: null,
email: null,
mobile: cleanMobile,
}).returning();
user = newUser;
}
const existingCode = await couponDbService.getCouponByCode(couponCode); // Generate unique coupon code
if (existingCode) { const timestamp = Date.now().toString().slice(-6);
throw new Error("Generated coupon code already exists - please try again"); const random = Math.random().toString(36).substring(2, 6).toUpperCase();
} const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
const coupon = await couponDbService.createCoupon({ // Check if coupon code already exists (very unlikely but safe)
couponCode, const existingCode = await db.query.coupons.findFirst({
isUserBased: true, where: eq(coupons.couponCode, couponCode),
discountPercent: "20", });
minOrder: "1000",
maxValue: "500",
maxLimitForUser: 1,
isApplyForAll: false,
exclusiveApply: false,
createdBy: staffUserId,
validTill: dayjs().add(90, 'days').toDate(),
});
await couponDbService.addApplicableUsers(coupon.id, [user.id]); if (existingCode) {
throw new Error("Generated coupon code already exists - please try again");
}
return { // Create the coupon
success: true, const [coupon] = await db.insert(coupons).values({
coupon: { couponCode,
id: coupon.id, isUserBased: true,
couponCode: coupon.couponCode, discountPercent: "20", // 20% discount
minOrder: "1000", // ₹1000 minimum order
maxValue: "500", // ₹500 maximum discount
maxLimitForUser: 1, // One-time use
isApplyForAll: false,
exclusiveApply: false,
createdBy: staffUserId,
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
}).returning();
// Associate coupon with user
await db.insert(couponApplicableUsers).values({
couponId: coupon.id,
userId: user.id, userId: user.id,
userMobile: user.mobile, });
discountPercent: 20,
minOrder: 1000, return {
maxValue: 500, success: true,
maxLimitForUser: 1, coupon: {
}, id: coupon.id,
}; couponCode: coupon.couponCode,
}), userId: user.id,
userMobile: user.mobile,
discountPercent: 20,
minOrder: 1000,
maxValue: 500,
maxLimitForUser: 1,
},
};
}),
}); });

Some files were not shown because too many files have changed in this diff Show more