Compare commits

..

38 commits

Author SHA1 Message Date
shafi54
17e2644759 enh 2026-03-24 18:48:31 +05:30
shafi54
23be301cc0 enh 2026-03-23 11:17:38 +05:30
shafi54
6ff1fd63e5 enh 2026-03-23 10:57:28 +05:30
shafi54
ea848992c9 enh 2026-03-23 04:26:04 +05:30
shafi54
95d2c861c0 enh 2026-03-22 21:29:04 +05:30
shafi54
a23d3bf5b8 enh 2026-03-22 20:20:28 +05:30
shafi54
56b606ebcf enh 2026-03-22 20:20:18 +05:30
shafi54
cd5ab79f44 enh 2026-03-22 16:52:25 +05:30
shafi54
b49015b446 enh 2026-03-22 16:38:37 +05:30
shafi54
a0a05615b1 enh 2026-03-22 16:35:39 +05:30
shafi54
501667a4d2 enh 2026-03-22 16:11:01 +05:30
shafi54
1122159552 enh 2026-03-22 14:57:53 +05:30
shafi54
8f4cddee1a enh 2026-03-21 22:28:45 +05:30
shafi54
77e3eb21d6 enh 2026-03-21 20:59:45 +05:30
shafi54
b38ff13950 enh 2026-03-20 14:48:31 +05:30
shafi54
e2abc7cb02 enh 2026-03-20 00:41:36 +05:30
shafi54
4f1f52ffee enh 2026-03-20 00:40:31 +05:30
shafi54
71cad727fd enh 2026-03-20 00:39:48 +05:30
shafi54
44e53d2978 enh 2026-03-16 22:15:47 +05:30
shafi54
a5bde12f19 enh 2026-03-16 21:18:14 +05:30
shafi54
31029cc3a7 enh 2026-03-16 21:15:07 +05:30
shafi54
a4758ea9cd enh 2026-03-16 21:14:23 +05:30
shafi54
0c84808637 enh 2026-03-16 19:55:06 +05:30
shafi54
f2763b0597 enh 2026-03-16 18:20:40 +05:30
shafi54
8f48ec39c2 enh 2026-03-16 18:10:28 +05:30
shafi54
5d598b0752 enh 2026-03-15 23:23:44 +05:30
shafi54
4aab508286 enh 2026-03-15 23:23:33 +05:30
shafi54
ad2447d14e enh 2026-03-15 22:10:52 +05:30
shafi54
b4caa383b5 enh 2026-03-15 21:26:00 +05:30
shafi54
a7350914e0 enh 2026-03-15 21:11:54 +05:30
76c43d869d Merge pull request 'enh' (#3) from main into api_cache
Reviewed-on: #3
2026-03-14 12:31:48 +00:00
shafi54
2d37726c62 enh 2026-03-14 17:25:41 +05:30
shafi54
5df040de9a enh 2026-03-12 19:26:21 +05:30
shafi54
ca9eb8a7d2 enh 2026-03-11 16:31:23 +05:30
shafi54
aa900db3e1 enh 2026-03-10 14:20:21 +05:30
shafi54
f7c55ea492 enh 2026-03-10 14:20:14 +05:30
shafi54
c14e32522a enh 2026-03-10 13:05:33 +05:30
shafi54
a4218ee1ad enh 2026-03-10 10:03:49 +05:30
688 changed files with 39631 additions and 40859 deletions

9
.dockerignore Normal file
View file

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

4
APIS_TO_REMOVE.md Normal file
View file

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

File diff suppressed because one or more lines are too long

View file

@ -227,7 +227,6 @@ export default function Layout() {
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
<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="user-management" options={{ title: "User Management" }} />

View file

@ -1,108 +0,0 @@
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[] = [
{
title: 'Manage Orders',
title: 'Manage Orderss',
icon: 'shopping-bag',
description: 'View and manage customer orders',
route: '/(drawer)/manage-orders',
@ -158,6 +158,15 @@ export default function Dashboard() {
iconColor: '#8B5CF6',
iconBg: '#F3E8FF',
},
{
title: 'Stocking Schedules',
icon: 'schedule',
description: 'Manage product stocking schedules',
route: '/(drawer)/stocking-schedules',
category: 'products',
iconColor: '#0EA5E9',
iconBg: '#E0F2FE',
},
{
title: 'Stores',
icon: 'store',
@ -175,15 +184,6 @@ export default function Dashboard() {
category: 'marketing',
iconColor: '#F97316',
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',

View file

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

View file

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

View file

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

View file

@ -2,13 +2,13 @@ import React from 'react';
import { Alert } from 'react-native';
import { AppContainer } from 'common-ui';
import ProductForm from '@/src/components/ProductForm';
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client';
export default function AddProduct() {
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
const createProduct = trpc.admin.product.createProduct.useMutation();
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
const payload: CreateProductPayload = {
const handleSubmit = (values: any, imageKeys?: string[]) => {
createProduct.mutate({
name: values.name,
shortDescription: values.shortDescription,
longDescription: values.longDescription,
@ -18,37 +18,12 @@ export default function AddProduct() {
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
};
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
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, {
isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
tagIds: values.tagIds || [],
imageKeys: imageKeys || [],
}, {
onSuccess: (data) => {
Alert.alert('Success', 'Product created successfully!');
// Reset form or navigate
@ -81,7 +56,7 @@ export default function AddProduct() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isCreating}
isLoading={createProduct.isPending}
existingImages={[]}
/>
</AppContainer>

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

@ -2,10 +2,11 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
import { View, TouchableOpacity, Alert } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
export interface StoreFormData {
name: string;
@ -15,6 +16,12 @@ export interface StoreFormData {
products: number[];
}
interface StoreImage {
uri: string;
mimeType: string;
isExisting: boolean;
}
export interface StoreFormRef {
// Add methods if needed
}
@ -27,6 +34,11 @@ interface StoreFormProps {
storeId?: number;
}
// Extend Formik values with images array
interface FormikStoreValues extends StoreFormData {
images: StoreImage[];
}
const validationSchema = Yup.object().shape({
name: Yup.string().required('Name is required'),
description: Yup.string(),
@ -40,9 +52,23 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
// Build initial form values with images array
const buildInitialValues = (): FormikStoreValues => {
const images: StoreImage[] = [];
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
const initialSelectedProducts = useMemo(() => {
@ -54,7 +80,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
useEffect(() => {
setFormInitialValues({
...initialValues,
...buildInitialValues(),
products: initialSelectedProducts,
});
}, [initialValues, initialSelectedProducts]);
@ -64,41 +90,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
value: staff.id,
})) || [];
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);
}
};
const { uploadSingle, isUploading } = useUploadToObjectStorage();
return (
<Formik
@ -108,51 +100,78 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
enableReinitialize
>
{({ 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 () => {
try {
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store',
mimeTypes,
});
// Get new images that need to be uploaded
const newImages = values.images.filter(img => !img.isExisting);
// Upload images
for (let i = 0; i < uploadUrls.length; i++) {
const uploadUrl = uploadUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
if (newImages.length > 0) {
// Upload the first new image (single image for stores)
const image = newImages[0];
const response = await fetch(image.uri);
const imageBlob = await response.blob();
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
imageUrl = key;
} else {
// Check if there's an existing image remaining
const existingImage = values.images.find(img => img.isExisting);
if (existingImage) {
imageUrl = existingImage.uri;
}
}
// 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
onSubmit({ ...values, imageUrl });
// Submit form with imageUrl (without images array)
const { images, ...submitValues } = values;
onSubmit({ ...submitValues, imageUrl });
} catch (error) {
console.error('Upload error:', error);
Alert.alert('Error', 'Failed to upload image');
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
}
};
// Prepare images for ImageUploaderNeo (convert to expected format)
const imagesForUploader = (values.images || []).map(img => ({
uri: img.uri,
mimeType: img.mimeType,
}));
return (
<View>
<MyTextInput
@ -193,22 +212,21 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
/>
<View style={tw`mb-6`}>
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader
images={displayImages}
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
onAddImage={handleImagePick}
<ImageUploaderNeo
images={imagesForUploader}
onUploadImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
allowMultiple={false}
/>
</View>
<TouchableOpacity
onPress={submit}
disabled={isLoading || generateUploadUrls.isPending}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText>
</TouchableOpacity>
</View>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,12 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { View, TouchableOpacity, Alert } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
interface StoreOption {
id: number;
@ -23,7 +24,7 @@ interface TagFormProps {
mode: 'create' | 'edit';
initialValues: TagFormData;
existingImageUrl?: string;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void;
isLoading: boolean;
stores?: StoreOption[];
}
@ -36,24 +37,35 @@ const TagForm = forwardRef<any, TagFormProps>(({
isLoading,
stores = [],
}, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null);
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const { uploadSingle, isUploading } = useUploadToObjectStorage();
// Update checkbox when initial values change
useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl})
}, [initialValues.isDashboardTag]);
const pickImage = usePickImage({
setFile: (files) => {
setFile: async (assets: any) => {
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
setNewImage(null);
return;
}
setImage(files || null)
const asset = Array.isArray(assets) ? assets[0] : assets;
const response = await fetch(asset.uri);
const blob = await response.blob();
setNewImage({
blob,
mimeType: asset.mimeType || 'image/jpeg',
uri: asset.uri
});
},
multiple: false,
});
const validationSchema = Yup.object().shape({
tagName: Yup.string()
.required('Tag name is required')
@ -63,18 +75,44 @@ const TagForm = forwardRef<any, TagFormProps>(({
.max(500, 'Description must be less than 500 characters'),
});
// Display images for ImageUploader
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
const existingImages = existingImageUrl ? [existingImageUrl] : [];
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, image || undefined)}
onSubmit={async (values) => {
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
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImage(null);
setNewImage(null);
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
@ -107,11 +145,15 @@ const TagForm = forwardRef<any, TagFormProps>(({
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
</MyText>
<ImageUploader
images={image ? [image] : []}
images={displayImages}
existingImageUrls={mode === 'edit' ? existingImages : []}
onAddImage={pickImage}
onRemoveImage={() => setImage(null)}
onRemoveImage={() => setNewImage(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>
@ -122,7 +164,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
onPress={() => {
const newValue = !isDashboardTagChecked;
setIsDashboardTagChecked(newValue);
formikSetFieldValue('isDashboardTag', newValue);
setFieldValue('isDashboardTag', newValue);
}}
/>
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
@ -143,7 +185,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
}))}
onValueChange={(selectedValues) => {
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
formikSetFieldValue('relatedStores', numericValues);
setFieldValue('relatedStores', numericValues);
}}
multiple={true}
/>
@ -151,11 +193,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
<TouchableOpacity
onPress={() => handleSubmit()}
disabled={isLoading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
disabled={isLoading || isUploading}
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
</MyText>
</TouchableOpacity>
</View>

View file

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

View file

@ -1,7 +1,10 @@
ENV_MODE=PROD
# 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_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
PHONE_PE_CLIENT_VERSION=1
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
@ -17,10 +20,14 @@ S3_REGION=apac
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
S3_BUCKET_NAME=meatfarmer
S3_BUCKET_NAME=meatfarmer-dev
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
JWT_SECRET=my_meatfarmer_jwt_secret_key
ASSETS_DOMAIN=https://assets.freshyo.in/
ASSETS_DOMAIN=https://assets2.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@57.128.212.174:6379
APP_URL=http://localhost:4000

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,14 @@
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,6 +540,13 @@
"when": 1772637259874,
"tag": "0076_sturdy_wolverine",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1773927855512,
"tag": "0077_wakeful_norrin_radd",
"breakpoints": true
}
]
}

View file

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

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

189
apps/backend/index-express.ts Executable file
View file

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

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

@ -1,185 +1,167 @@
import 'dotenv/config';
import express, { NextFunction, Request, Response } from "express";
import cors from "cors";
// import bodyParser from "body-parser";
import multer from "multer";
import path from "path";
import fs from "fs";
import { 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 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';
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { serve } from 'bun'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/src/trpc/router'
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils'
import { db } from '@/src/db/db_index'
import { staffUsers, userDetails } from '@/src/db/schema'
import { eq } from 'drizzle-orm'
import { TRPCError } from '@trpc/server'
import signedUrlCache from '@/src/lib/signed-url-cache'
import { seed } from '@/src/db/seed'
import initFunc from '@/src/lib/init'
import '@/src/jobs/jobs-index'
import { startAutomatedJobs } from '@/src/lib/automatedJobs'
// Initialize
seed()
initFunc()
startAutomatedJobs()
const app = express();
const app = new Hono()
app.use(cors({
origin: 'http://localhost:5174'
}));
// CORS middleware
app.use('*', cors({
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())
signedUrlCache.loadFromDisk();
// Health check
app.get('/health', (c) => {
return c.json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
message: 'Hello world'
})
})
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({
// tRPC handler with context
app.use('/api/trpc/*', async (c) => {
const response = await fetchRequestHandler({
endpoint: '/api/trpc',
req: c.req.raw,
router: appRouter,
createContext: async ({ req, res }) => {
let user = null;
let staffUser = null;
const authHeader = req.headers.authorization;
createContext: async ({ req }) => {
let user = null
let staffUser = null
const authHeader = req.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const token = authHeader.substring(7)
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
const decoded = await verifyToken(token)
// Check if this is a staff token (has staffId)
if (decoded.staffId) {
// This is a staff token, verify staff exists
if ('staffId' in decoded) {
const staffPayload = decoded as StaffJWTPayload
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, decoded.staffId),
});
where: eq(staffUsers.id, staffPayload.staffId)
})
if (staff) {
user=staffUser
staffUser = {
id: staff.id,
name: staff.name,
};
staffUser = { id: staff.id, name: staff.name }
}
} else {
const userPayload = decoded as UserJWTPayload
user = {
userId: userPayload.userId,
name: userPayload.name,
email: userPayload.email,
mobile: userPayload.mobile
}
// 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),
});
where: eq(userDetails.userId, userPayload.userId)
})
if (details?.isSuspended) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account suspended',
});
message: 'Account suspended'
})
}
}
} catch (err) {
// Invalid token, both user and staffUser remain null
} catch {
// Invalid token
}
}
return { req, res, user, staffUser };
return { req, res: c.res, user, staffUser }
},
onError({ error, path, type, ctx }) {
onError: ({ error, path, 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)
userId: ctx?.user?.userId
})
app.get(/.*/, (req,res) => {
res.sendFile(fallbackUiIndex)
}
})
} else {
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
return response
})
// Static files - Fallback UI
app.use('/*', async (c) => {
const url = new URL(c.req.url)
let filePath = url.pathname
// Default to index.html for root
if (filePath === '/') {
filePath = '/index.html'
}
// 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);
// 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
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.onError((err, c) => {
console.error('Error:', err)
app.listen(4000, '::', () => {
console.log("Server is running on http://localhost:4000/api/mobile/");
});
const status = err instanceof TRPCError
? (err.code === 'UNAUTHORIZED' ? 401 : 500)
: 500
const message = err.message || 'Internal Server Error'
return c.json({ message }, status)
})
// Start server
serve({
fetch: app.fetch,
port: 4000,
hostname: '0.0.0.0'
})
console.log('🚀 Server running on http://localhost:4000')

View file

@ -4,14 +4,21 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"migrate": "drizzle-kit generate:pg",
"migrate": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
"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",
"build2": "rimraf ./dist && tsc",
"db:push": "drizzle-kit push:pg",
"db:push": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
"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",
"dev2": "tsx watch index.ts",
"dev_node": "tsx watch index.ts",
"dev:express": "bun --watch index-express.ts",
"dev:hono": "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:push": "docker push mohdshafiuddin54/health_petal:latest"
},
@ -26,8 +33,6 @@
"@turf/turf": "^7.2.0",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"axios": "^1.11.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.63.0",
@ -36,18 +41,16 @@
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0",
"express": "^5.1.0",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"hono": "^4.6.3",
"jose": "^5.10.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"razorpay": "^2.9.6",
"redis": "^5.9.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/bun": "^1.1.10",
"@types/node": "^24.5.2",
"@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4",

BIN
apps/backend/sqlite.db Normal file

Binary file not shown.

View file

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

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

@ -1,303 +0,0 @@
import { Request, Response } from "express";
import { db } from "@/src/db/db_index";
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error";
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
import { deleteS3Image } from "@/src/lib/delete-image";
import type { SpecialDeal } from "@/src/db/types";
import { 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

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

View file

@ -1,14 +0,0 @@
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,85 +1,19 @@
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 { Context } from 'hono'
/**
* 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;
};
import { getProductsSummaryData } from '@/src/db/common-product'
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
/**
* Get all products summary for dropdown
*/
export const getAllProductsSummary = async (req: Request, res: Response) => {
export const getAllProductsSummary = async (c: Context) => {
try {
const { tagId } = req.query;
const tagIdNum = tagId ? parseInt(tagId as string) : null;
const tagId = c.req.query('tagId')
const tagIdNum = tagId ? parseInt(tagId) : null
let productIds: number[] | null = null;
const productsWithUnits = await getProductsSummaryData(tagIdNum)
// If tagId is provided, get products that have this tag
if (tagIdNum) {
const taggedProducts = await db
.select({ productId: productTags.productId })
.from(productTags)
.where(eq(productTags.tagId, tagIdNum));
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 {
const formattedProducts = productsWithUnits.map((product) => ({
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
@ -88,18 +22,16 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
unit: product.unitShortNotation,
productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
images: scaffoldAssetUrl((product.images as string[]) || []),
};
})
);
}))
return res.status(200).json({
return c.json({
products: formattedProducts,
count: formattedProducts.length,
});
})
} catch (error) {
console.error("Get products summary error:", error);
return res.status(500).json({ error: "Failed to fetch products summary" });
console.error('Get products summary error:', error)
return c.json({ error: 'Failed to fetch products summary' }, 500)
}
}
};

View file

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

View file

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

View file

@ -0,0 +1,10 @@
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 Executable file → Normal file
View file

@ -1,8 +1,10 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import path from "path"
import * as schema from "@/src/db/schema"
import { db as postgresDb } from '@db-helper-postgres/db/db_index'
import { db as sqliteDb } from '@db-helper-sqlite/db/db_index'
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
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 }

View file

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

View file

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

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

@ -1,689 +1 @@
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] }),
}));
export * from './schema-sqlite'

