Compare commits
No commits in common. "edge_redacted" and "main" have entirely different histories.
edge_redac
...
main
688 changed files with 40852 additions and 39624 deletions
|
|
@ -1,9 +0,0 @@
|
|||
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/users-ui/app
|
||||
apps/users-ui/src
|
||||
apps/admin-ui/app
|
||||
apps/users-ui/src
|
||||
**/package-lock.json
|
||||
test/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,14 +7,10 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
*.apk
|
||||
**/appBinaries
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
test/appBinaries
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
||||
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
||||
- remove slots from products cache
|
||||
- remove redundant product details like name, description etc from the slots api
|
||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,36 +1,32 @@
|
|||
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||
|
||||
# 1. ---- Base Bun image
|
||||
FROM oven/bun:1.3.10 AS base
|
||||
# 1. ---- Base Node image
|
||||
FROM node:20-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# 2. ---- Pruner ----
|
||||
FROM base AS pruner
|
||||
WORKDIR /app
|
||||
# Copy config files first for better caching
|
||||
COPY package.json turbo.json ./
|
||||
COPY package.json package-lock.json turbo.json ./
|
||||
COPY apps/backend/package.json ./apps/backend/
|
||||
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||
COPY packages/shared/ ./packages/shared
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
RUN bun install -g turbo
|
||||
RUN npm install -g turbo
|
||||
COPY . .
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
|
||||
# RUN find . -path "./node_modules" -prune -o -print
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||
|
||||
# 3. ---- Builder ----
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# Copy package files first to cache bun install
|
||||
# Copy package files first to cache npm install
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
#RUN cat ./bun.lock
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
COPY --from=pruner /app/turbo.json .
|
||||
RUN bun install
|
||||
RUN npm ci
|
||||
# Copy source code after dependencies are installed
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
|
||||
# 4. ---- Runner ----
|
||||
FROM base AS runner
|
||||
|
|
@ -38,15 +34,12 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
# Copy package files and install production deps
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
RUN bun install --production
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci --production --omit=dev
|
||||
# Copy built applications
|
||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
|
||||
# RUN ls -R
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
EXPOSE 4000
|
||||
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
|
||||
RUN npm i -g bun
|
||||
CMD ["bun", "apps/backend/dist/index.js"]
|
||||
# CMD ["node", "apps/backend/dist/index.js"]
|
||||
12
apps/admin-ui/.expo/types/router.d.ts
vendored
12
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -227,6 +227,7 @@ export default function Layout() {
|
|||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||
|
|
|
|||
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { BottomDialog , tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import AddressZoneForm from '@/components/AddressZoneForm'
|
||||
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
|
||||
const AddressManagement: React.FC = () => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
||||
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
||||
|
||||
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
||||
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
||||
|
||||
const createZone = trpc.admin.address.createZone.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchZones()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const createArea = trpc.admin.address.createArea.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchAreas()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddZone = () => {
|
||||
setDialogType('zone')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAddPlace = () => {
|
||||
setDialogType('place')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const toggleZone = (zoneId: number) => {
|
||||
setExpandedZones(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(zoneId)) {
|
||||
newSet.delete(zoneId)
|
||||
} else {
|
||||
newSet.add(zoneId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const groupedAreas = areas?.reduce((acc, area) => {
|
||||
if (area.zoneId) {
|
||||
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
||||
acc[area.zoneId].push(area)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<number, typeof areas[0][]>) || {}
|
||||
|
||||
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
<View style={tw`flex-row justify-between p-4`}>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
||||
<Text style={tw`text-white`}>Add Zone</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
||||
<Text style={tw`text-white`}>Add Place</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={tw`flex-1 p-4`}>
|
||||
{zones?.map(zone => (
|
||||
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
||||
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
||||
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
||||
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
||||
</TouchableOpacity>
|
||||
{expandedZones.has(zone.id) && (
|
||||
<View style={tw`p-3`}>
|
||||
{groupedAreas[zone.id]?.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={tw`mt-6`}>
|
||||
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
||||
{unzonedAreas.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
))}
|
||||
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
</BottomDialog>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressManagement
|
||||
|
|
@ -74,7 +74,7 @@ export default function Dashboard() {
|
|||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Manage Orderss',
|
||||
title: 'Manage Orders',
|
||||
icon: 'shopping-bag',
|
||||
description: 'View and manage customer orders',
|
||||
route: '/(drawer)/manage-orders',
|
||||
|
|
@ -158,15 +158,6 @@ export default function Dashboard() {
|
|||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
{
|
||||
title: 'Stocking Schedules',
|
||||
icon: 'schedule',
|
||||
description: 'Manage product stocking schedules',
|
||||
route: '/(drawer)/stocking-schedules',
|
||||
category: 'products',
|
||||
iconColor: '#0EA5E9',
|
||||
iconBg: '#E0F2FE',
|
||||
},
|
||||
{
|
||||
title: 'Stores',
|
||||
icon: 'store',
|
||||
|
|
@ -184,6 +175,15 @@ export default function Dashboard() {
|
|||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Address Management',
|
||||
icon: 'location-on',
|
||||
description: 'Manage service areas',
|
||||
route: '/(drawer)/address-management',
|
||||
category: 'settings',
|
||||
iconColor: '#EAB308',
|
||||
iconBg: '#FEF9C3',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
|
|||
import { useRouter } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagFormData {
|
||||
|
|
@ -14,17 +15,36 @@ interface TagFormData {
|
|||
|
||||
export default function AddTag() {
|
||||
const router = useRouter();
|
||||
const createTag = trpc.admin.tag.createTag.useMutation();
|
||||
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
createTag.mutate({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
}, {
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
createTag(formData, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag created successfully', [
|
||||
{
|
||||
|
|
@ -56,7 +76,7 @@ export default function AddTag() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createTag.isPending}
|
||||
isLoading={isCreating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
|
|||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagFormData {
|
||||
|
|
@ -10,6 +11,7 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
existingImageUrl?: string;
|
||||
}
|
||||
|
||||
export default function EditTag() {
|
||||
|
|
@ -17,25 +19,39 @@ export default function EditTag() {
|
|||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.tag.getTagById.useQuery(
|
||||
{ id: tagIdNum! },
|
||||
{ enabled: !!tagIdNum }
|
||||
);
|
||||
const updateTag = trpc.admin.tag.updateTag.useMutation();
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
if (!tagIdNum) return;
|
||||
|
||||
updateTag.mutate({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
deleteExistingImage: deleteExistingImage,
|
||||
}, {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
updateTag({ id: tagIdNum, formData }, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag updated successfully', [
|
||||
{
|
||||
|
|
@ -76,7 +92,8 @@ export default function EditTag() {
|
|||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
relatedStores: tag.relatedStores || [],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -89,7 +106,7 @@ export default function EditTag() {
|
|||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateTag.isPending}
|
||||
isLoading={isUpdating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,7 @@ import { useRouter } from 'expo-router';
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||
import { TagMenu } from '@/src/components/TagMenu';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: any;
|
||||
createdAt?: string;
|
||||
}
|
||||
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||
|
||||
interface TagItemProps {
|
||||
item: Tag;
|
||||
|
|
@ -70,7 +60,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
|||
|
||||
export default function ProductTags() {
|
||||
const router = useRouter();
|
||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.tag.getTags.useQuery();
|
||||
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const tags = tagsData?.tags || [];
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
|||
import { Alert } from 'react-native';
|
||||
import { AppContainer } from 'common-ui';
|
||||
import ProductForm from '@/src/components/ProductForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
||||
|
||||
export default function AddProduct() {
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||
|
||||
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
||||
createProduct.mutate({
|
||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||
const payload: CreateProductPayload = {
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -18,12 +18,37 @@ export default function AddProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
tagIds: values.tagIds || [],
|
||||
imageKeys: imageKeys || [],
|
||||
}, {
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Append tag IDs
|
||||
if (values.tagIds && values.tagIds.length > 0) {
|
||||
values.tagIds.forEach((tagId: number) => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Append images
|
||||
if (images) {
|
||||
images.forEach((image, index) => {
|
||||
if (image.uri) {
|
||||
formData.append('images', {
|
||||
uri: image.uri,
|
||||
name: `image-${index}.jpg`,
|
||||
// type: 'image/jpeg',
|
||||
type: image.mimeType as any,
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createProduct(formData, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
// Reset form or navigate
|
||||
|
|
@ -56,7 +81,7 @@ export default function AddProduct() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createProduct.isPending}
|
||||
isLoading={isCreating}
|
||||
existingImages={[]}
|
||||
/>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
|||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
|
||||
import { Formik } from 'formik';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -27,7 +26,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||
|
||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -63,16 +62,30 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
|
||||
const handleSubmit = async (adminResponse: string) => {
|
||||
try {
|
||||
let keys: string[] = [];
|
||||
let generatedUrls: string[] = [];
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
const result = await upload({
|
||||
images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'review',
|
||||
mimeTypes,
|
||||
});
|
||||
keys = result.keys;
|
||||
generatedUrls = result.presignedUrls;
|
||||
const keys = generatedUrls.map(url => {
|
||||
const u = new URL(url);
|
||||
const rawKey = u.pathname.replace(/^\/+/, "");
|
||||
const decodedKey = decodeURIComponent(rawKey);
|
||||
const parts = decodedKey.split('/');
|
||||
parts.shift();
|
||||
return parts.join('/');
|
||||
});
|
||||
setUploadUrls(generatedUrls);
|
||||
|
||||
for (let i = 0; i < generatedUrls.length; i++) {
|
||||
const uploadUrl = generatedUrls[i];
|
||||
const { blob, mimeType } = selectedImages[i];
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
});
|
||||
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
await respondToReview.mutateAsync({
|
||||
|
|
@ -88,7 +101,8 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
setSelectedImages([]);
|
||||
setDisplayImages([]);
|
||||
setUploadUrls([]);
|
||||
} catch (error: any) {
|
||||
} catch (error:any) {
|
||||
|
||||
Alert.alert('Error', error.message || 'Failed to submit response.');
|
||||
}
|
||||
};
|
||||
|
|
@ -123,7 +137,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
<TouchableOpacity
|
||||
onPress={() => formikSubmit()}
|
||||
activeOpacity={0.8}
|
||||
disabled={respondToReview.isPending || isUploading}
|
||||
disabled={respondToReview.isPending}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#2563EB', '#1D4ED8']}
|
||||
|
|
@ -131,9 +145,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
end={{ x: 1, y: 0 }}
|
||||
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : respondToReview.isPending ? (
|
||||
{respondToReview.isPending ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { View, Text, Alert } from 'react-native';
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export default function EditProduct() {
|
||||
|
|
@ -10,18 +11,18 @@ export default function EditProduct() {
|
|||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
//
|
||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
||||
updateProduct.mutate({
|
||||
id: productId,
|
||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -31,9 +32,6 @@ export default function EditProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended,
|
||||
isFlashAvailable: values.isFlashAvailable,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
deals: values.deals?.filter((deal: any) =>
|
||||
deal.quantity && deal.price && deal.validTill
|
||||
).map((deal: any) => ({
|
||||
|
|
@ -41,12 +39,47 @@ export default function EditProduct() {
|
|||
price: parseFloat(deal.price),
|
||||
validTill: deal.validTill instanceof Date
|
||||
? deal.validTill.toISOString().split('T')[0]
|
||||
: deal.validTill,
|
||||
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||
})),
|
||||
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) => {
|
||||
Alert.alert('Success', 'Product updated successfully!');
|
||||
// Clear newly added images after successful update
|
||||
|
|
@ -55,7 +88,8 @@ export default function EditProduct() {
|
|||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update product');
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
|
|
@ -91,7 +125,7 @@ export default function EditProduct() {
|
|||
deals: productData.deals?.map(deal => ({
|
||||
quantity: deal.quantity,
|
||||
price: deal.price,
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||
})) || [{ quantity: '', price: '', validTill: null }],
|
||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||
isSuspended: productData.isSuspended || false,
|
||||
|
|
@ -107,7 +141,7 @@ export default function EditProduct() {
|
|||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateProduct.isPending}
|
||||
isLoading={isUpdating}
|
||||
existingImages={productData.images || []}
|
||||
/>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
} from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -27,6 +26,12 @@ interface User {
|
|||
isEligibleForNotif: boolean;
|
||||
}
|
||||
|
||||
const extractKeyFromUrl = (url: string): string => {
|
||||
const u = new URL(url);
|
||||
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||
return decodeURIComponent(rawKey);
|
||||
};
|
||||
|
||||
export default function SendNotifications() {
|
||||
const router = useRouter();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
|
|
@ -41,7 +46,8 @@ export default function SendNotifications() {
|
|||
search: searchQuery,
|
||||
});
|
||||
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
// Generate upload URLs mutation
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
|
||||
// Send notification mutation
|
||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||
|
|
@ -121,8 +127,28 @@ export default function SendNotifications() {
|
|||
|
||||
// Upload image if selected
|
||||
if (selectedImage) {
|
||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
||||
imageUrl = key;
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'notification',
|
||||
mimeTypes: [selectedImage.mimeType],
|
||||
});
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
const uploadUrl = uploadUrls[0];
|
||||
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||
|
||||
// Upload image
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: selectedImage.blob,
|
||||
headers: {
|
||||
'Content-Type': selectedImage.mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification
|
||||
|
|
@ -230,15 +256,15 @@ export default function SendNotifications() {
|
|||
{/* Submit Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSend}
|
||||
disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0}
|
||||
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
|
||||
style={tw`${
|
||||
sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0
|
||||
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
|
||||
? 'bg-gray-300'
|
||||
: 'bg-blue-600'
|
||||
} rounded-xl py-4 items-center shadow-sm`}
|
||||
>
|
||||
<MyText style={tw`text-white font-bold text-base`}>
|
||||
{isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -1,443 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, Alert, FlatList, TouchableOpacity } from 'react-native';
|
||||
import {
|
||||
theme,
|
||||
AppContainer,
|
||||
MyText,
|
||||
tw,
|
||||
useManualRefresh,
|
||||
useMarkDataFetchers,
|
||||
MyTouchableOpacity,
|
||||
RawBottomDialog,
|
||||
BottomDialog,
|
||||
} from 'common-ui';
|
||||
import { trpc } from '../../../src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Ionicons, Entypo } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import AvailabilityScheduleForm from '../../../components/AvailabilityScheduleForm';
|
||||
|
||||
interface Schedule {
|
||||
id: number;
|
||||
scheduleName: string;
|
||||
time: string;
|
||||
action: 'in' | 'out';
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
productIds: number[];
|
||||
groupIds: number[];
|
||||
productCount: number;
|
||||
groupCount: number;
|
||||
}
|
||||
|
||||
const ScheduleItem = ({
|
||||
schedule,
|
||||
onDelete,
|
||||
index,
|
||||
onViewProducts,
|
||||
onViewGroups,
|
||||
onReplicate,
|
||||
}: {
|
||||
schedule: Schedule;
|
||||
onDelete: (id: number) => void;
|
||||
index: number;
|
||||
onViewProducts: (productIds: number[]) => void;
|
||||
onViewGroups: (groupIds: number[]) => void;
|
||||
onReplicate: (schedule: Schedule) => void;
|
||||
}) => {
|
||||
const isIn = schedule.action === 'in';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={tw``}>
|
||||
<View style={tw`p-6`}>
|
||||
{/* Top Header: Name & Action Badge */}
|
||||
<View style={tw`flex-row justify-between items-start mb-4`}>
|
||||
<View style={tw`flex-row items-center flex-1`}>
|
||||
<View
|
||||
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="schedule"
|
||||
size={24}
|
||||
color={theme.colors.brand600}
|
||||
/>
|
||||
</View>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyText
|
||||
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
|
||||
>
|
||||
Schedule Name
|
||||
</MyText>
|
||||
<MyText
|
||||
style={tw`text-xl font-black text-slate-900`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{schedule.scheduleName}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<View
|
||||
style={[
|
||||
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
|
||||
{ backgroundColor: isIn ? '#F0FDF4' : '#FFF1F2' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
tw`w-1.5 h-1.5 rounded-full mr-2`,
|
||||
{ backgroundColor: isIn ? '#10B981' : '#E11D48' },
|
||||
]}
|
||||
/>
|
||||
<MyText
|
||||
style={[
|
||||
tw`text-[10px] font-black uppercase tracking-tighter`,
|
||||
{ color: isIn ? '#10B981' : '#E11D48' },
|
||||
]}
|
||||
>
|
||||
{isIn ? 'In Stock' : 'Out of Stock'}
|
||||
</MyText>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={tw`p-1`}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Entypo name="dots-three-vertical" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Menu Dialog */}
|
||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>{schedule.scheduleName}</MyText>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
onReplicate(schedule);
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-800`}>Replicate items</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
Alert.alert('Coming Soon', 'Edit functionality will be available soon');
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="edit" size={20} color="#4B5563" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-800`}>Edit</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
onDelete(schedule.id);
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="delete" size={20} color="#E11D48" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-red-500`}>Delete</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => setMenuOpen(false)}
|
||||
style={tw`py-4 mt-2`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="close" size={20} color="#6B7280" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-600`}>Cancel</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Middle: Time Banner */}
|
||||
<View
|
||||
style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-4 border border-slate-100`}
|
||||
>
|
||||
<View
|
||||
style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}
|
||||
>
|
||||
<MaterialIcons name="access-time" size={20} color="#64748B" />
|
||||
</View>
|
||||
<View style={tw`ml-4 flex-1`}>
|
||||
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||
{schedule.time}
|
||||
</MyText>
|
||||
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
||||
Daily at this time
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats & Actions */}
|
||||
<View style={tw`flex-row items-center justify-between`}>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => onViewProducts(schedule.productIds)}
|
||||
style={tw`flex-row items-center mr-4`}
|
||||
>
|
||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
||||
{schedule.productCount} Products
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
{schedule.groupCount > 0 && (
|
||||
<MyTouchableOpacity
|
||||
onPress={() => onViewGroups(schedule.groupIds)}
|
||||
style={tw`flex-row items-center`}
|
||||
>
|
||||
<MaterialIcons name="category" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
||||
{schedule.groupCount} Groups
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StockingSchedules() {
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.productAvailabilitySchedules.getAll.useQuery();
|
||||
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
const deleteSchedule = trpc.admin.productAvailabilitySchedules.delete.useMutation();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'products' | 'groups'>('products');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// Replication state
|
||||
const [replicatingSchedule, setReplicatingSchedule] = useState<Schedule | null>(null);
|
||||
|
||||
useManualRefresh(refetch);
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
Alert.alert(
|
||||
'Delete Schedule',
|
||||
'Are you sure you want to delete this schedule? This action cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteSchedule.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to delete schedule');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewProducts = (productIds: number[]) => {
|
||||
setDialogType('products');
|
||||
setSelectedIds(productIds);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewGroups = (groupIds: number[]) => {
|
||||
setDialogType('groups');
|
||||
setSelectedIds(groupIds);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleReplicate = (schedule: Schedule) => {
|
||||
setReplicatingSchedule(schedule);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowCreateForm(false);
|
||||
setReplicatingSchedule(null);
|
||||
};
|
||||
|
||||
// Get product/group names from IDs
|
||||
const getProductNames = () => {
|
||||
const allProducts = productsData?.products || [];
|
||||
return selectedIds.map(id => {
|
||||
const product = allProducts.find(p => p.id === id);
|
||||
return product?.name || `Product #${id}`;
|
||||
});
|
||||
};
|
||||
|
||||
const getGroupNames = () => {
|
||||
const allGroups = groupsData?.groups || [];
|
||||
return selectedIds.map(id => {
|
||||
const group = allGroups.find(g => g.id === id);
|
||||
return group?.groupName || `Group #${id}`;
|
||||
});
|
||||
};
|
||||
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<AvailabilityScheduleForm
|
||||
onClose={handleCloseForm}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
handleCloseForm();
|
||||
}}
|
||||
initialProductIds={replicatingSchedule?.productIds}
|
||||
initialGroupIds={replicatingSchedule?.groupIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading schedules...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Error loading schedules</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 bg-white h-full`}>
|
||||
<ScrollView
|
||||
style={tw`flex-1`}
|
||||
contentContainerStyle={tw`pt-2 pb-32`}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{schedules && schedules.length === 0 ? (
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<View
|
||||
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
|
||||
>
|
||||
<Ionicons name="time-outline" size={48} color="#94A3B8" />
|
||||
</View>
|
||||
<MyText
|
||||
style={tw`text-slate-900 text-xl font-black tracking-tight`}
|
||||
>
|
||||
No Schedules Yet
|
||||
</MyText>
|
||||
<MyText
|
||||
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
|
||||
>
|
||||
Start by creating your first availability schedule using the
|
||||
button below.
|
||||
</MyText>
|
||||
</View>
|
||||
) : (
|
||||
schedules?.map((schedule, index) => (
|
||||
<React.Fragment key={schedule.id}>
|
||||
<ScheduleItem
|
||||
schedule={schedule}
|
||||
index={index}
|
||||
onDelete={handleDelete}
|
||||
onViewProducts={handleViewProducts}
|
||||
onViewGroups={handleViewGroups}
|
||||
onReplicate={handleReplicate}
|
||||
/>
|
||||
{index < schedules.length - 1 && (
|
||||
<View style={tw`h-px bg-slate-200 w-full`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</AppContainer>
|
||||
<MyTouchableOpacity
|
||||
onPress={handleCreate}
|
||||
activeOpacity={0.95}
|
||||
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#1570EF', '#194185']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
||||
>
|
||||
<MaterialIcons name="add" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
|
||||
{/* Products/Groups Dialog */}
|
||||
<RawBottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>
|
||||
{dialogType === 'products' ? 'Products' : 'Groups'}
|
||||
</MyText>
|
||||
<FlatList
|
||||
data={dialogType === 'products' ? getProductNames() : getGroupNames()}
|
||||
keyExtractor={(item, index) => index.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={tw`py-3 border-b border-gray-100`}>
|
||||
<MyText style={tw`text-base text-gray-800`}>{item}</MyText>
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={tw`max-h-80`}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`py-8 items-center`}>
|
||||
<MyText style={tw`text-gray-500`}>
|
||||
No {dialogType} found
|
||||
</MyText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</RawBottomDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
interface AddressPlaceFormProps {
|
||||
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
|
||||
const { data: zones } = trpc.admin.address.getZones.useQuery()
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
placeName: Yup.string().required('Place name is required'),
|
||||
zoneId: Yup.number().optional(),
|
||||
})
|
||||
|
||||
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
|
||||
<Formik
|
||||
initialValues={{ placeName: '', zoneId: null as number | null }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Place Name"
|
||||
value={values.placeName}
|
||||
onChangeText={handleChange('placeName')}
|
||||
error={!!(touched.placeName && errors.placeName)}
|
||||
/>
|
||||
<BottomDropdown
|
||||
label="Zone (Optional)"
|
||||
value={values.zoneId as any}
|
||||
options={zoneOptions}
|
||||
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
|
||||
placeholder="Select Zone"
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressPlaceForm
|
||||
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, tw } from 'common-ui'
|
||||
|
||||
interface AddressZoneFormProps {
|
||||
onSubmit: (values: { zoneName: string }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
|
||||
const validationSchema = Yup.object({
|
||||
zoneName: Yup.string().required('Zone name is required'),
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
|
||||
<Formik
|
||||
initialValues={{ zoneName: '' }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Zone Name"
|
||||
value={values.zoneName}
|
||||
onChangeText={handleChange('zoneName')}
|
||||
error={!!(touched.zoneName && errors.zoneName)}
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressZoneForm
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useFormik } from 'formik';
|
||||
import { MyText, tw, MyTextInput, MyTouchableOpacity, DateTimePickerMod } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
|
||||
interface AvailabilityScheduleFormProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
initialProductIds?: number[];
|
||||
initialGroupIds?: number[];
|
||||
}
|
||||
|
||||
const AvailabilityScheduleForm: React.FC<AvailabilityScheduleFormProps> = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
initialProductIds,
|
||||
initialGroupIds,
|
||||
}) => {
|
||||
const createSchedule = trpc.admin.productAvailabilitySchedules.create.useMutation();
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
// Map groups data to match ProductsSelector types (convert price from string to number)
|
||||
const groups = (groupsData?.groups || []).map(group => ({
|
||||
...group,
|
||||
products: group.products.map(product => ({
|
||||
...product,
|
||||
price: parseFloat(product.price as unknown as string) || 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
scheduleName: '',
|
||||
timeDate: null as Date | null,
|
||||
action: 'in' as 'in' | 'out',
|
||||
productIds: initialProductIds || ([] as number[]),
|
||||
groupIds: initialGroupIds || ([] as number[]),
|
||||
},
|
||||
validate: (values) => {
|
||||
const errors: {[key: string]: string} = {};
|
||||
|
||||
if (!values.scheduleName.trim()) {
|
||||
errors.scheduleName = 'Schedule name is required';
|
||||
}
|
||||
|
||||
if (!values.timeDate) {
|
||||
errors.timeDate = 'Time is required';
|
||||
}
|
||||
|
||||
if (!values.action) {
|
||||
errors.action = 'Action is required';
|
||||
}
|
||||
|
||||
if (values.productIds.length === 0) {
|
||||
errors.productIds = 'At least one product must be selected';
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
// Convert Date to HH:MM string
|
||||
const hours = values.timeDate!.getHours().toString().padStart(2, '0');
|
||||
const minutes = values.timeDate!.getMinutes().toString().padStart(2, '0');
|
||||
const timeString = `${hours}:${minutes}`;
|
||||
|
||||
await createSchedule.mutateAsync({
|
||||
scheduleName: values.scheduleName,
|
||||
time: timeString,
|
||||
action: values.action,
|
||||
productIds: values.productIds,
|
||||
groupIds: values.groupIds,
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Schedule created successfully');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create schedule');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const actionOptions = [
|
||||
{ label: 'In Stock', value: 'in' },
|
||||
{ label: 'Out of Stock', value: 'out' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
{/* Header */}
|
||||
<View style={tw`flex-row items-center justify-between p-4 border-b border-gray-200 bg-white`}>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>
|
||||
Create Availability Schedule
|
||||
</MyText>
|
||||
<MyTouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color="#6B7280" />
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={tw`flex-1 p-4`} showsVerticalScrollIndicator={false}>
|
||||
{/* Schedule Name */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Schedule Name
|
||||
</MyText>
|
||||
<MyTextInput
|
||||
placeholder="Enter schedule name"
|
||||
value={formik.values.scheduleName}
|
||||
onChangeText={formik.handleChange('scheduleName')}
|
||||
onBlur={formik.handleBlur('scheduleName')}
|
||||
style={tw`border rounded-lg p-3 ${
|
||||
formik.touched.scheduleName && formik.errors.scheduleName
|
||||
? 'border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{formik.touched.scheduleName && formik.errors.scheduleName && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.scheduleName}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Time */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Time
|
||||
</MyText>
|
||||
<DateTimePickerMod
|
||||
value={formik.values.timeDate}
|
||||
setValue={(date) => formik.setFieldValue('timeDate', date)}
|
||||
timeOnly={true}
|
||||
showLabels={false}
|
||||
/>
|
||||
{formik.touched.timeDate && formik.errors.timeDate && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.timeDate}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Action
|
||||
</MyText>
|
||||
<View style={tw`flex-row gap-3`}>
|
||||
{actionOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => formik.setFieldValue('action', option.value)}
|
||||
style={tw`flex-1 flex-row items-center p-4 rounded-lg border ${
|
||||
formik.values.action === option.value
|
||||
? 'bg-blue-50 border-blue-500'
|
||||
: 'bg-white border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
style={tw`w-5 h-5 rounded-full border-2 mr-3 items-center justify-center ${
|
||||
formik.values.action === option.value
|
||||
? 'border-blue-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{formik.values.action === option.value && (
|
||||
<View style={tw`w-3 h-3 rounded-full bg-blue-500`} />
|
||||
)}
|
||||
</View>
|
||||
<MyText
|
||||
style={tw`font-medium ${
|
||||
formik.values.action === option.value
|
||||
? 'text-blue-700'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Products and Groups */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Products & Groups
|
||||
</MyText>
|
||||
<ProductsSelector
|
||||
value={formik.values.productIds}
|
||||
onChange={(value) => formik.setFieldValue('productIds', value)}
|
||||
groups={groups}
|
||||
selectedGroupIds={formik.values.groupIds}
|
||||
onGroupChange={(groupIds) => formik.setFieldValue('groupIds', groupIds)}
|
||||
showGroups={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products for this schedule"
|
||||
/>
|
||||
{formik.touched.productIds && formik.errors.productIds && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.productIds}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Spacer for bottom padding */}
|
||||
<View style={tw`h-24`} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<View style={tw`p-4 border-t border-gray-200 bg-white flex-row gap-3`}>
|
||||
<MyTouchableOpacity
|
||||
onPress={onClose}
|
||||
style={tw`flex-1 py-3 px-4 rounded-lg border border-gray-300 items-center`}
|
||||
>
|
||||
<MyText style={tw`font-medium text-gray-700`}>Cancel</MyText>
|
||||
</MyTouchableOpacity>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => formik.handleSubmit()}
|
||||
disabled={formik.isSubmitting}
|
||||
style={tw`flex-1 py-3 px-4 rounded-lg bg-blue-600 items-center ${
|
||||
formik.isSubmitting ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<MyText style={tw`font-medium text-white`}>
|
||||
{formik.isSubmitting ? 'Creating...' : 'Create Schedule'}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailabilityScheduleForm;
|
||||
|
|
@ -8,7 +8,6 @@ import ProductsSelector from './ProductsSelector';
|
|||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
||||
|
||||
export interface BannerFormData {
|
||||
name: string;
|
||||
|
|
@ -53,7 +52,14 @@ export default function BannerForm({
|
|||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
// Fetch products for dropdown
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const products = productsData?.products || [];
|
||||
|
||||
|
||||
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -91,15 +97,37 @@ export default function BannerForm({
|
|||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store', // Using 'store' for now
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
// Upload image
|
||||
const uploadUrl = uploadUrls[0];
|
||||
const { blob, mimeType } = selectedImages[0];
|
||||
const { key, presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||
imageUrl = presignedUrl;
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
imageUrl = uploadUrl;
|
||||
}
|
||||
|
||||
// Call onSubmit with form values and imageUrl
|
||||
await onSubmit(values, imageUrl);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
Alert.alert('Error', 'Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -211,15 +239,15 @@ export default function BannerForm({
|
|||
|
||||
<MyTouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isSubmitting || isUploading || !isValid || !dirty}
|
||||
disabled={isSubmitting || !isValid || !dirty}
|
||||
style={tw`flex-1 rounded-lg py-4 items-center ${
|
||||
isSubmitting || isUploading || !isValid || !dirty
|
||||
isSubmitting || !isValid || !dirty
|
||||
? 'bg-blue-400'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>
|
||||
{isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText}
|
||||
{isSubmitting ? 'Saving...' : submitButtonText}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
|
|||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
|
||||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
||||
|
||||
export interface StoreFormData {
|
||||
name: string;
|
||||
|
|
@ -16,12 +15,6 @@ export interface StoreFormData {
|
|||
products: number[];
|
||||
}
|
||||
|
||||
interface StoreImage {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
isExisting: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFormRef {
|
||||
// Add methods if needed
|
||||
}
|
||||
|
|
@ -34,11 +27,6 @@ interface StoreFormProps {
|
|||
storeId?: number;
|
||||
}
|
||||
|
||||
// Extend Formik values with images array
|
||||
interface FormikStoreValues extends StoreFormData {
|
||||
images: StoreImage[];
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required'),
|
||||
description: Yup.string(),
|
||||
|
|
@ -52,23 +40,9 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
// Build initial form values with images array
|
||||
const buildInitialValues = (): FormikStoreValues => {
|
||||
const images: StoreImage[] = [];
|
||||
if (initialValues.imageUrl) {
|
||||
images.push({
|
||||
uri: initialValues.imageUrl,
|
||||
mimeType: 'image/jpeg',
|
||||
isExisting: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...initialValues,
|
||||
images,
|
||||
};
|
||||
};
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
|
||||
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
|
||||
// For edit mode, pre-select products belonging to this store
|
||||
const initialSelectedProducts = useMemo(() => {
|
||||
|
|
@ -80,7 +54,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
|
||||
useEffect(() => {
|
||||
setFormInitialValues({
|
||||
...buildInitialValues(),
|
||||
...initialValues,
|
||||
products: initialSelectedProducts,
|
||||
});
|
||||
}, [initialValues, initialSelectedProducts]);
|
||||
|
|
@ -90,7 +64,41 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
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 (
|
||||
<Formik
|
||||
|
|
@ -100,78 +108,51 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||
// Image picker that adds to Formik field
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const newImages: StoreImage[] = files.map((asset) => ({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
isExisting: false,
|
||||
}));
|
||||
|
||||
// Add to Formik images field
|
||||
const currentImages = values.images || [];
|
||||
setFieldValue('images', [...currentImages, ...newImages]);
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Remove image - works for both existing and new
|
||||
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
|
||||
const currentImages = values.images || [];
|
||||
const removedImage = currentImages.find(img => img.uri === image.uri);
|
||||
const newImages = currentImages.filter(img => img.uri !== image.uri);
|
||||
|
||||
setFieldValue('images', newImages);
|
||||
|
||||
// If we removed an existing image, also clear the imageUrl
|
||||
if (removedImage?.isExisting) {
|
||||
setFieldValue('imageUrl', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
// Get new images that need to be uploaded
|
||||
const newImages = values.images.filter(img => !img.isExisting);
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store',
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
if (newImages.length > 0) {
|
||||
// Upload the first new image (single image for stores)
|
||||
const image = newImages[0];
|
||||
const response = await fetch(image.uri);
|
||||
const imageBlob = await response.blob();
|
||||
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
|
||||
imageUrl = key;
|
||||
} else {
|
||||
// Check if there's an existing image remaining
|
||||
const existingImage = values.images.find(img => img.isExisting);
|
||||
if (existingImage) {
|
||||
imageUrl = existingImage.uri;
|
||||
// Upload images
|
||||
for (let i = 0; i < uploadUrls.length; i++) {
|
||||
const uploadUrl = uploadUrls[i];
|
||||
const { blob, mimeType } = selectedImages[i];
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Submit form with imageUrl (without images array)
|
||||
const { images, ...submitValues } = values;
|
||||
onSubmit({ ...submitValues, imageUrl });
|
||||
// Extract key from first upload URL
|
||||
// const u = new URL(uploadUrls[0]);
|
||||
// const rawKey = u.pathname.replace(/^\/+/, "");
|
||||
// imageUrl = decodeURIComponent(rawKey);
|
||||
imageUrl = uploadUrls[0];
|
||||
}
|
||||
|
||||
// Submit form with imageUrl
|
||||
onSubmit({ ...values, imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
Alert.alert('Error', 'Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare images for ImageUploaderNeo (convert to expected format)
|
||||
const imagesForUploader = (values.images || []).map(img => ({
|
||||
uri: img.uri,
|
||||
mimeType: img.mimeType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View>
|
||||
<MyTextInput
|
||||
|
|
@ -212,21 +193,22 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
/>
|
||||
<View style={tw`mb-6`}>
|
||||
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={imagesForUploader}
|
||||
onUploadImage={handleImagePick}
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
||||
onAddImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || generateUploadUrls.isPending}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"distribution": "internal",
|
||||
"autoIncrement": true
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
|
||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
|
||||
|
||||
interface UploadInput {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface UploadBatchInput {
|
||||
images: UploadInput[];
|
||||
contextString: ContextString;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
keys: string[];
|
||||
presignedUrls: string[];
|
||||
}
|
||||
|
||||
export function useUploadToObjectStorage() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setProgress({ completed: 0, total: input.images.length });
|
||||
|
||||
try {
|
||||
const { images, contextString } = input;
|
||||
|
||||
if (images.length === 0) {
|
||||
return { keys: [], presignedUrls: [] };
|
||||
}
|
||||
|
||||
// 1. Get presigned URLs from backend (one call for all images)
|
||||
const mimeTypes = images.map(img => img.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString,
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
if (uploadUrls.length !== images.length) {
|
||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
||||
}
|
||||
|
||||
// 2. Upload all images in parallel
|
||||
const uploadPromises = images.map(async (image, index) => {
|
||||
const presignedUrl = uploadUrls[index];
|
||||
const { blob, mimeType } = image;
|
||||
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
||||
|
||||
return {
|
||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
||||
presignedUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// Use Promise.all - if any fails, entire batch fails
|
||||
const results = await Promise.all(uploadPromises);
|
||||
|
||||
return {
|
||||
keys: results.map(r => r.key),
|
||||
presignedUrls: results.map(r => r.presignedUrl),
|
||||
};
|
||||
} catch (err) {
|
||||
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
||||
setError(uploadError);
|
||||
throw uploadError;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType }],
|
||||
contextString,
|
||||
});
|
||||
return {
|
||||
key: result.keys[0],
|
||||
presignedUrl: result.presignedUrls[0],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploadSingle,
|
||||
isUploading,
|
||||
error,
|
||||
progress,
|
||||
isPending: generateUploadUrls.isPending
|
||||
};
|
||||
}
|
||||
|
||||
function extractKeyFromPresignedUrl(url: string): string {
|
||||
const u = new URL(url);
|
||||
let rawKey = u.pathname.replace(/^\/+/, '');
|
||||
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
||||
return decodeURIComponent(rawKey);
|
||||
}
|
||||
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from '../../services/axios-admin-ui';
|
||||
|
||||
// Types
|
||||
export interface CreateProductPayload {
|
||||
name: string;
|
||||
shortDescription?: string;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
incrementStep?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
deals?: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface UpdateProductPayload {
|
||||
name: string;
|
||||
shortDescription?: string;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
incrementStep?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
deals?: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescription?: string | null;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
images?: string[];
|
||||
createdAt: string;
|
||||
unit?: {
|
||||
id: number;
|
||||
shortNotation: string;
|
||||
fullName: string;
|
||||
};
|
||||
deals?: {
|
||||
id: number;
|
||||
quantity: string;
|
||||
price: string;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CreateProductResponse {
|
||||
product: Product;
|
||||
deals?: any[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
||||
const response = await axios.post('/av/products', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
||||
const response = await axios.put(`/av/products/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useCreateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createProductApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateProductApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal file
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from '../../services/axios-admin-ui';
|
||||
|
||||
// Types
|
||||
export interface CreateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTagResponse {
|
||||
tag: Tag;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GetTagsResponse {
|
||||
tags: Tag[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
|
||||
const response = await axios.post('/av/product-tags', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
|
||||
const response = await axios.put(`/av/product-tags/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
|
||||
const response = await axios.delete(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagsApi = async (): Promise<GetTagsResponse> => {
|
||||
const response = await axios.get('/av/product-tags');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
|
||||
const response = await axios.get(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useCreateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: getTagsApi,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTag = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['tags', id],
|
||||
queryFn: () => getTagApi(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
|
@ -7,7 +7,7 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
|
|||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
|
|
@ -38,7 +38,7 @@ export interface ProductFormRef {
|
|||
interface ProductFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialValues: ProductFormData;
|
||||
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||
isLoading: boolean;
|
||||
existingImages?: string[];
|
||||
}
|
||||
|
|
@ -60,9 +60,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
existingImages = []
|
||||
}, ref) => {
|
||||
const { theme } = useTheme();
|
||||
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]);
|
||||
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||
const storeOptions = storesData?.stores.map(store => ({
|
||||
|
|
@ -70,8 +69,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
value: store.id,
|
||||
})) || [];
|
||||
|
||||
const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
|
||||
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
|
||||
const { data: tagsData } = useGetTags();
|
||||
const tagOptions = tagsData?.tags.map(tag => ({
|
||||
label: tag.tagName,
|
||||
value: tag.id.toString(),
|
||||
})) || [];
|
||||
|
|
@ -84,62 +83,23 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
}, [existingImages]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
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]);
|
||||
},
|
||||
setFile: (files) => setImages(prev => [...prev, ...files]),
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
// Calculate which existing images were deleted
|
||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||
|
||||
// Display images for ImageUploader component
|
||||
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={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');
|
||||
}
|
||||
}}
|
||||
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setNewImages([]);
|
||||
setImages([]);
|
||||
setExistingImagesState([]);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -183,9 +143,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
{mode === 'create' && (
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
images={images}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -206,9 +166,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
<View style={{ marginBottom: 16 }}>
|
||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
images={images}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -395,11 +355,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface StoreOption {
|
||||
id: number;
|
||||
|
|
@ -24,7 +23,7 @@ interface TagFormProps {
|
|||
mode: 'create' | 'edit';
|
||||
initialValues: TagFormData;
|
||||
existingImageUrl?: string;
|
||||
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void;
|
||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||
isLoading: boolean;
|
||||
stores?: StoreOption[];
|
||||
}
|
||||
|
|
@ -37,35 +36,24 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
isLoading,
|
||||
stores = [],
|
||||
}, ref) => {
|
||||
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
|
||||
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
existingImageUrl && setImage({uri:existingImageUrl})
|
||||
}, [initialValues.isDashboardTag]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setNewImage(null);
|
||||
return;
|
||||
}
|
||||
setFile: (files) => {
|
||||
|
||||
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
|
||||
});
|
||||
setImage(files || null)
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
tagName: Yup.string()
|
||||
.required('Tag name is required')
|
||||
|
|
@ -75,44 +63,18 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
.max(500, 'Description must be less than 500 characters'),
|
||||
});
|
||||
|
||||
// Display images for ImageUploader
|
||||
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
|
||||
const existingImages = existingImageUrl ? [existingImageUrl] : [];
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={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');
|
||||
}
|
||||
}}
|
||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setNewImage(null);
|
||||
setImage(null);
|
||||
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -145,15 +107,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||
</MyText>
|
||||
|
||||
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={mode === 'edit' ? existingImages : []}
|
||||
images={image ? [image] : []}
|
||||
onAddImage={pickImage}
|
||||
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}
|
||||
onRemoveImage={() => setImage(null)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
@ -164,7 +122,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
onPress={() => {
|
||||
const newValue = !isDashboardTagChecked;
|
||||
setIsDashboardTagChecked(newValue);
|
||||
setFieldValue('isDashboardTag', newValue);
|
||||
formikSetFieldValue('isDashboardTag', newValue);
|
||||
}}
|
||||
/>
|
||||
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
||||
|
|
@ -185,7 +143,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
||||
setFieldValue('relatedStores', numericValues);
|
||||
formikSetFieldValue('relatedStores', numericValues);
|
||||
}}
|
||||
multiple={true}
|
||||
/>
|
||||
|
|
@ -193,11 +151,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
|
|||
import { Entypo } from '@expo/vector-icons';
|
||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useDeleteTag } from '../api-hooks/tag.api';
|
||||
|
||||
export interface TagMenuProps {
|
||||
tagId: number;
|
||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const deleteTag = trpc.admin.tag.deleteTag.useMutation();
|
||||
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true);
|
||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
};
|
||||
|
||||
const performDelete = () => {
|
||||
deleteTag.mutate({ id: tagId }, {
|
||||
deleteTag(tagId, {
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Tag deleted successfully');
|
||||
onDeleteSuccess?.();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
ENV_MODE=PROD
|
||||
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||
SQLITE_DB_PATH='./sqlite.db'
|
||||
DB_DIALECT='sqlite'
|
||||
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||
|
||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||
PHONE_PE_CLIENT_VERSION=1
|
||||
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
|
||||
|
|
@ -20,14 +17,10 @@ S3_REGION=apac
|
|||
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
||||
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
||||
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||
S3_BUCKET_NAME=meatfarmer-dev
|
||||
S3_BUCKET_NAME=meatfarmer
|
||||
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||
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
|
||||
ASSETS_DOMAIN=https://assets.freshyo.in/
|
||||
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
||||
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
||||
APP_URL=http://localhost:4000
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,11 @@
|
|||
import postgresConfig from '../db-helper-postgres/drizzle.config'
|
||||
import sqliteConfig from '../db-helper-sqlite/drizzle.config'
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default process.env.DB_DIALECT === 'sqlite'
|
||||
? sqliteConfig
|
||||
: postgresConfig
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
CREATE TYPE "public"."product_availability_action" AS ENUM('in', 'out');--> statement-breakpoint
|
||||
CREATE TABLE "mf"."product_availability_schedules" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."product_availability_schedules_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"time" varchar(10) NOT NULL,
|
||||
"schedule_name" varchar(255) NOT NULL,
|
||||
"action" "product_availability_action" NOT NULL,
|
||||
"product_ids" integer[] DEFAULT '{}' NOT NULL,
|
||||
"group_ids" integer[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_updated" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "product_availability_schedules_schedule_name_unique" UNIQUE("schedule_name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mf"."product_info" ADD COLUMN "scheduled_availability" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -540,13 +540,6 @@
|
|||
"when": 1772637259874,
|
||||
"tag": "0076_sturdy_wolverine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1773927855512,
|
||||
"tag": "0077_wakeful_norrin_radd",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,515 +0,0 @@
|
|||
CREATE TABLE `address_areas` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`place_name` text NOT NULL,
|
||||
`zone_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `address_zones` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`zone_name` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `addresses` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`phone` text NOT NULL,
|
||||
`address_line1` text NOT NULL,
|
||||
`address_line2` text,
|
||||
`city` text NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
`pincode` text NOT NULL,
|
||||
`is_default` integer DEFAULT false NOT NULL,
|
||||
`latitude` real,
|
||||
`longitude` real,
|
||||
`google_maps_url` text,
|
||||
`admin_latitude` real,
|
||||
`admin_longitude` real,
|
||||
`zone_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `cart_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_user_product` ON `cart_items` (`user_id`,`product_id`);--> statement-breakpoint
|
||||
CREATE TABLE `complaints` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`complaint_body` text NOT NULL,
|
||||
`images` text,
|
||||
`response` text,
|
||||
`is_resolved` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `coupon_applicable_products` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_product` ON `coupon_applicable_products` (`coupon_id`,`product_id`);--> statement-breakpoint
|
||||
CREATE TABLE `coupon_applicable_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_user` ON `coupon_applicable_users` (`coupon_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `coupon_usage` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`order_item_id` integer,
|
||||
`used_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_item_id`) REFERENCES `order_items`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `coupons` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_code` text NOT NULL,
|
||||
`is_user_based` integer DEFAULT false NOT NULL,
|
||||
`discount_percent` text,
|
||||
`flat_discount` text,
|
||||
`min_order` text,
|
||||
`product_ids` text,
|
||||
`created_by` integer,
|
||||
`max_value` text,
|
||||
`is_apply_for_all` integer DEFAULT false NOT NULL,
|
||||
`valid_till` integer,
|
||||
`max_limit_for_user` integer,
|
||||
`is_invalidated` integer DEFAULT false NOT NULL,
|
||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_code` ON `coupons` (`coupon_code`);--> statement-breakpoint
|
||||
CREATE TABLE `delivery_slot_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`delivery_time` integer NOT NULL,
|
||||
`freeze_time` integer NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`is_flash` integer DEFAULT false NOT NULL,
|
||||
`is_capacity_full` integer DEFAULT false NOT NULL,
|
||||
`delivery_sequence` text DEFAULT '{}',
|
||||
`group_ids` text DEFAULT '[]'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `home_banners` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`image_url` text NOT NULL,
|
||||
`description` text,
|
||||
`product_ids` text,
|
||||
`redirect_url` text,
|
||||
`serial_num` integer,
|
||||
`is_active` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `key_val_store` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `notif_creds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`last_verified` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notif_creds_token_unique` ON `notif_creds` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `notifications` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`type` text,
|
||||
`is_read` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `order_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`discounted_price` text,
|
||||
`is_packaged` integer DEFAULT false NOT NULL,
|
||||
`is_package_verified` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `order_status` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`is_packaged` integer DEFAULT false NOT NULL,
|
||||
`is_delivered` integer DEFAULT false NOT NULL,
|
||||
`is_cancelled` integer DEFAULT false NOT NULL,
|
||||
`cancel_reason` text,
|
||||
`is_cancelled_by_admin` integer,
|
||||
`payment_state` text DEFAULT 'pending' NOT NULL,
|
||||
`cancellation_user_notes` text,
|
||||
`cancellation_admin_notes` text,
|
||||
`cancellation_reviewed` integer DEFAULT false NOT NULL,
|
||||
`cancellation_reviewed_at` integer,
|
||||
`refund_coupon_id` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`refund_coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `orders` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`address_id` integer NOT NULL,
|
||||
`slot_id` integer,
|
||||
`is_cod` integer DEFAULT false NOT NULL,
|
||||
`is_online_payment` integer DEFAULT false NOT NULL,
|
||||
`payment_info_id` integer,
|
||||
`total_amount` text NOT NULL,
|
||||
`delivery_charge` text DEFAULT '0' NOT NULL,
|
||||
`readable_id` integer NOT NULL,
|
||||
`admin_notes` text,
|
||||
`user_notes` text,
|
||||
`order_group_id` text,
|
||||
`order_group_proportion` text,
|
||||
`is_flash_delivery` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`address_id`) REFERENCES `addresses`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`payment_info_id`) REFERENCES `payment_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `payment_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`gateway` text NOT NULL,
|
||||
`order_id` text,
|
||||
`token` text,
|
||||
`merchant_order_id` text NOT NULL,
|
||||
`payload` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `payment_info_merchant_order_id_unique` ON `payment_info` (`merchant_order_id`);--> statement-breakpoint
|
||||
CREATE TABLE `payments` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`gateway` text NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`token` text,
|
||||
`merchant_order_id` text NOT NULL,
|
||||
`payload` text,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `payments_merchant_order_id_unique` ON `payments` (`merchant_order_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_availability_schedules` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`time` text NOT NULL,
|
||||
`schedule_name` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`product_ids` text DEFAULT '[]' NOT NULL,
|
||||
`group_ids` text DEFAULT '[]' NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_availability_schedules_schedule_name_unique` ON `product_availability_schedules` (`schedule_name`);--> statement-breakpoint
|
||||
CREATE TABLE `product_categories` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_group_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`group_name` text NOT NULL,
|
||||
`description` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_group_membership` (
|
||||
`product_id` integer NOT NULL,
|
||||
`group_id` integer NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `product_group_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_group_membership_pk` ON `product_group_membership` (`product_id`,`group_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`short_description` text,
|
||||
`long_description` text,
|
||||
`unit_id` integer NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`market_price` text,
|
||||
`images` text,
|
||||
`is_out_of_stock` integer DEFAULT false NOT NULL,
|
||||
`is_suspended` integer DEFAULT false NOT NULL,
|
||||
`is_flash_available` integer DEFAULT false NOT NULL,
|
||||
`flash_price` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`increment_step` real DEFAULT 1 NOT NULL,
|
||||
`product_quantity` real DEFAULT 1 NOT NULL,
|
||||
`store_id` integer,
|
||||
`scheduled_availability` integer DEFAULT true NOT NULL,
|
||||
FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`store_id`) REFERENCES `store_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_reviews` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`review_body` text NOT NULL,
|
||||
`image_urls` text DEFAULT '[]',
|
||||
`review_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`ratings` real NOT NULL,
|
||||
`admin_response` text,
|
||||
`admin_response_images` text DEFAULT '[]',
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
CONSTRAINT "rating_check" CHECK("product_reviews"."ratings" >= 1 AND "product_reviews"."ratings" <= 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_slots` (
|
||||
`product_id` integer NOT NULL,
|
||||
`slot_id` integer NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_slot_pk` ON `product_slots` (`product_id`,`slot_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_tag_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`tag_name` text NOT NULL,
|
||||
`tag_description` text,
|
||||
`image_url` text,
|
||||
`is_dashboard_tag` integer DEFAULT false NOT NULL,
|
||||
`related_stores` text DEFAULT '[]',
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_tag_info_tag_name_unique` ON `product_tag_info` (`tag_name`);--> statement-breakpoint
|
||||
CREATE TABLE `product_tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
`assigned_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `product_tag_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_product_tag` ON `product_tags` (`product_id`,`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `refunds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`refund_amount` text,
|
||||
`refund_status` text DEFAULT 'none',
|
||||
`merchant_refund_id` text,
|
||||
`refund_processed_at` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reserved_coupons` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`secret_code` text NOT NULL,
|
||||
`coupon_code` text NOT NULL,
|
||||
`discount_percent` text,
|
||||
`flat_discount` text,
|
||||
`min_order` text,
|
||||
`product_ids` text,
|
||||
`max_value` text,
|
||||
`valid_till` integer,
|
||||
`max_limit_for_user` integer,
|
||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
||||
`is_redeemed` integer DEFAULT false NOT NULL,
|
||||
`redeemed_by` integer,
|
||||
`redeemed_at` integer,
|
||||
`created_by` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`redeemed_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `reserved_coupons_secret_code_unique` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_secret_code` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
||||
CREATE TABLE `special_deals` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`valid_till` integer NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `staff_permissions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`permission_name` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_permission_name` ON `staff_permissions` (`permission_name`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_role_permissions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`staff_role_id` integer NOT NULL,
|
||||
`staff_permission_id` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`staff_permission_id`) REFERENCES `staff_permissions`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_role_permission` ON `staff_role_permissions` (`staff_role_id`,`staff_permission_id`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_roles` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`role_name` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_role_name` ON `staff_roles` (`role_name`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`password` text NOT NULL,
|
||||
`staff_role_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `store_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`image_url` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`owner` integer NOT NULL,
|
||||
FOREIGN KEY (`owner`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `units` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`short_notation` text NOT NULL,
|
||||
`full_name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_short_notation` ON `units` (`short_notation`);--> statement-breakpoint
|
||||
CREATE TABLE `unlogged_user_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_verified` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unlogged_user_tokens_token_unique` ON `unlogged_user_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `upload_url_status` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_creds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`user_password` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_details` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`bio` text,
|
||||
`date_of_birth` integer,
|
||||
`gender` text,
|
||||
`occupation` text,
|
||||
`profile_image` text,
|
||||
`is_suspended` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`updated_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_details_user_id_unique` ON `user_details` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user_incidents` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`date_added` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`admin_comment` text,
|
||||
`added_by` integer,
|
||||
`negativity_score` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_notifications` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`image_url` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`applicable_users` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text,
|
||||
`email` text,
|
||||
`mobile` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_email` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `vendor_snippets` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`snippet_code` text NOT NULL,
|
||||
`slot_id` integer,
|
||||
`is_permanent` integer DEFAULT false NOT NULL,
|
||||
`product_ids` text NOT NULL,
|
||||
`valid_till` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `vendor_snippets_snippet_code_unique` ON `vendor_snippets` (`snippet_code`);
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1774244805277,
|
||||
"tag": "0000_goofy_oracle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import 'dotenv/config';
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
// import bodyParser from "body-parser";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/db/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils';
|
||||
|
||||
seed()
|
||||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174'
|
||||
}));
|
||||
|
||||
|
||||
signedUrlCache.loadFromDisk();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Middleware to log all request URLs
|
||||
app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
//cors middleware
|
||||
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Allow requests from any origin (for production, replace * with your domain)
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Allow specific headers clients can send
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
||||
);
|
||||
|
||||
// Allow specific HTTP methods
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
|
||||
// Allow credentials if needed (optional)
|
||||
// res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Handle preflight (OPTIONS) requests quickly
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
app.use('/api/trpc', createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = await verifyToken(token);
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if ('staffId' in decoded) {
|
||||
const staffPayload = decoded as StaffJWTPayload;
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.id, staffPayload.staffId),
|
||||
});
|
||||
|
||||
if (staff) {
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userPayload = decoded as UserJWTPayload;
|
||||
// This is a regular user token
|
||||
user = {
|
||||
userId: userPayload.userId,
|
||||
name: userPayload.name,
|
||||
email: userPayload.email,
|
||||
mobile: userPayload.mobile,
|
||||
};
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userPayload.userId),
|
||||
});
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
}
|
||||
}
|
||||
return { req, res, user, staffUser };
|
||||
},
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
userId: ctx?.user?.userId,
|
||||
stack: error.stack,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
app.use('/api', mainRouter)
|
||||
|
||||
const fallbackUiDirCandidates = [
|
||||
path.resolve(__dirname, '../fallback-ui/dist'),
|
||||
path.resolve(__dirname, '../../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), '../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), './apps/fallback-ui/dist')
|
||||
]
|
||||
|
||||
const fallbackUiDir =
|
||||
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
|
||||
fallbackUiDirCandidates[0]
|
||||
|
||||
|
||||
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
|
||||
// const fallbackUiMountPath = '/admin-web'
|
||||
const fallbackUiMountPath = '/';
|
||||
|
||||
if (fs.existsSync(fallbackUiIndex)) {
|
||||
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
const fallbackUiRegex = new RegExp(
|
||||
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
|
||||
)
|
||||
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
})
|
||||
app.get(/.*/, (req,res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
})
|
||||
} else {
|
||||
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
||||
}
|
||||
|
||||
// Serve /assets/public folder at /assets route
|
||||
const assetsPublicDir = path.resolve(__dirname, './assets/public');
|
||||
if (fs.existsSync(assetsPublicDir)) {
|
||||
app.use('/assets', express.static(assetsPublicDir));
|
||||
console.log('Serving /assets from', assetsPublicDir);
|
||||
} else {
|
||||
console.warn('Assets public folder not found at', assetsPublicDir);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
const status = err.statusCode || err.status || 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
res.status(status).json({ message });
|
||||
});
|
||||
|
||||
app.listen(4000, () => {
|
||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||
});
|
||||
276
apps/backend/index.ts
Normal file → Executable file
276
apps/backend/index.ts
Normal file → Executable file
|
|
@ -1,167 +1,185 @@
|
|||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { serve } from 'bun'
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/src/trpc/router'
|
||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache'
|
||||
import { seed } from '@/src/db/seed'
|
||||
import initFunc from '@/src/lib/init'
|
||||
import '@/src/jobs/jobs-index'
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs'
|
||||
import 'dotenv/config';
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
// import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import jwt from 'jsonwebtoken'
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/db/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
|
||||
// Initialize
|
||||
seed()
|
||||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = new Hono()
|
||||
const app = express();
|
||||
|
||||
// CORS middleware
|
||||
app.use('*', cors({
|
||||
origin: ['http://localhost:5174', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
||||
credentials: true
|
||||
}))
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174'
|
||||
}));
|
||||
|
||||
// Request logging
|
||||
app.use('*', logger())
|
||||
|
||||
// Health check
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
message: 'Hello world'
|
||||
})
|
||||
})
|
||||
signedUrlCache.loadFromDisk();
|
||||
|
||||
// tRPC handler with context
|
||||
app.use('/api/trpc/*', async (c) => {
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req: c.req.raw,
|
||||
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 }) => {
|
||||
let user = null
|
||||
let staffUser = null
|
||||
const authHeader = req.headers.get('authorization')
|
||||
createContext: async ({ req, res }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = await verifyToken(token)
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||
|
||||
if ('staffId' in decoded) {
|
||||
const staffPayload = decoded as StaffJWTPayload
|
||||
// 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, staffPayload.staffId)
|
||||
})
|
||||
where: eq(staffUsers.id, decoded.staffId),
|
||||
});
|
||||
|
||||
if (staff) {
|
||||
staffUser = { id: staff.id, name: staff.name }
|
||||
user=staffUser
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userPayload = decoded as UserJWTPayload
|
||||
user = {
|
||||
userId: userPayload.userId,
|
||||
name: userPayload.name,
|
||||
email: userPayload.email,
|
||||
mobile: userPayload.mobile
|
||||
}
|
||||
|
||||
// This is a regular user token
|
||||
user = decoded;
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userPayload.userId)
|
||||
})
|
||||
where: eq(userDetails.userId, user.userId),
|
||||
});
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended'
|
||||
})
|
||||
message: 'Account suspended',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid token
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
}
|
||||
}
|
||||
|
||||
return { req, res: c.res, user, staffUser }
|
||||
return { req, res, user, staffUser };
|
||||
},
|
||||
onError: ({ error, path, ctx }) => {
|
||||
console.error('🚨 tRPC Error:', {
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
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}`)
|
||||
}
|
||||
|
||||
return response
|
||||
})
|
||||
|
||||
// Static files - Fallback UI
|
||||
app.use('/*', async (c) => {
|
||||
const url = new URL(c.req.url)
|
||||
let filePath = url.pathname
|
||||
|
||||
// Default to index.html for root
|
||||
if (filePath === '/') {
|
||||
filePath = '/index.html'
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
// 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.onError((err, c) => {
|
||||
console.error('Error:', err)
|
||||
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 });
|
||||
});
|
||||
|
||||
const status = err instanceof TRPCError
|
||||
? (err.code === 'UNAUTHORIZED' ? 401 : 500)
|
||||
: 500
|
||||
|
||||
const message = err.message || 'Internal Server Error'
|
||||
|
||||
return c.json({ message }, status)
|
||||
})
|
||||
|
||||
// Start server
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: 4000,
|
||||
hostname: '0.0.0.0'
|
||||
})
|
||||
|
||||
console.log('🚀 Server running on http://localhost:4000')
|
||||
app.listen(4000, '::', () => {
|
||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,21 +4,14 @@
|
|||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"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",
|
||||
"migrate": "drizzle-kit generate:pg",
|
||||
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"build2": "rimraf ./dist && tsc",
|
||||
"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:push": "drizzle-kit push:pg",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"dev:express": "bun --watch index-express.ts",
|
||||
"dev:hono": "bun --watch index.ts",
|
||||
"dev2": "tsx watch index.ts",
|
||||
"dev_node": "tsx watch index.ts",
|
||||
"dev": "bun --watch index.ts",
|
||||
"start": "bun index.ts",
|
||||
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
|
||||
"docker:push": "docker push mohdshafiuddin54/health_petal:latest"
|
||||
},
|
||||
|
|
@ -33,6 +26,8 @@
|
|||
"@turf/turf": "^7.2.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.63.0",
|
||||
|
|
@ -41,16 +36,18 @@
|
|||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"expo-server-sdk": "^4.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hono": "^4.6.3",
|
||||
"jose": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"razorpay": "^2.9.6",
|
||||
"redis": "^5.9.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.10",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
|
|
|
|||
Binary file not shown.
19
apps/backend/src/apis/admin-apis/apis/av-router.ts
Executable file
19
apps/backend/src/apis/admin-apis/apis/av-router.ts
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
||||
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply staff authentication to all admin routes
|
||||
router.use(authenticateStaff);
|
||||
|
||||
// Product routes
|
||||
router.use("/products", productRouter);
|
||||
|
||||
// Tag routes
|
||||
router.use("/product-tags", tagRouter);
|
||||
|
||||
const avRouter = router;
|
||||
|
||||
export default avRouter;
|
||||
222
apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts
Normal file
222
apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index";
|
||||
import { productTagInfo } from "@/src/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||
|
||||
/**
|
||||
* Create a new product tag
|
||||
*/
|
||||
export const createTag = async (req: Request, res: Response) => {
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
if (!tagName) {
|
||||
throw new ApiError("Tag name is required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate tag name
|
||||
const existingTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.tagName, tagName.trim()),
|
||||
});
|
||||
|
||||
if (existingTag) {
|
||||
throw new ApiError("A tag with this name already exists", 400);
|
||||
}
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
// Handle image upload if file is provided
|
||||
if (req.file) {
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||
}
|
||||
|
||||
// Parse relatedStores if it's a string (from FormData)
|
||||
let parsedRelatedStores: number[] = [];
|
||||
if (relatedStores) {
|
||||
try {
|
||||
parsedRelatedStores = typeof relatedStores === 'string'
|
||||
? JSON.parse(relatedStores)
|
||||
: relatedStores;
|
||||
} catch (e) {
|
||||
parsedRelatedStores = [];
|
||||
}
|
||||
}
|
||||
|
||||
const [newTag] = await db
|
||||
.insert(productTagInfo)
|
||||
.values({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag: isDashboardTag || false,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(201).json({
|
||||
tag: newTag,
|
||||
message: "Tag created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all product tags
|
||||
*/
|
||||
export const getAllTags = async (req: Request, res: Response) => {
|
||||
const tags = await db
|
||||
.select()
|
||||
.from(productTagInfo)
|
||||
.orderBy(productTagInfo.tagName);
|
||||
|
||||
// Generate signed URLs for tag images
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
}))
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
tags: tagsWithSignedUrls,
|
||||
message: "Tags retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single product tag by ID
|
||||
*/
|
||||
export const getTagById = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate signed URL for tag image
|
||||
const tagWithSignedUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
tag: tagWithSignedUrl,
|
||||
message: "Tag retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product tag
|
||||
*/
|
||||
export const updateTag = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
// Get the current tag to check for existing image
|
||||
const currentTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
let imageUrl = currentTag.imageUrl;
|
||||
|
||||
// Handle image upload if new file is provided
|
||||
if (req.file) {
|
||||
// Delete old image if it exists
|
||||
if (currentTag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete old image:", error);
|
||||
// Continue with update even if delete fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Upload new image
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
console.log('file', key)
|
||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||
}
|
||||
|
||||
// Parse relatedStores if it's a string (from FormData)
|
||||
let parsedRelatedStores: number[] | undefined;
|
||||
if (relatedStores !== undefined) {
|
||||
try {
|
||||
parsedRelatedStores = typeof relatedStores === 'string'
|
||||
? JSON.parse(relatedStores)
|
||||
: relatedStores;
|
||||
} catch (e) {
|
||||
parsedRelatedStores = [];
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedTag] = await db
|
||||
.update(productTagInfo)
|
||||
.set({
|
||||
tagName: tagName?.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.where(eq(productTagInfo.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
tag: updatedTag,
|
||||
message: "Tag updated successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a product tag
|
||||
*/
|
||||
export const deleteTag = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if tag exists
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Delete image from S3 if it exists
|
||||
if (tag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(tag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete image from S3:", error);
|
||||
// Continue with deletion even if image delete fails
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This will fail if tag is still assigned to products due to foreign key constraint
|
||||
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Tag deleted successfully",
|
||||
});
|
||||
};
|
||||
303
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
303
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index";
|
||||
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import type { SpecialDeal } from "@/src/db/types";
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||
|
||||
type CreateDeal = {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new product
|
||||
*/
|
||||
export const createProduct = async (req: Request, res: Response) => {
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const existingProduct = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.name, name.trim()),
|
||||
});
|
||||
|
||||
if (existingProduct) {
|
||||
throw new ApiError("A product with this name already exists", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unit = await db.query.units.findFirst({
|
||||
where: eq(units.id, unitId),
|
||||
});
|
||||
|
||||
if (!unit) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Extract images from req.files
|
||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images && Array.isArray(images)) {
|
||||
const imageUploadPromises = images.map((file, index) => {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||
});
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||
}
|
||||
|
||||
// Create product
|
||||
const productData: any = {
|
||||
name,
|
||||
shortDescription,
|
||||
longDescription,
|
||||
unitId,
|
||||
storeId,
|
||||
price,
|
||||
marketPrice,
|
||||
incrementStep: incrementStep || 1,
|
||||
productQuantity: productQuantity || 1,
|
||||
isSuspended: isSuspended || false,
|
||||
isFlashAvailable: isFlashAvailable || false,
|
||||
images: uploadedImageUrls,
|
||||
};
|
||||
|
||||
if (flashPrice) {
|
||||
productData.flashPrice = parseFloat(flashPrice);
|
||||
}
|
||||
|
||||
const [newProduct] = await db
|
||||
.insert(productInfo)
|
||||
.values(productData)
|
||||
.returning();
|
||||
|
||||
// Handle deals if provided
|
||||
let createdDeals: SpecialDeal[] = [];
|
||||
if (deals && Array.isArray(deals)) {
|
||||
const dealInserts = deals.map((deal: CreateDeal) => ({
|
||||
productId: newProduct.id,
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
|
||||
createdDeals = await db
|
||||
.insert(specialDeals)
|
||||
.values(dealInserts)
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
if (tagIds && Array.isArray(tagIds)) {
|
||||
const tagAssociations = tagIds.map((tagId: number) => ({
|
||||
productId: newProduct.id,
|
||||
tagId,
|
||||
}));
|
||||
|
||||
await db.insert(productTags).values(tagAssociations);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(201).json({
|
||||
product: newProduct,
|
||||
deals: createdDeals,
|
||||
message: "Product created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product
|
||||
*/
|
||||
export const updateProduct = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
||||
|
||||
|
||||
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
||||
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
||||
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unit = await db.query.units.findFirst({
|
||||
where: eq(units.id, unitId),
|
||||
});
|
||||
|
||||
if (!unit) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Get current product to handle image updates
|
||||
const currentProduct = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!currentProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle image deletions
|
||||
let currentImages = (currentProduct.images as string[]) || [];
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
// Convert signed URLs to original S3 URLs for comparison
|
||||
const originalUrlsToDelete = imagesToDelete
|
||||
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
||||
.filter(Boolean); // Remove nulls
|
||||
|
||||
// Find which stored images match the ones to delete
|
||||
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
||||
originalUrlsToDelete.includes(storedUrl)
|
||||
);
|
||||
|
||||
// Delete the matching images from S3
|
||||
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
// Remove deleted images from current images array
|
||||
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
||||
}
|
||||
|
||||
// Extract new images from req.files
|
||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images && Array.isArray(images)) {
|
||||
const imageUploadPromises = images.map((file, index) => {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||
});
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||
}
|
||||
|
||||
// Combine remaining current images with new uploaded images
|
||||
const finalImages = [...currentImages, ...uploadedImageUrls];
|
||||
|
||||
const updateData: any = {
|
||||
name,
|
||||
shortDescription,
|
||||
longDescription,
|
||||
unitId,
|
||||
storeId,
|
||||
price,
|
||||
marketPrice,
|
||||
incrementStep: incrementStep || 1,
|
||||
productQuantity: productQuantity || 1,
|
||||
isSuspended: isSuspended || false,
|
||||
images: finalImages.length > 0 ? finalImages : undefined,
|
||||
};
|
||||
|
||||
if (isFlashAvailable !== undefined) {
|
||||
updateData.isFlashAvailable = isFlashAvailable;
|
||||
}
|
||||
|
||||
if (flashPrice !== undefined) {
|
||||
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
||||
}
|
||||
|
||||
const [updatedProduct] = await db
|
||||
.update(productInfo)
|
||||
.set(updateData)
|
||||
.where(eq(productInfo.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle deals if provided
|
||||
if (deals && Array.isArray(deals)) {
|
||||
// Get existing deals
|
||||
const existingDeals = await db.query.specialDeals.findMany({
|
||||
where: eq(specialDeals.productId, parseInt(id)),
|
||||
});
|
||||
|
||||
// Create maps for comparison
|
||||
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
||||
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
||||
|
||||
// Find deals to add, update, and remove
|
||||
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
return !existingDealsMap.has(key);
|
||||
});
|
||||
|
||||
const dealsToRemove = existingDeals.filter(deal => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
return !newDealsMap.has(key);
|
||||
});
|
||||
|
||||
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
const existing = existingDealsMap.get(key);
|
||||
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
||||
});
|
||||
|
||||
// Remove old deals
|
||||
if (dealsToRemove.length > 0) {
|
||||
await db.delete(specialDeals).where(
|
||||
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
||||
);
|
||||
}
|
||||
|
||||
// Add new deals
|
||||
if (dealsToAdd.length > 0) {
|
||||
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
||||
productId: parseInt(id),
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
await db.insert(specialDeals).values(dealInserts);
|
||||
}
|
||||
|
||||
// Update existing deals
|
||||
for (const deal of dealsToUpdate) {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
const existingDeal = existingDealsMap.get(key);
|
||||
if (existingDeal) {
|
||||
await db.update(specialDeals)
|
||||
.set({ validTill: new Date(deal.validTill) })
|
||||
.where(eq(specialDeals.id, existingDeal.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
// if (tagIds && Array.isArray(tagIds)) {
|
||||
if (tagIds && Boolean(tagIds)) {
|
||||
// Remove existing tags
|
||||
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
||||
|
||||
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
||||
// Add new tags
|
||||
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
||||
productId: parseInt(id),
|
||||
tagId,
|
||||
}));
|
||||
|
||||
await db.insert(productTags).values(tagAssociations);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
product: updatedProduct,
|
||||
message: "Product updated successfully",
|
||||
});
|
||||
};
|
||||
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Router } from "express";
|
||||
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
||||
import uploadHandler from '@/src/lib/upload-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Product routes
|
||||
router.post("/", uploadHandler.array('images'), createProduct);
|
||||
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
||||
|
||||
export default router;
|
||||
14
apps/backend/src/apis/admin-apis/apis/tag.router.ts
Normal file
14
apps/backend/src/apis/admin-apis/apis/tag.router.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Router } from "express";
|
||||
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
||||
import uploadHandler from '@/src/lib/upload-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Tag routes
|
||||
router.post("/", uploadHandler.single('image'), createTag);
|
||||
router.get("/", getAllTags);
|
||||
router.get("/:id", getTagById);
|
||||
router.put("/:id", uploadHandler.single('image'), updateTag);
|
||||
router.delete("/:id", deleteTag);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,19 +1,85 @@
|
|||
import { Context } from 'hono'
|
||||
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
||||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
||||
|
||||
import { getProductsSummaryData } from '@/src/db/common-product'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
/**
|
||||
* Get next delivery date for a product
|
||||
*/
|
||||
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
||||
const result = await db
|
||||
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
||||
.from(productSlots)
|
||||
.innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
|
||||
.where(
|
||||
and(
|
||||
eq(productSlots.productId, productId),
|
||||
eq(deliverySlotInfo.isActive, true),
|
||||
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
|
||||
)
|
||||
)
|
||||
.orderBy(deliverySlotInfo.deliveryTime)
|
||||
.limit(1);
|
||||
|
||||
|
||||
return result[0]?.deliveryTime || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all products summary for dropdown
|
||||
*/
|
||||
export const getAllProductsSummary = async (c: Context) => {
|
||||
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tagId = c.req.query('tagId')
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null
|
||||
const { tagId } = req.query;
|
||||
const tagIdNum = tagId ? parseInt(tagId as string) : null;
|
||||
|
||||
const productsWithUnits = await getProductsSummaryData(tagIdNum)
|
||||
let productIds: number[] | null = null;
|
||||
|
||||
const formattedProducts = productsWithUnits.map((product) => ({
|
||||
// If tagId is provided, get products that have this tag
|
||||
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,
|
||||
name: product.name,
|
||||
shortDescription: product.shortDescription,
|
||||
|
|
@ -22,16 +88,18 @@ export const getAllProductsSummary = async (c: Context) => {
|
|||
unit: product.unitShortNotation,
|
||||
productQuantity: product.productQuantity,
|
||||
isOutOfStock: product.isOutOfStock,
|
||||
nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
|
||||
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||
}))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({
|
||||
return res.status(200).json({
|
||||
products: formattedProducts,
|
||||
count: formattedProducts.length,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get products summary error:', error)
|
||||
return c.json({ error: 'Failed to fetch products summary' }, 500)
|
||||
console.error("Get products summary error:", error);
|
||||
return res.status(500).json({ error: "Failed to fetch products summary" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Hono } from 'hono'
|
||||
import { getAllProductsSummary } from '@/src/apis/common-apis/apis/common-product.controller'
|
||||
import { Router } from "express";
|
||||
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||
|
||||
const app = new Hono()
|
||||
const router = Router();
|
||||
|
||||
// GET /summary - Get all products summary
|
||||
app.get('/summary', getAllProductsSummary)
|
||||
router.get("/summary", getAllProductsSummary);
|
||||
|
||||
export default app
|
||||
|
||||
const commonProductsRouter= router;
|
||||
export default commonProductsRouter;
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { Hono } from 'hono'
|
||||
import commonProductsRouter from '@/src/apis/common-apis/apis/common-product.router'
|
||||
import { Router } from "express";
|
||||
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
||||
|
||||
const app = new Hono()
|
||||
const router = Router();
|
||||
|
||||
// Mount product routes at /products
|
||||
app.route('/products', commonProductsRouter)
|
||||
router.use('/products', commonProductsRouter)
|
||||
|
||||
export default app
|
||||
const commonRouter = router;
|
||||
|
||||
export default commonRouter;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { getProductsSummaryData as getProductsSummaryDataPostgres } from '@db-helper-postgres/apis/common-apis/common-product'
|
||||
import { getProductsSummaryData as getProductsSummaryDataSqlite } from '@db-helper-sqlite/apis/common-apis/common-product'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
const getProductsSummaryData = dialect === 'sqlite'
|
||||
? getProductsSummaryDataSqlite
|
||||
: getProductsSummaryDataPostgres
|
||||
|
||||
export { getProductsSummaryData }
|
||||
14
apps/backend/src/db/db_index.ts
Normal file → Executable file
14
apps/backend/src/db/db_index.ts
Normal file → Executable file
|
|
@ -1,10 +1,8 @@
|
|||
import { db as postgresDb } from '@db-helper-postgres/db/db_index'
|
||||
import { db as sqliteDb } from '@db-helper-sqlite/db/db_index'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
type Db = typeof DB_DIALECT_TYPE extends 'sqlite' ? typeof sqliteDb : typeof postgresDb
|
||||
|
||||
const db = (dialect === 'sqlite' ? sqliteDb : postgresDb) as Db
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import path from "path"
|
||||
import * as schema from "@/src/db/schema"
|
||||
|
||||
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
|
||||
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
|
||||
export { db }
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
* This was a one time script to change the composition of the signed urls
|
||||
*/
|
||||
|
||||
import { db } from './db_index'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import {
|
||||
userDetails,
|
||||
productInfo,
|
||||
productTagInfo,
|
||||
complaints
|
||||
} from './schema';
|
||||
} from '@/src/db/schema';
|
||||
import { eq, not, isNull } from 'drizzle-orm';
|
||||
|
||||
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from '@/db-helper-postgres/db/schema'
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from '@/db-helper-sqlite/db/schema'
|
||||
690
apps/backend/src/db/schema.ts
Normal file → Executable file
690
apps/backend/src/db/schema.ts
Normal file → Executable file
|
|
@ -1 +1,689 @@
|
|||
export * from './schema-sqlite'
|
||||
import { pgTable, pgSchema, integer, varchar, date, boolean, timestamp, numeric, jsonb, pgEnum, unique, real, text, check, decimal } from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
const mf = pgSchema('mf');
|
||||
|
||||
|
||||
|
||||
export const users = mf.table('users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }),
|
||||
email: varchar({ length: 255 }),
|
||||
mobile: varchar({ length: 255 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_email: unique('unique_email').on(t.email),
|
||||
}));
|
||||
|
||||
export const userDetails = mf.table('user_details', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id).unique(),
|
||||
bio: varchar('bio', { length: 500 }),
|
||||
dateOfBirth: date('date_of_birth'),
|
||||
gender: varchar('gender', { length: 20 }),
|
||||
occupation: varchar('occupation', { length: 100 }),
|
||||
profileImage: varchar('profile_image', { length: 500 }),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const userCreds = mf.table('user_creds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
userPassword: varchar('user_password', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addresses = mf.table('addresses', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
phone: varchar('phone', { length: 15 }).notNull(),
|
||||
addressLine1: varchar('address_line1', { length: 255 }).notNull(),
|
||||
addressLine2: varchar('address_line2', { length: 255 }),
|
||||
city: varchar('city', { length: 100 }).notNull(),
|
||||
state: varchar('state', { length: 100 }).notNull(),
|
||||
pincode: varchar('pincode', { length: 10 }).notNull(),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
latitude: real('latitude'),
|
||||
longitude: real('longitude'),
|
||||
googleMapsUrl: varchar('google_maps_url', { length: 500 }),
|
||||
adminLatitude: real('admin_latitude'),
|
||||
adminLongitude: real('admin_longitude'),
|
||||
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addressZones = mf.table('address_zones', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
zoneName: varchar('zone_name', { length: 255 }).notNull(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addressAreas = mf.table('address_areas', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
placeName: varchar('place_name', { length: 255 }).notNull(),
|
||||
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const staffUsers = mf.table('staff_users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
password: varchar({ length: 255 }).notNull(),
|
||||
staffRoleId: integer('staff_role_id').references(() => staffRoles.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const storeInfo = mf.table('store_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
owner: integer('owner').notNull().references(() => staffUsers.id),
|
||||
});
|
||||
|
||||
export const units = mf.table('units', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
shortNotation: varchar('short_notation', { length: 50 }).notNull(),
|
||||
fullName: varchar('full_name', { length: 100 }).notNull(),
|
||||
}, (t) => ({
|
||||
unq_short_notation: unique('unique_short_notation').on(t.shortNotation),
|
||||
}));
|
||||
|
||||
export const productInfo = mf.table('product_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
shortDescription: varchar('short_description', { length: 500 }),
|
||||
longDescription: varchar('long_description', { length: 1000 }),
|
||||
unitId: integer('unit_id').notNull().references(() => units.id),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
marketPrice: numeric('market_price', { precision: 10, scale: 2 }),
|
||||
images: jsonb('images'),
|
||||
isOutOfStock: boolean('is_out_of_stock').notNull().default(false),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
isFlashAvailable: boolean('is_flash_available').notNull().default(false),
|
||||
flashPrice: numeric('flash_price', { precision: 10, scale: 2 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
incrementStep: real('increment_step').notNull().default(1),
|
||||
productQuantity: real('product_quantity').notNull().default(1),
|
||||
storeId: integer('store_id').references(() => storeInfo.id),
|
||||
});
|
||||
|
||||
export const productGroupInfo = mf.table('product_group_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
groupName: varchar('group_name', { length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productGroupMembership = mf.table('product_group_membership', {
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
groupId: integer('group_id').notNull().references(() => productGroupInfo.id),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
pk: unique('product_group_membership_pk').on(t.productId, t.groupId),
|
||||
}));
|
||||
|
||||
export const homeBanners = mf.table('home_banners', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
imageUrl: varchar('image_url', { length: 500 }).notNull(),
|
||||
description: varchar('description', { length: 500 }),
|
||||
productIds: integer('product_ids').array(),
|
||||
redirectUrl: varchar('redirect_url', { length: 500 }),
|
||||
serialNum: integer('serial_num'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
lastUpdated: timestamp('last_updated').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productReviews = mf.table('product_reviews', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
reviewBody: text('review_body').notNull(),
|
||||
imageUrls: jsonb('image_urls').$defaultFn(() => []),
|
||||
reviewTime: timestamp('review_time').notNull().defaultNow(),
|
||||
ratings: real('ratings').notNull(),
|
||||
adminResponse: text('admin_response'),
|
||||
adminResponseImages: jsonb('admin_response_images').$defaultFn(() => []),
|
||||
}, (t) => ({
|
||||
ratingCheck: check('rating_check', sql`${t.ratings} >= 1 AND ${t.ratings} <= 5`),
|
||||
}));
|
||||
|
||||
export const uploadStatusEnum = pgEnum('upload_status', ['pending', 'claimed']);
|
||||
|
||||
export const staffRoleEnum = pgEnum('staff_role', ['super_admin', 'admin', 'marketer', 'delivery_staff']);
|
||||
|
||||
export const staffPermissionEnum = pgEnum('staff_permission', ['crud_product', 'make_coupon', 'crud_staff_users']);
|
||||
|
||||
export const uploadUrlStatus = mf.table('upload_url_status', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
key: varchar('key', { length: 500 }).notNull(),
|
||||
status: uploadStatusEnum('status').notNull().default('pending'),
|
||||
});
|
||||
|
||||
export const productTagInfo = mf.table('product_tag_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
tagName: varchar('tag_name', { length: 100 }).notNull().unique(),
|
||||
tagDescription: varchar('tag_description', { length: 500 }),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
isDashboardTag: boolean('is_dashboard_tag').notNull().default(false),
|
||||
relatedStores: jsonb('related_stores').$defaultFn(() => []),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productTags = mf.table('product_tags', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
tagId: integer('tag_id').notNull().references(() => productTagInfo.id),
|
||||
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_product_tag: unique('unique_product_tag').on(t.productId, t.tagId),
|
||||
}));
|
||||
|
||||
export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
deliveryTime: timestamp('delivery_time').notNull(),
|
||||
freezeTime: timestamp('freeze_time').notNull(),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
isFlash: boolean('is_flash').notNull().default(false),
|
||||
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
|
||||
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||
});
|
||||
|
||||
export const vendorSnippets = mf.table('vendor_snippets', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
|
||||
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||
isPermanent: boolean('is_permanent').notNull().default(false),
|
||||
productIds: integer('product_ids').array().notNull(),
|
||||
validTill: timestamp('valid_till'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
||||
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
||||
}));
|
||||
|
||||
export const productSlots = mf.table('product_slots', {
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id),
|
||||
}, (t) => ({
|
||||
pk: unique('product_slot_pk').on(t.productId, t.slotId),
|
||||
}));
|
||||
|
||||
export const specialDeals = mf.table('special_deals', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
validTill: timestamp('valid_till').notNull(),
|
||||
});
|
||||
|
||||
export const orders = mf.table('orders', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
addressId: integer('address_id').notNull().references(() => addresses.id),
|
||||
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||
isCod: boolean('is_cod').notNull().default(false),
|
||||
isOnlinePayment: boolean('is_online_payment').notNull().default(false),
|
||||
paymentInfoId: integer('payment_info_id').references(() => paymentInfoTable.id),
|
||||
totalAmount: numeric('total_amount', { precision: 10, scale: 2 }).notNull(),
|
||||
deliveryCharge: numeric('delivery_charge', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
readableId: integer('readable_id').notNull(),
|
||||
adminNotes: text('admin_notes'),
|
||||
userNotes: text('user_notes'),
|
||||
orderGroupId: varchar('order_group_id', { length: 255 }),
|
||||
orderGroupProportion: decimal('order_group_proportion', { precision: 10, scale: 4 }),
|
||||
isFlashDelivery: boolean('is_flash_delivery').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const orderItems = mf.table('order_items', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: varchar('quantity', { length: 50 }).notNull(),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
discountedPrice: numeric('discounted_price', { precision: 10, scale: 2 }),
|
||||
is_packaged: boolean('is_packaged').notNull().default(false),
|
||||
is_package_verified: boolean('is_package_verified').notNull().default(false),
|
||||
});
|
||||
|
||||
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'success', 'cod', 'failed']);
|
||||
|
||||
export const orderStatus = mf.table('order_status', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderTime: timestamp('order_time').notNull().defaultNow(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
isPackaged: boolean('is_packaged').notNull().default(false),
|
||||
isDelivered: boolean('is_delivered').notNull().default(false),
|
||||
isCancelled: boolean('is_cancelled').notNull().default(false),
|
||||
cancelReason: varchar('cancel_reason', { length: 255 }),
|
||||
isCancelledByAdmin: boolean('is_cancelled_by_admin'),
|
||||
paymentStatus: paymentStatusEnum('payment_state').notNull().default('pending'),
|
||||
cancellationUserNotes: text('cancellation_user_notes'),
|
||||
cancellationAdminNotes: text('cancellation_admin_notes'),
|
||||
cancellationReviewed: boolean('cancellation_reviewed').notNull().default(false),
|
||||
cancellationReviewedAt: timestamp('cancellation_reviewed_at'),
|
||||
refundCouponId: integer('refund_coupon_id').references(() => coupons.id),
|
||||
});
|
||||
|
||||
export const paymentInfoTable = mf.table('payment_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
status: varchar({ length: 50 }).notNull(),
|
||||
gateway: varchar({ length: 50 }).notNull(),
|
||||
orderId: varchar('order_id', { length: 500 }),
|
||||
token: varchar({ length: 500 }),
|
||||
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||
payload: jsonb('payload'),
|
||||
});
|
||||
|
||||
export const payments = mf.table('payments', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
status: varchar({ length: 50 }).notNull(),
|
||||
gateway: varchar({ length: 50 }).notNull(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
token: varchar({ length: 500 }),
|
||||
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||
payload: jsonb('payload'),
|
||||
});
|
||||
|
||||
export const refunds = mf.table('refunds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
refundAmount: numeric('refund_amount', { precision: 10, scale: 2 }),
|
||||
refundStatus: varchar('refund_status', { length: 50 }).default('none'),
|
||||
merchantRefundId: varchar('merchant_refund_id', { length: 255 }),
|
||||
refundProcessedAt: timestamp('refund_processed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const keyValStore = mf.table('key_val_store', {
|
||||
key: varchar('key', { length: 255 }).primaryKey(),
|
||||
value: jsonb('value'),
|
||||
});
|
||||
|
||||
export const notifications = mf.table('notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
title: varchar({ length: 255 }).notNull(),
|
||||
body: varchar({ length: 512 }).notNull(),
|
||||
type: varchar({ length: 50 }),
|
||||
isRead: boolean('is_read').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productCategories = mf.table('product_categories', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
});
|
||||
|
||||
export const cartItems = mf.table('cart_items', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_user_product: unique('unique_user_product').on(t.userId, t.productId),
|
||||
}));
|
||||
|
||||
export const complaints = mf.table('complaints', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
complaintBody: varchar('complaint_body', { length: 1000 }).notNull(),
|
||||
images: jsonb('images'),
|
||||
response: varchar('response', { length: 1000 }),
|
||||
isResolved: boolean('is_resolved').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const coupons = mf.table('coupons', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponCode: varchar('coupon_code', { length: 50 }).notNull().unique('unique_coupon_code'),
|
||||
isUserBased: boolean('is_user_based').notNull().default(false),
|
||||
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||
productIds: jsonb('product_ids'),
|
||||
createdBy: integer('created_by').references(() => staffUsers.id),
|
||||
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||
isApplyForAll: boolean('is_apply_for_all').notNull().default(false),
|
||||
validTill: timestamp('valid_till'),
|
||||
maxLimitForUser: integer('max_limit_for_user'),
|
||||
isInvalidated: boolean('is_invalidated').notNull().default(false),
|
||||
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const couponUsage = mf.table('coupon_usage', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
orderItemId: integer('order_item_id').references(() => orderItems.id),
|
||||
usedAt: timestamp('used_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const couponApplicableUsers = mf.table('coupon_applicable_users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
}, (t) => ({
|
||||
unq_coupon_user: unique('unique_coupon_user').on(t.couponId, t.userId),
|
||||
}));
|
||||
|
||||
export const couponApplicableProducts = mf.table('coupon_applicable_products', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
}, (t) => ({
|
||||
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
|
||||
}));
|
||||
|
||||
export const userIncidents = mf.table('user_incidents', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
dateAdded: timestamp('date_added').notNull().defaultNow(),
|
||||
adminComment: text('admin_comment'),
|
||||
addedBy: integer('added_by').references(() => staffUsers.id),
|
||||
negativityScore: integer('negativity_score'),
|
||||
});
|
||||
|
||||
export const reservedCoupons = mf.table('reserved_coupons', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
|
||||
couponCode: varchar('coupon_code', { length: 50 }).notNull(),
|
||||
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||
productIds: jsonb('product_ids'),
|
||||
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||
validTill: timestamp('valid_till'),
|
||||
maxLimitForUser: integer('max_limit_for_user'),
|
||||
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||
isRedeemed: boolean('is_redeemed').notNull().default(false),
|
||||
redeemedBy: integer('redeemed_by').references(() => users.id),
|
||||
redeemedAt: timestamp('redeemed_at'),
|
||||
createdBy: integer('created_by').notNull().references(() => staffUsers.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_secret_code: unique('unique_secret_code').on(t.secretCode),
|
||||
}));
|
||||
|
||||
export const notifCreds = mf.table('notif_creds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
token: varchar({ length: 500 }).notNull().unique(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
lastVerified: timestamp('last_verified'),
|
||||
});
|
||||
|
||||
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
token: varchar({ length: 500 }).notNull().unique(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
lastVerified: timestamp('last_verified'),
|
||||
});
|
||||
|
||||
export const userNotifications = mf.table('user_notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
body: text('body').notNull(),
|
||||
applicableUsers: jsonb('applicable_users'),
|
||||
});
|
||||
|
||||
export const staffRoles = mf.table('staff_roles', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
roleName: staffRoleEnum('role_name').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_role_name: unique('unique_role_name').on(t.roleName),
|
||||
}));
|
||||
|
||||
export const staffPermissions = mf.table('staff_permissions', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
permissionName: staffPermissionEnum('permission_name').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_permission_name: unique('unique_permission_name').on(t.permissionName),
|
||||
}));
|
||||
|
||||
export const staffRolePermissions = mf.table('staff_role_permissions', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
staffRoleId: integer('staff_role_id').notNull().references(() => staffRoles.id),
|
||||
staffPermissionId: integer('staff_permission_id').notNull().references(() => staffPermissions.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_role_permission: unique('unique_role_permission').on(t.staffRoleId, t.staffPermissionId),
|
||||
}));
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many, one }) => ({
|
||||
addresses: many(addresses),
|
||||
orders: many(orders),
|
||||
notifications: many(notifications),
|
||||
cartItems: many(cartItems),
|
||||
userCreds: one(userCreds),
|
||||
coupons: many(coupons),
|
||||
couponUsages: many(couponUsage),
|
||||
applicableCoupons: many(couponApplicableUsers),
|
||||
userDetails: one(userDetails),
|
||||
notifCreds: many(notifCreds),
|
||||
userIncidents: many(userIncidents),
|
||||
}));
|
||||
|
||||
export const userCredsRelations = relations(userCreds, ({ one }) => ({
|
||||
user: one(users, { fields: [userCreds.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const staffUsersRelations = relations(staffUsers, ({ one, many }) => ({
|
||||
role: one(staffRoles, { fields: [staffUsers.staffRoleId], references: [staffRoles.id] }),
|
||||
coupons: many(coupons),
|
||||
stores: many(storeInfo),
|
||||
}));
|
||||
|
||||
export const addressesRelations = relations(addresses, ({ one, many }) => ({
|
||||
user: one(users, { fields: [addresses.userId], references: [users.id] }),
|
||||
orders: many(orders),
|
||||
zone: one(addressZones, { fields: [addresses.zoneId], references: [addressZones.id] }),
|
||||
}));
|
||||
|
||||
export const unitsRelations = relations(units, ({ many }) => ({
|
||||
products: many(productInfo),
|
||||
}));
|
||||
|
||||
export const productInfoRelations = relations(productInfo, ({ one, many }) => ({
|
||||
unit: one(units, { fields: [productInfo.unitId], references: [units.id] }),
|
||||
store: one(storeInfo, { fields: [productInfo.storeId], references: [storeInfo.id] }),
|
||||
productSlots: many(productSlots),
|
||||
specialDeals: many(specialDeals),
|
||||
orderItems: many(orderItems),
|
||||
cartItems: many(cartItems),
|
||||
tags: many(productTags),
|
||||
applicableCoupons: many(couponApplicableProducts),
|
||||
reviews: many(productReviews),
|
||||
groups: many(productGroupMembership),
|
||||
}));
|
||||
|
||||
export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({
|
||||
products: many(productTags),
|
||||
}));
|
||||
|
||||
export const productTagsRelations = relations(productTags, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productTags.productId], references: [productInfo.id] }),
|
||||
tag: one(productTagInfo, { fields: [productTags.tagId], references: [productTagInfo.id] }),
|
||||
}));
|
||||
|
||||
export const deliverySlotInfoRelations = relations(deliverySlotInfo, ({ many }) => ({
|
||||
productSlots: many(productSlots),
|
||||
orders: many(orders),
|
||||
vendorSnippets: many(vendorSnippets),
|
||||
}));
|
||||
|
||||
export const productSlotsRelations = relations(productSlots, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productSlots.productId], references: [productInfo.id] }),
|
||||
slot: one(deliverySlotInfo, { fields: [productSlots.slotId], references: [deliverySlotInfo.id] }),
|
||||
}));
|
||||
|
||||
export const specialDealsRelations = relations(specialDeals, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [specialDeals.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
user: one(users, { fields: [orders.userId], references: [users.id] }),
|
||||
address: one(addresses, { fields: [orders.addressId], references: [addresses.id] }),
|
||||
slot: one(deliverySlotInfo, { fields: [orders.slotId], references: [deliverySlotInfo.id] }),
|
||||
orderItems: many(orderItems),
|
||||
payment: one(payments),
|
||||
paymentInfo: one(paymentInfoTable, { fields: [orders.paymentInfoId], references: [paymentInfoTable.id] }),
|
||||
orderStatus: many(orderStatus),
|
||||
refunds: many(refunds),
|
||||
couponUsages: many(couponUsage),
|
||||
userIncidents: many(userIncidents),
|
||||
}));
|
||||
|
||||
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
||||
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
|
||||
product: one(productInfo, { fields: [orderItems.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const orderStatusRelations = relations(orderStatus, ({ one }) => ({
|
||||
order: one(orders, { fields: [orderStatus.orderId], references: [orders.id] }),
|
||||
user: one(users, { fields: [orderStatus.userId], references: [users.id] }),
|
||||
refundCoupon: one(coupons, { fields: [orderStatus.refundCouponId], references: [coupons.id] }),
|
||||
}));
|
||||
|
||||
export const paymentInfoRelations = relations(paymentInfoTable, ({ one }) => ({
|
||||
order: one(orders, { fields: [paymentInfoTable.id], references: [orders.paymentInfoId] }),
|
||||
}));
|
||||
|
||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
order: one(orders, { fields: [payments.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const refundsRelations = relations(refunds, ({ one }) => ({
|
||||
order: one(orders, { fields: [refunds.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, { fields: [notifications.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const productCategoriesRelations = relations(productCategories, ({}) => ({}));
|
||||
|
||||
export const cartItemsRelations = relations(cartItems, ({ one }) => ({
|
||||
user: one(users, { fields: [cartItems.userId], references: [users.id] }),
|
||||
product: one(productInfo, { fields: [cartItems.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const complaintsRelations = relations(complaints, ({ one }) => ({
|
||||
user: one(users, { fields: [complaints.userId], references: [users.id] }),
|
||||
order: one(orders, { fields: [complaints.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const couponsRelations = relations(coupons, ({ one, many }) => ({
|
||||
creator: one(staffUsers, { fields: [coupons.createdBy], references: [staffUsers.id] }),
|
||||
usages: many(couponUsage),
|
||||
applicableUsers: many(couponApplicableUsers),
|
||||
applicableProducts: many(couponApplicableProducts),
|
||||
}));
|
||||
|
||||
export const couponUsageRelations = relations(couponUsage, ({ one }) => ({
|
||||
user: one(users, { fields: [couponUsage.userId], references: [users.id] }),
|
||||
coupon: one(coupons, { fields: [couponUsage.couponId], references: [coupons.id] }),
|
||||
order: one(orders, { fields: [couponUsage.orderId], references: [orders.id] }),
|
||||
orderItem: one(orderItems, { fields: [couponUsage.orderItemId], references: [orderItems.id] }),
|
||||
}));
|
||||
|
||||
export const userDetailsRelations = relations(userDetails, ({ one }) => ({
|
||||
user: one(users, { fields: [userDetails.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
||||
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
|
||||
// No relations needed for now
|
||||
}));
|
||||
|
||||
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
||||
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||
products: many(productInfo),
|
||||
}));
|
||||
|
||||
export const couponApplicableUsersRelations = relations(couponApplicableUsers, ({ one }) => ({
|
||||
coupon: one(coupons, { fields: [couponApplicableUsers.couponId], references: [coupons.id] }),
|
||||
user: one(users, { fields: [couponApplicableUsers.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const couponApplicableProductsRelations = relations(couponApplicableProducts, ({ one }) => ({
|
||||
coupon: one(coupons, { fields: [couponApplicableProducts.couponId], references: [coupons.id] }),
|
||||
product: one(productInfo, { fields: [couponApplicableProducts.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const reservedCouponsRelations = relations(reservedCoupons, ({ one }) => ({
|
||||
redeemedUser: one(users, { fields: [reservedCoupons.redeemedBy], references: [users.id] }),
|
||||
creator: one(staffUsers, { fields: [reservedCoupons.createdBy], references: [staffUsers.id] }),
|
||||
}));
|
||||
|
||||
export const productReviewsRelations = relations(productReviews, ({ one }) => ({
|
||||
user: one(users, { fields: [productReviews.userId], references: [users.id] }),
|
||||
product: one(productInfo, { fields: [productReviews.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const addressZonesRelations = relations(addressZones, ({ many }) => ({
|
||||
addresses: many(addresses),
|
||||
areas: many(addressAreas),
|
||||
}));
|
||||
|
||||
export const addressAreasRelations = relations(addressAreas, ({ one }) => ({
|
||||
zone: one(addressZones, { fields: [addressAreas.zoneId], references: [addressZones.id] }),
|
||||
}));
|
||||
|
||||
export const productGroupInfoRelations = relations(productGroupInfo, ({ many }) => ({
|
||||
memberships: many(productGroupMembership),
|
||||
}));
|
||||
|
||||
export const productGroupMembershipRelations = relations(productGroupMembership, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productGroupMembership.productId], references: [productInfo.id] }),
|
||||
group: one(productGroupInfo, { fields: [productGroupMembership.groupId], references: [productGroupInfo.id] }),
|
||||
}));
|
||||
|
||||
export const homeBannersRelations = relations(homeBanners, ({}) => ({
|
||||
// Relations for productIds array would be more complex, skipping for now
|
||||
}));
|
||||
|
||||
export const staffRolesRelations = relations(staffRoles, ({ many }) => ({
|
||||
staffUsers: many(staffUsers),
|
||||
rolePermissions: many(staffRolePermissions),
|
||||
}));
|
||||
|
||||
export const staffPermissionsRelations = relations(staffPermissions, ({ many }) => ({
|
||||
rolePermissions: many(staffRolePermissions),
|
||||
}));
|
||||
|
||||
export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({
|
||||
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
||||
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
||||
}));
|
||||
|
||||
export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
||||
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,138 @@
|
|||
import { seed as seedPostgres } from '@db-helper-postgres/db/seed'
|
||||
import { seed as seedSqlite } from '@db-helper-sqlite/db/seed'
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
|
||||
import { eq } from "drizzle-orm";
|
||||
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
export async function seed() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
const seedImpl = dialect === 'sqlite' ? seedSqlite : seedPostgres
|
||||
// Seed units individually
|
||||
const unitsToSeed = [
|
||||
{ shortNotation: "Kg", fullName: "Kilogram" },
|
||||
{ shortNotation: "L", fullName: "Litre" },
|
||||
{ shortNotation: "Dz", fullName: "Dozen" },
|
||||
{ shortNotation: "Pc", fullName: "Unit Piece" },
|
||||
];
|
||||
|
||||
export const seed = async () => seedImpl()
|
||||
for (const unit of unitsToSeed) {
|
||||
const existingUnit = await db.query.units.findFirst({
|
||||
where: eq(units.shortNotation, unit.shortNotation),
|
||||
});
|
||||
if (!existingUnit) {
|
||||
await db.insert(units).values(unit);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed staff roles individually
|
||||
const rolesToSeed = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const;
|
||||
|
||||
for (const roleName of rolesToSeed) {
|
||||
const existingRole = await db.query.staffRoles.findFirst({
|
||||
where: eq(staffRoles.roleName, roleName),
|
||||
});
|
||||
if (!existingRole) {
|
||||
await db.insert(staffRoles).values({ roleName });
|
||||
}
|
||||
}
|
||||
|
||||
// Seed staff permissions individually
|
||||
const permissionsToSeed = ['crud_product', 'make_coupon', 'crud_staff_users'] as const;
|
||||
|
||||
for (const permissionName of permissionsToSeed) {
|
||||
const existingPermission = await db.query.staffPermissions.findFirst({
|
||||
where: eq(staffPermissions.permissionName, permissionName),
|
||||
});
|
||||
if (!existingPermission) {
|
||||
await db.insert(staffPermissions).values({ permissionName });
|
||||
}
|
||||
}
|
||||
|
||||
// Seed role-permission assignments
|
||||
await db.transaction(async (tx) => {
|
||||
// Get role IDs
|
||||
const superAdminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'super_admin') });
|
||||
const adminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'admin') });
|
||||
const marketerRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'marketer') });
|
||||
|
||||
// Get permission IDs
|
||||
const crudProductPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_product') });
|
||||
const makeCouponPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'make_coupon') });
|
||||
const crudStaffUsersPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_staff_users') });
|
||||
|
||||
// Assign all permissions to super_admin
|
||||
[crudProductPerm, makeCouponPerm, crudStaffUsersPerm].forEach(async (perm) => {
|
||||
if (superAdminRole && perm) {
|
||||
const existingSuperAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, superAdminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||
});
|
||||
if (!existingSuperAdminPerm) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: superAdminRole.id,
|
||||
staffPermissionId: perm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Assign all permissions to admin
|
||||
[crudProductPerm, makeCouponPerm].forEach(async (perm) => {
|
||||
if (adminRole && perm) {
|
||||
const existingAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, adminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||
});
|
||||
if (!existingAdminPerm) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: adminRole.id,
|
||||
staffPermissionId: perm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Assign make_coupon to marketer
|
||||
if (marketerRole && makeCouponPerm) {
|
||||
const existingMarketerCoupon = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, marketerRole.id) && eq(staffRolePermissions.staffPermissionId, makeCouponPerm.id),
|
||||
});
|
||||
if (!existingMarketerCoupon) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: marketerRole.id,
|
||||
staffPermissionId: makeCouponPerm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Seed key-val store constants using CONST_KEYS
|
||||
const constantsToSeed = [
|
||||
{ key: CONST_KEYS.readableOrderId, value: 0 },
|
||||
{ key: CONST_KEYS.minRegularOrderValue, value: minOrderValue },
|
||||
{ key: CONST_KEYS.freeDeliveryThreshold, value: minOrderValue },
|
||||
{ key: CONST_KEYS.deliveryCharge, value: deliveryCharge },
|
||||
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
|
||||
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
|
||||
{ key: CONST_KEYS.popularItems, value: [] },
|
||||
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
||||
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
||||
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
|
||||
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
||||
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
||||
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||
];
|
||||
|
||||
for (const constant of constantsToSeed) {
|
||||
const existing = await db.query.keyValStore.findFirst({
|
||||
where: eq(keyValStore.key, constant.key),
|
||||
});
|
||||
if (!existing) {
|
||||
await db.insert(keyValStore).values({
|
||||
key: constant.key,
|
||||
value: constant.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seeding completed.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from '@/db-helper-sqlite/db/sqlite-casts'
|
||||
|
|
@ -1,58 +1,47 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type {
|
||||
User as PostgresUser,
|
||||
Address as PostgresAddress,
|
||||
Unit as PostgresUnit,
|
||||
ProductInfo as PostgresProductInfo,
|
||||
DeliverySlotInfo as PostgresDeliverySlotInfo,
|
||||
ProductSlot as PostgresProductSlot,
|
||||
SpecialDeal as PostgresSpecialDeal,
|
||||
Order as PostgresOrder,
|
||||
OrderItem as PostgresOrderItem,
|
||||
Payment as PostgresPayment,
|
||||
Notification as PostgresNotification,
|
||||
ProductCategory as PostgresProductCategory,
|
||||
CartItem as PostgresCartItem,
|
||||
Coupon as PostgresCoupon,
|
||||
ProductWithUnit as PostgresProductWithUnit,
|
||||
OrderWithItems as PostgresOrderWithItems,
|
||||
CartItemWithProduct as PostgresCartItemWithProduct,
|
||||
} from '@db-helper-postgres/db/types'
|
||||
import type {
|
||||
User as SqliteUser,
|
||||
Address as SqliteAddress,
|
||||
Unit as SqliteUnit,
|
||||
ProductInfo as SqliteProductInfo,
|
||||
DeliverySlotInfo as SqliteDeliverySlotInfo,
|
||||
ProductSlot as SqliteProductSlot,
|
||||
SpecialDeal as SqliteSpecialDeal,
|
||||
Order as SqliteOrder,
|
||||
OrderItem as SqliteOrderItem,
|
||||
Payment as SqlitePayment,
|
||||
Notification as SqliteNotification,
|
||||
ProductCategory as SqliteProductCategory,
|
||||
CartItem as SqliteCartItem,
|
||||
Coupon as SqliteCoupon,
|
||||
ProductWithUnit as SqliteProductWithUnit,
|
||||
OrderWithItems as SqliteOrderWithItems,
|
||||
CartItemWithProduct as SqliteCartItemWithProduct,
|
||||
} from '@db-helper-sqlite/db/types'
|
||||
users,
|
||||
addresses,
|
||||
units,
|
||||
productInfo,
|
||||
deliverySlotInfo,
|
||||
productSlots,
|
||||
specialDeals,
|
||||
orders,
|
||||
orderItems,
|
||||
payments,
|
||||
notifications,
|
||||
productCategories,
|
||||
cartItems,
|
||||
coupons,
|
||||
} from "@/src/db/schema";
|
||||
|
||||
type UseSqlite = typeof DB_DIALECT_TYPE extends 'sqlite' ? true : false
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Address = InferSelectModel<typeof addresses>;
|
||||
export type Unit = InferSelectModel<typeof units>;
|
||||
export type ProductInfo = InferSelectModel<typeof productInfo>;
|
||||
export type DeliverySlotInfo = InferSelectModel<typeof deliverySlotInfo>;
|
||||
export type ProductSlot = InferSelectModel<typeof productSlots>;
|
||||
export type SpecialDeal = InferSelectModel<typeof specialDeals>;
|
||||
export type Order = InferSelectModel<typeof orders>;
|
||||
export type OrderItem = InferSelectModel<typeof orderItems>;
|
||||
export type Payment = InferSelectModel<typeof payments>;
|
||||
export type Notification = InferSelectModel<typeof notifications>;
|
||||
export type ProductCategory = InferSelectModel<typeof productCategories>;
|
||||
export type CartItem = InferSelectModel<typeof cartItems>;
|
||||
export type Coupon = InferSelectModel<typeof coupons>;
|
||||
|
||||
export type User = UseSqlite extends true ? SqliteUser : PostgresUser
|
||||
export type Address = UseSqlite extends true ? SqliteAddress : PostgresAddress
|
||||
export type Unit = UseSqlite extends true ? SqliteUnit : PostgresUnit
|
||||
export type ProductInfo = UseSqlite extends true ? SqliteProductInfo : PostgresProductInfo
|
||||
export type DeliverySlotInfo = UseSqlite extends true ? SqliteDeliverySlotInfo : PostgresDeliverySlotInfo
|
||||
export type ProductSlot = UseSqlite extends true ? SqliteProductSlot : PostgresProductSlot
|
||||
export type SpecialDeal = UseSqlite extends true ? SqliteSpecialDeal : PostgresSpecialDeal
|
||||
export type Order = UseSqlite extends true ? SqliteOrder : PostgresOrder
|
||||
export type OrderItem = UseSqlite extends true ? SqliteOrderItem : PostgresOrderItem
|
||||
export type Payment = UseSqlite extends true ? SqlitePayment : PostgresPayment
|
||||
export type Notification = UseSqlite extends true ? SqliteNotification : PostgresNotification
|
||||
export type ProductCategory = UseSqlite extends true ? SqliteProductCategory : PostgresProductCategory
|
||||
export type CartItem = UseSqlite extends true ? SqliteCartItem : PostgresCartItem
|
||||
export type Coupon = UseSqlite extends true ? SqliteCoupon : PostgresCoupon
|
||||
export type ProductWithUnit = UseSqlite extends true ? SqliteProductWithUnit : PostgresProductWithUnit
|
||||
export type OrderWithItems = UseSqlite extends true ? SqliteOrderWithItems : PostgresOrderWithItems
|
||||
export type CartItemWithProduct = UseSqlite extends true ? SqliteCartItemWithProduct : PostgresCartItemWithProduct
|
||||
// Combined types
|
||||
export type ProductWithUnit = ProductInfo & {
|
||||
unit: Unit;
|
||||
};
|
||||
|
||||
export type OrderWithItems = Order & {
|
||||
items: (OrderItem & { product: ProductInfo })[];
|
||||
address: Address;
|
||||
slot: DeliverySlotInfo;
|
||||
};
|
||||
|
||||
export type CartItemWithProduct = CartItem & {
|
||||
product: ProductInfo;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { claimUploadUrlStatus as claimUploadUrlStatusPostgres, createUploadUrlStatus as createUploadUrlStatusPostgres } from '@db-helper-postgres/lib/upload-url'
|
||||
import { claimUploadUrlStatus as claimUploadUrlStatusSqlite, createUploadUrlStatus as createUploadUrlStatusSqlite } from '@db-helper-sqlite/lib/upload-url'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
const createUploadUrlStatus = dialect === 'sqlite'
|
||||
? createUploadUrlStatusSqlite
|
||||
: createUploadUrlStatusPostgres
|
||||
|
||||
const claimUploadUrlStatus = dialect === 'sqlite'
|
||||
? claimUploadUrlStatusSqlite
|
||||
: claimUploadUrlStatusPostgres
|
||||
|
||||
export { claimUploadUrlStatus, createUploadUrlStatus }
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
||||
|
||||
const runCombinedJob = async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
console.log('Starting combined job');
|
||||
console.log('Starting combined job: payments and refunds check');
|
||||
|
||||
// Run payment check
|
||||
// await checkPendingPayments();
|
||||
|
||||
// Run refund check
|
||||
// await checkRefundStatuses();
|
||||
|
||||
console.log('Combined job completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error in combined job:', error);
|
||||
|
|
|
|||
79
apps/backend/src/jobs/payment-status-checker.ts
Normal file
79
apps/backend/src/jobs/payment-status-checker.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
|
||||
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
||||
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
|
||||
|
||||
interface PendingPaymentRecord {
|
||||
payment: typeof payments.$inferSelect;
|
||||
order: typeof orders.$inferSelect;
|
||||
slot: typeof deliverySlotInfo.$inferSelect;
|
||||
}
|
||||
|
||||
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||
// Construct message from record data
|
||||
const message = `Payment pending for order ORD${record.order.id}. Please complete before orders close time.`;
|
||||
|
||||
// TODO: Implement notification sending logic using record.order.userId, record.order.id, message
|
||||
console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`);
|
||||
};
|
||||
|
||||
export const checkRefundStatuses = async () => {
|
||||
try {
|
||||
const initiatedRefunds = await db
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(and(
|
||||
eq(refunds.refundStatus, 'initiated'),
|
||||
isNotNull(refunds.merchantRefundId)
|
||||
));
|
||||
|
||||
// Process refunds concurrently using Promise.allSettled
|
||||
const promises = initiatedRefunds.map(async (refund) => {
|
||||
if (!refund.merchantRefundId) return;
|
||||
|
||||
try {
|
||||
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
|
||||
|
||||
if (razorpayRefund.status === 'processed') {
|
||||
await db
|
||||
.update(refunds)
|
||||
.set({ refundStatus: 'success', refundProcessedAt: new Date() })
|
||||
.where(eq(refunds.id, refund.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking refund ${refund.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all promises to complete
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error('Error in checkRefundStatuses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPendingPayments = async () => {
|
||||
try {
|
||||
const pendingPayments = await db
|
||||
.select({
|
||||
payment: payments,
|
||||
order: orders,
|
||||
slot: deliverySlotInfo,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(orders, eq(payments.orderId, orders.id))
|
||||
.innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id))
|
||||
.where(and(
|
||||
eq(payments.status, 'pending'),
|
||||
gt(deliverySlotInfo.freezeTime, new Date()) // Freeze time not passed
|
||||
));
|
||||
|
||||
for (const record of pendingPayments) {
|
||||
createPaymentNotification(record);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking pending payments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
85
apps/backend/src/lib/automatedJobs.ts
Normal file
85
apps/backend/src/lib/automatedJobs.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||
import { inArray, eq } from 'drizzle-orm';
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { computeConstants } from '@/src/lib/const-store'
|
||||
|
||||
|
||||
const MUTTON_ITEMS = [
|
||||
12, //Lamb mutton
|
||||
14, // Mutton Boti
|
||||
35, //Mutton Kheema
|
||||
84, //Mutton Brain
|
||||
4, //Mutton
|
||||
86, //Mutton Chops
|
||||
87, //Mutton Soup bones
|
||||
85 //Mutton paya
|
||||
];
|
||||
|
||||
|
||||
|
||||
export const startAutomatedJobs = () => {
|
||||
// Job to disable flash delivery for mutton at 12 PM daily
|
||||
cron.schedule('0 12 * * *', async () => {
|
||||
try {
|
||||
console.log('Disabling flash delivery for products at 12 PM');
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ isFlashAvailable: false })
|
||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||
console.log('Flash delivery disabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disabling flash delivery:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to enable flash delivery for mutton at 6 AM daily
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
try {
|
||||
console.log('Enabling flash delivery for products at 5 AM');
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ isFlashAvailable: true })
|
||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||
console.log('Flash delivery enabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error enabling flash delivery:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to disable flash delivery feature at 9 PM daily
|
||||
cron.schedule('0 21 * * *', async () => {
|
||||
try {
|
||||
console.log('Disabling flash delivery feature at 9 PM');
|
||||
await db
|
||||
.update(keyValStore)
|
||||
.set({ value: false })
|
||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature disabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disabling flash delivery feature:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to enable flash delivery feature at 6 AM daily
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
try {
|
||||
console.log('Enabling flash delivery feature at 6 AM');
|
||||
await db
|
||||
.update(keyValStore)
|
||||
.set({ value: true })
|
||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature enabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error enabling flash delivery feature:', error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Automated jobs scheduled');
|
||||
};
|
||||
|
||||
// Optional: Call on import if desired, or export and call in main app
|
||||
// startAutomatedJobs();
|
||||
6
apps/backend/src/lib/catch-async.ts
Executable file
6
apps/backend/src/lib/catch-async.ts
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
import express from 'express';
|
||||
const catchAsync =
|
||||
(fn: express.RequestHandler) =>
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) =>
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
export default catchAsync;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from '../db/db_index'
|
||||
import { keyValStore } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { keyValStore } from '@/src/db/schema'
|
||||
import redisClient from '@/src/lib/redis-client'
|
||||
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
|
||||
|
||||
|
|
@ -1,54 +1,46 @@
|
|||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from '@/src/lib/s3-client'
|
||||
import { assetsDomain, s3Url } from '@/src/lib/env-exporter'
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||
import { s3Url } from "@/src/lib/env-exporter"
|
||||
|
||||
function extractS3Key(url: string): string | null {
|
||||
try {
|
||||
// Check if this is a signed URL first and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(url) || url
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(url) || url;
|
||||
|
||||
// Find the index of '.com/' in the URL
|
||||
// const comIndex = originalUrl.indexOf(".com/");
|
||||
const baseUrlIndex = originalUrl.indexOf(s3Url)
|
||||
const baseUrlIndex = originalUrl.indexOf(s3Url);
|
||||
|
||||
// If '.com/' is found, return everything after it
|
||||
if (baseUrlIndex !== -1) {
|
||||
return originalUrl.substring(baseUrlIndex + s3Url.length) // +5 to skip '.com/'
|
||||
return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting key from URL:', error)
|
||||
console.error("Error extracting key from URL:", error);
|
||||
}
|
||||
|
||||
// Return null if the pattern isn't found or there was an error
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export async function deleteS3Image(imageUrl: string) {
|
||||
try {
|
||||
|
||||
let key: string | null = ''
|
||||
|
||||
if (imageUrl.includes(assetsDomain)) {
|
||||
key = imageUrl.replace(assetsDomain, '')
|
||||
}
|
||||
else if (imageUrl.startsWith('http')) {
|
||||
// First check if this is a signed URL and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||
|
||||
const key = extractS3Key(originalUrl || "");
|
||||
|
||||
key = extractS3Key(originalUrl || '')
|
||||
}
|
||||
|
||||
else {
|
||||
key = imageUrl
|
||||
}
|
||||
if (!key) {
|
||||
throw new Error('Invalid image URL format')
|
||||
throw new Error("Invalid image URL format");
|
||||
}
|
||||
const deleteS3 = await deleteImageUtil({ keys: [key] })
|
||||
const deleteS3 = await deleteImageUtil({keys: [key] });
|
||||
if (!deleteS3) {
|
||||
throw new Error('Failed to delete image from S3')
|
||||
throw new Error("Failed to delete image from S3");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting image from S3:', error)
|
||||
console.error("Error deleting image from S3:", error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from '../db/db_index'
|
||||
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
|
|
@ -17,12 +17,6 @@ export const s3Region = process.env.S3_REGION as string
|
|||
|
||||
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||
|
||||
export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
||||
|
||||
export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
||||
|
||||
export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
||||
|
||||
export const s3Url = process.env.S3_URL as string
|
||||
|
||||
export const redisUrl = process.env.REDIS_URL as string
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
|
|||
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
||||
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
||||
import { deleteOrders } from '@/src/lib/delete-orders'
|
||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
||||
import { verifyProductsAvailabilityBySchedule } from './manage-scheduled-availability'
|
||||
|
||||
/**
|
||||
* Initialize all application services
|
||||
|
|
@ -20,7 +18,6 @@ export const initFunc = async (): Promise<void> => {
|
|||
try {
|
||||
console.log('Starting application initialization...');
|
||||
|
||||
await verifyProductsAvailabilityBySchedule(false);
|
||||
await Promise.all([
|
||||
initializeAllStores(),
|
||||
initializeUserNegativityStore(),
|
||||
|
|
@ -28,10 +25,6 @@ export const initFunc = async (): Promise<void> => {
|
|||
startCancellationHandler(),
|
||||
]);
|
||||
|
||||
// Create all cache files after stores are initialized
|
||||
await createAllCacheFiles();
|
||||
console.log('Cache files created successfully');
|
||||
|
||||
console.log('Application initialization completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Application initialization failed:', error);
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { SignJWT, jwtVerify, errors, JWTPayload } from 'jose';
|
||||
import { jwtSecret } from './env-exporter';
|
||||
|
||||
// JWT Payload Types
|
||||
export interface UserJWTPayload extends JWTPayload {
|
||||
userId: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface StaffJWTPayload extends JWTPayload {
|
||||
staffId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Convert string secret to Uint8Array
|
||||
const getSecret = () => {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT secret not configured');
|
||||
}
|
||||
return new TextEncoder().encode(jwtSecret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign a JWT token
|
||||
* Compatible with tokens signed by jsonwebtoken library
|
||||
*/
|
||||
export const signToken = async (
|
||||
payload: Record<string, unknown>,
|
||||
expiresIn: string = '7d'
|
||||
): Promise<string> => {
|
||||
const secret = getSecret();
|
||||
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
* Compatible with tokens signed by jsonwebtoken library
|
||||
*/
|
||||
export const verifyToken = async (token: string): Promise<JWTPayload> => {
|
||||
try {
|
||||
const secret = getSecret();
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof errors.JWTExpired) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
if (error instanceof errors.JWTInvalid) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error is a JWT-related error
|
||||
*/
|
||||
export const isJWTError = (error: unknown): boolean => {
|
||||
return error instanceof errors.JOSEError ||
|
||||
(error instanceof Error && (
|
||||
error.message.includes('token') ||
|
||||
error.message.includes('JWT')
|
||||
));
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Queue, Worker } from 'bullmq';
|
||||
import { Expo } from 'expo-server-sdk';
|
||||
import { redisUrl } from '@/src/lib/env-exporter'
|
||||
import { db } from '../db/db_index'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
import {
|
||||
NOTIFS_QUEUE,
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { sendPushNotificationsMany } from '@/src/lib/expo-service'
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
// Core notification dispatch methods (renamed for clarity)
|
||||
export async function dispatchBulkNotification({
|
||||
|
|
|
|||
59
apps/backend/src/lib/payments-utils.ts
Normal file
59
apps/backend/src/lib/payments-utils.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Razorpay from "razorpay";
|
||||
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { payments } from "@/src/db/schema"
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
export class RazorpayPaymentService {
|
||||
private static instance = new Razorpay({
|
||||
key_id: razorpayId,
|
||||
key_secret: razorpaySecret,
|
||||
});
|
||||
|
||||
static async createOrder(orderId: number, amount: string) {
|
||||
// Create Razorpay order
|
||||
const razorpayOrder = await this.instance.orders.create({
|
||||
amount: parseFloat(amount) * 100, // Convert to paisa
|
||||
currency: 'INR',
|
||||
receipt: `order_${orderId}`,
|
||||
notes: {
|
||||
customerOrderId: orderId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return razorpayOrder;
|
||||
}
|
||||
|
||||
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
|
||||
// Use transaction if provided, otherwise use db
|
||||
const dbInstance = tx || db;
|
||||
|
||||
// Insert payment record
|
||||
const [payment] = await dbInstance
|
||||
.insert(payments)
|
||||
.values({
|
||||
status: 'pending',
|
||||
gateway: 'razorpay',
|
||||
orderId,
|
||||
token: orderId.toString(),
|
||||
merchantOrderId: razorpayOrder.id,
|
||||
payload: razorpayOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
static async initiateRefund(paymentId: string, amount: number) {
|
||||
const refund = await this.instance.payments.refund(paymentId, {
|
||||
amount,
|
||||
});
|
||||
return refund;
|
||||
}
|
||||
|
||||
static async fetchRefund(refundId: string) {
|
||||
const refund = await this.instance.refunds.fetch(refundId);
|
||||
return refund;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from '../db/db_index'
|
||||
import { orders, orderStatus } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { orders, orderStatus } from '@/src/db/schema'
|
||||
import redisClient from '@/src/lib/redis-client'
|
||||
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||
import { inArray, eq } from 'drizzle-orm';
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
export async function retryWithExponentialBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
delayMs *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { db } from "@/src/db/db_index"
|
||||
|
||||
/**
|
||||
* Constants for role names to avoid hardcoding and typos
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client,
|
|||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import signedUrlCache from "@/src/lib/signed-url-cache"
|
||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/db/upload-url'
|
||||
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
||||
import { uploadUrlStatus } from "@/src/db/schema"
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: s3Region,
|
||||
|
|
@ -159,7 +161,10 @@ export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expi
|
|||
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
||||
try {
|
||||
// Insert record into upload_url_status
|
||||
await createUploadUrlStatus(key)
|
||||
await db.insert(uploadUrlStatus).values({
|
||||
key: key,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Generate signed upload URL
|
||||
const command = new PutObjectCommand({
|
||||
|
|
@ -196,13 +201,19 @@ export function extractKeyFromPresignedUrl(url: string): string {
|
|||
|
||||
export async function claimUploadUrl(url: string): Promise<void> {
|
||||
try {
|
||||
let semiKey:string = ''
|
||||
const semiKey = extractKeyFromPresignedUrl(url);
|
||||
const key = s3BucketName+'/'+ semiKey
|
||||
|
||||
if(url.startsWith('http'))
|
||||
semiKey = extractKeyFromPresignedUrl(url);
|
||||
else
|
||||
semiKey = url
|
||||
await claimUploadUrlStatus(semiKey)
|
||||
// Update status to 'claimed' if currently 'pending'
|
||||
const result = await db
|
||||
.update(uploadUrlStatus)
|
||||
.set({ status: 'claimed' })
|
||||
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('Upload URL not found or already claimed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error claiming upload URL:', error);
|
||||
throw new Error('Failed to claim upload URL');
|
||||
|
|
|
|||
132
apps/backend/src/lib/signed-url-cache.ts
Normal file → Executable file
132
apps/backend/src/lib/signed-url-cache.ts
Normal file → Executable file
|
|
@ -1,3 +1,8 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
|
|
@ -11,7 +16,18 @@ class SignedURLCache {
|
|||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
console.log('SignedURLCache: Initialized (in-memory only)');
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,7 +110,7 @@ class SignedURLCache {
|
|||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
console.log('SignedURLCache: Cleared all entries');
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -129,27 +145,119 @@ class SignedURLCache {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* Save the cache to disk
|
||||
*/
|
||||
getStats(): { totalEntries: number } {
|
||||
return {
|
||||
totalEntries: this.originalToSignedCache.size
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub methods for backward compatibility - do nothing in in-memory mode
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
// No-op: In-memory cache only
|
||||
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 {
|
||||
// No-op: In-memory cache only
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
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;
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAt: number; // Timestamp when this entry expires
|
||||
}
|
||||
|
||||
class SignedURLCache {
|
||||
private originalToSignedCache: Map<string, CacheEntry>;
|
||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
||||
|
||||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL from the cache using an original URL as the key
|
||||
*/
|
||||
get(originalUrl: string): string | undefined {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
// Also remove from reverse mapping if it exists
|
||||
this.signedToOriginalCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original URL from the cache using a signed URL as the key
|
||||
*/
|
||||
getOriginalUrl(signedUrl: string): string | undefined {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// Also remove from primary mapping if it exists
|
||||
this.originalToSignedCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache with a TTL (Time To Live)
|
||||
* @param originalUrl The original S3 URL
|
||||
* @param signedUrl The signed URL
|
||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
||||
*/
|
||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
|
||||
const entry: CacheEntry = {
|
||||
value: signedUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const reverseEntry: CacheEntry = {
|
||||
value: originalUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
||||
}
|
||||
|
||||
has(originalUrl: string): boolean {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
hasSignedUrl(signedUrl: string): boolean {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries from the cache
|
||||
* @returns The number of expired entries that were removed
|
||||
*/
|
||||
clearExpired(): number {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
// Clear expired entries from original to signed cache
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear expired entries from signed to original cache
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// No need to increment removedCount as we've already counted these in the first loop
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to disk
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
try {
|
||||
// Remove expired entries before saving
|
||||
const removedCount = this.clearExpired();
|
||||
|
||||
// Convert Maps to serializable objects
|
||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
serializedOriginalToSigned[originalUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
serializedSignedToOriginal[signedUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const serializedCache = {
|
||||
originalToSigned: serializedOriginalToSigned,
|
||||
signedToOriginal: serializedSignedToOriginal
|
||||
};
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(
|
||||
CACHE_FILE_PATH,
|
||||
JSON.stringify(serializedCache),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||
} catch (error) {
|
||||
console.error('Error saving SignedURLCache to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from disk
|
||||
*/
|
||||
loadFromDisk(): void {
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
const signedUrlCache = new SignedURLCache();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default signedUrlCache;
|
||||
8
apps/backend/src/lib/upload-handler.ts
Executable file
8
apps/backend/src/lib/upload-handler.ts
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
import multerParent from 'multer';
|
||||
const uploadHandler = multerParent({
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10 MB
|
||||
}
|
||||
});
|
||||
|
||||
export default uploadHandler
|
||||
|
|
@ -1,34 +1,65 @@
|
|||
import { Hono } from 'hono'
|
||||
import { authenticateUser } from '@/src/middleware/auth.middleware'
|
||||
import v1Router from '@/src/v1-router'
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import avRouter from "@/src/apis/admin-apis/apis/av-router"
|
||||
import { ApiError } from "@/src/lib/api-error"
|
||||
import v1Router from "@/src/v1-router"
|
||||
import testController from "@/src/test-controller"
|
||||
import { authenticateUser } from "@/src/middleware/auth.middleware"
|
||||
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
|
||||
import uploadHandler from "@/src/lib/upload-handler"
|
||||
|
||||
// Note: This router is kept for compatibility during migration
|
||||
// Most routes have been moved to tRPC
|
||||
const router = new Hono()
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Health check endpoints (no auth required)
|
||||
// Note: These are also defined in index.ts, keeping for compatibility
|
||||
router.get('/health', (c) => {
|
||||
return c.json({
|
||||
router.get('/health', (req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
message: 'Hello world'
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/seed', (c) => {
|
||||
return c.json({
|
||||
});
|
||||
});
|
||||
router.get('/seed', (req:Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
// Mount v1 routes (REST API)
|
||||
router.route('/v1', v1Router)
|
||||
|
||||
// Apply authentication middleware to all subsequent routes
|
||||
router.use('*', authenticateUser)
|
||||
router.use(authenticateUser);
|
||||
|
||||
export default router
|
||||
router.use('/v1', v1Router);
|
||||
// router.use('/av', avRouter);
|
||||
router.use('/test', testController);
|
||||
|
||||
// User REST APIs
|
||||
router.post('/uv/complaints/raise',
|
||||
uploadHandler.array('images', 5),
|
||||
raiseComplaint
|
||||
);
|
||||
|
||||
// Global error handling middleware
|
||||
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.message,
|
||||
details: err.details,
|
||||
statusCode: err.statusCode
|
||||
});
|
||||
}
|
||||
|
||||
// Handle unknown errors
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
statusCode: 500
|
||||
});
|
||||
});
|
||||
|
||||
const mainRouter = router;
|
||||
|
||||
export default mainRouter;
|
||||
67
apps/backend/src/middleware/auth.middleware.ts
Normal file
67
apps/backend/src/middleware/auth.middleware.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
};
|
||||
staffUser?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authenticateUser = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new ApiError('Authorization token required', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
console.log(req.headers)
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if (decoded.staffId) {
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.id, decoded.staffId),
|
||||
});
|
||||
|
||||
if (!staff) {
|
||||
throw new ApiError('Invalid staff token', 401);
|
||||
}
|
||||
|
||||
req.staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
} else {
|
||||
// This is a regular user token
|
||||
req.user = decoded;
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, decoded.userId),
|
||||
});
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new ApiError('Account suspended', 403);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,67 +1,67 @@
|
|||
import { createMiddleware } from 'hono/factory'
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { verifyToken, isJWTError, UserJWTPayload } from '@/src/lib/jwt-utils'
|
||||
|
||||
// Type for Hono context variables
|
||||
type Variables = {
|
||||
user: UserJWTPayload
|
||||
// Extend the Request interface to include user property
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono middleware to verify JWT token and attach user to context
|
||||
*/
|
||||
export const verifyTokenMiddleware = createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
||||
export const verifyToken = (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = c.req.header('authorization')
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new ApiError('Access denied. No token provided', 401)
|
||||
throw new ApiError('Access denied. No token provided', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1]
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
throw new ApiError('Access denied. Invalid token format', 401)
|
||||
throw new ApiError('Access denied. Invalid token format', 401);
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = await verifyToken(token) as UserJWTPayload
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
|
||||
// Add user info to context
|
||||
c.set('user', decoded)
|
||||
|
||||
await next()
|
||||
// Add user info to request
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (isJWTError(error)) {
|
||||
throw new ApiError('Invalid Auth Credentials', 401)
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
next(new ApiError('Invalid Auth Credentials', 401));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Keep old name for backward compatibility
|
||||
export { verifyTokenMiddleware as verifyToken }
|
||||
|
||||
/**
|
||||
* Hono middleware to require specific roles
|
||||
*/
|
||||
export const requireRole = (roles: string[]) => {
|
||||
return createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
||||
const user = c.get('user')
|
||||
|
||||
if (!user) {
|
||||
throw new ApiError('Authentication required', 401)
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('Authentication required', 401);
|
||||
}
|
||||
|
||||
// Check if user has any of the required roles
|
||||
const userRoles = user.roles || []
|
||||
const hasPermission = roles.some(role => userRoles.includes(role))
|
||||
const userRoles = req.user.roles || [];
|
||||
const hasPermission = roles.some(role => userRoles.includes(role));
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ApiError('Access denied. Insufficient permissions', 403)
|
||||
throw new ApiError('Access denied. Insufficient permissions', 403);
|
||||
}
|
||||
|
||||
await next()
|
||||
})
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,37 +1,40 @@
|
|||
import { createMiddleware } from 'hono/factory'
|
||||
import { db } from '../db/db_index'
|
||||
import { staffUsers } from '../db/schema'
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { verifyToken, StaffJWTPayload } from '@/src/lib/jwt-utils'
|
||||
|
||||
// Type for Hono context variables
|
||||
type Variables = {
|
||||
// Extend Request interface to include staffUser
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
staffUser?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token and extract payload
|
||||
*/
|
||||
const verifyStaffToken = async (token: string): Promise<StaffJWTPayload> => {
|
||||
const verifyStaffToken = (token: string) => {
|
||||
try {
|
||||
const payload = await verifyToken(token);
|
||||
return payload as StaffJWTPayload;
|
||||
return jwt.verify(token, process.env.JWT_SECRET || 'default-secret');
|
||||
} catch (error) {
|
||||
throw new ApiError('Access denied. Invalid auth credentials', 401);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hono middleware to authenticate staff users and attach staffUser to context
|
||||
* Middleware to authenticate staff users and attach staffUser to request
|
||||
*/
|
||||
export const authenticateStaff = createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
||||
export const authenticateStaff = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Extract token from Authorization header
|
||||
const authHeader = c.req.header('authorization');
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new ApiError('Staff authentication required', 401);
|
||||
|
|
@ -44,7 +47,7 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
|
|||
}
|
||||
|
||||
// Verify token and extract payload
|
||||
const decoded = await verifyStaffToken(token);
|
||||
const decoded = verifyStaffToken(token) as any;
|
||||
|
||||
// Verify staffId exists in token
|
||||
if (!decoded.staffId) {
|
||||
|
|
@ -60,14 +63,14 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
|
|||
throw new ApiError('Staff user not found', 401);
|
||||
}
|
||||
|
||||
// Attach staff user to context
|
||||
c.set('staffUser', {
|
||||
// Attach staff user to request
|
||||
req.staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
});
|
||||
};
|
||||
|
||||
await next();
|
||||
next();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
405
apps/backend/src/services/user/order-service.ts
Normal file
405
apps/backend/src/services/user/order-service.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { db } from '@/src/db/db_index'
|
||||
import {
|
||||
orders,
|
||||
orderItems,
|
||||
orderStatus,
|
||||
addresses,
|
||||
productInfo,
|
||||
paymentInfoTable,
|
||||
coupons,
|
||||
couponUsage,
|
||||
payments,
|
||||
cartItems,
|
||||
refunds,
|
||||
units,
|
||||
userDetails,
|
||||
} from '@/src/db/schema'
|
||||
import { eq, and, inArray, desc, gte } from 'drizzle-orm'
|
||||
|
||||
// ============ User/Auth Queries ============
|
||||
|
||||
/**
|
||||
* Get user details by user ID
|
||||
*/
|
||||
export async function getUserDetails(userId: number) {
|
||||
return db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userId),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Address Queries ============
|
||||
|
||||
/**
|
||||
* Get user address by ID
|
||||
*/
|
||||
export async function getUserAddress(userId: number, addressId: number) {
|
||||
return db.query.addresses.findFirst({
|
||||
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Product Queries ============
|
||||
|
||||
/**
|
||||
* Get product by ID
|
||||
*/
|
||||
export async function getProductById(productId: number) {
|
||||
return db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, productId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple products by IDs with unit info
|
||||
*/
|
||||
export async function getProductsByIdsWithUnits(productIds: number[]) {
|
||||
return db
|
||||
.select({
|
||||
id: productInfo.id,
|
||||
name: productInfo.name,
|
||||
shortDescription: productInfo.shortDescription,
|
||||
price: productInfo.price,
|
||||
images: productInfo.images,
|
||||
isOutOfStock: productInfo.isOutOfStock,
|
||||
unitShortNotation: units.shortNotation,
|
||||
incrementStep: productInfo.incrementStep,
|
||||
})
|
||||
.from(productInfo)
|
||||
.innerJoin(units, eq(productInfo.unitId, units.id))
|
||||
.where(and(inArray(productInfo.id, productIds), eq(productInfo.isSuspended, false)))
|
||||
.orderBy(desc(productInfo.createdAt))
|
||||
}
|
||||
|
||||
// ============ Coupon Queries ============
|
||||
|
||||
/**
|
||||
* Get coupon with usages for user
|
||||
*/
|
||||
export async function getCouponWithUsages(couponId: number, userId: number) {
|
||||
return db.query.coupons.findFirst({
|
||||
where: eq(coupons.id, couponId),
|
||||
with: {
|
||||
usages: { where: eq(couponUsage.userId, userId) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert coupon usage
|
||||
*/
|
||||
export async function insertCouponUsage(data: {
|
||||
userId: number
|
||||
couponId: number
|
||||
orderId: number
|
||||
orderItemId: number | null
|
||||
usedAt: Date
|
||||
}) {
|
||||
return db.insert(couponUsage).values(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coupon usages for order
|
||||
*/
|
||||
export async function getCouponUsagesForOrder(orderId: number) {
|
||||
return db.query.couponUsage.findMany({
|
||||
where: eq(couponUsage.orderId, orderId),
|
||||
with: {
|
||||
coupon: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Cart Queries ============
|
||||
|
||||
/**
|
||||
* Delete cart items for user by product IDs
|
||||
*/
|
||||
export async function deleteCartItems(userId: number, productIds: number[]) {
|
||||
return db.delete(cartItems).where(
|
||||
and(
|
||||
eq(cartItems.userId, userId),
|
||||
inArray(cartItems.productId, productIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Payment Info Queries ============
|
||||
|
||||
/**
|
||||
* Create payment info
|
||||
*/
|
||||
export async function createPaymentInfo(data: {
|
||||
status: string
|
||||
gateway: string
|
||||
merchantOrderId: string
|
||||
}) {
|
||||
return db.insert(paymentInfoTable).values(data).returning()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ Order Queries ============
|
||||
|
||||
/**
|
||||
* Insert multiple orders
|
||||
*/
|
||||
export async function insertOrders(ordersData: any[]) {
|
||||
return db.insert(orders).values(ordersData).returning()
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple order items
|
||||
*/
|
||||
export async function insertOrderItems(itemsData: any[]) {
|
||||
return db.insert(orderItems).values(itemsData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple order statuses
|
||||
*/
|
||||
export async function insertOrderStatuses(statusesData: any[]) {
|
||||
return db.insert(orderStatus).values(statusesData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user orders with all relations
|
||||
*/
|
||||
export async function getUserOrdersWithRelations(userId: number, limit: number, offset: number) {
|
||||
return db.query.orders.findMany({
|
||||
where: eq(orders.userId, userId),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
slot: true,
|
||||
paymentInfo: true,
|
||||
orderStatus: true,
|
||||
refunds: true,
|
||||
},
|
||||
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user orders
|
||||
*/
|
||||
export async function countUserOrders(userId: number) {
|
||||
return db.$count(orders, eq(orders.userId, userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with all relations
|
||||
*/
|
||||
export async function getOrderByIdWithRelations(orderId: number) {
|
||||
return db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
slot: true,
|
||||
paymentInfo: true,
|
||||
orderStatus: {
|
||||
with: {
|
||||
refundCoupon: true,
|
||||
},
|
||||
},
|
||||
refunds: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with order status
|
||||
*/
|
||||
export async function getOrderWithStatus(orderId: number) {
|
||||
return db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
orderStatus: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order status to cancelled
|
||||
*/
|
||||
export async function updateOrderStatusToCancelled(
|
||||
statusId: number,
|
||||
data: {
|
||||
isCancelled: boolean
|
||||
cancelReason: string
|
||||
cancellationUserNotes: string
|
||||
cancellationReviewed: boolean
|
||||
}
|
||||
) {
|
||||
return db
|
||||
.update(orderStatus)
|
||||
.set(data)
|
||||
.where(eq(orderStatus.id, statusId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert refund record
|
||||
*/
|
||||
export async function insertRefund(data: { orderId: number; refundStatus: string }) {
|
||||
return db.insert(refunds).values(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order notes
|
||||
*/
|
||||
export async function updateOrderNotes(orderId: number, userNotes: string | null) {
|
||||
return db
|
||||
.update(orders)
|
||||
.set({ userNotes })
|
||||
.where(eq(orders.id, orderId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent delivered orders for user
|
||||
*/
|
||||
export async function getRecentDeliveredOrders(
|
||||
userId: number,
|
||||
since: Date,
|
||||
limit: number
|
||||
) {
|
||||
return db
|
||||
.select({ id: orders.id })
|
||||
.from(orders)
|
||||
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.userId, userId),
|
||||
eq(orderStatus.isDelivered, true),
|
||||
gte(orders.createdAt, since)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(orders.createdAt))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order items by order IDs
|
||||
*/
|
||||
export async function getOrderItemsByOrderIds(orderIds: number[]) {
|
||||
return db
|
||||
.select({ productId: orderItems.productId })
|
||||
.from(orderItems)
|
||||
.where(inArray(orderItems.orderId, orderIds))
|
||||
}
|
||||
|
||||
// ============ Transaction Helper ============
|
||||
|
||||
/**
|
||||
* Execute function within a database transaction
|
||||
*/
|
||||
export async function withTransaction<T>(fn: (tx: any) => Promise<T>): Promise<T> {
|
||||
return db.transaction(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel order with refund record in a transaction
|
||||
*/
|
||||
export async function cancelOrderWithRefund(
|
||||
statusId: number,
|
||||
orderId: number,
|
||||
isCod: boolean,
|
||||
reason: string
|
||||
): Promise<{ orderId: number }> {
|
||||
return db.transaction(async (tx) => {
|
||||
// Update order status
|
||||
await tx
|
||||
.update(orderStatus)
|
||||
.set({
|
||||
isCancelled: true,
|
||||
cancelReason: reason,
|
||||
cancellationUserNotes: reason,
|
||||
cancellationReviewed: false,
|
||||
})
|
||||
.where(eq(orderStatus.id, statusId))
|
||||
|
||||
// Insert refund record
|
||||
const refundStatus = isCod ? "na" : "pending"
|
||||
await tx.insert(refunds).values({
|
||||
orderId,
|
||||
refundStatus,
|
||||
})
|
||||
|
||||
return { orderId }
|
||||
})
|
||||
}
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* Create orders with payment info in a transaction
|
||||
*/
|
||||
export async function createOrdersWithPayment(
|
||||
ordersData: any[],
|
||||
paymentMethod: "online" | "cod",
|
||||
totalWithDelivery: number,
|
||||
razorpayOrderCreator?: (paymentInfoId: number, amount: string) => Promise<any>,
|
||||
paymentRecordInserter?: (paymentInfoId: number, razorpayOrder: any, tx: Tx) => Promise<any>
|
||||
): Promise<typeof orders.$inferSelect[]> {
|
||||
return db.transaction(async (tx) => {
|
||||
let sharedPaymentInfoId: number | null = null
|
||||
if (paymentMethod === "online") {
|
||||
const [paymentInfo] = await tx
|
||||
.insert(paymentInfoTable)
|
||||
.values({
|
||||
status: "pending",
|
||||
gateway: "razorpay",
|
||||
merchantOrderId: `multi_order_${Date.now()}`,
|
||||
})
|
||||
.returning()
|
||||
sharedPaymentInfoId = paymentInfo.id
|
||||
}
|
||||
|
||||
const ordersToInsert: Omit<typeof orders.$inferInsert, "id">[] = ordersData.map(
|
||||
(od) => ({
|
||||
...od.order,
|
||||
paymentInfoId: sharedPaymentInfoId,
|
||||
})
|
||||
)
|
||||
|
||||
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning()
|
||||
|
||||
const allOrderItems: Omit<typeof orderItems.$inferInsert, "id">[] = []
|
||||
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, "id">[] = []
|
||||
|
||||
insertedOrders.forEach((order: typeof orders.$inferSelect, index: number) => {
|
||||
const od = ordersData[index]
|
||||
od.orderItems.forEach((item: any) => {
|
||||
allOrderItems.push({ ...item, orderId: order.id as number })
|
||||
})
|
||||
allOrderStatuses.push({
|
||||
...od.orderStatus,
|
||||
orderId: order.id as number,
|
||||
})
|
||||
})
|
||||
|
||||
await tx.insert(orderItems).values(allOrderItems)
|
||||
await tx.insert(orderStatus).values(allOrderStatuses)
|
||||
|
||||
if (paymentMethod === "online" && sharedPaymentInfoId && razorpayOrderCreator && paymentRecordInserter) {
|
||||
const razorpayOrder = await razorpayOrderCreator(
|
||||
sharedPaymentInfoId,
|
||||
totalWithDelivery.toString()
|
||||
)
|
||||
await paymentRecordInserter(
|
||||
sharedPaymentInfoId,
|
||||
razorpayOrder,
|
||||
tx
|
||||
)
|
||||
}
|
||||
|
||||
return insertedOrders
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from '../../db/db_index'
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '../../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '@/src/db/schema'
|
||||
import { eq, and, gt, sql, desc } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
|
|
@ -55,8 +55,8 @@ export async function getProductDeliverySlots(productId: number) {
|
|||
and(
|
||||
eq(productSlots.productId, productId),
|
||||
eq(deliverySlotInfo.isActive, true),
|
||||
gt(deliverySlotInfo.deliveryTime, new Date()),
|
||||
gt(deliverySlotInfo.freezeTime, new Date())
|
||||
gt(deliverySlotInfo.deliveryTime, sql`NOW()`),
|
||||
gt(deliverySlotInfo.freezeTime, sql`NOW()`)
|
||||
)
|
||||
)
|
||||
.orderBy(deliverySlotInfo.deliveryTime)
|
||||
|
|
@ -76,7 +76,7 @@ export async function getProductSpecialDeals(productId: number) {
|
|||
.where(
|
||||
and(
|
||||
eq(specialDeals.productId, productId),
|
||||
gt(specialDeals.validTill, new Date())
|
||||
gt(specialDeals.validTill, sql`NOW()`)
|
||||
)
|
||||
)
|
||||
.orderBy(specialDeals.quantity)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// import redisClient from '@/src/stores/redis-client';
|
||||
import redisClient from '@/src/lib/redis-client';
|
||||
import { db } from '../db/db_index'
|
||||
import { homeBanners } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { homeBanners } from '@/src/db/schema'
|
||||
import { isNotNull, asc } from 'drizzle-orm';
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// import redisClient from '@/src/stores/redis-client';
|
||||
import redisClient from '@/src/lib/redis-client';
|
||||
import { db } from '../db/db_index'
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '@/src/db/schema'
|
||||
import { eq, and, gt, sql } from 'drizzle-orm';
|
||||
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ export async function initializeProducts(): Promise<void> {
|
|||
and(
|
||||
eq(deliverySlotInfo.isActive, true),
|
||||
eq(deliverySlotInfo.isCapacityFull, false),
|
||||
gt(deliverySlotInfo.deliveryTime, new Date())
|
||||
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
|
||||
)
|
||||
);
|
||||
const deliverySlotsMap = new Map<number, typeof allDeliverySlots>();
|
||||
|
|
@ -90,7 +90,7 @@ export async function initializeProducts(): Promise<void> {
|
|||
validTill: specialDeals.validTill,
|
||||
})
|
||||
.from(specialDeals)
|
||||
.where(gt(specialDeals.validTill, new Date()));
|
||||
.where(gt(specialDeals.validTill, sql`NOW()`));
|
||||
const specialDealsMap = new Map<number, typeof allSpecialDeals>();
|
||||
for (const deal of allSpecialDeals) {
|
||||
if (!specialDealsMap.has(deal.productId)) specialDealsMap.set(deal.productId, []);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// import redisClient from '@/src/stores/redis-client';
|
||||
import redisClient from '@/src/lib/redis-client';
|
||||
import { db } from '../db/db_index'
|
||||
import { productTagInfo, productTags } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productTagInfo, productTags } from '@/src/db/schema'
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import redisClient from '@/src/lib/redis-client';
|
||||
import { db } from '../db/db_index'
|
||||
import { deliverySlotInfo, productSlots, productInfo, units } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { deliverySlotInfo, productSlots, productInfo, units } from '@/src/db/schema'
|
||||
import { eq, and, gt, asc } from 'drizzle-orm';
|
||||
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -4,11 +4,6 @@ import { initializeProducts } from '@/src/stores/product-store'
|
|||
import { initializeProductTagStore } from '@/src/stores/product-tag-store'
|
||||
import { initializeSlotStore } from '@/src/stores/slot-store'
|
||||
import { initializeBannerStore } from '@/src/stores/banner-store'
|
||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
||||
|
||||
// const STORE_INIT_DELAY_MS = 3 * 60 * 1000
|
||||
const STORE_INIT_DELAY_MS = 0.5 * 60 * 1000
|
||||
let storeInitializationTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
/**
|
||||
* Initialize all application stores
|
||||
|
|
@ -34,27 +29,8 @@ export const initializeAllStores = async (): Promise<void> => {
|
|||
]);
|
||||
|
||||
console.log('All application stores initialized successfully');
|
||||
|
||||
// Regenerate all cache files (fire-and-forget)
|
||||
createAllCacheFiles().catch(error => {
|
||||
console.error('Failed to regenerate cache files during store initialization:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Application stores initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleStoreInitialization = (): void => {
|
||||
if (storeInitializationTimeout) {
|
||||
clearTimeout(storeInitializationTimeout)
|
||||
storeInitializationTimeout = null
|
||||
}
|
||||
|
||||
storeInitializationTimeout = setTimeout(() => {
|
||||
storeInitializationTimeout = null
|
||||
initializeAllStores().catch(error => {
|
||||
console.error('Scheduled store initialization failed:', error)
|
||||
})
|
||||
}, STORE_INIT_DELAY_MS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import redisClient from '@/src/lib/redis-client';
|
||||
import { db } from '../db/db_index'
|
||||
import { userIncidents } from '../db/schema'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { userIncidents } from '@/src/db/schema'
|
||||
import { eq, sum } from 'drizzle-orm';
|
||||
|
||||
export async function initializeUserNegativityStore(): Promise<void> {
|
||||
13
apps/backend/src/test-controller.ts
Normal file
13
apps/backend/src/test-controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Health check passed',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
apps/backend/src/trpc/apis/admin-apis/apis/address.ts
Normal file
32
apps/backend/src/trpc/apis/admin-apis/apis/address.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { z } from 'zod';
|
||||
import { addressZones, addressAreas } from '@/src/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
|
||||
const addressRouter = router({
|
||||
getZones: protectedProcedure.query(async () => {
|
||||
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
|
||||
return zones
|
||||
}),
|
||||
|
||||
getAreas: protectedProcedure.query(async () => {
|
||||
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
|
||||
return areas
|
||||
}),
|
||||
|
||||
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
|
||||
|
||||
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
|
||||
return {zone: zone};
|
||||
}),
|
||||
|
||||
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
|
||||
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
|
||||
return {area};
|
||||
}),
|
||||
|
||||
// TODO: Add update and delete mutations if needed
|
||||
});
|
||||
|
||||
export default addressRouter;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { router } from '@/src/trpc/trpc-index'
|
||||
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
|
||||
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
|
||||
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
|
||||
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
|
||||
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
|
||||
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
|
||||
|
|
@ -9,15 +10,15 @@ import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
|
|||
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
|
||||
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
|
||||
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
|
||||
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
|
||||
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
||||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
|
||||
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
|
||||
|
||||
export const adminRouter = router({
|
||||
complaint: complaintRouter,
|
||||
coupon: couponRouter,
|
||||
cancelledOrders: cancelledOrdersRouter,
|
||||
order: orderRouter,
|
||||
vendorSnippets: vendorSnippetsRouter,
|
||||
slots: slotsRouter,
|
||||
|
|
@ -25,11 +26,10 @@ export const adminRouter = router({
|
|||
staffUser: staffUserRouter,
|
||||
store: storeRouter,
|
||||
payments: adminPaymentsRouter,
|
||||
address: addressRouter,
|
||||
banner: bannerRouter,
|
||||
user: userRouter,
|
||||
const: constRouter,
|
||||
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
|
||||
tag: tagRouter,
|
||||
});
|
||||
|
||||
export type AdminRouter = typeof adminRouter;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
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 { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client'
|
||||
import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
import { ApiError } from '@/src/lib/api-error';
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||
|
||||
export const bannerRouter = router({
|
||||
// Get all banners
|
||||
getBanners: protectedProcedure
|
||||
.query(async () => {
|
||||
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
|
||||
const bannersWithSignedUrls = await Promise.all(
|
||||
|
|
@ -18,14 +24,16 @@ export const bannerRouter = router({
|
|||
try {
|
||||
return {
|
||||
...banner,
|
||||
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
|
||||
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl,
|
||||
// Ensure productIds is always an array
|
||||
productIds: banner.productIds || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||
return {
|
||||
...banner,
|
||||
imageUrl: banner.imageUrl,
|
||||
imageUrl: banner.imageUrl, // Keep original on error
|
||||
// Ensure productIds is always an array
|
||||
productIds: banner.productIds || [],
|
||||
};
|
||||
}
|
||||
|
|
@ -35,8 +43,10 @@ export const bannerRouter = router({
|
|||
return {
|
||||
banners: bannersWithSignedUrls,
|
||||
};
|
||||
} catch (e: any) {
|
||||
}
|
||||
catch(e:any) {
|
||||
console.log(e)
|
||||
|
||||
throw new ApiError(e.message);
|
||||
}
|
||||
}),
|
||||
|
|
@ -45,17 +55,23 @@ export const bannerRouter = router({
|
|||
getBanner: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const banner = await bannerDbService.getBannerById(input.id)
|
||||
const banner = await db.query.homeBanners.findFirst({
|
||||
where: eq(homeBanners.id, input.id),
|
||||
// Removed product relationship since we now use productIds array
|
||||
});
|
||||
|
||||
if (banner) {
|
||||
try {
|
||||
// Convert S3 key to signed URL for client
|
||||
if (banner.imageUrl) {
|
||||
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
|
||||
banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||
// Keep original imageUrl on error
|
||||
}
|
||||
|
||||
// Ensure productIds is always an array (handle migration compatibility)
|
||||
if (!banner.productIds) {
|
||||
banner.productIds = [];
|
||||
}
|
||||
|
|
@ -68,31 +84,32 @@ export const bannerRouter = router({
|
|||
createBanner: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
imageUrl: z.string(),
|
||||
imageUrl: z.string().url(),
|
||||
description: z.string().optional(),
|
||||
productIds: z.array(z.number()).optional(),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
// serialNum removed completely
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
||||
|
||||
const banner = await bannerDbService.createBanner({
|
||||
const [banner] = await db.insert(homeBanners).values({
|
||||
name: input.name,
|
||||
imageUrl: imageUrl,
|
||||
description: input.description,
|
||||
productIds: input.productIds || [],
|
||||
redirectUrl: input.redirectUrl,
|
||||
serialNum: 999,
|
||||
isActive: false,
|
||||
})
|
||||
serialNum: 999, // Default value, not used
|
||||
isActive: false, // Default to inactive
|
||||
}).returning();
|
||||
|
||||
scheduleStoreInitialization()
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return banner;
|
||||
} catch (error) {
|
||||
console.error('Error creating banner:', error);
|
||||
throw error;
|
||||
throw error; // Re-throw to maintain tRPC error handling
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -110,21 +127,31 @@ export const bannerRouter = router({
|
|||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
|
||||
const { id, ...updateData } = input;
|
||||
const incomingProductIds = input.productIds;
|
||||
// Extract S3 key from presigned URL if imageUrl is provided
|
||||
const processedData = {
|
||||
...updateData,
|
||||
...(updateData.imageUrl && {
|
||||
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
|
||||
}),
|
||||
};
|
||||
|
||||
const processedData: any = { ...updateData }
|
||||
|
||||
if (updateData.imageUrl) {
|
||||
processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl)
|
||||
// Handle serialNum null case
|
||||
const finalData: any = { ...processedData };
|
||||
if ('serialNum' in finalData && finalData.serialNum === null) {
|
||||
// Set to null explicitly
|
||||
finalData.serialNum = null;
|
||||
}
|
||||
|
||||
if ('serialNum' in processedData && processedData.serialNum === null) {
|
||||
processedData.serialNum = null;
|
||||
}
|
||||
const [banner] = await db.update(homeBanners)
|
||||
.set({ ...finalData, lastUpdated: new Date(), })
|
||||
.where(eq(homeBanners.id, id))
|
||||
.returning();
|
||||
|
||||
const banner = await bannerDbService.updateBannerById(id, processedData)
|
||||
|
||||
scheduleStoreInitialization()
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return banner;
|
||||
} catch (error) {
|
||||
|
|
@ -137,9 +164,10 @@ export const bannerRouter = router({
|
|||
deleteBanner: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await bannerDbService.deleteBannerById(input.id)
|
||||
await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
|
||||
|
||||
scheduleStoreInitialization()
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
|
|
|||
179
apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts
Normal file
179
apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
const updateCancellationReviewSchema = z.object({
|
||||
orderId: z.number(),
|
||||
cancellationReviewed: z.boolean(),
|
||||
adminNotes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateRefundSchema = z.object({
|
||||
orderId: z.number(),
|
||||
isRefundDone: z.boolean(),
|
||||
});
|
||||
|
||||
export const cancelledOrdersRouter = router({
|
||||
getAll: protectedProcedure
|
||||
.query(async () => {
|
||||
// First get cancelled order statuses with order details
|
||||
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
|
||||
where: eq(orderStatus.isCancelled, true),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
refunds: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(orderStatus.orderTime)],
|
||||
});
|
||||
|
||||
const filteredStatuses = cancelledOrderStatuses.filter(status => {
|
||||
return status.order.isCod || status.paymentStatus === 'success';
|
||||
});
|
||||
|
||||
return filteredStatuses.map(status => {
|
||||
const refund = status.order.refunds[0];
|
||||
return {
|
||||
id: status.order.id,
|
||||
readableId: status.order.id,
|
||||
customerName: `${status.order.user.name}`,
|
||||
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
|
||||
totalAmount: status.order.totalAmount,
|
||||
cancellationReviewed: status.cancellationReviewed || false,
|
||||
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||
adminNotes: status.order.adminNotes,
|
||||
cancelReason: status.cancelReason,
|
||||
paymentMode: status.order.isCod ? 'COD' : 'Online',
|
||||
paymentStatus: status.paymentStatus || 'pending',
|
||||
items: status.order.orderItems.map(item => ({
|
||||
name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
unit: item.product.unit?.shortNotation,
|
||||
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
|
||||
})),
|
||||
createdAt: status.order.createdAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
updateReview: protectedProcedure
|
||||
.input(updateCancellationReviewSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { orderId, cancellationReviewed, adminNotes } = input;
|
||||
|
||||
const result = await db.update(orderStatus)
|
||||
.set({
|
||||
cancellationReviewed,
|
||||
cancellationAdminNotes: adminNotes || null,
|
||||
cancellationReviewedAt: new Date(),
|
||||
})
|
||||
.where(eq(orderStatus.orderId, orderId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Cancellation record not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Get cancelled order with full details
|
||||
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
|
||||
where: eq(orderStatus.id, id),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
|
||||
throw new Error("Cancelled order not found");
|
||||
}
|
||||
|
||||
// Get refund details separately
|
||||
const refund = await db.query.refunds.findFirst({
|
||||
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
|
||||
});
|
||||
|
||||
const order = cancelledOrderStatus.order;
|
||||
|
||||
// Format the response similar to the getAll method
|
||||
const formattedOrder = {
|
||||
id: order.id,
|
||||
readableId: order.id,
|
||||
customerName: order.user.name,
|
||||
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
|
||||
totalAmount: order.totalAmount,
|
||||
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
|
||||
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
|
||||
cancelReason: cancelledOrderStatus.cancelReason || null,
|
||||
items: order.orderItems.map((item: any) => ({
|
||||
name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: parseFloat(item.price.toString()),
|
||||
unit: item.product.unit?.shortNotation || 'unit',
|
||||
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||
image: item.product.images?.[0] || null,
|
||||
})),
|
||||
createdAt: order.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
return { order: formattedOrder };
|
||||
}),
|
||||
|
||||
updateRefund: protectedProcedure
|
||||
.input(updateRefundSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { orderId, isRefundDone } = input;
|
||||
|
||||
const refundStatus = isRefundDone ? 'processed' : 'none';
|
||||
const result = await db.update(refunds)
|
||||
.set({
|
||||
refundStatus,
|
||||
refundProcessedAt: isRefundDone ? new Date() : null,
|
||||
})
|
||||
.where(eq(refunds.orderId, orderId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Cancellation record not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { complaints, users } from '@/src/db/schema'
|
||||
import { eq, desc, lt, and } from 'drizzle-orm';
|
||||
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
|
||||
|
||||
export const complaintRouter = router({
|
||||
getAll: protectedProcedure
|
||||
|
|
@ -12,7 +14,27 @@ export const complaintRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { cursor, limit } = input;
|
||||
|
||||
const complaintsData = await complaintDbService.getComplaints(cursor, limit);
|
||||
let whereCondition = cursor
|
||||
? lt(complaints.id, cursor)
|
||||
: undefined;
|
||||
|
||||
const complaintsData = await db
|
||||
.select({
|
||||
id: complaints.id,
|
||||
complaintBody: complaints.complaintBody,
|
||||
userId: complaints.userId,
|
||||
orderId: complaints.orderId,
|
||||
isResolved: complaints.isResolved,
|
||||
createdAt: complaints.createdAt,
|
||||
userName: users.name,
|
||||
userMobile: users.mobile,
|
||||
images: complaints.images,
|
||||
})
|
||||
.from(complaints)
|
||||
.leftJoin(users, eq(complaints.userId, users.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(complaints.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = complaintsData.length > limit;
|
||||
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
|
||||
|
|
@ -20,7 +42,7 @@ export const complaintRouter = router({
|
|||
const complaintsWithSignedImages = await Promise.all(
|
||||
complaintsToReturn.map(async (c) => {
|
||||
const signedImages = c.images
|
||||
? scaffoldAssetUrl(c.images as string[])
|
||||
? await generateSignedUrlsFromS3Urls(c.images as string[])
|
||||
: [];
|
||||
|
||||
return {
|
||||
|
|
@ -48,7 +70,10 @@ export const complaintRouter = router({
|
|||
resolve: protectedProcedure
|
||||
.input(z.object({ id: z.string(), response: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await complaintDbService.resolveComplaint(parseInt(input.id), input.response);
|
||||
await db
|
||||
.update(complaints)
|
||||
.set({ isResolved: true, response: input.response })
|
||||
.where(eq(complaints.id, parseInt(input.id)));
|
||||
|
||||
return { message: 'Complaint resolved successfully' };
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { keyValStore } from '@/src/db/schema'
|
||||
import { computeConstants } from '@/src/lib/const-store'
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const constRouter = router({
|
||||
getConstants: protectedProcedure
|
||||
.query(async () => {
|
||||
const constants = await constantDbService.getAllConstants();
|
||||
|
||||
const constants = await db.select().from(keyValStore);
|
||||
|
||||
const resp = constants.map(c => ({
|
||||
key: c.key,
|
||||
|
|
@ -36,14 +38,23 @@ export const constRouter = router({
|
|||
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
const updatedCount = await constantDbService.upsertConstants(constants);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { key, value } of constants) {
|
||||
await tx.insert(keyValStore)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({
|
||||
target: keyValStore.key,
|
||||
set: { value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh all constants in Redis after database update
|
||||
await computeConstants();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount,
|
||||
updatedCount: constants.length,
|
||||
keys: constants.map(c => c.key),
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
|
||||
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
|
||||
import dayjs from 'dayjs';
|
||||
import { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const createCouponBodySchema = z.object({
|
||||
couponCode: z.string().optional(),
|
||||
|
|
@ -49,7 +51,10 @@ export const couponRouter = router({
|
|||
|
||||
// If applicableUsers is provided, verify users exist
|
||||
if (applicableUsers && applicableUsers.length > 0) {
|
||||
const existingUsers = await couponDbService.getUsersByIds(applicableUsers);
|
||||
const existingUsers = await db.query.users.findMany({
|
||||
where: inArray(users.id, applicableUsers),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (existingUsers.length !== applicableUsers.length) {
|
||||
throw new Error("Some applicable users not found");
|
||||
}
|
||||
|
|
@ -64,40 +69,56 @@ export const couponRouter = router({
|
|||
// Generate coupon code if not provided
|
||||
let finalCouponCode = couponCode;
|
||||
if (!finalCouponCode) {
|
||||
// Generate a unique coupon code
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
finalCouponCode = `MF${timestamp}${random}`;
|
||||
}
|
||||
|
||||
// Check if coupon code already exists
|
||||
const existingCoupon = await couponDbService.getCouponByCode(finalCouponCode);
|
||||
const existingCoupon = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, finalCouponCode),
|
||||
});
|
||||
|
||||
if (existingCoupon) {
|
||||
throw new Error("Coupon code already exists");
|
||||
}
|
||||
|
||||
const coupon = await couponDbService.createCoupon({
|
||||
const result = await db.insert(coupons).values({
|
||||
couponCode: finalCouponCode,
|
||||
isUserBased: isUserBased || false,
|
||||
discountPercent: discountPercent?.toString() || null,
|
||||
flatDiscount: flatDiscount?.toString() || null,
|
||||
minOrder: minOrder?.toString() || null,
|
||||
discountPercent: discountPercent?.toString(),
|
||||
flatDiscount: flatDiscount?.toString(),
|
||||
minOrder: minOrder?.toString(),
|
||||
productIds: productIds || null,
|
||||
createdBy: staffUserId,
|
||||
maxValue: maxValue?.toString() || null,
|
||||
maxValue: maxValue?.toString(),
|
||||
isApplyForAll: isApplyForAll || false,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
||||
maxLimitForUser: maxLimitForUser || null,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||
maxLimitForUser: maxLimitForUser,
|
||||
exclusiveApply: exclusiveApply || false,
|
||||
});
|
||||
}).returning();
|
||||
|
||||
const coupon = result[0];
|
||||
|
||||
// Insert applicable users
|
||||
if (applicableUsers && applicableUsers.length > 0) {
|
||||
await couponDbService.addApplicableUsers(coupon.id, applicableUsers);
|
||||
await db.insert(couponApplicableUsers).values(
|
||||
applicableUsers.map(userId => ({
|
||||
couponId: coupon.id,
|
||||
userId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Insert applicable products
|
||||
if (applicableProducts && applicableProducts.length > 0) {
|
||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
applicableProducts.map(productId => ({
|
||||
couponId: coupon.id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return coupon;
|
||||
|
|
@ -112,7 +133,39 @@ export const couponRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { cursor, limit, search } = input;
|
||||
|
||||
const result = await couponDbService.getAllCoupons({ cursor, limit, search });
|
||||
let whereCondition = undefined;
|
||||
const conditions = [];
|
||||
|
||||
if (cursor) {
|
||||
conditions.push(lt(coupons.id, cursor));
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
conditions.push(like(coupons.couponCode, `%${search}%`));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereCondition = and(...conditions);
|
||||
}
|
||||
|
||||
const result = await db.query.coupons.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
creator: true,
|
||||
applicableUsers: {
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
applicableProducts: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (coupons, { desc }) => [desc(coupons.createdAt)],
|
||||
limit: limit + 1,
|
||||
});
|
||||
|
||||
const hasMore = result.length > limit;
|
||||
const couponsList = hasMore ? result.slice(0, limit) : result;
|
||||
|
|
@ -124,7 +177,24 @@ export const couponRouter = router({
|
|||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const result = await couponDbService.getCouponById(input.id);
|
||||
const couponId = input.id;
|
||||
|
||||
const result = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.id, couponId),
|
||||
with: {
|
||||
creator: true,
|
||||
applicableUsers: {
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
applicableProducts: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Coupon not found");
|
||||
|
|
@ -157,7 +227,7 @@ export const couponRouter = router({
|
|||
|
||||
// If updating to user-based, applicableUsers is required
|
||||
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
|
||||
const existingCount = await couponDbService.countApplicableUsers(id);
|
||||
const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
|
||||
if (existingCount === 0) {
|
||||
throw new Error("applicableUsers is required for user-based coupons");
|
||||
}
|
||||
|
|
@ -165,14 +235,17 @@ export const couponRouter = router({
|
|||
|
||||
// If applicableUsers is provided, verify users exist
|
||||
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
|
||||
const existingUsers = await couponDbService.getUsersByIds(updates.applicableUsers);
|
||||
const existingUsers = await db.query.users.findMany({
|
||||
where: inArray(users.id, updates.applicableUsers),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (existingUsers.length !== updates.applicableUsers.length) {
|
||||
throw new Error("Some applicable users not found");
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = { ...updates };
|
||||
delete updateData.applicableUsers;
|
||||
delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
|
||||
if (updates.discountPercent !== undefined) {
|
||||
updateData.discountPercent = updates.discountPercent?.toString();
|
||||
}
|
||||
|
|
@ -189,31 +262,60 @@ export const couponRouter = router({
|
|||
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
|
||||
}
|
||||
|
||||
const result = await couponDbService.updateCoupon(id, updateData);
|
||||
const result = await db.update(coupons)
|
||||
.set(updateData)
|
||||
.where(eq(coupons.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Coupon not found");
|
||||
}
|
||||
|
||||
console.log('updated coupon successfully')
|
||||
|
||||
// Update applicable users: delete existing and insert new
|
||||
if (updates.applicableUsers !== undefined) {
|
||||
await couponDbService.removeAllApplicableUsers(id);
|
||||
await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
|
||||
if (updates.applicableUsers.length > 0) {
|
||||
await couponDbService.addApplicableUsers(id, updates.applicableUsers);
|
||||
await db.insert(couponApplicableUsers).values(
|
||||
updates.applicableUsers.map(userId => ({
|
||||
couponId: id,
|
||||
userId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update applicable products: delete existing and insert new
|
||||
if (updates.applicableProducts !== undefined) {
|
||||
await couponDbService.removeAllApplicableProducts(id);
|
||||
await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
|
||||
if (updates.applicableProducts.length > 0) {
|
||||
await couponDbService.addApplicableProducts(id, updates.applicableProducts);
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
updates.applicableProducts.map(productId => ({
|
||||
couponId: id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await couponDbService.invalidateCoupon(input.id);
|
||||
const { id } = input;
|
||||
|
||||
const result = await db.update(coupons)
|
||||
.set({ isInvalidated: true })
|
||||
.where(eq(coupons.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Coupon not found");
|
||||
}
|
||||
|
||||
return { message: "Coupon invalidated successfully" };
|
||||
}),
|
||||
|
||||
|
|
@ -226,9 +328,14 @@ export const couponRouter = router({
|
|||
return { valid: false, message: "Invalid coupon code" };
|
||||
}
|
||||
|
||||
const coupon = await couponDbService.getCouponByCode(code.toUpperCase());
|
||||
const coupon = await db.query.coupons.findFirst({
|
||||
where: and(
|
||||
eq(coupons.couponCode, code.toUpperCase()),
|
||||
eq(coupons.isInvalidated, false)
|
||||
),
|
||||
});
|
||||
|
||||
if (!coupon || coupon.isInvalidated) {
|
||||
if (!coupon) {
|
||||
return { valid: false, message: "Coupon not found or invalidated" };
|
||||
}
|
||||
|
||||
|
|
@ -276,39 +383,73 @@ export const couponRouter = router({
|
|||
}),
|
||||
|
||||
generateCancellationCoupon: protectedProcedure
|
||||
.input(z.object({ orderId: z.number() }))
|
||||
.input(
|
||||
z.object({
|
||||
orderId: z.number(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { orderId } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const order = await couponDbService.getOrderByIdWithUserAndStatus(orderId);
|
||||
// Find the order with user and order status information
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
user: true,
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
|
||||
// Check if order is cancelled (check if any status entry has isCancelled: true)
|
||||
// const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
|
||||
// if (!isOrderCancelled) {
|
||||
// throw new Error("Order is not cancelled");
|
||||
// }
|
||||
|
||||
// // Check if payment method is COD
|
||||
// if (order.isCod) {
|
||||
// throw new Error("Can't generate refund coupon for CoD Order");
|
||||
// }
|
||||
|
||||
// Verify user exists
|
||||
if (!order.user) {
|
||||
throw new Error("User not found for this order");
|
||||
}
|
||||
|
||||
// Generate coupon code: first 3 letters of user name or mobile + orderId
|
||||
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
|
||||
const couponCode = `${userNamePrefix}${orderId}`;
|
||||
|
||||
const existingCoupon = await couponDbService.getCouponByCode(couponCode);
|
||||
// Check if coupon code already exists
|
||||
const existingCoupon = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, couponCode),
|
||||
});
|
||||
|
||||
if (existingCoupon) {
|
||||
throw new Error("Coupon code already exists");
|
||||
}
|
||||
|
||||
// Get order total amount
|
||||
const orderAmount = parseFloat(order.totalAmount);
|
||||
|
||||
// Calculate expiry date (30 days from now)
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||
|
||||
const coupon = await couponDbService.withTransaction(async (tx) => {
|
||||
const newCoupon = await couponDbService.createCoupon({
|
||||
// Create the coupon and update order status in a transaction
|
||||
const coupon = await db.transaction(async (tx) => {
|
||||
// Create the coupon
|
||||
const result = await tx.insert(coupons).values({
|
||||
couponCode,
|
||||
isUserBased: true,
|
||||
flatDiscount: orderAmount.toString(),
|
||||
|
|
@ -318,12 +459,22 @@ export const couponRouter = router({
|
|||
maxLimitForUser: 1,
|
||||
createdBy: staffUserId,
|
||||
isApplyForAll: false,
|
||||
}).returning();
|
||||
|
||||
const coupon = result[0];
|
||||
|
||||
// Insert applicable users
|
||||
await tx.insert(couponApplicableUsers).values({
|
||||
couponId: coupon.id,
|
||||
userId: order.userId,
|
||||
});
|
||||
|
||||
await couponDbService.addApplicableUsers(newCoupon.id, [order.userId]);
|
||||
await couponDbService.updateOrderStatusRefundCoupon(orderId, newCoupon.id);
|
||||
// Update order_status with refund coupon ID
|
||||
await tx.update(orderStatus)
|
||||
.set({ refundCouponId: coupon.id })
|
||||
.where(eq(orderStatus.orderId, orderId));
|
||||
|
||||
return newCoupon;
|
||||
return coupon;
|
||||
});
|
||||
|
||||
return coupon;
|
||||
|
|
@ -336,52 +487,100 @@ export const couponRouter = router({
|
|||
search: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const result = await couponDbService.getReservedCoupons(input);
|
||||
const { cursor, limit, search } = input;
|
||||
|
||||
const hasMore = result.length > input.limit;
|
||||
const coupons = hasMore ? result.slice(0, input.limit) : result;
|
||||
let whereCondition = undefined;
|
||||
const conditions = [];
|
||||
|
||||
if (cursor) {
|
||||
conditions.push(lt(reservedCoupons.id, cursor));
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
conditions.push(or(
|
||||
like(reservedCoupons.secretCode, `%${search}%`),
|
||||
like(reservedCoupons.couponCode, `%${search}%`)
|
||||
));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereCondition = and(...conditions);
|
||||
}
|
||||
|
||||
const result = await db.query.reservedCoupons.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
redeemedUser: true,
|
||||
creator: true,
|
||||
},
|
||||
orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
|
||||
limit: limit + 1, // Fetch one extra to check if there's more
|
||||
});
|
||||
|
||||
const hasMore = result.length > limit;
|
||||
const coupons = hasMore ? result.slice(0, limit) : result;
|
||||
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
|
||||
|
||||
return { coupons, nextCursor };
|
||||
return {
|
||||
coupons,
|
||||
nextCursor,
|
||||
};
|
||||
}),
|
||||
|
||||
createReservedCoupon: protectedProcedure
|
||||
.input(createCouponBodySchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { couponCode, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input;
|
||||
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
|
||||
|
||||
// Validation: ensure at least one discount type is provided
|
||||
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
|
||||
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
|
||||
}
|
||||
|
||||
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Generate secret code if not provided (use couponCode as base)
|
||||
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
|
||||
const existing = await couponDbService.getCouponByCode(secretCode);
|
||||
// Check if secret code already exists
|
||||
const existing = await db.query.reservedCoupons.findFirst({
|
||||
where: eq(reservedCoupons.secretCode, secretCode),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("Secret code already exists");
|
||||
}
|
||||
|
||||
const coupon = await couponDbService.createReservedCoupon({
|
||||
const result = await db.insert(reservedCoupons).values({
|
||||
secretCode,
|
||||
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
|
||||
discountPercent: discountPercent?.toString() || null,
|
||||
flatDiscount: flatDiscount?.toString() || null,
|
||||
minOrder: minOrder?.toString() || null,
|
||||
productIds: productIds || null,
|
||||
maxValue: maxValue?.toString() || null,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
||||
maxLimitForUser: maxLimitForUser || null,
|
||||
discountPercent: discountPercent?.toString(),
|
||||
flatDiscount: flatDiscount?.toString(),
|
||||
minOrder: minOrder?.toString(),
|
||||
productIds,
|
||||
maxValue: maxValue?.toString(),
|
||||
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||
maxLimitForUser,
|
||||
exclusiveApply: exclusiveApply || false,
|
||||
createdBy: staffUserId,
|
||||
});
|
||||
}).returning();
|
||||
|
||||
const coupon = result[0];
|
||||
|
||||
// Insert applicable products if provided
|
||||
if (applicableProducts && applicableProducts.length > 0) {
|
||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
applicableProducts.map(productId => ({
|
||||
couponId: coupon.id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return coupon;
|
||||
|
|
@ -394,11 +593,27 @@ export const couponRouter = router({
|
|||
offset: z.number().min(0).default(0),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { search, limit, offset } = input;
|
||||
const { search, limit } = input;
|
||||
|
||||
const userList = search
|
||||
? await couponDbService.getUsersBySearch(search, limit, offset)
|
||||
: await couponDbService.getUsersByIds([]);
|
||||
let whereCondition = undefined;
|
||||
if (search && search.trim()) {
|
||||
whereCondition = or(
|
||||
like(users.name, `%${search}%`),
|
||||
like(users.mobile, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
const userList = await db.query.users.findMany({
|
||||
where: whereCondition,
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
mobile: true,
|
||||
},
|
||||
limit: limit,
|
||||
offset: input.offset,
|
||||
orderBy: (users, { asc }) => [asc(users.name)],
|
||||
});
|
||||
|
||||
return {
|
||||
users: userList.map(user => ({
|
||||
|
|
@ -410,54 +625,74 @@ export const couponRouter = router({
|
|||
}),
|
||||
|
||||
createCoupon: protectedProcedure
|
||||
.input(z.object({ mobile: z.string().min(1, 'Mobile number is required') }))
|
||||
.input(z.object({
|
||||
mobile: z.string().min(1, 'Mobile number is required'),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { mobile } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Clean mobile number (remove non-digits)
|
||||
const cleanMobile = mobile.replace(/\D/g, '');
|
||||
|
||||
// Validate: exactly 10 digits
|
||||
if (cleanMobile.length !== 10) {
|
||||
throw new Error("Mobile number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
let user = await couponDbService.getUserByMobile(cleanMobile);
|
||||
// Check if user exists, create if not
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(users.mobile, cleanMobile),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await couponDbService.createUser({
|
||||
// Create new user
|
||||
const [newUser] = await db.insert(users).values({
|
||||
name: null,
|
||||
email: null,
|
||||
mobile: cleanMobile,
|
||||
});
|
||||
}).returning();
|
||||
user = newUser;
|
||||
}
|
||||
|
||||
// Generate unique coupon code
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
|
||||
|
||||
const existingCode = await couponDbService.getCouponByCode(couponCode);
|
||||
// Check if coupon code already exists (very unlikely but safe)
|
||||
const existingCode = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, couponCode),
|
||||
});
|
||||
|
||||
if (existingCode) {
|
||||
throw new Error("Generated coupon code already exists - please try again");
|
||||
}
|
||||
|
||||
const coupon = await couponDbService.createCoupon({
|
||||
// Create the coupon
|
||||
const [coupon] = await db.insert(coupons).values({
|
||||
couponCode,
|
||||
isUserBased: true,
|
||||
discountPercent: "20",
|
||||
minOrder: "1000",
|
||||
maxValue: "500",
|
||||
maxLimitForUser: 1,
|
||||
discountPercent: "20", // 20% discount
|
||||
minOrder: "1000", // ₹1000 minimum order
|
||||
maxValue: "500", // ₹500 maximum discount
|
||||
maxLimitForUser: 1, // One-time use
|
||||
isApplyForAll: false,
|
||||
exclusiveApply: false,
|
||||
createdBy: staffUserId,
|
||||
validTill: dayjs().add(90, 'days').toDate(),
|
||||
});
|
||||
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
|
||||
}).returning();
|
||||
|
||||
await couponDbService.addApplicableUsers(coupon.id, [user.id]);
|
||||
// Associate coupon with user
|
||||
await db.insert(couponApplicableUsers).values({
|
||||
couponId: coupon.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue