Compare commits

...
Sign in to create a new pull request.

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

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

File diff suppressed because one or more lines are too long

View file

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

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

View file

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

View file

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

View file

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

View file

@ -2,13 +2,13 @@ import React from 'react';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { AppContainer } from 'common-ui'; import { AppContainer } from 'common-ui';
import ProductForm from '@/src/components/ProductForm'; import ProductForm from '@/src/components/ProductForm';
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api'; import { trpc } from '@/src/trpc-client';
export default function AddProduct() { 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 handleSubmit = (values: any, imageKeys?: string[]) => {
const payload: CreateProductPayload = { createProduct.mutate({
name: values.name, name: values.name,
shortDescription: values.shortDescription, shortDescription: values.shortDescription,
longDescription: values.longDescription, longDescription: values.longDescription,
@ -18,37 +18,12 @@ export default function AddProduct() {
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1, incrementStep: 1,
productQuantity: values.productQuantity || 1, productQuantity: values.productQuantity || 1,
}; isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
const formData = new FormData(); flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
Object.entries(payload).forEach(([key, value]) => { tagIds: values.tagIds || [],
if (value !== undefined && value !== null) { imageKeys: imageKeys || [],
formData.append(key, value as string); }, {
}
});
// Append tag IDs
if (values.tagIds && values.tagIds.length > 0) {
values.tagIds.forEach((tagId: number) => {
formData.append('tagIds', tagId.toString());
});
}
// Append images
if (images) {
images.forEach((image, index) => {
if (image.uri) {
formData.append('images', {
uri: image.uri,
name: `image-${index}.jpg`,
// type: 'image/jpeg',
type: image.mimeType as any,
} as any);
}
});
}
createProduct(formData, {
onSuccess: (data) => { onSuccess: (data) => {
Alert.alert('Success', 'Product created successfully!'); Alert.alert('Success', 'Product created successfully!');
// Reset form or navigate // Reset form or navigate
@ -81,7 +56,7 @@ export default function AddProduct() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isCreating} isLoading={createProduct.isPending}
existingImages={[]} existingImages={[]}
/> />
</AppContainer> </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 { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
@ -26,7 +27,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const [uploadUrls, setUploadUrls] = useState<string[]>([]); const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.useMutation(); const respondToReview = trpc.admin.product.respondToReview.useMutation();
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation(); const { upload, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -62,30 +63,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => { const handleSubmit = async (adminResponse: string) => {
try { try {
const mimeTypes = selectedImages.map(s => s.mimeType); let keys: string[] = [];
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({ let generatedUrls: string[] = [];
contextString: 'review',
mimeTypes,
});
const keys = generatedUrls.map(url => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, "");
const decodedKey = decodeURIComponent(rawKey);
const parts = decodedKey.split('/');
parts.shift();
return parts.join('/');
});
setUploadUrls(generatedUrls);
for (let i = 0; i < generatedUrls.length; i++) { if (selectedImages.length > 0) {
const uploadUrl = generatedUrls[i]; const result = await upload({
const { blob, mimeType } = selectedImages[i]; images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
const uploadResponse = await fetch(uploadUrl, { contextString: 'review',
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
}); });
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`); keys = result.keys;
generatedUrls = result.presignedUrls;
} }
await respondToReview.mutateAsync({ await respondToReview.mutateAsync({
@ -101,8 +88,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setSelectedImages([]); setSelectedImages([]);
setDisplayImages([]); setDisplayImages([]);
setUploadUrls([]); setUploadUrls([]);
} catch (error:any) { } catch (error: any) {
Alert.alert('Error', error.message || 'Failed to submit response.'); Alert.alert('Error', error.message || 'Failed to submit response.');
} }
}; };
@ -137,7 +123,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
<TouchableOpacity <TouchableOpacity
onPress={() => formikSubmit()} onPress={() => formikSubmit()}
activeOpacity={0.8} activeOpacity={0.8}
disabled={respondToReview.isPending} disabled={respondToReview.isPending || isUploading}
> >
<LinearGradient <LinearGradient
colors={['#2563EB', '#1D4ED8']} colors={['#2563EB', '#1D4ED8']}
@ -145,7 +131,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={tw`py-4 rounded-2xl items-center shadow-lg`} style={tw`py-4 rounded-2xl items-center shadow-lg`}
> >
{respondToReview.isPending ? ( {isUploading ? (
<ActivityIndicator color="white" />
) : respondToReview.isPending ? (
<ActivityIndicator color="white" /> <ActivityIndicator color="white" />
) : ( ) : (
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText> <MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>

View file

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

View file

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

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

View file

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

View file

@ -5,8 +5,8 @@
}, },
"build": { "build": {
"development": { "development": {
"developmentClient": true, "distribution": "internal",
"distribution": "internal" "autoIncrement": true
}, },
"preview": { "preview": {
"distribution": "internal", "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 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 { Image } from 'expo-image';
import { Formik, FieldArray } from 'formik'; import { Formik, FieldArray } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
@ -7,7 +7,7 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client'; import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api'; import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
interface ProductFormData { interface ProductFormData {
name: string; name: string;
@ -38,7 +38,7 @@ export interface ProductFormRef {
interface ProductFormProps { interface ProductFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: ProductFormData; initialValues: ProductFormData;
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void; onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
isLoading: boolean; isLoading: boolean;
existingImages?: string[]; existingImages?: string[];
} }
@ -60,8 +60,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
existingImages = [] existingImages = []
}, ref) => { }, ref) => {
const { theme } = useTheme(); 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 [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
const { upload, isUploading } = useUploadToObjectStorage();
const { data: storesData } = trpc.common.getStoresSummary.useQuery(); const { data: storesData } = trpc.common.getStoresSummary.useQuery();
const storeOptions = storesData?.stores.map(store => ({ const storeOptions = storesData?.stores.map(store => ({
@ -69,8 +70,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id, value: store.id,
})) || []; })) || [];
const { data: tagsData } = useGetTags(); const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
const tagOptions = tagsData?.tags.map(tag => ({ const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
label: tag.tagName, label: tag.tagName,
value: tag.id.toString(), value: tag.id.toString(),
})) || []; })) || [];
@ -83,23 +84,62 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
}, [existingImages]); }, [existingImages]);
const pickImage = usePickImage({ 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, multiple: true,
}); });
// Calculate which existing images were deleted // Calculate which existing images were deleted
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img)); const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
// Display images for ImageUploader component
const displayImages = newImages.map(img => ({ uri: img.uri }));
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={(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 enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
// Clear form when screen comes into focus // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setImages([]); setNewImages([]);
setExistingImagesState([]); setExistingImagesState([]);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
@ -143,9 +183,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
{mode === 'create' && ( {mode === 'create' && (
<ImageUploader <ImageUploader
images={images} images={displayImages}
onAddImage={pickImage} 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 }}> <View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText> <MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
<ImageUploader <ImageUploader
images={images} images={displayImages}
onAddImage={pickImage} onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))} onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
/> />
</View> </View>
)} )}
@ -355,11 +395,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading} disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{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> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

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, "when": 1772637259874,
"tag": "0076_sturdy_wolverine", "tag": "0076_sturdy_wolverine",
"breakpoints": true "breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1773927855512,
"tag": "0077_wakeful_norrin_radd",
"breakpoints": true
} }
] ]
} }

View file

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

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

@ -1,185 +1,167 @@
import 'dotenv/config'; import { Hono } from 'hono'
import express, { NextFunction, Request, Response } from "express"; import { cors } from 'hono/cors'
import cors from "cors"; import { logger } from 'hono/logger'
// import bodyParser from "body-parser"; import { serve } from 'bun'
import multer from "multer"; import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import path from "path"; import { appRouter } from '@/src/trpc/router'
import fs from "fs"; import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils'
import { db } from '@/src/db/db_index'; import { db } from '@/src/db/db_index'
import { staffUsers, userDetails } from '@/src/db/schema'; import { staffUsers, userDetails } from '@/src/db/schema'
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm'
import mainRouter from '@/src/main-router'; import { TRPCError } from '@trpc/server'
import initFunc from '@/src/lib/init'; import signedUrlCache from '@/src/lib/signed-url-cache'
import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { seed } from '@/src/db/seed'
import { appRouter } from '@/src/trpc/router'; import initFunc from '@/src/lib/init'
import { TRPCError } from '@trpc/server'; import '@/src/jobs/jobs-index'
import jwt from 'jsonwebtoken' import { startAutomatedJobs } from '@/src/lib/automatedJobs'
import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/db/seed';
import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
// Initialize
seed() seed()
initFunc() initFunc()
startAutomatedJobs() startAutomatedJobs()
const app = express(); const app = new Hono()
app.use(cors({ // CORS middleware
origin: 'http://localhost:5174' 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()); // tRPC handler with context
app.use(express.urlencoded({ extended: true })); app.use('/api/trpc/*', async (c) => {
const response = await fetchRequestHandler({
// Middleware to log all request URLs endpoint: '/api/trpc',
app.use((req, res, next) => { req: c.req.raw,
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, router: appRouter,
createContext: async ({ req, res }) => { createContext: async ({ req }) => {
let user = null; let user = null
let staffUser = null; let staffUser = null
const authHeader = req.headers.authorization; const authHeader = req.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7); const token = authHeader.substring(7)
try { 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 ('staffId' in decoded) {
if (decoded.staffId) { const staffPayload = decoded as StaffJWTPayload
// This is a staff token, verify staff exists
const staff = await db.query.staffUsers.findFirst({ const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, decoded.staffId), where: eq(staffUsers.id, staffPayload.staffId)
}); })
if (staff) { if (staff) {
user=staffUser staffUser = { id: staff.id, name: staff.name }
staffUser = {
id: staff.id,
name: staff.name,
};
} }
} else { } 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({ const details = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, user.userId), where: eq(userDetails.userId, userPayload.userId)
}); })
if (details?.isSuspended) { if (details?.isSuspended) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'Account suspended', message: 'Account suspended'
}); })
} }
} }
} catch (err) { } catch {
// Invalid token, both user and staffUser remain null // 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 :', { console.error('🚨 tRPC Error:', {
path, path,
type,
code: error.code, code: error.code,
message: error.message, message: error.message,
userId: ctx?.user?.userId, 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 return response
const assetsPublicDir = path.resolve(__dirname, './assets/public'); })
if (fs.existsSync(assetsPublicDir)) {
app.use('/assets', express.static(assetsPublicDir)); // Static files - Fallback UI
console.log('Serving /assets from', assetsPublicDir); app.use('/*', async (c) => {
} else { const url = new URL(c.req.url)
console.warn('Assets public folder not found at', assetsPublicDir); let filePath = url.pathname
}
// Default to index.html for root
if (filePath === '/') {
filePath = '/index.html'
}
// Try to serve the file
const file = Bun.file(`./fallback-ui/dist${filePath}`)
if (await file.exists()) {
return new Response(file)
}
// SPA fallback - serve index.html for any unmatched routes
const indexFile = Bun.file('./fallback-ui/dist/index.html')
if (await indexFile.exists()) {
return new Response(indexFile)
}
return c.notFound()
})
// Static files - Assets
app.use('/assets/*', async (c) => {
const path = c.req.path.replace('/assets/', '')
const file = Bun.file(`./assets/public/${path}`)
if (await file.exists()) {
return new Response(file)
}
return c.notFound()
})
// Global error handler // Global error handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { app.onError((err, c) => {
console.error(err); console.error('Error:', err)
const status = err.statusCode || err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({ message });
});
app.listen(4000, '::', () => { const status = err instanceof TRPCError
console.log("Server is running on http://localhost:4000/api/mobile/"); ? (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", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"migrate": "drizzle-kit generate: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", "build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"build2": "rimraf ./dist && tsc", "build2": "rimraf ./dist && tsc",
"db:push": "drizzle-kit push: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", "db:seed": "tsx src/db/seed.ts",
"dev2": "tsx watch index.ts", "dev:express": "bun --watch index-express.ts",
"dev_node": "tsx watch index.ts", "dev:hono": "bun --watch index.ts",
"dev": "bun --watch index.ts", "dev": "bun --watch index.ts",
"start": "bun index.ts",
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .", "docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
"docker:push": "docker push mohdshafiuddin54/health_petal:latest" "docker:push": "docker push mohdshafiuddin54/health_petal:latest"
}, },
@ -26,8 +33,6 @@
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bullmq": "^5.63.0", "bullmq": "^5.63.0",
@ -36,18 +41,16 @@
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0", "expo-server-sdk": "^4.0.0",
"express": "^5.1.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2", "hono": "^4.6.3",
"multer": "^2.0.2", "jose": "^5.10.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"razorpay": "^2.9.6",
"redis": "^5.9.0", "redis": "^5.9.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.3", "@types/bun": "^1.1.10",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",

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 { Context } from 'hono'
import { Request, Response } from "express";
import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
/** import { getProductsSummaryData } from '@/src/db/common-product'
* Get next delivery date for a product import { scaffoldAssetUrl } from '@/src/lib/s3-client'
*/
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
const result = await db
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
.from(productSlots)
.innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
.where(
and(
eq(productSlots.productId, productId),
eq(deliverySlotInfo.isActive, true),
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
)
)
.orderBy(deliverySlotInfo.deliveryTime)
.limit(1);
return result[0]?.deliveryTime || null;
};
/** /**
* Get all products summary for dropdown * Get all products summary for dropdown
*/ */
export const getAllProductsSummary = async (req: Request, res: Response) => { export const getAllProductsSummary = async (c: Context) => {
try { try {
const { tagId } = req.query; const tagId = c.req.query('tagId')
const tagIdNum = tagId ? parseInt(tagId as string) : null; 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 const formattedProducts = productsWithUnits.map((product) => ({
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 {
id: product.id, id: product.id,
name: product.name, name: product.name,
shortDescription: product.shortDescription, shortDescription: product.shortDescription,
@ -88,18 +22,16 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
unit: product.unitShortNotation, unit: product.unitShortNotation,
productQuantity: product.productQuantity, productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock, isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
images: scaffoldAssetUrl((product.images as string[]) || []), images: scaffoldAssetUrl((product.images as string[]) || []),
}; }))
})
);
return res.status(200).json({ return c.json({
products: formattedProducts, products: formattedProducts,
count: formattedProducts.length, count: formattedProducts.length,
}); })
} catch (error) { } catch (error) {
console.error("Get products summary error:", error); console.error('Get products summary error:', error)
return res.status(500).json({ error: "Failed to fetch products summary" }); return c.json({ error: 'Failed to fetch products summary' }, 500)
} }
}; }

View file

@ -1,10 +1,9 @@
import { Router } from "express"; import { Hono } from 'hono'
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller" 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)
export default app
const commonProductsRouter= router;
export default commonProductsRouter;

View file

@ -1,10 +1,9 @@
import { Router } from "express"; import { Hono } from 'hono'
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router" 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 app
export default commonRouter;

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

View file

@ -1,138 +1,8 @@
import { db } from "@/src/db/db_index" import { seed as seedPostgres } from '@db-helper-postgres/db/seed'
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema" import { seed as seedSqlite } from '@db-helper-sqlite/db/seed'
import { eq } from "drizzle-orm";
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
import { CONST_KEYS } from '@/src/lib/const-keys'
export async function seed() { const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
console.log("Seeding database...");
// Seed units individually const seedImpl = dialect === 'sqlite' ? seedSqlite : seedPostgres
const unitsToSeed = [
{ shortNotation: "Kg", fullName: "Kilogram" },
{ shortNotation: "L", fullName: "Litre" },
{ shortNotation: "Dz", fullName: "Dozen" },
{ shortNotation: "Pc", fullName: "Unit Piece" },
];
for (const unit of unitsToSeed) { export const seed = async () => seedImpl()
const existingUnit = await db.query.units.findFirst({
where: eq(units.shortNotation, unit.shortNotation),
});
if (!existingUnit) {
await db.insert(units).values(unit);
}
}
// Seed staff roles individually
const rolesToSeed = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const;
for (const roleName of rolesToSeed) {
const existingRole = await db.query.staffRoles.findFirst({
where: eq(staffRoles.roleName, roleName),
});
if (!existingRole) {
await db.insert(staffRoles).values({ roleName });
}
}
// Seed staff permissions individually
const permissionsToSeed = ['crud_product', 'make_coupon', 'crud_staff_users'] as const;
for (const permissionName of permissionsToSeed) {
const existingPermission = await db.query.staffPermissions.findFirst({
where: eq(staffPermissions.permissionName, permissionName),
});
if (!existingPermission) {
await db.insert(staffPermissions).values({ permissionName });
}
}
// Seed role-permission assignments
await db.transaction(async (tx) => {
// Get role IDs
const superAdminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'super_admin') });
const adminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'admin') });
const marketerRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'marketer') });
// Get permission IDs
const crudProductPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_product') });
const makeCouponPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'make_coupon') });
const crudStaffUsersPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_staff_users') });
// Assign all permissions to super_admin
[crudProductPerm, makeCouponPerm, crudStaffUsersPerm].forEach(async (perm) => {
if (superAdminRole && perm) {
const existingSuperAdminPerm = await tx.query.staffRolePermissions.findFirst({
where: eq(staffRolePermissions.staffRoleId, superAdminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
});
if (!existingSuperAdminPerm) {
await tx.insert(staffRolePermissions).values({
staffRoleId: superAdminRole.id,
staffPermissionId: perm.id,
});
}
}
});
// Assign all permissions to admin
[crudProductPerm, makeCouponPerm].forEach(async (perm) => {
if (adminRole && perm) {
const existingAdminPerm = await tx.query.staffRolePermissions.findFirst({
where: eq(staffRolePermissions.staffRoleId, adminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
});
if (!existingAdminPerm) {
await tx.insert(staffRolePermissions).values({
staffRoleId: adminRole.id,
staffPermissionId: perm.id,
});
}
}
});
// Assign make_coupon to marketer
if (marketerRole && makeCouponPerm) {
const existingMarketerCoupon = await tx.query.staffRolePermissions.findFirst({
where: eq(staffRolePermissions.staffRoleId, marketerRole.id) && eq(staffRolePermissions.staffPermissionId, makeCouponPerm.id),
});
if (!existingMarketerCoupon) {
await tx.insert(staffRolePermissions).values({
staffRoleId: marketerRole.id,
staffPermissionId: makeCouponPerm.id,
});
}
}
});
// Seed key-val store constants using CONST_KEYS
const constantsToSeed = [
{ key: CONST_KEYS.readableOrderId, value: 0 },
{ key: CONST_KEYS.minRegularOrderValue, value: minOrderValue },
{ key: CONST_KEYS.freeDeliveryThreshold, value: minOrderValue },
{ key: CONST_KEYS.deliveryCharge, value: deliveryCharge },
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
{ key: CONST_KEYS.popularItems, value: [] },
{ key: CONST_KEYS.allItemsOrder, value: [] },
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
];
for (const constant of constantsToSeed) {
const existing = await db.query.keyValStore.findFirst({
where: eq(keyValStore.key, constant.key),
});
if (!existing) {
await db.insert(keyValStore).values({
key: constant.key,
value: constant.value,
});
}
}
console.log("Seeding completed.");
}

View file

@ -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 { import type {
users, User as PostgresUser,
addresses, Address as PostgresAddress,
units, Unit as PostgresUnit,
productInfo, ProductInfo as PostgresProductInfo,
deliverySlotInfo, DeliverySlotInfo as PostgresDeliverySlotInfo,
productSlots, ProductSlot as PostgresProductSlot,
specialDeals, SpecialDeal as PostgresSpecialDeal,
orders, Order as PostgresOrder,
orderItems, OrderItem as PostgresOrderItem,
payments, Payment as PostgresPayment,
notifications, Notification as PostgresNotification,
productCategories, ProductCategory as PostgresProductCategory,
cartItems, CartItem as PostgresCartItem,
coupons, Coupon as PostgresCoupon,
} from "@/src/db/schema"; 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>; type UseSqlite = typeof DB_DIALECT_TYPE extends 'sqlite' ? true : false
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>;
// Combined types export type User = UseSqlite extends true ? SqliteUser : PostgresUser
export type ProductWithUnit = ProductInfo & { export type Address = UseSqlite extends true ? SqliteAddress : PostgresAddress
unit: Unit; 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 OrderWithItems = Order & { export type ProductSlot = UseSqlite extends true ? SqliteProductSlot : PostgresProductSlot
items: (OrderItem & { product: ProductInfo })[]; export type SpecialDeal = UseSqlite extends true ? SqliteSpecialDeal : PostgresSpecialDeal
address: Address; export type Order = UseSqlite extends true ? SqliteOrder : PostgresOrder
slot: DeliverySlotInfo; 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 CartItemWithProduct = CartItem & { export type ProductCategory = UseSqlite extends true ? SqliteProductCategory : PostgresProductCategory
product: ProductInfo; 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 * as cron from 'node-cron';
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
const runCombinedJob = async () => { const runCombinedJob = async () => {
const start = Date.now(); const start = Date.now();
try { try {
console.log('Starting combined job: payments and refunds check'); console.log('Starting combined job');
// Run payment check
// await checkPendingPayments();
// Run refund check
// await checkRefundStatuses();
console.log('Combined job completed successfully'); console.log('Combined job completed successfully');
} catch (error) { } catch (error) {
console.error('Error in combined job:', error); console.error('Error in combined job:', error);

View file

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

View file

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

View file

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

View file

@ -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 { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
// Core notification dispatch methods (renamed for clarity) // Core notification dispatch methods (renamed for clarity)
export async function dispatchBulkNotification({ export async function dispatchBulkNotification({

View file

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

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 for cache entries with TTL
interface CacheEntry { interface CacheEntry {
value: string; value: string;
@ -16,18 +11,7 @@ class SignedURLCache {
constructor() { constructor() {
this.originalToSignedCache = new Map(); this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map(); this.signedToOriginalCache = new Map();
console.log('SignedURLCache: Initialized (in-memory only)');
// Create cache directory if it doesn't exist
const cacheDir = path.dirname(CACHE_FILE_PATH);
if (!fs.existsSync(cacheDir)) {
console.log('creating the directory')
fs.mkdirSync(cacheDir, { recursive: true });
}
else {
console.log('the directory is already present')
}
} }
/** /**
@ -110,7 +94,7 @@ class SignedURLCache {
clear(): void { clear(): void {
this.originalToSignedCache.clear(); this.originalToSignedCache.clear();
this.signedToOriginalCache.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 { getStats(): { totalEntries: number } {
try { return {
// Remove expired entries before saving totalEntries: this.originalToSignedCache.size
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 * Stub methods for backward compatibility - do nothing in in-memory mode
*/ */
saveToDisk(): void {
// No-op: In-memory cache only
}
loadFromDisk(): void { loadFromDisk(): void {
try { // No-op: In-memory cache only
if (fs.existsSync(CACHE_FILE_PATH)) {
// Read from file
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
// Parse the data
const parsedData = JSON.parse(data) as {
originalToSigned: Record<string, { value: string; expiresAt: number }>,
signedToOriginal: Record<string, { value: string; expiresAt: number }>
};
// Only load entries that haven't expired yet
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
// Load original to signed mappings
if (parsedData.originalToSigned) {
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
if (now <= entry.expiresAt) {
this.originalToSignedCache.set(originalUrl, entry);
loadedCount++;
} else {
expiredCount++;
}
}
}
// Load signed to original mappings
if (parsedData.signedToOriginal) {
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
if (now <= entry.expiresAt) {
this.signedToOriginalCache.set(signedUrl, entry);
// Don't increment loadedCount as these are pairs of what we already counted
} else {
// Don't increment expiredCount as these are pairs of what we already counted
}
}
}
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
} else {
console.log('SignedURLCache: No cache file found, starting with empty cache');
}
} catch (error) {
console.error('Error loading SignedURLCache from disk:', error);
// Start with empty caches if loading fails
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
}
} }
} }
// Create a singleton instance to be used throughout the application // Create a singleton instance to be used throughout the application
const signedUrlCache = new SignedURLCache(); const signedUrlCache = new SignedURLCache();
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; 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 { Hono } from 'hono'
import avRouter from "@/src/apis/admin-apis/apis/av-router" import { authenticateUser } from '@/src/middleware/auth.middleware'
import { ApiError } from "@/src/lib/api-error" import v1Router from '@/src/v1-router'
import v1Router from "@/src/v1-router"
import testController from "@/src/test-controller"
import { authenticateUser } from "@/src/middleware/auth.middleware"
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
import uploadHandler from "@/src/lib/upload-handler"
// Note: This router is kept for compatibility during migration
const router = Router(); // Most routes have been moved to tRPC
const router = new Hono()
// Health check endpoints (no auth required) // Health check endpoints (no auth required)
router.get('/health', (req: Request, res: Response) => { // Note: These are also defined in index.ts, keeping for compatibility
res.status(200).json({ router.get('/health', (c) => {
return c.json({
status: 'OK', status: 'OK',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
message: 'Hello world' message: 'Hello world'
}); })
}); })
router.get('/seed', (req:Request, res: Response) => {
res.status(200).json({ router.get('/seed', (c) => {
return c.json({
status: 'OK', status: 'OK',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime() uptime: process.uptime()
}); })
}) })
// Mount v1 routes (REST API)
router.route('/v1', v1Router)
// Apply authentication middleware to all subsequent routes // Apply authentication middleware to all subsequent routes
router.use(authenticateUser); router.use('*', authenticateUser)
router.use('/v1', v1Router); export default router
// router.use('/av', avRouter);
router.use('/test', testController);
// User REST APIs
router.post('/uv/complaints/raise',
uploadHandler.array('images', 5),
raiseComplaint
);
// Global error handling middleware
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Error:', err);
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: err.message,
details: err.details,
statusCode: err.statusCode
});
}
// Handle unknown errors
return res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
statusCode: 500
});
});
const mainRouter = router;
export default mainRouter;

View file

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

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

View file

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

View file

@ -1,22 +1,16 @@
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { homeBanners } from '@/src/db/schema'
import { eq, and, desc, sql } from 'drizzle-orm';
import { protectedProcedure, router } from '@/src/trpc/trpc-index' import { protectedProcedure, router } from '@/src/trpc/trpc-index'
import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client'
import { ApiError } from '@/src/lib/api-error'; 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({ export const bannerRouter = router({
// Get all banners // Get all banners
getBanners: protectedProcedure getBanners: protectedProcedure
.query(async () => { .query(async () => {
try { try {
const banners = await bannerDbService.getAllBanners()
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
});
// Convert S3 keys to signed URLs for client // Convert S3 keys to signed URLs for client
const bannersWithSignedUrls = await Promise.all( const bannersWithSignedUrls = await Promise.all(
@ -24,16 +18,14 @@ export const bannerRouter = router({
try { try {
return { return {
...banner, ...banner,
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl, imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
// Ensure productIds is always an array
productIds: banner.productIds || [], productIds: banner.productIds || [],
}; };
} catch (error) { } catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
return { return {
...banner, ...banner,
imageUrl: banner.imageUrl, // Keep original on error imageUrl: banner.imageUrl,
// Ensure productIds is always an array
productIds: banner.productIds || [], productIds: banner.productIds || [],
}; };
} }
@ -43,10 +35,8 @@ export const bannerRouter = router({
return { return {
banners: bannersWithSignedUrls, banners: bannersWithSignedUrls,
}; };
} } catch (e: any) {
catch(e:any) {
console.log(e) console.log(e)
throw new ApiError(e.message); throw new ApiError(e.message);
} }
}), }),
@ -55,23 +45,17 @@ export const bannerRouter = router({
getBanner: protectedProcedure getBanner: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.query(async ({ input }) => { .query(async ({ input }) => {
const banner = await db.query.homeBanners.findFirst({ const banner = await bannerDbService.getBannerById(input.id)
where: eq(homeBanners.id, input.id),
// Removed product relationship since we now use productIds array
});
if (banner) { if (banner) {
try { try {
// Convert S3 key to signed URL for client
if (banner.imageUrl) { if (banner.imageUrl) {
banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl); banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
} }
} catch (error) { } catch (error) {
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
// Keep original imageUrl on error
} }
// Ensure productIds is always an array (handle migration compatibility)
if (!banner.productIds) { if (!banner.productIds) {
banner.productIds = []; banner.productIds = [];
} }
@ -84,32 +68,31 @@ export const bannerRouter = router({
createBanner: protectedProcedure createBanner: protectedProcedure
.input(z.object({ .input(z.object({
name: z.string().min(1), name: z.string().min(1),
imageUrl: z.string().url(), imageUrl: z.string(),
description: z.string().optional(), description: z.string().optional(),
productIds: z.array(z.number()).optional(), productIds: z.array(z.number()).optional(),
redirectUrl: z.string().url().optional(), redirectUrl: z.string().url().optional(),
// serialNum removed completely
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl) const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
const [banner] = await db.insert(homeBanners).values({
const banner = await bannerDbService.createBanner({
name: input.name, name: input.name,
imageUrl: imageUrl, imageUrl: imageUrl,
description: input.description, description: input.description,
productIds: input.productIds || [], productIds: input.productIds || [],
redirectUrl: input.redirectUrl, redirectUrl: input.redirectUrl,
serialNum: 999, // Default value, not used serialNum: 999,
isActive: false, // Default to inactive isActive: false,
}).returning(); })
// Reinitialize stores to reflect changes scheduleStoreInitialization()
await initializeAllStores();
return banner; return banner;
} catch (error) { } catch (error) {
console.error('Error creating banner:', 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 }) => { .mutation(async ({ input }) => {
try { try {
const { id, ...updateData } = input; 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 processedData: any = { ...updateData }
const finalData: any = { ...processedData };
if ('serialNum' in finalData && finalData.serialNum === null) { if (updateData.imageUrl) {
// Set to null explicitly processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl)
finalData.serialNum = null;
} }
const [banner] = await db.update(homeBanners) if ('serialNum' in processedData && processedData.serialNum === null) {
.set({ ...finalData, lastUpdated: new Date(), }) processedData.serialNum = null;
.where(eq(homeBanners.id, id)) }
.returning();
// Reinitialize stores to reflect changes const banner = await bannerDbService.updateBannerById(id, processedData)
await initializeAllStores();
scheduleStoreInitialization()
return banner; return banner;
} catch (error) { } catch (error) {
@ -164,10 +137,9 @@ export const bannerRouter = router({
deleteBanner: protectedProcedure deleteBanner: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db.delete(homeBanners).where(eq(homeBanners.id, input.id)); await bannerDbService.deleteBannerById(input.id)
// Reinitialize stores to reflect changes scheduleStoreInitialization()
await initializeAllStores();
return { success: true }; 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 { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index' import { scaffoldAssetUrl } from '@/src/lib/s3-client'
import { complaints, users } from '@/src/db/schema' import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
import { eq, desc, lt, and } from 'drizzle-orm';
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
export const complaintRouter = router({ export const complaintRouter = router({
getAll: protectedProcedure getAll: protectedProcedure
@ -14,27 +12,7 @@ export const complaintRouter = router({
.query(async ({ input }) => { .query(async ({ input }) => {
const { cursor, limit } = input; const { cursor, limit } = input;
let whereCondition = cursor const complaintsData = await complaintDbService.getComplaints(cursor, limit);
? lt(complaints.id, cursor)
: undefined;
const complaintsData = await db
.select({
id: complaints.id,
complaintBody: complaints.complaintBody,
userId: complaints.userId,
orderId: complaints.orderId,
isResolved: complaints.isResolved,
createdAt: complaints.createdAt,
userName: users.name,
userMobile: users.mobile,
images: complaints.images,
})
.from(complaints)
.leftJoin(users, eq(complaints.userId, users.id))
.where(whereCondition)
.orderBy(desc(complaints.id))
.limit(limit + 1);
const hasMore = complaintsData.length > limit; const hasMore = complaintsData.length > limit;
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData; const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
@ -42,7 +20,7 @@ export const complaintRouter = router({
const complaintsWithSignedImages = await Promise.all( const complaintsWithSignedImages = await Promise.all(
complaintsToReturn.map(async (c) => { complaintsToReturn.map(async (c) => {
const signedImages = c.images const signedImages = c.images
? await generateSignedUrlsFromS3Urls(c.images as string[]) ? scaffoldAssetUrl(c.images as string[])
: []; : [];
return { return {
@ -70,10 +48,7 @@ export const complaintRouter = router({
resolve: protectedProcedure resolve: protectedProcedure
.input(z.object({ id: z.string(), response: z.string().optional() })) .input(z.object({ id: z.string(), response: z.string().optional() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db await complaintDbService.resolveComplaint(parseInt(input.id), input.response);
.update(complaints)
.set({ isResolved: true, response: input.response })
.where(eq(complaints.id, parseInt(input.id)));
return { message: 'Complaint resolved successfully' }; return { message: 'Complaint resolved successfully' };
}), }),

View file

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

View file

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

View file

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

View file

@ -1,15 +1,7 @@
import { router, protectedProcedure } from "@/src/trpc/trpc-index" import { router, protectedProcedure } from "@/src/trpc/trpc-index"
import { z } from "zod"; import { z } from "zod";
import { db } from "@/src/db/db_index"
import {
orders,
orderStatus,
payments,
refunds,
} from "@/src/db/schema";
import { and, eq } from "drizzle-orm";
import { ApiError } from "@/src/lib/api-error" 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 const initiateRefundSchema = z
.object({ .object({
@ -37,18 +29,14 @@ export const adminPaymentsRouter = router({
const { orderId, refundPercent, refundAmount } = input; const { orderId, refundPercent, refundAmount } = input;
// Validate order exists // Validate order exists
const order = await db.query.orders.findFirst({ const order = await refundDbService.getOrderById(orderId);
where: eq(orders.id, orderId),
});
if (!order) { if (!order) {
throw new ApiError("Order not found", 404); throw new ApiError("Order not found", 404);
} }
// Check if order is paid // Check if order is paid
const orderStatusRecord = await db.query.orderStatus.findFirst({ const orderStatusRecord = await refundDbService.getOrderStatusByOrderId(orderId);
where: eq(orderStatus.orderId, orderId),
});
if(order.isCod) { if(order.isCod) {
throw new ApiError("Order is a Cash On Delivery. Not eligible for refund") 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); throw new ApiError("Invalid refund parameters", 400);
} }
let razorpayRefund = null; let merchantRefundId = 'xxx'; //temporary suppressal
let merchantRefundId = null;
// Get payment record for online payments // Get payment record for online payments
const payment = await db.query.payments.findFirst({ const payment = await refundDbService.getSuccessfulPaymentByOrderId(orderId);
where: and(
eq(payments.orderId, orderId),
eq(payments.status, "success")
),
});
if (!payment || payment.status !== "success") { if (!payment || payment.status !== "success") {
throw new ApiError("Payment not found or not successful", 404); 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 // Check if refund already exists for this order
const existingRefund = await db.query.refunds.findFirst({ const existingRefund = await refundDbService.getRefundByOrderId(orderId);
where: eq(refunds.orderId, orderId),
});
const refundStatus = "initiated"; const refundStatus = "initiated";
if (existingRefund) { if (existingRefund) {
// Update existing refund // Update existing refund
await db await refundDbService.updateRefund(existingRefund.id, {
.update(refunds)
.set({
refundAmount: calculatedRefundAmount.toString(), refundAmount: calculatedRefundAmount.toString(),
refundStatus, refundStatus,
merchantRefundId, merchantRefundId,
refundProcessedAt: order.isCod ? new Date() : null, refundProcessedAt: order.isCod ? new Date() : null,
}) });
.where(eq(refunds.id, existingRefund.id));
} else { } else {
// Insert new refund // Insert new refund
await db await refundDbService.createRefund({
.insert(refunds)
.values({
orderId, orderId,
refundAmount: calculatedRefundAmount.toString(), refundAmount: calculatedRefundAmount.toString(),
refundStatus, 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 { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index' import { productDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { imageUploadS3, generateSignedUrlsFromS3Urls, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client' import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image' import { deleteS3Image } from '@/src/lib/delete-image'
import type { SpecialDeal } from '@/src/db/types' import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { initializeAllStores } from '@/src/stores/store-initializer'
type CreateDeal = { type CreateDeal = {
quantity: number; quantity: number;
@ -18,19 +16,13 @@ type CreateDeal = {
export const productRouter = router({ export const productRouter = router({
getProducts: protectedProcedure getProducts: protectedProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const products = await db.query.productInfo.findMany({ const products = await productDbService.getAllProducts();
orderBy: productInfo.name,
with: {
unit: true,
store: true,
},
});
// Generate signed URLs for all product images // Generate signed URLs for all product images
const productsWithSignedUrls = await Promise.all( const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({ products.map(async (product) => ({
...product, ...product,
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), images: scaffoldAssetUrl((product.images as string[]) || []),
})) }))
); );
@ -47,35 +39,22 @@ export const productRouter = router({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { id } = input; const { id } = input;
const product = await db.query.productInfo.findFirst({ const product = await productDbService.getProductById(id);
where: eq(productInfo.id, id),
with: {
unit: true,
},
});
if (!product) { if (!product) {
throw new ApiError("Product not found", 404); throw new ApiError("Product not found", 404);
} }
// Fetch special deals for this product // Fetch special deals for this product
const deals = await db.query.specialDeals.findMany({ const deals = await productDbService.getDealsByProductId(id);
where: eq(specialDeals.productId, id),
orderBy: specialDeals.quantity,
});
// Fetch associated tags for this product // Fetch associated tags for this product
const productTagsData = await db.query.productTags.findMany({ const productTagsData = await productDbService.getTagsByProductId(id);
where: eq(productTags.productId, id),
with: {
tag: true,
},
});
// Generate signed URLs for product images // Generate signed URLs for product images
const productWithSignedUrls = { const productWithSignedUrls = {
...product, ...product,
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []), images: scaffoldAssetUrl((product.images as string[]) || []),
deals, deals,
tags: productTagsData.map(pt => pt.tag), tags: productTagsData.map(pt => pt.tag),
}; };
@ -92,23 +71,231 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id } = input; const { id } = input;
const [deletedProduct] = await db const deletedProduct = await productDbService.deleteProduct(id);
.delete(productInfo)
.where(eq(productInfo.id, id))
.returning();
if (!deletedProduct) { if (!deletedProduct) {
throw new ApiError("Product not found", 404); throw new ApiError("Product not found", 404);
} }
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
message: "Product deleted successfully", 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 toggleOutOfStock: protectedProcedure
.input(z.object({ .input(z.object({
id: z.number(), id: z.number(),
@ -116,24 +303,18 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id } = input; const { id } = input;
const product = await db.query.productInfo.findFirst({ const product = await productDbService.getProductById(id);
where: eq(productInfo.id, id),
});
if (!product) { if (!product) {
throw new ApiError("Product not found", 404); throw new ApiError("Product not found", 404);
} }
const [updatedProduct] = await db const updatedProduct = await productDbService.updateProduct(id, {
.update(productInfo)
.set({
isOutOfStock: !product.isOutOfStock, isOutOfStock: !product.isOutOfStock,
}) });
.where(eq(productInfo.id, id))
.returning();
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
product: updatedProduct, product: updatedProduct,
@ -154,12 +335,7 @@ export const productRouter = router({
} }
// Get current associations // Get current associations
const currentAssociations = await db.query.productSlots.findMany({ const currentAssociations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(assoc => assoc.productId); const currentProductIds = currentAssociations.map(assoc => assoc.productId);
const newProductIds = productIds.map((id: string) => parseInt(id)); 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 // Remove associations for products that are no longer selected
if (productsToRemove.length > 0) { if (productsToRemove.length > 0) {
await db.delete(productSlots).where( for (const productId of productsToRemove) {
and( await productDbService.deleteProductSlot(parseInt(slotId), productId);
eq(productSlots.slotId, parseInt(slotId)), }
inArray(productSlots.productId, productsToRemove)
)
);
} }
// Add associations for newly selected products // Add associations for newly selected products
if (productsToAdd.length > 0) { if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map(productId => ({ for (const productId of productsToAdd) {
productId, await productDbService.createProductSlot(parseInt(slotId), productId);
slotId: parseInt(slotId), }
}));
await db.insert(productSlots).values(newAssociations);
} }
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
message: "Slot products updated successfully", message: "Slot products updated successfully",
@ -205,12 +375,7 @@ export const productRouter = router({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { slotId } = input; const { slotId } = input;
const associations = await db.query.productSlots.findMany({ const associations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const productIds = associations.map(assoc => assoc.productId); const productIds = associations.map(assoc => assoc.productId);
@ -235,13 +400,7 @@ export const productRouter = router({
} }
// Fetch all associations for the requested slots // Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({ const associations = await productDbService.getProductSlotsBySlotIds(slotIds);
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId // Group by slotId
const result = associations.reduce((acc, assoc) => { const result = associations.reduce((acc, assoc) => {
@ -271,40 +430,19 @@ export const productRouter = router({
.query(async ({ input }) => { .query(async ({ input }) => {
const { productId, limit, offset } = input; const { productId, limit, offset } = input;
const reviews = await db const reviews = await productDbService.getReviewsByProductId(productId, limit, offset);
.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);
// Generate signed URLs for images // Generate signed URLs for images
const reviewsWithSignedUrls = await Promise.all( const reviewsWithSignedUrls = await Promise.all(
reviews.map(async (review) => ({ reviews.map(async (review) => ({
...review, ...review,
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []), signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []), signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
})) }))
); );
// Check if more reviews exist // Check if more reviews exist
const totalCountResult = await db const totalCount = await productDbService.getReviewCountByProductId(productId);
.select({ count: sql`count(*)` })
.from(productReviews)
.where(eq(productReviews.productId, productId));
const totalCount = Number(totalCountResult[0].count);
const hasMore = offset + limit < totalCount; const hasMore = offset + limit < totalCount;
return { reviews: reviewsWithSignedUrls, hasMore }; return { reviews: reviewsWithSignedUrls, hasMore };
@ -320,14 +458,10 @@ export const productRouter = router({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input; const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
const [updatedReview] = await db const updatedReview = await productDbService.updateReview(reviewId, {
.update(productReviews)
.set({
adminResponse, adminResponse,
adminResponseImages, adminResponseImages,
}) });
.where(eq(productReviews.id, reviewId))
.returning();
if (!updatedReview) { if (!updatedReview) {
throw new ApiError('Review not found', 404); throw new ApiError('Review not found', 404);
@ -335,7 +469,6 @@ export const productRouter = router({
// Claim upload URLs // Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) { if (uploadUrls && uploadUrls.length > 0) {
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
} }
@ -344,22 +477,13 @@ export const productRouter = router({
getGroups: protectedProcedure getGroups: protectedProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const groups = await db.query.productGroupInfo.findMany({ const groups = await productDbService.getAllGroups() as any[];
with: {
memberships: {
with: {
product: true,
},
},
},
orderBy: desc(productGroupInfo.createdAt),
});
return { return {
groups: groups.map(group => ({ groups: groups.map(group => ({
...group, ...group,
products: group.memberships.map(m => m.product), products: group.memberships?.map((m: any) => m.product) || [],
productCount: group.memberships.length, productCount: group.memberships?.length || 0,
})), })),
}; };
}), }),
@ -373,13 +497,10 @@ export const productRouter = router({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { group_name, description, product_ids } = input; const { group_name, description, product_ids } = input;
const [newGroup] = await db const newGroup = await productDbService.createGroup({
.insert(productGroupInfo)
.values({
groupName: group_name, groupName: group_name,
description, description,
}) });
.returning();
if (product_ids.length > 0) { if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({ const memberships = product_ids.map(productId => ({
@ -387,11 +508,11 @@ export const productRouter = router({
groupId: newGroup.id, groupId: newGroup.id,
})); }));
await db.insert(productGroupMembership).values(memberships); await productDbService.createGroupMemberships(memberships);
} }
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
group: newGroup, group: newGroup,
@ -413,11 +534,7 @@ export const productRouter = router({
if (group_name !== undefined) updateData.groupName = group_name; if (group_name !== undefined) updateData.groupName = group_name;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
const [updatedGroup] = await db const updatedGroup = await productDbService.updateGroup(id, updateData);
.update(productGroupInfo)
.set(updateData)
.where(eq(productGroupInfo.id, id))
.returning();
if (!updatedGroup) { if (!updatedGroup) {
throw new ApiError('Group not found', 404); throw new ApiError('Group not found', 404);
@ -425,7 +542,7 @@ export const productRouter = router({
if (product_ids !== undefined) { if (product_ids !== undefined) {
// Delete existing memberships // Delete existing memberships
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); await productDbService.deleteGroupMembershipsByGroupId(id);
// Insert new memberships // Insert new memberships
if (product_ids.length > 0) { if (product_ids.length > 0) {
@ -434,12 +551,12 @@ export const productRouter = router({
groupId: id, groupId: id,
})); }));
await db.insert(productGroupMembership).values(memberships); await productDbService.createGroupMemberships(memberships);
} }
} }
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
group: updatedGroup, group: updatedGroup,
@ -455,20 +572,17 @@ export const productRouter = router({
const { id } = input; const { id } = input;
// Delete memberships first // Delete memberships first
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); await productDbService.deleteGroupMembershipsByGroupId(id);
// Delete group // Delete group
const [deletedGroup] = await db const deletedGroup = await productDbService.deleteGroup(id);
.delete(productGroupInfo)
.where(eq(productGroupInfo.id, id))
.returning();
if (!deletedGroup) { if (!deletedGroup) {
throw new ApiError('Group not found', 404); throw new ApiError('Group not found', 404);
} }
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
message: 'Group deleted successfully', message: 'Group deleted successfully',
@ -494,37 +608,31 @@ export const productRouter = router({
// Validate that all productIds exist // Validate that all productIds exist
const productIds = updates.map(u => u.productId); const productIds = updates.map(u => u.productId);
const existingProducts = await db.query.productInfo.findMany({ const allExist = await productDbService.validateProductIdsExist(productIds);
where: inArray(productInfo.id, productIds),
columns: { id: true },
});
const existingIds = new Set(existingProducts.map(p => p.id)); if (!allExist) {
const invalidIds = productIds.filter(id => !existingIds.has(id)); throw new ApiError('Some product IDs are invalid', 400);
if (invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
} }
// Perform batch update // Perform batch update
const updatePromises = updates.map(async (update) => { const batchUpdates = updates.map(update => {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update; const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
const updateData: any = {}; const updateData: any = {};
if (price !== undefined) updateData.price = price; if (price !== undefined) updateData.price = price.toString();
if (marketPrice !== undefined) updateData.marketPrice = marketPrice; if (marketPrice !== undefined) updateData.marketPrice = marketPrice?.toString();
if (flashPrice !== undefined) updateData.flashPrice = flashPrice; if (flashPrice !== undefined) updateData.flashPrice = flashPrice?.toString();
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable; if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
return db return {
.update(productInfo) productId,
.set(updateData) data: updateData,
.where(eq(productInfo.id, productId)); };
}); });
await Promise.all(updatePromises); await productDbService.batchUpdateProducts(batchUpdates);
// Reinitialize stores to reflect changes // Reinitialize stores to reflect changes
await initializeAllStores(); scheduleStoreInitialization()
return { return {
message: `Updated prices for ${updates.length} product(s)`, message: `Updated prices for ${updates.length} product(s)`,

View file

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

View file

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

View file

@ -1,29 +1,22 @@
import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { storeInfo, productInfo } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { extractKeyFromPresignedUrl, deleteImageUtil, generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { initializeAllStores } from '@/src/stores/store-initializer' import { storeDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
export const storeRouter = router({ export const storeRouter = router({
getStores: protectedProcedure getStores: protectedProcedure
.query(async ({ ctx }) => { .query(async () => {
const stores = await db.query.storeInfo.findMany({ const stores = await storeDbService.getAllStores();
with: {
owner: true,
},
});
Promise.all(stores.map(async store => { Promise.all(stores.map(async store => {
if(store.imageUrl) if(store.imageUrl)
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl) store.imageUrl = scaffoldAssetUrl(store.imageUrl)
})).catch((e) => { })).catch((e) => {
throw new ApiError("Unable to find store image urls") throw new ApiError("Unable to find store image urls")
} })
)
return { return {
stores, stores,
count: stores.length, count: stores.length,
@ -34,20 +27,17 @@ export const storeRouter = router({
.input(z.object({ .input(z.object({
id: z.number(), id: z.number(),
})) }))
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { id } = input; const { id } = input;
const store = await db.query.storeInfo.findFirst({ const store = await storeDbService.getStoreById(id);
where: eq(storeInfo.id, id),
with: {
owner: true,
},
});
if (!store) { if (!store) {
throw new ApiError("Store not found", 404); throw new ApiError("Store not found", 404);
} }
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl);
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
return { return {
store, store,
}; };
@ -61,31 +51,22 @@ export const storeRouter = router({
owner: z.number().min(1, "Owner is required"), owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(), products: z.array(z.number()).optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const { name, description, imageUrl, owner, products } = input; const { name, description, imageUrl, owner, products } = input;
const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined; const newStore = await storeDbService.createStore({
const [newStore] = await db
.insert(storeInfo)
.values({
name, name,
description, description,
imageUrl: imageKey, imageUrl: imageUrl || null,
owner, owner,
}) });
.returning();
// Assign selected products to this store // Assign selected products to this store
if (products && products.length > 0) { if (products && products.length > 0) {
await db await storeDbService.assignProductsToStore(newStore.id, products);
.update(productInfo)
.set({ storeId: newStore.id })
.where(inArray(productInfo.id, products));
} }
// Reinitialize stores to reflect changes scheduleStoreInitialization()
await initializeAllStores();
return { return {
store: newStore, store: newStore,
@ -102,12 +83,10 @@ export const storeRouter = router({
owner: z.number().min(1, "Owner is required"), owner: z.number().min(1, "Owner is required"),
products: z.array(z.number()).optional(), products: z.array(z.number()).optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const { id, name, description, imageUrl, owner, products } = input; const { id, name, description, imageUrl, owner, products } = input;
const existingStore = await db.query.storeInfo.findFirst({ const existingStore = await storeDbService.getStoreById(id);
where: eq(storeInfo.id, id),
});
if (!existingStore) { if (!existingStore) {
throw new ApiError("Store not found", 404); throw new ApiError("Store not found", 404);
@ -127,44 +106,28 @@ export const storeRouter = router({
await deleteImageUtil({keys: [oldImageKey]}); await deleteImageUtil({keys: [oldImageKey]});
} catch (error) { } catch (error) {
console.error('Failed to delete old image:', error); console.error('Failed to delete old image:', error);
// Continue with update even if deletion fails
} }
} }
const [updatedStore] = await db const updatedStore = await storeDbService.updateStore(id, {
.update(storeInfo)
.set({
name, name,
description, description,
imageUrl: newImageKey, imageUrl: newImageKey,
owner, owner,
}) });
.where(eq(storeInfo.id, id))
.returning();
if (!updatedStore) {
throw new ApiError("Store not found", 404);
}
// Update products if provided // Update products if provided
if (products) { if (products) {
// First, set storeId to null for products not in the list but currently assigned to this store // First, remove all products from this store
await db await storeDbService.removeProductsFromStore(id);
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, id));
// Then, assign the selected products to this store // Then, assign the selected products to this store
if (products.length > 0) { if (products.length > 0) {
await db await storeDbService.assignProductsToStore(id, products);
.update(productInfo)
.set({ storeId: id })
.where(inArray(productInfo.id, products));
} }
} }
// Reinitialize stores to reflect changes scheduleStoreInitialization()
await initializeAllStores();
return { return {
store: updatedStore, store: updatedStore,
@ -176,34 +139,19 @@ export const storeRouter = router({
.input(z.object({ .input(z.object({
storeId: z.number(), storeId: z.number(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const { storeId } = input; const { storeId } = input;
const result = await db.transaction(async (tx) => { // First, remove all products from this store
// First, update all products of this store to set storeId to null await storeDbService.removeProductsFromStore(storeId);
await tx
.update(productInfo)
.set({ storeId: null })
.where(eq(productInfo.storeId, storeId));
// Then delete the store // Then delete the store
const [deletedStore] = await tx await storeDbService.deleteStore(storeId);
.delete(storeInfo)
.where(eq(storeInfo.id, storeId))
.returning();
if (!deletedStore) { scheduleStoreInitialization()
throw new ApiError("Store not found", 404);
}
return { return {
message: "Store deleted successfully", 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 { protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod'; 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 { ApiError } from '@/src/lib/api-error';
import { notificationQueue } from '@/src/lib/notif-job'; import { notificationQueue } from '@/src/lib/notif-job';
import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store'; 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> { async function createUserByMobile(mobile: string) {
// Clean mobile number (remove non-digits)
const cleanMobile = mobile.replace(/\D/g, ''); const cleanMobile = mobile.replace(/\D/g, '');
// Validate: exactly 10 digits
if (cleanMobile.length !== 10) { if (cleanMobile.length !== 10) {
throw new ApiError('Mobile number must be exactly 10 digits', 400); throw new ApiError('Mobile number must be exactly 10 digits', 400);
} }
// Check if user already exists const existingUser = await userDbService.getUserByMobile(cleanMobile);
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.mobile, cleanMobile))
.limit(1);
if (existingUser) { if (existingUser) {
throw new ApiError('User with this mobile number already exists', 409); throw new ApiError('User with this mobile number already exists', 409);
} }
// Create user const newUser = await userDbService.createUser({
const [newUser] = await db
.insert(users)
.values({
name: null, name: null,
email: null, email: null,
mobile: cleanMobile, mobile: cleanMobile,
}) });
.returning();
return newUser; return newUser;
} }
@ -56,7 +43,7 @@ export const userRouter = {
getEssentials: protectedProcedure getEssentials: protectedProcedure
.query(async () => { .query(async () => {
const count = await db.$count(complaints, eq(complaints.isResolved, false)); const count = await userDbService.getUnresolvedComplaintCount();
return { return {
unresolvedComplaints: count || 0, unresolvedComplaints: count || 0,
@ -72,78 +59,23 @@ export const userRouter = {
.query(async ({ input }) => { .query(async ({ input }) => {
const { limit, cursor, search } = input; const { limit, cursor, search } = input;
// Build where conditions const usersList = await userDbService.getUsers({ limit, cursor, search });
const whereConditions = [];
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 hasMore = usersList.length > limit;
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
// Get order stats for each user
const userIds = usersToReturn.map(u => u.id); const userIds = usersToReturn.map(u => u.id);
let orderCounts: { userId: number; totalOrders: number }[] = []; const orderCounts = await userDbService.getOrderCountByUserIds(userIds);
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = []; const lastOrders = await userDbService.getLastOrderDateByUserIds(userIds);
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
if (userIds.length > 0) { const userDetailsList = await Promise.all(
// Get total orders per user userIds.map(id => userDbService.getUserDetailsByUserId(id))
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);
// 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 orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate])); 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 => ({ const usersWithStats = usersToReturn.map(user => ({
...user, ...user,
totalOrders: orderCountMap.get(user.id) || 0, totalOrders: orderCountMap.get(user.id) || 0,
@ -151,7 +83,6 @@ export const userRouter = {
isSuspended: suspensionMap.get(user.id) ?? false, isSuspended: suspensionMap.get(user.id) ?? false,
})); }));
// Get next cursor
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined; const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
return { return {
@ -168,76 +99,22 @@ export const userRouter = {
.query(async ({ input }) => { .query(async ({ input }) => {
const { userId } = input; const { userId } = input;
// Get user info const user = await userDbService.getUserById(userId);
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);
if (!user || user.length === 0) { if (!user) {
throw new ApiError('User not found', 404); throw new ApiError('User not found', 404);
} }
// Get user suspension status const userDetail = await userDbService.getUserDetailsByUserId(userId);
const userDetail = await db const userOrders = await userDbService.getOrdersByUserId(userId);
.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 orderIds = userOrders.map(o => o.id); 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 statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount])); const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
// Determine status string
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => { const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
if (!status) return 'pending'; if (!status) return 'pending';
if (status.isCancelled) return 'cancelled'; if (status.isCancelled) return 'cancelled';
@ -245,15 +122,14 @@ export const userRouter = {
return 'pending'; return 'pending';
}; };
// Combine data
const ordersWithDetails = userOrders.map(order => { const ordersWithDetails = userOrders.map(order => {
const status = statusMap.get(order.id); const status = statusMap.get(order.id);
return { return {
id: order.id, id: order.id,
readableId: order.readableId, readableId: (order as any).readableId,
totalAmount: order.totalAmount, totalAmount: order.totalAmount,
createdAt: order.createdAt, createdAt: order.createdAt,
isFlashDelivery: order.isFlashDelivery, isFlashDelivery: (order as any).isFlashDelivery,
status: getStatus(status), status: getStatus(status),
itemCount: itemCountMap.get(order.id) || 0, itemCount: itemCountMap.get(order.id) || 0,
}; };
@ -261,8 +137,8 @@ export const userRouter = {
return { return {
user: { user: {
...user[0], ...user,
isSuspended: userDetail[0]?.isSuspended ?? false, isSuspended: userDetail?.isSuspended ?? false,
}, },
orders: ordersWithDetails, orders: ordersWithDetails,
}; };
@ -276,39 +152,13 @@ export const userRouter = {
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { userId, isSuspended } = input; const { userId, isSuspended } = input;
// Check if user exists const user = await userDbService.getUserById(userId);
const user = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user || user.length === 0) { if (!user) {
throw new ApiError('User not found', 404); throw new ApiError('User not found', 404);
} }
// Check if user_details record exists await userDbService.upsertUserDetails({ userId, isSuspended });
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,
});
}
return { return {
success: true, success: true,
@ -323,40 +173,17 @@ export const userRouter = {
.query(async ({ input }) => { .query(async ({ input }) => {
const { search } = input; const { search } = input;
// Get all users const usersList = await userDbService.getUsers({ limit: 1000, search });
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);
}
// Get eligible users (have notif_creds entry) const allTokens = await userDbService.getAllNotifTokens();
const eligibleUsers = await db const eligibleSet = new Set(allTokens);
.select({ userId: notifCreds.userId })
.from(notifCreds);
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
return { return {
users: usersList.map(user => ({ users: usersList.map(user => ({
id: user.id, id: user.id,
name: user.name, name: user.name,
mobile: user.mobile, mobile: user.mobile,
isEligibleForNotif: eligibleSet.has(user.id), isEligibleForNotif: eligibleSet.has(user.mobile || ''),
})), })),
}; };
}), }),
@ -374,25 +201,13 @@ export const userRouter = {
let tokens: string[] = []; let tokens: string[] = [];
if (userIds.length === 0) { if (userIds.length === 0) {
// Send to all users - get tokens from both logged-in and unlogged users const allTokens = await userDbService.getAllNotifTokens();
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds); const unloggedTokens = await userDbService.getUnloggedTokens();
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens); tokens = [...allTokens, ...unloggedTokens];
tokens = [
...loggedInTokens.map(t => t.token),
...unloggedTokens.map(t => t.token)
];
} else { } else {
// Send to specific users - get their tokens tokens = await userDbService.getNotifTokensByUserIds(userIds);
const userTokens = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(inArray(notifCreds.userId, userIds));
tokens = userTokens.map(t => t.token);
} }
// Queue one job per token
let queuedCount = 0; let queuedCount = 0;
for (const token of tokens) { for (const token of tokens) {
try { try {
@ -427,18 +242,7 @@ export const userRouter = {
.query(async ({ input }) => { .query(async ({ input }) => {
const { userId } = input; const { userId } = input;
const incidents = await db.query.userIncidents.findMany({ const incidents = await userDbService.getUserIncidentsByUserId(userId);
where: eq(userIncidents.userId, userId),
with: {
order: {
with: {
orderStatus: true,
},
},
addedBy: true,
},
orderBy: desc(userIncidents.dateAdded),
});
return { return {
incidents: incidents.map(incident => ({ incidents: incidents.map(incident => ({
@ -470,14 +274,13 @@ export const userRouter = {
throw new ApiError('Admin user not authenticated', 401); throw new ApiError('Admin user not authenticated', 401);
} }
const incident = await userDbService.createUserIncident({
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; userId,
orderId: orderId || null,
const [incident] = await db.insert(userIncidents) adminComment: adminComment || null,
.values({ addedBy: adminUserId,
...incidentObj, negativityScore: negativityScore || null,
}) });
.returning();
recomputeUserNegativityScore(userId); recomputeUserNegativityScore(userId);

View file

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