View file

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

View file

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

View file

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

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

View file

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

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

@ -1,6 +0,0 @@
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,46 +1,54 @@
import { eq } from "drizzle-orm";
import { db } from "@/src/db/db_index"
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
import { s3Url } from "@/src/lib/env-exporter"
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from '@/src/lib/s3-client'
import { assetsDomain, s3Url } from '@/src/lib/env-exporter'
function extractS3Key(url: string): string | null {
try {
// 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
// const comIndex = originalUrl.indexOf(".com/");
const baseUrlIndex = originalUrl.indexOf(s3Url);
const baseUrlIndex = originalUrl.indexOf(s3Url)
// If '.com/' is found, return everything after it
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) {
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;
return null
}
export async function deleteS3Image(imageUrl: string) {
try {
// First check if this is a signed URL and get the original if it is
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
const key = extractS3Key(originalUrl || "");
let key: string | null = ''
if (!key) {
throw new Error("Invalid image URL format");
if (imageUrl.includes(assetsDomain)) {
key = imageUrl.replace(assetsDomain, '')
}
const deleteS3 = await deleteImageUtil({keys: [key] });
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) {
throw new Error('Invalid image URL format')
}
const deleteS3 = await deleteImageUtil({ keys: [key] })
if (!deleteS3) {
throw new Error("Failed to delete image from S3");
throw new Error('Failed to delete image from S3')
}
} catch (error) {
console.error("Error deleting image from S3:", error);
console.error('Error deleting image from S3:', error)
}
}

View file

@ -17,6 +17,12 @@ export const s3Region = process.env.S3_REGION as string
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
export const apiCacheKey = process.env.API_CACHE_KEY as string;
export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
export const s3Url = process.env.S3_URL as string
export const redisUrl = process.env.REDIS_URL as string

View file

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

View file

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

View file

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

@ -0,0 +1,23 @@
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,5 +1,3 @@
import { db } from "@/src/db/db_index"
/**
* Constants for role names to avoid hardcoding and typos
*/

View file

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

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

@ -1,8 +1,3 @@
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;
@ -16,18 +11,7 @@ class SignedURLCache {
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')
}
console.log('SignedURLCache: Initialized (in-memory only)');
}
/**
@ -110,7 +94,7 @@ class SignedURLCache {
clear(): void {
this.originalToSignedCache.clear();
this.signedToOriginalCache.clear();
this.saveToDisk();
console.log('SignedURLCache: Cleared all entries');
}
/**
@ -145,119 +129,27 @@ class SignedURLCache {
}
/**
* Save the cache to disk
* Get cache statistics
*/
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
getStats(): { totalEntries: number } {
return {
totalEntries: this.originalToSignedCache.size
};
}
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
* Stub methods for backward compatibility - do nothing in in-memory mode
*/
saveToDisk(): void {
// No-op: In-memory cache only
}
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();
}
// No-op: In-memory cache only
}
}
// 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,263 @@
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

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

View file

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

View file

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

View file

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

@ -4,6 +4,11 @@ import { initializeProducts } from '@/src/stores/product-store'
import { initializeProductTagStore } from '@/src/stores/product-tag-store'
import { initializeSlotStore } from '@/src/stores/slot-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
@ -29,8 +34,27 @@ export const initializeAllStores = async (): Promise<void> => {
]);
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) {
console.error('Application stores initialization failed:', 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,13 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,28 +1,15 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod";
import { db } from "@/src/db/db_index"
import {
orders,
orderItems,
orderStatus,
users,
addresses,
refunds,
coupons,
couponUsage,
complaints,
payments,
} from "@/src/db/schema";
import { eq, and, gte, lt, desc, SQL, inArray } from "drizzle-orm";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { ApiError } from "@/src/lib/api-error"
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { ApiError } from '@/src/lib/api-error'
import {
sendOrderPackagedNotification,
sendOrderDeliveredNotification,
} from "@/src/lib/notif-job";
import { publishCancellation } from "@/src/lib/post-order-handler"
import { getMultipleUserNegativityScores } from "@/src/stores/user-negativity-store"
} from '@/src/lib/notif-job'
import { publishCancellation } from '@/src/lib/post-order-handler'
import { getMultipleUserNegativityScores } from '@/src/stores/user-negativity-store'
import { orderDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
const updateOrderNotesSchema = z.object({
orderId: z.number(),
@ -89,19 +76,13 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const { orderId, adminNotes } = input;
const result = await db
.update(orders)
.set({
adminNotes: adminNotes || null,
})
.where(eq(orders.id, orderId))
.returning();
const result = await orderDbService.updateOrderNotes(orderId, adminNotes || null)
if (result.length === 0) {
if (!result) {
throw new Error("Order not found");
}
return result[0];
return result;
}),
getFullOrder: protectedProcedure
@ -109,34 +90,14 @@ export const orderRouter = router({
.query(async ({ input }) => {
const { orderId } = input;
const orderData = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
user: true,
address: true,
slot: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
payment: true,
paymentInfo: true,
},
});
const orderData = await orderDbService.getOrderWithRelations(orderId)
if (!orderData) {
throw new Error("Order not found");
}
// Get order status separately
const statusRecord = await db.query.orderStatus.findFirst({
where: eq(orderStatus.orderId, orderId),
});
const statusRecord = await orderDbService.getOrderStatusByOrderId(orderId)
let status: "pending" | "delivered" | "cancelled" = "pending";
if (statusRecord?.isCancelled) {
@ -148,9 +109,7 @@ export const orderRouter = router({
// Get refund details if order is cancelled
let refund = null;
if (status === "cancelled") {
refund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, orderId),
});
refund = await orderDbService.getRefundByOrderId(orderId)
}
return {
@ -220,39 +179,14 @@ export const orderRouter = router({
const { orderId } = input;
// Single optimized query with all relations
const orderData = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
user: true,
address: true,
slot: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
payment: true,
paymentInfo: true,
orderStatus: true, // Include in main query
refunds: true, // Include in main query
},
});
const orderData = await orderDbService.getOrderWithDetails(orderId)
if (!orderData) {
throw new Error("Order not found");
}
// Get coupon usage for this specific order using new orderId field
const couponUsageData = await db.query.couponUsage.findMany({
where: eq(couponUsage.orderId, orderData.id), // Use new orderId field
with: {
coupon: true,
},
});
const couponUsageData = await orderDbService.getCouponUsageByOrderId(orderData.id)
let couponData = null;
if (couponUsageData.length > 0) {
@ -388,27 +322,15 @@ export const orderRouter = router({
const { orderId, isPackaged } = input;
// Update all order items to the specified packaged state
await db
.update(orderItems)
.set({ is_packaged: isPackaged })
.where(eq(orderItems.orderId, parseInt(orderId)));
const parsedOrderId = parseInt(orderId)
await orderDbService.updateOrderItemsPackaged(parsedOrderId, isPackaged)
// Also update the order status table for backward compatibility
if (!isPackaged) {
await db
.update(orderStatus)
.set({ isPackaged, isDelivered: false })
.where(eq(orderStatus.orderId, parseInt(orderId)));
} else {
await db
.update(orderStatus)
.set({ isPackaged })
.where(eq(orderStatus.orderId, parseInt(orderId)));
}
const currentStatus = await orderDbService.getOrderStatusByOrderId(parsedOrderId)
const isDelivered = !isPackaged ? false : currentStatus?.isDelivered || false
const order = await db.query.orders.findFirst({
where: eq(orders.id, parseInt(orderId)),
});
await orderDbService.updateOrderStatusPackaged(parsedOrderId, isPackaged, isDelivered)
const order = await orderDbService.getOrderById(parsedOrderId)
if (order) await sendOrderPackagedNotification(order.userId, orderId);
return { success: true };
@ -419,14 +341,10 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const { orderId, isDelivered } = input;
await db
.update(orderStatus)
.set({ isDelivered })
.where(eq(orderStatus.orderId, parseInt(orderId)));
const parsedOrderId = parseInt(orderId)
await orderDbService.updateOrderStatusDelivered(parsedOrderId, isDelivered)
const order = await db.query.orders.findFirst({
where: eq(orders.id, parseInt(orderId)),
});
const order = await orderDbService.getOrderById(parsedOrderId)
if (order) await sendOrderDeliveredNotification(order.userId, orderId);
return { success: true };
@ -438,9 +356,7 @@ export const orderRouter = router({
const { orderItemId, isPackaged, isPackageVerified } = input;
// Validate that orderItem exists
const orderItem = await db.query.orderItems.findFirst({
where: eq(orderItems.id, orderItemId),
});
const orderItem = await orderDbService.getOrderItemById(orderItemId)
if (!orderItem) {
throw new ApiError("Order item not found", 404);
@ -456,10 +372,7 @@ export const orderRouter = router({
}
// Update the order item
await db
.update(orderItems)
.set(updateData)
.where(eq(orderItems.id, orderItemId));
await orderDbService.updateOrderItem(orderItemId, updateData)
return { success: true };
}),
@ -469,9 +382,7 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const { orderId } = input;
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
});
const order = await orderDbService.getOrderById(orderId)
if (!order) {
throw new Error('Order not found');
@ -481,13 +392,7 @@ export const orderRouter = router({
const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0');
const newTotalAmount = currentTotalAmount - currentDeliveryCharge;
await db
.update(orders)
.set({
deliveryCharge: '0',
totalAmount: newTotalAmount.toString()
})
.where(eq(orders.id, orderId));
await orderDbService.removeDeliveryCharge(orderId, newTotalAmount.toString())
return { success: true, message: 'Delivery charge removed' };
}),
@ -497,27 +402,10 @@ export const orderRouter = router({
.query(async ({ input }) => {
const { slotId } = input;
const slotOrders = await db.query.orders.findMany({
where: eq(orders.slotId, parseInt(slotId)),
with: {
user: true,
address: true,
slot: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
},
});
const slotOrders = await orderDbService.getOrdersBySlotId(parseInt(slotId))
const filteredOrders = slotOrders.filter((order) => {
const statusRecord = order.orderStatus[0];
const statusRecord = order.orderStatus?.[0];
return (
order.isCod ||
(statusRecord && statusRecord.paymentStatus === "success")
@ -525,7 +413,7 @@ export const orderRouter = router({
});
const formattedOrders = filteredOrders.map((order) => {
const statusRecord = order.orderStatus[0]; // assuming one status per order
const statusRecord = order.orderStatus?.[0]; // assuming one status per order
let status: "pending" | "delivered" | "cancelled" = "pending";
if (statusRecord?.isCancelled) {
status = "cancelled";
@ -582,39 +470,14 @@ export const orderRouter = router({
const start = dayjs().startOf("day").toDate();
const end = dayjs().endOf("day").toDate();
let whereCondition = and(
gte(orders.createdAt, start),
lt(orders.createdAt, end)
);
if (slotId) {
whereCondition = and(
whereCondition,
eq(orders.slotId, parseInt(slotId))
);
}
const todaysOrders = await db.query.orders.findMany({
where: whereCondition,
with: {
user: true,
address: true,
slot: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
},
});
const todaysOrders = await orderDbService.getOrdersByDateRange(
start,
end,
slotId ? parseInt(slotId) : undefined
)
const filteredOrders = todaysOrders.filter((order) => {
const statusRecord = order.orderStatus[0];
const statusRecord = order.orderStatus?.[0];
return (
order.isCod ||
(statusRecord && statusRecord.paymentStatus === "success")
@ -622,7 +485,7 @@ export const orderRouter = router({
});
const formattedOrders = filteredOrders.map((order) => {
const statusRecord = order.orderStatus[0]; // assuming one status per order
const statusRecord = order.orderStatus?.[0]; // assuming one status per order
let status: "pending" | "delivered" | "cancelled" = "pending";
if (statusRecord?.isCancelled) {
status = "cancelled";
@ -677,16 +540,9 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const { addressId, latitude, longitude } = input;
const result = await db
.update(addresses)
.set({
adminLatitude: latitude,
adminLongitude: longitude,
})
.where(eq(addresses.id, addressId))
.returning();
const result = await orderDbService.updateAddressCoords(addressId, latitude, longitude)
if (result.length === 0) {
if (!result) {
throw new ApiError("Address not found", 404);
}
@ -707,78 +563,15 @@ export const orderRouter = router({
flashDeliveryFilter,
} = input;
let whereCondition: SQL<unknown> | undefined = eq(orders.id, orders.id); // always true
if (cursor) {
whereCondition = and(whereCondition, lt(orders.id, cursor));
}
if (slotId) {
whereCondition = and(whereCondition, eq(orders.slotId, slotId));
}
if (packagedFilter === "packaged") {
whereCondition = and(
whereCondition,
eq(orderStatus.isPackaged, true)
);
} else if (packagedFilter === "not_packaged") {
whereCondition = and(
whereCondition,
eq(orderStatus.isPackaged, false)
);
}
if (deliveredFilter === "delivered") {
whereCondition = and(
whereCondition,
eq(orderStatus.isDelivered, true)
);
} else if (deliveredFilter === "not_delivered") {
whereCondition = and(
whereCondition,
eq(orderStatus.isDelivered, false)
);
}
if (cancellationFilter === "cancelled") {
whereCondition = and(
whereCondition,
eq(orderStatus.isCancelled, true)
);
} else if (cancellationFilter === "not_cancelled") {
whereCondition = and(
whereCondition,
eq(orderStatus.isCancelled, false)
);
}
if (flashDeliveryFilter === "flash") {
whereCondition = and(
whereCondition,
eq(orders.isFlashDelivery, true)
);
} else if (flashDeliveryFilter === "regular") {
whereCondition = and(
whereCondition,
eq(orders.isFlashDelivery, false)
);
}
const allOrders = await db.query.orders.findMany({
where: whereCondition,
orderBy: desc(orders.createdAt),
limit: limit + 1, // fetch one extra to check if there's more
with: {
user: true,
address: true,
slot: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
},
});
const allOrders = await orderDbService.getAllOrdersWithFilters({
cursor,
limit,
slotId,
packagedFilter,
deliveredFilter,
cancellationFilter,
flashDeliveryFilter,
})
const hasMore = allOrders.length > limit;
const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders;
@ -787,7 +580,7 @@ export const orderRouter = router({
const negativityScores = await getMultipleUserNegativityScores(userIds);
const filteredOrders = ordersToReturn.filter((order) => {
const statusRecord = order.orderStatus[0];
const statusRecord = order.orderStatus?.[0];
return (
order.isCod ||
(statusRecord && statusRecord.paymentStatus === "success")
@ -795,7 +588,7 @@ export const orderRouter = router({
});
const formattedOrders = filteredOrders.map((order) => {
const statusRecord = order.orderStatus[0];
const statusRecord = order.orderStatus?.[0];
let status: "pending" | "delivered" | "cancelled" = "pending";
if (statusRecord?.isCancelled) {
status = "cancelled";
@ -868,21 +661,7 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const slotIds = input.slotIds;
const ordersList = await db.query.orders.findMany({
where: inArray(orders.slotId, slotIds),
with: {
orderItems: {
with: {
product: true
}
},
couponUsages: {
with: {
coupon: true
}
},
}
});
const ordersList = await orderDbService.getOrdersBySlotIds(slotIds)
const processedOrdersData = ordersList.map((order) => {
@ -921,19 +700,19 @@ export const orderRouter = router({
})
const updatedOrderIds: number[] = [];
await db.transaction(async (tx) => {
for (const { order, updatedOrderItems, newTotal } of processedOrdersData) {
await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id));
updatedOrderIds.push(order.id);
for (const item of updatedOrderItems) {
await tx.update(orderItems).set({
await orderDbService.updateOrdersAndItemsInTransaction(
processedOrdersData.map((entry) => ({
orderId: entry.order.id,
totalAmount: entry.newTotal.toString(),
items: entry.updatedOrderItems.map((item) => ({
id: item.id,
price: item.price,
discountedPrice: item.discountedPrice
}).where(eq(orderItems.id, item.id));
}
}
});
discountedPrice: item.discountedPrice || item.price,
})),
}))
)
processedOrdersData.forEach((entry) => updatedOrderIds.push(entry.order.id))
return { success: true, updatedOrders: updatedOrderIds, message: `Rebalanced ${updatedOrderIds.length} orders.` };
}),
@ -946,12 +725,7 @@ export const orderRouter = router({
.mutation(async ({ input }) => {
const { orderId, reason } = input;
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
orderStatus: true,
},
});
const order = await orderDbService.getOrderWithStatus(orderId)
if (!order) {
throw new ApiError("Order not found", 404);
@ -970,28 +744,13 @@ export const orderRouter = router({
throw new ApiError("Cannot cancel delivered order", 400);
}
const result = await db.transaction(async (tx) => {
await tx
.update(orderStatus)
.set({
isCancelled: true,
isCancelledByAdmin: true,
cancelReason: reason,
cancellationAdminNotes: reason,
cancellationReviewed: true,
cancellationReviewedAt: new Date(),
})
.where(eq(orderStatus.id, status.id));
await orderDbService.cancelOrderStatus(status.id, reason)
const refundStatus = order.isCod ? "na" : "pending";
const refundStatus = order.isCod ? 'na' : 'pending'
await tx.insert(refunds).values({
orderId: order.id,
refundStatus,
});
await orderDbService.createRefund(order.id, refundStatus)
return { orderId: order.id, userId: order.userId };
});
const result = { orderId: order.id, userId: order.userId }
// Publish to Redis for Telegram notification
await publishCancellation(result.orderId, 'admin', reason);
@ -1005,14 +764,5 @@ export const orderRouter = router({
type RefundStatus = "success" | "pending" | "failed" | "none" | "na";
export async function deleteOrderById(orderId: number): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(orderItems).where(eq(orderItems.orderId, orderId));
await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId));
await tx.delete(payments).where(eq(payments.orderId, orderId));
await tx.delete(refunds).where(eq(refunds.orderId, orderId));
await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId));
await tx.delete(complaints).where(eq(complaints.orderId, orderId));
await tx.delete(orders).where(eq(orders.id, orderId));
});
await orderDbService.deleteOrderById(orderId)
}

View file

@ -1,15 +1,7 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod";
import { db } from "@/src/db/db_index"
import {
orders,
orderStatus,
payments,
refunds,
} from "@/src/db/schema";
import { and, eq } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { RazorpayPaymentService } from "@/src/lib/payments-utils"
import { refundDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
const initiateRefundSchema = z
.object({
@ -37,18 +29,14 @@ export const adminPaymentsRouter = router({
const { orderId, refundPercent, refundAmount } = input;
// Validate order exists
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
});
const order = await refundDbService.getOrderById(orderId);
if (!order) {
throw new ApiError("Order not found", 404);
}
// Check if order is paid
const orderStatusRecord = await db.query.orderStatus.findFirst({
where: eq(orderStatus.orderId, orderId),
});
const orderStatusRecord = await refundDbService.getOrderStatusByOrderId(orderId);
if(order.isCod) {
throw new ApiError("Order is a Cash On Delivery. Not eligible for refund")
@ -76,54 +64,31 @@ export const adminPaymentsRouter = router({
throw new ApiError("Invalid refund parameters", 400);
}
let razorpayRefund = null;
let merchantRefundId = null;
let merchantRefundId = 'xxx'; //temporary suppressal
// Get payment record for online payments
const payment = await db.query.payments.findFirst({
where: and(
eq(payments.orderId, orderId),
eq(payments.status, "success")
),
});
const payment = await refundDbService.getSuccessfulPaymentByOrderId(orderId);
if (!payment || payment.status !== "success") {
throw new ApiError("Payment not found or not successful", 404);
}
const payload = payment.payload as any;
// Initiate Razorpay refund
razorpayRefund = await RazorpayPaymentService.initiateRefund(
payload.payment_id,
Math.round(calculatedRefundAmount * 100) // Convert to paisa
);
merchantRefundId = razorpayRefund.id;
// Check if refund already exists for this order
const existingRefund = await db.query.refunds.findFirst({
where: eq(refunds.orderId, orderId),
});
const existingRefund = await refundDbService.getRefundByOrderId(orderId);
const refundStatus = "initiated";
if (existingRefund) {
// Update existing refund
await db
.update(refunds)
.set({
await refundDbService.updateRefund(existingRefund.id, {
refundAmount: calculatedRefundAmount.toString(),
refundStatus,
merchantRefundId,
refundProcessedAt: order.isCod ? new Date() : null,
})
.where(eq(refunds.id, existingRefund.id));
});
} else {
// Insert new refund
await db
.insert(refunds)
.values({
await refundDbService.createRefund({
orderId,
refundAmount: calculatedRefundAmount.toString(),
refundStatus,

View file

@ -0,0 +1,128 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { refreshScheduleJobs } from '@/src/lib/automatedJobs';
import { scheduleDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
const createScheduleSchema = z.object({
scheduleName: z.string().min(1, "Schedule name is required"),
time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"),
action: z.enum(['in', 'out']),
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
groupIds: z.array(z.number().int().positive()).default([]),
});
const updateScheduleSchema = z.object({
id: z.number().int().positive(),
updates: createScheduleSchema.partial().extend({
scheduleName: z.string().min(1).optional(),
productIds: z.array(z.number().int().positive()).optional(),
groupIds: z.array(z.number().int().positive()).optional(),
}),
});
export const productAvailabilitySchedulesRouter = router({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
const { scheduleName, time, action, productIds, groupIds } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
}
// Check if schedule name already exists
const existingSchedule = await scheduleDbService.getScheduleByName(scheduleName);
if (existingSchedule) {
throw new Error("Schedule name already exists");
}
// Create schedule with arrays
const scheduleResult = await scheduleDbService.createSchedule({
scheduleName,
time,
action,
productIds,
groupIds,
});
// Refresh cron jobs to include new schedule
await refreshScheduleJobs();
return scheduleResult;
}),
getAll: protectedProcedure
.query(async () => {
const schedules = await scheduleDbService.getAllSchedules();
return schedules.map(schedule => ({
...schedule,
productCount: schedule.productIds.length,
groupCount: schedule.groupIds.length,
}));
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const schedule = await scheduleDbService.getScheduleById(id);
if (!schedule) {
throw new Error("Schedule not found");
}
return schedule;
}),
update: protectedProcedure
.input(updateScheduleSchema)
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if schedule exists
const existingSchedule = await scheduleDbService.getScheduleById(id);
if (!existingSchedule) {
throw new Error("Schedule not found");
}
// Check schedule name uniqueness if being updated
if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) {
const duplicateSchedule = await scheduleDbService.getScheduleByName(updates.scheduleName);
if (duplicateSchedule) {
throw new Error("Schedule name already exists");
}
}
// Update schedule
const updateData: any = {};
if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName;
if (updates.time !== undefined) updateData.time = updates.time;
if (updates.action !== undefined) updateData.action = updates.action;
if (updates.productIds !== undefined) updateData.productIds = updates.productIds;
if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds;
const result = await scheduleDbService.updateSchedule(id, updateData);
// Refresh cron jobs to reflect changes
await refreshScheduleJobs();
return result;
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
await scheduleDbService.deleteSchedule(id);
// Refresh cron jobs to remove deleted schedule
await refreshScheduleJobs();
return { message: "Schedule deleted successfully" };
}),
});

View file

@ -1,13 +1,11 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { productDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
import { ApiError } from '@/src/lib/api-error'
import { imageUploadS3, generateSignedUrlsFromS3Urls, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { scaffoldAssetUrl, claimUploadUrl } 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'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
type CreateDeal = {
quantity: number;
@ -18,19 +16,13 @@ type CreateDeal = {
export const productRouter = router({
getProducts: protectedProcedure
.query(async ({ ctx }) => {
const products = await db.query.productInfo.findMany({
orderBy: productInfo.name,
with: {
unit: true,
store: true,
},
});
const products = await productDbService.getAllProducts();
// Generate signed URLs for all product images
const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({
...product,
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
images: scaffoldAssetUrl((product.images as string[]) || []),
}))
);
@ -47,35 +39,22 @@ export const productRouter = router({
.query(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
with: {
unit: true,
},
});
const product = await productDbService.getProductById(id);
if (!product) {
throw new ApiError("Product not found", 404);
}
// Fetch special deals for this product
const deals = await db.query.specialDeals.findMany({
where: eq(specialDeals.productId, id),
orderBy: specialDeals.quantity,
});
const deals = await productDbService.getDealsByProductId(id);
// Fetch associated tags for this product
const productTagsData = await db.query.productTags.findMany({
where: eq(productTags.productId, id),
with: {
tag: true,
},
});
const productTagsData = await productDbService.getTagsByProductId(id);
// Generate signed URLs for product images
const productWithSignedUrls = {
...product,
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
images: scaffoldAssetUrl((product.images as string[]) || []),
deals,
tags: productTagsData.map(pt => pt.tag),
};
@ -92,23 +71,231 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => {
const { id } = input;
const [deletedProduct] = await db
.delete(productInfo)
.where(eq(productInfo.id, id))
.returning();
const deletedProduct = await productDbService.deleteProduct(id);
if (!deletedProduct) {
throw new ApiError("Product not found", 404);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
message: "Product deleted successfully",
};
}),
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number(),
storeId: z.number(),
price: z.number(),
marketPrice: z.number().optional(),
incrementStep: z.number().default(1),
productQuantity: z.number().default(1),
isSuspended: z.boolean().default(false),
isFlashAvailable: z.boolean().default(false),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const {
name, shortDescription, longDescription, unitId, storeId,
price, marketPrice, incrementStep, productQuantity,
isSuspended, isFlashAvailable, flashPrice,
deals, tagIds, imageKeys
} = input;
// Validation
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const allProducts = await productDbService.getAllProducts();
const existingProduct = allProducts.find(p => p.name === name.trim());
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await productDbService.getUnitById(unitId);
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
console.log(imageKeys)
const newProduct = await productDbService.createProduct({
name: name.trim(),
shortDescription,
longDescription,
unitId,
storeId,
price: price.toString(),
marketPrice: marketPrice?.toString(),
incrementStep,
productQuantity,
isSuspended,
isFlashAvailable,
flashPrice: flashPrice?.toString(),
images: imageKeys || [],
});
// Handle deals
if (deals && deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await productDbService.createDeals(dealInserts);
}
// Handle tags
if (tagIds && tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: newProduct.id,
tagId,
}));
await productDbService.createTagAssociations(tagAssociations);
}
// Claim upload URLs
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn("Failed to claim upload URL for key:", key, e);
}
}
}
scheduleStoreInitialization();
return {
product: newProduct,
message: "Product created successfully",
};
}),
updateProduct: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number().optional(),
storeId: z.number().optional(),
price: z.number().optional(),
marketPrice: z.number().optional(),
incrementStep: z.number().optional(),
productQuantity: z.number().optional(),
isSuspended: z.boolean().optional(),
isFlashAvailable: z.boolean().optional(),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
newImageKeys: z.array(z.string()).optional(),
imagesToDelete: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
// Get current product
const currentProduct = await productDbService.getProductById(id);
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
for (const imageUrl of imagesToDelete) {
try {
await deleteS3Image(imageUrl);
} catch (e) {
console.error("Failed to delete image:", imageUrl, e);
}
}
currentImages = currentImages.filter(img => {
// imagesToDelete.includes(img)
const isRemoved = imagesToDelete.some(item => item.includes(img));
return !isRemoved;
});
}
// Add new images
if (newImageKeys && newImageKeys.length > 0) {
currentImages = [...currentImages, ...newImageKeys];
for (const key of newImageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn("Failed to claim upload URL for key:", key, e);
}
}
}
// Update product - convert numeric fields to strings for PostgreSQL numeric type
const { price, marketPrice, flashPrice, ...otherData } = updateData;
const updatedProduct = await productDbService.updateProduct(id, {
...otherData,
...(price !== undefined && { price: price.toString() }),
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
images: currentImages,
});
// Handle deals update
if (deals !== undefined) {
await productDbService.deleteDealsByProductId(id);
if (deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await productDbService.createDeals(dealInserts);
}
}
// Handle tags update
if (tagIds !== undefined) {
await productDbService.deleteTagAssociationsByProductId(id);
if (tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: id,
tagId,
}));
await productDbService.createTagAssociations(tagAssociations);
}
}
scheduleStoreInitialization();
return {
product: updatedProduct,
message: "Product updated successfully",
};
}),
toggleOutOfStock: protectedProcedure
.input(z.object({
id: z.number(),
@ -116,24 +303,18 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
const product = await productDbService.getProductById(id);
if (!product) {
throw new ApiError("Product not found", 404);
}
const [updatedProduct] = await db
.update(productInfo)
.set({
const updatedProduct = await productDbService.updateProduct(id, {
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productInfo.id, id))
.returning();
});
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
product: updatedProduct,
@ -154,12 +335,7 @@ export const productRouter = router({
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const currentAssociations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
const currentProductIds = currentAssociations.map(assoc => assoc.productId);
const newProductIds = productIds.map((id: string) => parseInt(id));
@ -170,26 +346,20 @@ export const productRouter = router({
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db.delete(productSlots).where(
and(
eq(productSlots.slotId, parseInt(slotId)),
inArray(productSlots.productId, productsToRemove)
)
);
for (const productId of productsToRemove) {
await productDbService.deleteProductSlot(parseInt(slotId), productId);
}
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map(productId => ({
productId,
slotId: parseInt(slotId),
}));
await db.insert(productSlots).values(newAssociations);
for (const productId of productsToAdd) {
await productDbService.createProductSlot(parseInt(slotId), productId);
}
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
@ -205,12 +375,7 @@ export const productRouter = router({
.query(async ({ input, ctx }) => {
const { slotId } = input;
const associations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const associations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
const productIds = associations.map(assoc => assoc.productId);
@ -235,13 +400,7 @@ export const productRouter = router({
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
const associations = await productDbService.getProductSlotsBySlotIds(slotIds);
// Group by slotId
const result = associations.reduce((acc, assoc) => {
@ -271,40 +430,19 @@ export const productRouter = router({
.query(async ({ input }) => {
const { productId, limit, offset } = input;
const reviews = await db
.select({
id: productReviews.id,
reviewBody: productReviews.reviewBody,
ratings: productReviews.ratings,
imageUrls: productReviews.imageUrls,
reviewTime: productReviews.reviewTime,
adminResponse: productReviews.adminResponse,
adminResponseImages: productReviews.adminResponseImages,
userName: users.name,
})
.from(productReviews)
.innerJoin(users, eq(productReviews.userId, users.id))
.where(eq(productReviews.productId, productId))
.orderBy(desc(productReviews.reviewTime))
.limit(limit)
.offset(offset);
const reviews = await productDbService.getReviewsByProductId(productId, limit, offset);
// Generate signed URLs for images
const reviewsWithSignedUrls = await Promise.all(
reviews.map(async (review) => ({
...review,
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []),
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []),
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
}))
);
// Check if more reviews exist
const totalCountResult = await db
.select({ count: sql`count(*)` })
.from(productReviews)
.where(eq(productReviews.productId, productId));
const totalCount = Number(totalCountResult[0].count);
const totalCount = await productDbService.getReviewCountByProductId(productId);
const hasMore = offset + limit < totalCount;
return { reviews: reviewsWithSignedUrls, hasMore };
@ -320,14 +458,10 @@ export const productRouter = router({
.mutation(async ({ input }) => {
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
const [updatedReview] = await db
.update(productReviews)
.set({
const updatedReview = await productDbService.updateReview(reviewId, {
adminResponse,
adminResponseImages,
})
.where(eq(productReviews.id, reviewId))
.returning();
});
if (!updatedReview) {
throw new ApiError('Review not found', 404);
@ -335,7 +469,6 @@ export const productRouter = router({
// Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) {
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
}
@ -344,22 +477,13 @@ export const productRouter = router({
getGroups: protectedProcedure
.query(async ({ ctx }) => {
const groups = await db.query.productGroupInfo.findMany({
with: {
memberships: {
with: {
product: true,
},
},
},
orderBy: desc(productGroupInfo.createdAt),
});
const groups = await productDbService.getAllGroups() as any[];
return {
groups: groups.map(group => ({
...group,
products: group.memberships.map(m => m.product),
productCount: group.memberships.length,
products: group.memberships?.map((m: any) => m.product) || [],
productCount: group.memberships?.length || 0,
})),
};
}),
@ -373,13 +497,10 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => {
const { group_name, description, product_ids } = input;
const [newGroup] = await db
.insert(productGroupInfo)
.values({
const newGroup = await productDbService.createGroup({
groupName: group_name,
description,
})
.returning();
});
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
@ -387,11 +508,11 @@ export const productRouter = router({
groupId: newGroup.id,
}));
await db.insert(productGroupMembership).values(memberships);
await productDbService.createGroupMemberships(memberships);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
group: newGroup,
@ -413,11 +534,7 @@ export const productRouter = router({
if (group_name !== undefined) updateData.groupName = group_name;
if (description !== undefined) updateData.description = description;
const [updatedGroup] = await db
.update(productGroupInfo)
.set(updateData)
.where(eq(productGroupInfo.id, id))
.returning();
const updatedGroup = await productDbService.updateGroup(id, updateData);
if (!updatedGroup) {
throw new ApiError('Group not found', 404);
@ -425,7 +542,7 @@ export const productRouter = router({
if (product_ids !== undefined) {
// Delete existing memberships
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
await productDbService.deleteGroupMembershipsByGroupId(id);
// Insert new memberships
if (product_ids.length > 0) {
@ -434,12 +551,12 @@ export const productRouter = router({
groupId: id,
}));
await db.insert(productGroupMembership).values(memberships);
await productDbService.createGroupMemberships(memberships);
}
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
group: updatedGroup,
@ -455,20 +572,17 @@ export const productRouter = router({
const { id } = input;
// Delete memberships first
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
await productDbService.deleteGroupMembershipsByGroupId(id);
// Delete group
const [deletedGroup] = await db
.delete(productGroupInfo)
.where(eq(productGroupInfo.id, id))
.returning();
const deletedGroup = await productDbService.deleteGroup(id);
if (!deletedGroup) {
throw new ApiError('Group not found', 404);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
message: 'Group deleted successfully',
@ -494,37 +608,31 @@ export const productRouter = router({
// Validate that all productIds exist
const productIds = updates.map(u => u.productId);
const existingProducts = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
columns: { id: true },
});
const allExist = await productDbService.validateProductIdsExist(productIds);
const existingIds = new Set(existingProducts.map(p => p.id));
const invalidIds = productIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
if (!allExist) {
throw new ApiError('Some product IDs are invalid', 400);
}
// Perform batch update
const updatePromises = updates.map(async (update) => {
const batchUpdates = updates.map(update => {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
const updateData: any = {};
if (price !== undefined) updateData.price = price;
if (marketPrice !== undefined) updateData.marketPrice = marketPrice;
if (flashPrice !== undefined) updateData.flashPrice = flashPrice;
if (price !== undefined) updateData.price = price.toString();
if (marketPrice !== undefined) updateData.marketPrice = marketPrice?.toString();
if (flashPrice !== undefined) updateData.flashPrice = flashPrice?.toString();
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
return db
.update(productInfo)
.set(updateData)
.where(eq(productInfo.id, productId));
return {
productId,
data: updateData,
};
});
await Promise.all(updatePromises);
await productDbService.batchUpdateProducts(batchUpdates);
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
message: `Updated prices for ${updates.length} product(s)`,

View file

@ -1,14 +1,12 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { db } from "@/src/db/db_index"
import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "@/src/db/schema"
import { eq, inArray, and, desc } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error"
import { appUrl } from "@/src/lib/env-exporter"
import redisClient from "@/src/lib/redis-client"
import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
import { initializeAllStores } from '@/src/stores/store-initializer'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { slotDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
interface CachedDeliverySequence {
[userId: string]: number[];
@ -57,50 +55,29 @@ const getDeliverySequenceSchema = z.object({
const updateDeliverySequenceSchema = z.object({
id: z.number(),
// deliverySequence: z.array(z.number()),
deliverySequence: z.any(),
});
export const slotsRouter = router({
// Exact replica of GET /av/slots
getAll: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo
.findMany({
where: eq(deliverySlotInfo.isActive, true),
orderBy: desc(deliverySlotInfo.deliveryTime),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
},
})
.then((slots) =>
slots.map((slot) => ({
const slots = await slotDbService.getAllSlots();
const transformedSlots = slots.map((slot) => ({
...slot,
deliverySequence: slot.deliverySequence as number[],
products: slot.productSlots.map((ps) => ps.product),
}))
);
products: slot.productSlots.map((ps: any) => ps.product),
}));
return {
slots,
count: slots.length,
slots: transformedSlots,
count: transformedSlots.length,
};
}),
// Exact replica of POST /av/products/slots/product-ids
getSlotsProductIds: protectedProcedure
.input(z.object({ slotIds: z.array(z.number()) }))
.query(async ({ input, ctx }) => {
@ -121,25 +98,16 @@ export const slotsRouter = router({
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
const associations = await slotDbService.getProductSlotsBySlotIds(slotIds);
// Group by slotId
const result = associations.reduce((acc, assoc) => {
const result = associations.reduce((acc: Record<number, number[]>, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
}, {});
// Ensure all requested slots have entries (even if empty)
slotIds.forEach((slotId) => {
if (!result[slotId]) {
result[slotId] = [];
@ -149,14 +117,8 @@ export const slotsRouter = router({
return result;
}),
// Exact replica of PUT /av/products/slots/:slotId/products
updateSlotProducts: protectedProcedure
.input(
z.object({
slotId: z.number(),
productIds: z.array(z.number()),
})
)
.input(z.object({ slotId: z.number(), productIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
if (!ctx.staffUser?.id) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
@ -171,51 +133,22 @@ export const slotsRouter = router({
});
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, slotId),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(
(assoc) => assoc.productId
);
const currentAssociations = await slotDbService.getProductSlotsBySlotId(slotId);
const currentProductIds = currentAssociations.map((assoc) => assoc.productId);
const newProductIds = productIds;
// Find products to add and remove
const productsToAdd = newProductIds.filter(
(id) => !currentProductIds.includes(id)
);
const productsToRemove = currentProductIds.filter(
(id) => !newProductIds.includes(id)
);
const productsToAdd = newProductIds.filter((id) => !currentProductIds.includes(id));
const productsToRemove = currentProductIds.filter((id) => !newProductIds.includes(id));
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db
.delete(productSlots)
.where(
and(
eq(productSlots.slotId, slotId),
inArray(productSlots.productId, productsToRemove)
)
);
for (const productId of productsToRemove) {
await slotDbService.deleteProductSlot(slotId, productId);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map((productId) => ({
productId,
slotId,
}));
await db.insert(productSlots).values(newAssociations);
for (const productId of productsToAdd) {
await slotDbService.createProductSlot(slotId, productId);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization();
return {
message: "Slot products updated successfully",
@ -233,58 +166,43 @@ export const slotsRouter = router({
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
// Validate required fields
if (!deliveryTime || !freezeTime) {
throw new ApiError("Delivery time and orders close time are required", 400);
}
const result = await db.transaction(async (tx) => {
// Create slot
const [newSlot] = await tx
.insert(deliverySlotInfo)
.values({
const result = await slotDbService.withTransaction(async (tx) => {
const newSlot = await slotDbService.createSlot({
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: groupIds !== undefined ? groupIds : [],
})
.returning();
});
// Insert product associations if provided
if (productIds && productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: newSlot.id,
}));
await tx.insert(productSlots).values(associations);
for (const productId of productIds) {
await slotDbService.createProductSlot(newSlot.id, productId);
}
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
const productsValid = await slotDbService.validateProductsExist(snippet.productIds);
if (!productsValid) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name);
if (codeExists) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
const createdSnippet = await slotDbService.createVendorSnippet({
snippetCode: snippet.name,
slotId: newSlot.id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
});
createdSnippets.push(createdSnippet);
}
@ -297,8 +215,7 @@ export const slotsRouter = router({
};
});
// Reinitialize stores to reflect changes (outside transaction)
await initializeAllStores();
scheduleStoreInitialization();
return result;
}),
@ -308,9 +225,7 @@ export const slotsRouter = router({
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
}
const slots = await db.query.deliverySlotInfo.findMany({
where: eq(deliverySlotInfo.isActive, true),
});
const slots = await slotDbService.getActiveSlots();
return {
slots,
@ -327,23 +242,7 @@ export const slotsRouter = router({
const { id } = input;
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, id),
with: {
productSlots: {
with: {
product: {
columns: {
id: true,
name: true,
images: true,
},
},
},
},
vendorSnippets: true,
},
});
const slot = await slotDbService.getSlotById(id);
if (!slot) {
throw new ApiError("Slot not found", 404);
@ -354,8 +253,8 @@ export const slotsRouter = router({
...slot,
deliverySequence: slot.deliverySequence as number[],
groupIds: slot.groupIds as number[],
products: slot.productSlots.map((ps) => ps.product),
vendorSnippets: slot.vendorSnippets?.map(snippet => ({
products: slot.productSlots.map((ps: any) => ps.product),
vendorSnippets: slot.vendorSnippets?.map((snippet: any) => ({
...snippet,
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
})),
@ -376,74 +275,53 @@ export const slotsRouter = router({
throw new ApiError("Delivery time and orders close time are required", 400);
}
// Filter groupIds to only include valid (existing) groups
let validGroupIds = groupIds;
if (groupIds && groupIds.length > 0) {
const existingGroups = await db.query.productGroupInfo.findMany({
where: inArray(productGroupInfo.id, groupIds),
columns: { id: true },
});
validGroupIds = existingGroups.map(g => g.id);
const existingGroups = await slotDbService.getGroupsByIds(groupIds);
validGroupIds = existingGroups.map((g: any) => g.id);
}
const result = await db.transaction(async (tx) => {
const [updatedSlot] = await tx
.update(deliverySlotInfo)
.set({
const result = await slotDbService.withTransaction(async (tx) => {
const updatedSlot = await slotDbService.updateSlot(id, {
deliveryTime: new Date(deliveryTime),
freezeTime: new Date(freezeTime),
isActive: isActive !== undefined ? isActive : true,
groupIds: validGroupIds !== undefined ? validGroupIds : [],
})
.where(eq(deliverySlotInfo.id, id))
.returning();
});
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Update product associations
if (productIds !== undefined) {
// Delete existing associations
await tx.delete(productSlots).where(eq(productSlots.slotId, id));
await slotDbService.deleteProductSlotsBySlotId(id);
// Insert new associations
if (productIds.length > 0) {
const associations = productIds.map((productId) => ({
productId,
slotId: id,
}));
await tx.insert(productSlots).values(associations);
for (const productId of productIds) {
await slotDbService.createProductSlot(id, productId);
}
}
}
// Create vendor snippets if provided
let createdSnippets: any[] = [];
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
// Validate products exist
const products = await tx.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
});
if (products.length !== snippet.productIds.length) {
const productsValid = await slotDbService.validateProductsExist(snippet.productIds);
if (!productsValid) {
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
}
// Check if snippet name already exists
const existingSnippet = await tx.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippet.name),
});
if (existingSnippet) {
const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name);
if (codeExists) {
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
}
const [createdSnippet] = await tx.insert(vendorSnippets).values({
const createdSnippet = await slotDbService.createVendorSnippet({
snippetCode: snippet.name,
slotId: id,
productIds: snippet.productIds,
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
}).returning();
});
createdSnippets.push(createdSnippet);
}
@ -456,13 +334,11 @@ export const slotsRouter = router({
};
});
// Reinitialize stores to reflect changes (outside transaction)
await initializeAllStores();
scheduleStoreInitialization();
return result;
}
catch(e) {
console.log(e)
} catch (e) {
console.log(e);
throw new ApiError("Unable to Update Slot");
}
}),
@ -476,18 +352,13 @@ export const slotsRouter = router({
const { id } = input;
const [deletedSlot] = await db
.update(deliverySlotInfo)
.set({ isActive: false })
.where(eq(deliverySlotInfo.id, id))
.returning();
const deletedSlot = await slotDbService.deactivateSlot(id);
if (!deletedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization();
return {
message: "Slot deleted successfully",
@ -496,8 +367,7 @@ export const slotsRouter = router({
getDeliverySequence: protectedProcedure
.input(getDeliverySequenceSchema)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
const { id } = input;
const slotId = parseInt(id);
const cacheKey = getSlotSequenceKey(slotId);
@ -507,19 +377,14 @@ export const slotsRouter = router({
if (cached) {
const parsed = JSON.parse(cached);
const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence;
console.log('sending cached response')
console.log('sending cached response');
return { deliverySequence: validated };
}
} catch (error) {
console.warn('Redis cache read/validation failed, falling back to DB:', error);
// Continue to DB fallback
}
// Fallback to DB
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
const slot = await slotDbService.getSlotById(slotId);
if (!slot) {
throw new ApiError("Slot not found", 404);
@ -527,7 +392,6 @@ export const slotsRouter = router({
const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence;
// Cache the validated result
try {
const validated = cachedSequenceSchema.parse(sequence);
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
@ -547,20 +411,12 @@ export const slotsRouter = router({
const { id, deliverySequence } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ deliverySequence })
.where(eq(deliverySlotInfo.id, id))
.returning({
id: deliverySlotInfo.id,
deliverySequence: deliverySlotInfo.deliverySequence,
});
const updatedSlot = await slotDbService.updateSlot(id, { deliverySequence });
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Cache the updated sequence
const cacheKey = getSlotSequenceKey(id);
try {
const validated = cachedSequenceSchema.parse(deliverySequence);
@ -570,7 +426,7 @@ export const slotsRouter = router({
}
return {
slot: updatedSlot,
slot: { id: updatedSlot.id, deliverySequence: updatedSlot.deliverySequence },
message: "Delivery sequence updated successfully",
};
}),
@ -587,18 +443,13 @@ export const slotsRouter = router({
const { slotId, isCapacityFull } = input;
const [updatedSlot] = await db
.update(deliverySlotInfo)
.set({ isCapacityFull })
.where(eq(deliverySlotInfo.id, slotId))
.returning();
const updatedSlot = await slotDbService.updateSlot(slotId, { isCapacityFull });
if (!updatedSlot) {
throw new ApiError("Slot not found", 404);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization();
return {
success: true,

View file

@ -1,11 +1,9 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema'
import { eq, or, ilike, and, lt, desc } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { ApiError } from '@/src/lib/api-error'
import { signToken } from '@/src/lib/jwt-utils'
import { staffUserDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const staffUserRouter = router({
login: publicProcedure
@ -20,9 +18,7 @@ export const staffUserRouter = router({
throw new ApiError('Name and password are required', 400);
}
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
const staff = await staffUserDbService.getStaffUserByName(name);
if (!staff) {
throw new ApiError('Invalid credentials', 401);
@ -33,10 +29,9 @@ export const staffUserRouter = router({
throw new ApiError('Invalid credentials', 401);
}
const token = jwt.sign(
const token = await signToken(
{ staffId: staff.id, name: staff.name },
process.env.JWT_SECRET || 'default-secret',
{ expiresIn: '30d' }
'30d'
);
return {
@ -47,24 +42,8 @@ export const staffUserRouter = router({
}),
getStaff: protectedProcedure
.query(async ({ ctx }) => {
const staff = await db.query.staffUsers.findMany({
columns: {
id: true,
name: true,
},
with: {
role: {
with: {
rolePermissions: {
with: {
permission: true,
},
},
},
},
},
});
.query(async () => {
const staff = await staffUserDbService.getAllStaff();
// Transform the data to include role and permissions in a cleaner format
const transformedStaff = staff.map((user) => ({
@ -94,29 +73,7 @@ export const staffUserRouter = router({
.query(async ({ input }) => {
const { cursor, limit, search } = input;
let whereCondition = undefined;
if (search) {
whereCondition = or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
ilike(users.mobile, `%${search}%`)
);
}
if (cursor) {
const cursorCondition = lt(users.id, cursor);
whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition;
}
const allUsers = await db.query.users.findMany({
where: whereCondition,
with: {
userDetails: true,
},
orderBy: desc(users.id),
limit: limit + 1, // fetch one extra to check if there's more
});
const allUsers = await staffUserDbService.getUsers({ cursor, limit, search });
const hasMore = allUsers.length > limit;
const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers;
@ -140,22 +97,13 @@ export const staffUserRouter = router({
.query(async ({ input }) => {
const { userId } = input;
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
userDetails: true,
orders: {
orderBy: desc(orders.createdAt),
limit: 1,
},
},
});
const user = await staffUserDbService.getUserById(userId);
if (!user) {
throw new ApiError("User not found", 404);
}
const lastOrder = user.orders[0];
const lastOrder = user.orders?.[0];
return {
id: user.id,
@ -173,13 +121,7 @@ export const staffUserRouter = router({
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
await db
.insert(userDetails)
.values({ userId, isSuspended })
.onConflictDoUpdate({
target: userDetails.userId,
set: { isSuspended },
});
await staffUserDbService.upsertUserDetails({ userId, isSuspended });
return { success: true };
}),
@ -190,22 +132,18 @@ export const staffUserRouter = router({
password: z.string().min(6, 'Password must be at least 6 characters'),
roleId: z.number().int().positive('Role is required'),
}))
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const { name, password, roleId } = input;
// Check if staff user already exists
const existingUser = await db.query.staffUsers.findFirst({
where: eq(staffUsers.name, name),
});
const existingUser = await staffUserDbService.getStaffUserByName(name);
if (existingUser) {
throw new ApiError('Staff user with this name already exists', 409);
}
// Check if role exists
const role = await db.query.staffRoles.findFirst({
where: eq(staffRoles.id, roleId),
});
const role = await staffUserDbService.getRoleById(roleId);
if (!role) {
throw new ApiError('Invalid role selected', 400);
@ -215,23 +153,18 @@ export const staffUserRouter = router({
const hashedPassword = await bcrypt.hash(password, 12);
// Create staff user
const [newUser] = await db.insert(staffUsers).values({
const newUser = await staffUserDbService.createStaffUser({
name: name.trim(),
password: hashedPassword,
staffRoleId: roleId,
}).returning();
});
return { success: true, user: { id: newUser.id, name: newUser.name } };
}),
getRoles: protectedProcedure
.query(async ({ ctx }) => {
const roles = await db.query.staffRoles.findMany({
columns: {
id: true,
roleName: true,
},
});
.query(async () => {
const roles = await staffUserDbService.getAllRoles();
return {
roles: roles.map(role => ({

View file

@ -1,29 +1,22 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { storeInfo, productInfo } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { extractKeyFromPresignedUrl, deleteImageUtil, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { initializeAllStores } from '@/src/stores/store-initializer'
import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { storeDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const storeRouter = router({
getStores: protectedProcedure
.query(async ({ ctx }) => {
const stores = await db.query.storeInfo.findMany({
with: {
owner: true,
},
});
.query(async () => {
const stores = await storeDbService.getAllStores();
Promise.all(stores.map(async store => {
if(store.imageUrl)
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl)
store.imageUrl = scaffoldAssetUrl(store.imageUrl)
})).catch((e) => {
throw new ApiError("Unable to find store image urls")
}
)
})
return {
stores,
count: stores.length,
@ -34,20 +27,17 @@ export const storeRouter = router({
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
const { id } = input;
const store = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
with: {
owner: true,
},
});
const store = await storeDbService.getStoreById(id);
if (!store) {
throw new ApiError("Store not found", 404);
}
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl);
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
return {
store,
};
@ -61,31 +51,22 @@ export const storeRouter = router({
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const { name, description, imageUrl, owner, products } = input;
const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
const [newStore] = await db
.insert(storeInfo)
.values({
const newStore = await storeDbService.createStore({
name,
description,
imageUrl: imageKey,
imageUrl: imageUrl || null,
owner,
})
.returning();
});
// Assign selected products to this store
if (products && products.length > 0) {
await db
.update(productInfo)
.set({ storeId: newStore.id })
.where(inArray(productInfo.id, products));
await storeDbService.assignProductsToStore(newStore.id, products);
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
store: newStore,
@ -102,12 +83,10 @@ export const storeRouter = router({
owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const { id, name, description, imageUrl, owner, products } = input;
const existingStore = await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, id),
});
const existingStore = await storeDbService.getStoreById(id);
if (!existingStore) {
throw new ApiError("Store not found", 404);
@ -127,44 +106,28 @@ export const storeRouter = router({
await deleteImageUtil({keys: [oldImageKey]});
} catch (error) {
console.error('Failed to delete old image:', error);
// Continue with update even if deletion fails
}
}
const [updatedStore] = await db
.update(storeInfo)
.set({
const updatedStore = await storeDbService.updateStore(id, {
name,
description,
imageUrl: newImageKey,
owner,
})
.where(eq(storeInfo.id, id))
.returning();
if (!updatedStore) {
throw new ApiError("Store not found", 404);
}
});
// Update products if provided
if (products) {
// First, set storeId to null for products not in the list but currently assigned to this store
await db
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, id));
// First, remove all products from this store
await storeDbService.removeProductsFromStore(id);
// Then, assign the selected products to this store
if (products.length > 0) {
await db
.update(productInfo)
.set({ storeId: id })
.where(inArray(productInfo.id, products));
await storeDbService.assignProductsToStore(id, products);
}
}
// Reinitialize stores to reflect changes
await initializeAllStores();
scheduleStoreInitialization()
return {
store: updatedStore,
@ -176,34 +139,19 @@ export const storeRouter = router({
.input(z.object({
storeId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const { storeId } = input;
const result = await db.transaction(async (tx) => {
// First, update all products of this store to set storeId to null
await tx
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, storeId));
// First, remove all products from this store
await storeDbService.removeProductsFromStore(storeId);
// Then delete the store
const [deletedStore] = await tx
.delete(storeInfo)
.where(eq(storeInfo.id, storeId))
.returning();
await storeDbService.deleteStore(storeId);
if (!deletedStore) {
throw new ApiError("Store not found", 404);
}
scheduleStoreInitialization()
return {
message: "Store deleted successfully",
};
});
// Reinitialize stores to reflect changes (outside transaction)
await initializeAllStores();
return result;
}),
});

View file

@ -0,0 +1,194 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { ApiError } from '@/src/lib/api-error'
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { tagDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const tagRouter = router({
getTags: protectedProcedure
.query(async () => {
const tags = await tagDbService.getAllTags();
// Generate asset URLs for tag images
const tagsWithUrls = tags.map(tag => ({
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
}));
return {
tags: tagsWithUrls,
message: "Tags retrieved successfully",
};
}),
getTagById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input }) => {
const tag = await tagDbService.getTagById(input.id);
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Generate asset URL for tag image
const tagWithUrl = {
...tag,
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
};
return {
tag: tagWithUrl,
message: "Tag retrieved successfully",
};
}),
createTag: protectedProcedure
.input(z.object({
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean().default(false),
relatedStores: z.array(z.number()).default([]),
imageKey: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
// Check for duplicate tag name
const existingTag = await tagDbService.getTagByName(tagName);
if (existingTag) {
throw new ApiError("A tag with this name already exists", 400);
}
const newTag = await tagDbService.createTag({
tagName: tagName.trim(),
tagDescription,
imageUrl: imageKey || null,
isDashboardTag,
relatedStores,
});
// Claim upload URL if image was provided
if (imageKey) {
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
scheduleStoreInitialization();
return {
tag: newTag,
message: "Tag created successfully",
};
}),
updateTag: protectedProcedure
.input(z.object({
id: z.number(),
tagName: z.string().min(1),
tagDescription: z.string().optional(),
isDashboardTag: z.boolean(),
relatedStores: z.array(z.number()),
imageKey: z.string().optional(),
deleteExistingImage: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, imageKey, deleteExistingImage, ...updateData } = input;
// Get current tag
const currentTag = await tagDbService.getTagById(id);
if (!currentTag) {
throw new ApiError("Tag not found", 404);
}
let newImageUrl = currentTag.imageUrl;
// Handle image deletion
if (deleteExistingImage && currentTag.imageUrl) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
newImageUrl = null;
}
// Handle new image upload (only if different from existing)
if (imageKey && imageKey !== currentTag.imageUrl) {
// Delete old image if exists and not already deleted
if (currentTag.imageUrl && !deleteExistingImage) {
try {
await deleteS3Image(currentTag.imageUrl);
} catch (e) {
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
}
}
newImageUrl = imageKey;
// Claim upload URL
try {
await claimUploadUrl(imageKey);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
}
}
const updatedTag = await tagDbService.updateTag(id, {
tagName: updateData.tagName.trim(),
tagDescription: updateData.tagDescription,
isDashboardTag: updateData.isDashboardTag,
relatedStores: updateData.relatedStores,
imageUrl: newImageUrl,
});
scheduleStoreInitialization();
return {
tag: updatedTag,
message: "Tag updated successfully",
};
}),
deleteTag: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input }) => {
const { id } = input;
// Get tag to check for image
const tag = await tagDbService.getTagById(id);
if (!tag) {
throw new ApiError("Tag not found", 404);
}
// Delete image from S3 if exists
if (tag.imageUrl) {
try {
await deleteS3Image(tag.imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
}
}
// Delete tag (will fail if tag is assigned to products due to FK constraint)
await tagDbService.deleteTag(id);
scheduleStoreInitialization();
return {
message: "Tag deleted successfully",
};
}),
});
export type TagRouter = typeof tagRouter;

View file

@ -1,41 +1,28 @@
import { protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema';
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error';
import { notificationQueue } from '@/src/lib/notif-job';
import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store';
import { userDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main';
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
// Clean mobile number (remove non-digits)
async function createUserByMobile(mobile: string) {
const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) {
throw new ApiError('Mobile number must be exactly 10 digits', 400);
}
// Check if user already exists
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
const existingUser = await userDbService.getUserByMobile(cleanMobile);
if (existingUser) {
throw new ApiError('User with this mobile number already exists', 409);
}
// Create user
const [newUser] = await db
.insert(users)
.values({
const newUser = await userDbService.createUser({
name: null,
email: null,
mobile: cleanMobile,
})
.returning();
});
return newUser;
}
@ -56,7 +43,7 @@ export const userRouter = {
getEssentials: protectedProcedure
.query(async () => {
const count = await db.$count(complaints, eq(complaints.isResolved, false));
const count = await userDbService.getUnresolvedComplaintCount();
return {
unresolvedComplaints: count || 0,
@ -72,78 +59,23 @@ export const userRouter = {
.query(async ({ input }) => {
const { limit, cursor, search } = input;
// Build where conditions
const whereConditions = [];
const usersList = await userDbService.getUsers({ limit, cursor, search });
if (search && search.trim()) {
whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`);
}
if (cursor) {
whereConditions.push(sql`${users.id} > ${cursor}`);
}
// Get users with filters applied
const usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined)
.orderBy(asc(users.id))
.limit(limit + 1); // Get one extra to determine if there's more
// Check if there are more results
const hasMore = usersList.length > limit;
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
// Get order stats for each user
const userIds = usersToReturn.map(u => u.id);
let orderCounts: { userId: number; totalOrders: number }[] = [];
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
const orderCounts = await userDbService.getOrderCountByUserIds(userIds);
const lastOrders = await userDbService.getLastOrderDateByUserIds(userIds);
if (userIds.length > 0) {
// Get total orders per user
orderCounts = await db
.select({
userId: orders.userId,
totalOrders: count(orders.id),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
const userDetailsList = await Promise.all(
userIds.map(id => userDbService.getUserDetailsByUserId(id))
);
// Get last order date per user
lastOrders = await db
.select({
userId: orders.userId,
lastOrderDate: max(orders.createdAt),
})
.from(orders)
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
.groupBy(orders.userId);
// Get suspension status for each user
suspensionStatuses = await db
.select({
userId: userDetails.userId,
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`);
}
// Create lookup maps
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended]));
const suspensionMap = new Map(userDetailsList.map((ud, idx) => [userIds[idx], ud?.isSuspended ?? false]));
// Combine data
const usersWithStats = usersToReturn.map(user => ({
...user,
totalOrders: orderCountMap.get(user.id) || 0,
@ -151,7 +83,6 @@ export const userRouter = {
isSuspended: suspensionMap.get(user.id) ?? false,
}));
// Get next cursor
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
return {
@ -168,76 +99,22 @@ export const userRouter = {
.query(async ({ input }) => {
const { userId } = input;
// Get user info
const user = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
const user = await userDbService.getUserById(userId);
if (!user || user.length === 0) {
if (!user) {
throw new ApiError('User not found', 404);
}
// Get user suspension status
const userDetail = await db
.select({
isSuspended: userDetails.isSuspended,
})
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
// Get all orders for this user with order items count
const userOrders = await db
.select({
id: orders.id,
readableId: orders.readableId,
totalAmount: orders.totalAmount,
createdAt: orders.createdAt,
isFlashDelivery: orders.isFlashDelivery,
})
.from(orders)
.where(eq(orders.userId, userId))
.orderBy(desc(orders.createdAt));
// Get order status for each order
const userDetail = await userDbService.getUserDetailsByUserId(userId);
const userOrders = await userDbService.getOrdersByUserId(userId);
const orderIds = userOrders.map(o => o.id);
let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = [];
const orderStatuses = await userDbService.getOrderStatusByOrderIds(orderIds);
const itemCounts = await userDbService.getOrderItemCountByOrderIds(orderIds);
if (orderIds.length > 0) {
const { orderStatus } = await import('@/src/db/schema');
orderStatuses = await db
.select({
orderId: orderStatus.orderId,
isDelivered: orderStatus.isDelivered,
isCancelled: orderStatus.isCancelled,
})
.from(orderStatus)
.where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`);
}
// Get item counts for each order
const itemCounts = await db
.select({
orderId: orderItems.orderId,
itemCount: count(orderItems.id),
})
.from(orderItems)
.where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`)
.groupBy(orderItems.orderId);
// Create lookup maps
const statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
// Determine status string
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
if (!status) return 'pending';
if (status.isCancelled) return 'cancelled';
@ -245,15 +122,14 @@ export const userRouter = {
return 'pending';
};
// Combine data
const ordersWithDetails = userOrders.map(order => {
const status = statusMap.get(order.id);
return {
id: order.id,
readableId: order.readableId,
readableId: (order as any).readableId,
totalAmount: order.totalAmount,
createdAt: order.createdAt,
isFlashDelivery: order.isFlashDelivery,
isFlashDelivery: (order as any).isFlashDelivery,
status: getStatus(status),
itemCount: itemCountMap.get(order.id) || 0,
};
@ -261,8 +137,8 @@ export const userRouter = {
return {
user: {
...user[0],
isSuspended: userDetail[0]?.isSuspended ?? false,
...user,
isSuspended: userDetail?.isSuspended ?? false,
},
orders: ordersWithDetails,
};
@ -276,39 +152,13 @@ export const userRouter = {
.mutation(async ({ input }) => {
const { userId, isSuspended } = input;
// Check if user exists
const user = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const user = await userDbService.getUserById(userId);
if (!user || user.length === 0) {
if (!user) {
throw new ApiError('User not found', 404);
}
// Check if user_details record exists
const existingDetail = await db
.select({ id: userDetails.id })
.from(userDetails)
.where(eq(userDetails.userId, userId))
.limit(1);
if (existingDetail.length > 0) {
// Update existing record
await db
.update(userDetails)
.set({ isSuspended })
.where(eq(userDetails.userId, userId));
} else {
// Insert new record
await db
.insert(userDetails)
.values({
userId,
isSuspended,
});
}
await userDbService.upsertUserDetails({ userId, isSuspended });
return {
success: true,
@ -323,40 +173,17 @@ export const userRouter = {
.query(async ({ input }) => {
const { search } = input;
// Get all users
let usersList;
if (search && search.trim()) {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users)
.where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`);
} else {
usersList = await db
.select({
id: users.id,
name: users.name,
mobile: users.mobile,
})
.from(users);
}
const usersList = await userDbService.getUsers({ limit: 1000, search });
// Get eligible users (have notif_creds entry)
const eligibleUsers = await db
.select({ userId: notifCreds.userId })
.from(notifCreds);
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
const allTokens = await userDbService.getAllNotifTokens();
const eligibleSet = new Set(allTokens);
return {
users: usersList.map(user => ({
id: user.id,
name: user.name,
mobile: user.mobile,
isEligibleForNotif: eligibleSet.has(user.id),
isEligibleForNotif: eligibleSet.has(user.mobile || ''),
})),
};
}),
@ -374,25 +201,13 @@ export const userRouter = {
let tokens: string[] = [];
if (userIds.length === 0) {
// Send to all users - get tokens from both logged-in and unlogged users
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds);
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens);
tokens = [
...loggedInTokens.map(t => t.token),
...unloggedTokens.map(t => t.token)
];
const allTokens = await userDbService.getAllNotifTokens();
const unloggedTokens = await userDbService.getUnloggedTokens();
tokens = [...allTokens, ...unloggedTokens];
} else {
// Send to specific users - get their tokens
const userTokens = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(inArray(notifCreds.userId, userIds));
tokens = userTokens.map(t => t.token);
tokens = await userDbService.getNotifTokensByUserIds(userIds);
}
// Queue one job per token
let queuedCount = 0;
for (const token of tokens) {
try {
@ -427,18 +242,7 @@ export const userRouter = {
.query(async ({ input }) => {
const { userId } = input;
const incidents = await db.query.userIncidents.findMany({
where: eq(userIncidents.userId, userId),
with: {
order: {
with: {
orderStatus: true,
},
},
addedBy: true,
},
orderBy: desc(userIncidents.dateAdded),
});
const incidents = await userDbService.getUserIncidentsByUserId(userId);
return {
incidents: incidents.map(incident => ({
@ -470,14 +274,13 @@ export const userRouter = {
throw new ApiError('Admin user not authenticated', 401);
}
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore };
const [incident] = await db.insert(userIncidents)
.values({
...incidentObj,
})
.returning();
const incident = await userDbService.createUserIncident({
userId,
orderId: orderId || null,
adminComment: adminComment || null,
addedBy: adminUserId,
negativityScore: negativityScore || null,
});
recomputeUserNegativityScore(userId);

View file

@ -1,10 +1,8 @@
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import dayjs from 'dayjs';
import { db } from '@/src/db/db_index'
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema'
import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm';
import { appUrl } from '@/src/lib/env-exporter'
import { vendorSnippetDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
const createSnippetSchema = z.object({
snippetCode: z.string().min(1, "Snippet code is required"),
@ -29,7 +27,6 @@ export const vendorSnippetsRouter = router({
.mutation(async ({ input, ctx }) => {
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
// Get staff user ID from auth middleware
const staffUserId = ctx.staffUser?.id;
if (!staffUserId) {
throw new Error("Unauthorized");
@ -37,59 +34,42 @@ export const vendorSnippetsRouter = router({
// Validate slot exists
if(slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
const slot = await vendorSnippetDbService.getSlotById(slotId);
if (!slot) {
throw new Error("Invalid slot ID");
}
}
// Validate products exist
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
});
if (products.length !== productIds.length) {
const productsValid = await vendorSnippetDbService.validateProductsExist(productIds);
if (!productsValid) {
throw new Error("One or more invalid product IDs");
}
// Check if snippet code already exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
if (existingSnippet) {
const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(snippetCode);
if (codeExists) {
throw new Error("Snippet code already exists");
}
const result = await db.insert(vendorSnippets).values({
const result = await vendorSnippetDbService.createSnippet({
snippetCode,
slotId,
slotId: slotId || null,
productIds,
isPermanent,
validTill: validTill ? new Date(validTill) : undefined,
}).returning();
validTill: validTill ? new Date(validTill) : null,
});
return result[0];
return result;
}),
getAll: protectedProcedure
.query(async () => {
console.log('from the vendor snipptes methods')
try {
const result = await db.query.vendorSnippets.findMany({
with: {
slot: true,
},
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
});
const result = await vendorSnippetDbService.getAllSnippets();
const snippetsWithProducts = await Promise.all(
result.map(async (snippet) => {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, snippet.productIds),
columns: { id: true, name: true },
});
const products = await vendorSnippetDbService.getProductsByIds(snippet.productIds);
return {
...snippet,
@ -100,24 +80,12 @@ export const vendorSnippetsRouter = router({
);
return snippetsWithProducts;
}
catch(e) {
console.log(e)
}
return [];
}),
getById: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.query(async ({ input }) => {
const { id } = input;
const result = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
with: {
slot: true,
},
});
const result = await vendorSnippetDbService.getSnippetById(input.id);
if (!result) {
throw new Error("Vendor snippet not found");
@ -131,19 +99,14 @@ export const vendorSnippetsRouter = router({
.mutation(async ({ input }) => {
const { id, updates } = input;
// Check if snippet exists
const existingSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.id, id),
});
const existingSnippet = await vendorSnippetDbService.getSnippetById(id);
if (!existingSnippet) {
throw new Error("Vendor snippet not found");
}
// Validate slot if being updated
if (updates.slotId) {
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, updates.slotId),
});
const slot = await vendorSnippetDbService.getSlotById(updates.slotId);
if (!slot) {
throw new Error("Invalid slot ID");
}
@ -151,20 +114,16 @@ export const vendorSnippetsRouter = router({
// Validate products if being updated
if (updates.productIds) {
const products = await db.query.productInfo.findMany({
where: inArray(productInfo.id, updates.productIds),
});
if (products.length !== updates.productIds.length) {
const productsValid = await vendorSnippetDbService.validateProductsExist(updates.productIds);
if (!productsValid) {
throw new Error("One or more invalid product IDs");
}
}
// Check snippet code uniqueness if being updated
if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) {
const duplicateSnippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, updates.snippetCode),
});
if (duplicateSnippet) {
const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(updates.snippetCode);
if (codeExists) {
throw new Error("Snippet code already exists");
}
}
@ -174,91 +133,46 @@ export const vendorSnippetsRouter = router({
updateData.validTill = updates.validTill ? new Date(updates.validTill) : null;
}
const result = await db.update(vendorSnippets)
.set(updateData)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Failed to update vendor snippet");
}
return result[0];
const result = await vendorSnippetDbService.updateSnippet(id, updateData);
return result;
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input }) => {
const { id } = input;
const result = await db.delete(vendorSnippets)
.where(eq(vendorSnippets.id, id))
.returning();
if (result.length === 0) {
throw new Error("Vendor snippet not found");
}
await vendorSnippetDbService.deleteSnippet(input.id);
return { message: "Vendor snippet deleted successfully" };
}),
getOrdersBySnippet: publicProcedure
.input(z.object({
snippetCode: z.string().min(1, "Snippet code is required")
}))
.input(z.object({ snippetCode: z.string().min(1, "Snippet code is required") }))
.query(async ({ input }) => {
const { snippetCode } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
const snippet = await vendorSnippetDbService.getSnippetByCode(input.snippetCode);
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Check if snippet is still valid
if (snippet.validTill && new Date(snippet.validTill) < new Date()) {
throw new Error("Vendor snippet has expired");
}
// Query orders that match the snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, snippet.slotId!),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(snippet.slotId!);
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
// Filter and format orders
const formattedOrders = matchingOrders
.filter((order: any) => {
const status = order.orderStatus;
if (status[0].isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
if (status?.[0]?.isCancelled) return false;
const orderProductIds = order.orderItems.map((item: any) => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
})
.map((order: any) => {
const attachedOrderItems = order.orderItems.filter((item: any) =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
const products = attachedOrderItems.map((item: any) => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
@ -271,7 +185,7 @@ export const vendorSnippetsRouter = router({
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
@ -283,7 +197,7 @@ export const vendorSnippetsRouter = router({
sequence: order.slot.deliverySequence,
} : null,
products,
matchedProducts: snippet.productIds, // All snippet products are considered matched
matchedProducts: snippet.productIds,
snippetCode: snippet.snippetCode,
};
});
@ -305,45 +219,14 @@ export const vendorSnippetsRouter = router({
getVendorOrders: protectedProcedure
.query(async () => {
const vendorOrders = await db.query.orders.findMany({
with: {
user: true,
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
return vendorOrders.map(order => ({
id: order.id,
status: 'pending', // Default status since orders table may not have status field
orderDate: order.createdAt.toISOString(),
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
products: order.orderItems.map(item => ({
name: item.product.name,
quantity: parseFloat(item.quantity || '0'),
unit: item.product.unit?.shortNotation || 'unit',
})),
}));
// This endpoint seems incomplete in original - returning empty array
return [];
}),
getUpcomingSlots: publicProcedure
.query(async () => {
const threeHoursAgo = dayjs().subtract(3, 'hour').toDate();
const slots = await db.query.deliverySlotInfo.findMany({
where: and(
eq(deliverySlotInfo.isActive, true),
gt(deliverySlotInfo.deliveryTime, threeHoursAgo)
),
orderBy: asc(deliverySlotInfo.deliveryTime),
});
const slots = await vendorSnippetDbService.getUpcomingSlots(threeHoursAgo);
return {
success: true,
@ -364,60 +247,31 @@ export const vendorSnippetsRouter = router({
.query(async ({ input }) => {
const { snippetCode, slotId } = input;
// Find the snippet
const snippet = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.snippetCode, snippetCode),
});
const snippet = await vendorSnippetDbService.getSnippetByCode(snippetCode);
if (!snippet) {
throw new Error("Vendor snippet not found");
}
// Find the slot
const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, slotId),
});
const slot = await vendorSnippetDbService.getSlotById(slotId);
if (!slot) {
throw new Error("Slot not found");
}
// Query orders that match the slot and snippet criteria
const matchingOrders = await db.query.orders.findMany({
where: eq(orders.slotId, slotId),
with: {
orderItems: {
with: {
product: {
with: {
unit: true,
},
},
},
},
orderStatus: true,
user: true,
slot: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
});
const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(slotId);
// Filter orders that contain at least one of the snippet's products
const filteredOrders = matchingOrders.filter(order => {
const formattedOrders = matchingOrders
.filter((order: any) => {
const status = order.orderStatus;
if (status[0]?.isCancelled) return false;
const orderProductIds = order.orderItems.map(item => item.productId);
if (status?.[0]?.isCancelled) return false;
const orderProductIds = order.orderItems.map((item: any) => item.productId);
return snippet.productIds.some(productId => orderProductIds.includes(productId));
});
// Format the response
const formattedOrders = filteredOrders.map(order => {
// Filter orderItems to only include products attached to the snippet
const attachedOrderItems = order.orderItems.filter(item =>
})
.map((order: any) => {
const attachedOrderItems = order.orderItems.filter((item: any) =>
snippet.productIds.includes(item.productId)
);
const products = attachedOrderItems.map(item => ({
const products = attachedOrderItems.map((item: any) => ({
orderItemId: item.id,
productId: item.productId,
productName: item.product.name,
@ -430,7 +284,7 @@ export const vendorSnippetsRouter = router({
is_package_verified: item.is_package_verified,
}));
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0);
return {
orderId: `ORD${order.id}`,
@ -473,54 +327,16 @@ export const vendorSnippetsRouter = router({
orderItemId: z.number().int().positive("Valid order item ID required"),
is_packaged: z.boolean()
}))
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
const { orderItemId, is_packaged } = input;
// Get staff user ID from auth middleware
// const staffUserId = ctx.staffUser?.id;
// if (!staffUserId) {
// throw new Error("Unauthorized");
// }
// Check if order item exists and get related data
const orderItem = await db.query.orderItems.findFirst({
where: eq(orderItems.id, orderItemId),
with: {
order: {
with: {
slot: true
}
}
}
});
const orderItem = await vendorSnippetDbService.getOrderItemById(orderItemId);
if (!orderItem) {
throw new Error("Order item not found");
}
// Check if this order item belongs to a slot that has vendor snippets
// This ensures only order items from vendor-accessible orders can be updated
if (!orderItem.order.slotId) {
throw new Error("Order item not associated with a vendor slot");
}
const snippetExists = await db.query.vendorSnippets.findFirst({
where: eq(vendorSnippets.slotId, orderItem.order.slotId),
});
if (!snippetExists) {
throw new Error("No vendor snippet found for this order's slot");
}
// Update the is_packaged field
const result = await db.update(orderItems)
.set({ is_packaged })
.where(eq(orderItems.id, orderItemId))
.returning();
if (result.length === 0) {
throw new Error("Failed to update packaging status");
}
await vendorSnippetDbService.updateOrderItemPackaging(orderItemId, is_packaged);
return {
success: true,

View file

@ -0,0 +1,12 @@
import { homeBanners } from '@/src/db/schema'
export type Banner = typeof homeBanners.$inferSelect
export type NewBanner = typeof homeBanners.$inferInsert
export interface IBannerDbService {
getAllBanners(): Promise<Banner[]>
getBannerById(id: number): Promise<Banner | undefined>
createBanner(data: NewBanner): Promise<Banner>
updateBannerById(id: number, data: Partial<NewBanner>): Promise<Banner>
deleteBannerById(id: number): Promise<void>
}

View file

@ -0,0 +1,9 @@
import { complaints, users } from '@/src/db/schema'
export type Complaint = typeof complaints.$inferSelect
export type NewComplaint = typeof complaints.$inferInsert
export interface IComplaintDbService {
getComplaints(cursor?: number, limit?: number): Promise<Array<Complaint & { userName?: string | null; userMobile?: string | null }>>
resolveComplaint(id: number, response?: string): Promise<void>
}

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