Compare commits
38 commits
main
...
edge_redac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e2644759 | ||
|
|
23be301cc0 | ||
|
|
6ff1fd63e5 | ||
|
|
ea848992c9 | ||
|
|
95d2c861c0 | ||
|
|
a23d3bf5b8 | ||
|
|
56b606ebcf | ||
|
|
cd5ab79f44 | ||
|
|
b49015b446 | ||
|
|
a0a05615b1 | ||
|
|
501667a4d2 | ||
|
|
1122159552 | ||
|
|
8f4cddee1a | ||
|
|
77e3eb21d6 | ||
|
|
b38ff13950 | ||
|
|
e2abc7cb02 | ||
|
|
4f1f52ffee | ||
|
|
71cad727fd | ||
|
|
44e53d2978 | ||
|
|
a5bde12f19 | ||
|
|
31029cc3a7 | ||
|
|
a4758ea9cd | ||
|
|
0c84808637 | ||
|
|
f2763b0597 | ||
|
|
8f48ec39c2 | ||
|
|
5d598b0752 | ||
|
|
4aab508286 | ||
|
|
ad2447d14e | ||
|
|
b4caa383b5 | ||
|
|
a7350914e0 | ||
| 76c43d869d | |||
|
|
2d37726c62 | ||
|
|
5df040de9a | ||
|
|
ca9eb8a7d2 | ||
|
|
aa900db3e1 | ||
|
|
f7c55ea492 | ||
|
|
c14e32522a | ||
|
|
a4218ee1ad |
688 changed files with 39631 additions and 40859 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/users-ui/app
|
||||
apps/users-ui/src
|
||||
apps/admin-ui/app
|
||||
apps/users-ui/src
|
||||
**/package-lock.json
|
||||
test/
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,10 +7,14 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
*.apk
|
||||
**/appBinaries
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
test/appBinaries
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
|
|
|||
4
APIS_TO_REMOVE.md
Normal file
4
APIS_TO_REMOVE.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
||||
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
||||
- remove slots from products cache
|
||||
- remove redundant product details like name, description etc from the slots api
|
||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,32 +1,36 @@
|
|||
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||
|
||||
# 1. ---- Base Node image
|
||||
FROM node:20-slim AS base
|
||||
# 1. ---- Base Bun image
|
||||
FROM oven/bun:1.3.10 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# 2. ---- Pruner ----
|
||||
FROM base AS pruner
|
||||
WORKDIR /app
|
||||
# Copy config files first for better caching
|
||||
COPY package.json package-lock.json turbo.json ./
|
||||
COPY package.json turbo.json ./
|
||||
COPY apps/backend/package.json ./apps/backend/
|
||||
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||
COPY packages/shared/ ./packages/shared
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
RUN npm install -g turbo
|
||||
RUN bun install -g turbo
|
||||
COPY . .
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
|
||||
# RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
# 3. ---- Builder ----
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# Copy package files first to cache npm install
|
||||
# Copy package files first to cache bun install
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
#RUN cat ./bun.lock
|
||||
COPY --from=pruner /app/turbo.json .
|
||||
RUN npm ci
|
||||
RUN bun install
|
||||
# Copy source code after dependencies are installed
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
# 4. ---- Runner ----
|
||||
FROM base AS runner
|
||||
|
|
@ -34,12 +38,15 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
# Copy package files and install production deps
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci --production --omit=dev
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
RUN bun install --production
|
||||
# Copy built applications
|
||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
|
||||
# RUN ls -R
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
EXPOSE 4000
|
||||
RUN npm i -g bun
|
||||
CMD ["bun", "apps/backend/dist/index.js"]
|
||||
# CMD ["node", "apps/backend/dist/index.js"]
|
||||
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
|
||||
|
|
|
|||
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,7 +227,6 @@ export default function Layout() {
|
|||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { BottomDialog , tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import AddressZoneForm from '@/components/AddressZoneForm'
|
||||
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
|
||||
const AddressManagement: React.FC = () => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
||||
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
||||
|
||||
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
||||
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
||||
|
||||
const createZone = trpc.admin.address.createZone.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchZones()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const createArea = trpc.admin.address.createArea.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchAreas()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddZone = () => {
|
||||
setDialogType('zone')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAddPlace = () => {
|
||||
setDialogType('place')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const toggleZone = (zoneId: number) => {
|
||||
setExpandedZones(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(zoneId)) {
|
||||
newSet.delete(zoneId)
|
||||
} else {
|
||||
newSet.add(zoneId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const groupedAreas = areas?.reduce((acc, area) => {
|
||||
if (area.zoneId) {
|
||||
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
||||
acc[area.zoneId].push(area)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<number, typeof areas[0][]>) || {}
|
||||
|
||||
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
<View style={tw`flex-row justify-between p-4`}>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
||||
<Text style={tw`text-white`}>Add Zone</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
||||
<Text style={tw`text-white`}>Add Place</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={tw`flex-1 p-4`}>
|
||||
{zones?.map(zone => (
|
||||
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
||||
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
||||
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
||||
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
||||
</TouchableOpacity>
|
||||
{expandedZones.has(zone.id) && (
|
||||
<View style={tw`p-3`}>
|
||||
{groupedAreas[zone.id]?.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={tw`mt-6`}>
|
||||
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
||||
{unzonedAreas.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
))}
|
||||
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
</BottomDialog>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressManagement
|
||||
|
|
@ -74,7 +74,7 @@ export default function Dashboard() {
|
|||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Manage Orders',
|
||||
title: 'Manage Orderss',
|
||||
icon: 'shopping-bag',
|
||||
description: 'View and manage customer orders',
|
||||
route: '/(drawer)/manage-orders',
|
||||
|
|
@ -158,6 +158,15 @@ export default function Dashboard() {
|
|||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
{
|
||||
title: 'Stocking Schedules',
|
||||
icon: 'schedule',
|
||||
description: 'Manage product stocking schedules',
|
||||
route: '/(drawer)/stocking-schedules',
|
||||
category: 'products',
|
||||
iconColor: '#0EA5E9',
|
||||
iconBg: '#E0F2FE',
|
||||
},
|
||||
{
|
||||
title: 'Stores',
|
||||
icon: 'store',
|
||||
|
|
@ -175,15 +184,6 @@ export default function Dashboard() {
|
|||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Address Management',
|
||||
icon: 'location-on',
|
||||
description: 'Manage service areas',
|
||||
route: '/(drawer)/address-management',
|
||||
category: 'settings',
|
||||
iconColor: '#EAB308',
|
||||
iconBg: '#FEF9C3',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { View, Alert } from 'react-native';
|
|||
import { useRouter } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagFormData {
|
||||
|
|
@ -15,36 +14,17 @@ interface TagFormData {
|
|||
|
||||
export default function AddTag() {
|
||||
const router = useRouter();
|
||||
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||
const createTag = trpc.admin.tag.createTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
createTag(formData, {
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
createTag.mutate({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag created successfully', [
|
||||
{
|
||||
|
|
@ -76,7 +56,7 @@ export default function AddTag() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
isLoading={createTag.isPending}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { View, Alert } from 'react-native';
|
|||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, MyText, tw } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagFormData {
|
||||
|
|
@ -11,7 +10,6 @@ interface TagFormData {
|
|||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
existingImageUrl?: string;
|
||||
}
|
||||
|
||||
export default function EditTag() {
|
||||
|
|
@ -19,39 +17,25 @@ export default function EditTag() {
|
|||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.tag.getTagById.useQuery(
|
||||
{ id: tagIdNum! },
|
||||
{ enabled: !!tagIdNum }
|
||||
);
|
||||
const updateTag = trpc.admin.tag.updateTag.useMutation();
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
||||
if (!tagIdNum) return;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
}
|
||||
|
||||
updateTag({ id: tagIdNum, formData }, {
|
||||
updateTag.mutate({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
imageKey: imageKey,
|
||||
deleteExistingImage: deleteExistingImage,
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Tag updated successfully', [
|
||||
{
|
||||
|
|
@ -92,8 +76,7 @@ export default function EditTag() {
|
|||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: tag.relatedStores || [],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -106,7 +89,7 @@ export default function EditTag() {
|
|||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
isLoading={updateTag.isPending}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,17 @@ import { useRouter } from 'expo-router';
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||
import { TagMenu } from '@/src/components/TagMenu';
|
||||
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: any;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
item: Tag;
|
||||
|
|
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
|||
|
||||
export default function ProductTags() {
|
||||
const router = useRouter();
|
||||
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.tag.getTags.useQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const tags = tagsData?.tags || [];
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
|||
import { Alert } from 'react-native';
|
||||
import { AppContainer } from 'common-ui';
|
||||
import ProductForm from '@/src/components/ProductForm';
|
||||
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export default function AddProduct() {
|
||||
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||
|
||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||
const payload: CreateProductPayload = {
|
||||
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
||||
createProduct.mutate({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -18,37 +18,12 @@ export default function AddProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Append tag IDs
|
||||
if (values.tagIds && values.tagIds.length > 0) {
|
||||
values.tagIds.forEach((tagId: number) => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Append images
|
||||
if (images) {
|
||||
images.forEach((image, index) => {
|
||||
if (image.uri) {
|
||||
formData.append('images', {
|
||||
uri: image.uri,
|
||||
name: `image-${index}.jpg`,
|
||||
// type: 'image/jpeg',
|
||||
type: image.mimeType as any,
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createProduct(formData, {
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
tagIds: values.tagIds || [],
|
||||
imageKeys: imageKeys || [],
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
// Reset form or navigate
|
||||
|
|
@ -81,7 +56,7 @@ export default function AddProduct() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
isLoading={createProduct.isPending}
|
||||
existingImages={[]}
|
||||
/>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
|||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
|
||||
import { Formik } from 'formik';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -26,7 +27,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||
|
||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -62,30 +63,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
|
||||
const handleSubmit = async (adminResponse: string) => {
|
||||
try {
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'review',
|
||||
mimeTypes,
|
||||
});
|
||||
const keys = generatedUrls.map(url => {
|
||||
const u = new URL(url);
|
||||
const rawKey = u.pathname.replace(/^\/+/, "");
|
||||
const decodedKey = decodeURIComponent(rawKey);
|
||||
const parts = decodedKey.split('/');
|
||||
parts.shift();
|
||||
return parts.join('/');
|
||||
});
|
||||
setUploadUrls(generatedUrls);
|
||||
let keys: string[] = [];
|
||||
let generatedUrls: string[] = [];
|
||||
|
||||
for (let i = 0; i < generatedUrls.length; i++) {
|
||||
const uploadUrl = generatedUrls[i];
|
||||
const { blob, mimeType } = selectedImages[i];
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
if (selectedImages.length > 0) {
|
||||
const result = await upload({
|
||||
images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
||||
contextString: 'review',
|
||||
});
|
||||
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
keys = result.keys;
|
||||
generatedUrls = result.presignedUrls;
|
||||
}
|
||||
|
||||
await respondToReview.mutateAsync({
|
||||
|
|
@ -101,8 +88,7 @@ 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.');
|
||||
}
|
||||
};
|
||||
|
|
@ -137,7 +123,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
<TouchableOpacity
|
||||
onPress={() => formikSubmit()}
|
||||
activeOpacity={0.8}
|
||||
disabled={respondToReview.isPending}
|
||||
disabled={respondToReview.isPending || isUploading}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#2563EB', '#1D4ED8']}
|
||||
|
|
@ -145,7 +131,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
end={{ x: 1, y: 0 }}
|
||||
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
||||
>
|
||||
{respondToReview.isPending ? (
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : respondToReview.isPending ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { View, Text, Alert } from 'react-native';
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export default function EditProduct() {
|
||||
|
|
@ -11,18 +10,18 @@ export default function EditProduct() {
|
|||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
//
|
||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||
const payload = {
|
||||
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
||||
updateProduct.mutate({
|
||||
id: productId,
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -32,6 +31,9 @@ export default function EditProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended,
|
||||
isFlashAvailable: values.isFlashAvailable,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
deals: values.deals?.filter((deal: any) =>
|
||||
deal.quantity && deal.price && deal.validTill
|
||||
).map((deal: any) => ({
|
||||
|
|
@ -39,47 +41,12 @@ export default function EditProduct() {
|
|||
price: parseFloat(deal.price),
|
||||
validTill: deal.validTill instanceof Date
|
||||
? deal.validTill.toISOString().split('T')[0]
|
||||
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||
: deal.validTill,
|
||||
})),
|
||||
tagIds: values.tagIds,
|
||||
};
|
||||
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (key === 'deals' && Array.isArray(value)) {
|
||||
formData.append(key, JSON.stringify(value));
|
||||
} else if (key === 'tagIds' && Array.isArray(value)) {
|
||||
value.forEach(tagId => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
} else if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new images
|
||||
if (newImages && newImages.length > 0) {
|
||||
newImages.forEach((image, index) => {
|
||||
if (image.uri) {
|
||||
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
||||
formData.append('images', {
|
||||
uri: image.uri,
|
||||
name: fileName,
|
||||
type: 'image/jpeg',
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add images to delete
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
||||
}
|
||||
|
||||
updateProduct(
|
||||
{ id: productId, formData },
|
||||
{
|
||||
newImageKeys: newImageKeys || [],
|
||||
imagesToDelete: imagesToDelete || [],
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', 'Product updated successfully!');
|
||||
// Clear newly added images after successful update
|
||||
|
|
@ -88,8 +55,7 @@ export default function EditProduct() {
|
|||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update product');
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
|
|
@ -125,7 +91,7 @@ export default function EditProduct() {
|
|||
deals: productData.deals?.map(deal => ({
|
||||
quantity: deal.quantity,
|
||||
price: deal.price,
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||
})) || [{ quantity: '', price: '', validTill: null }],
|
||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||
isSuspended: productData.isSuspended || false,
|
||||
|
|
@ -141,7 +107,7 @@ export default function EditProduct() {
|
|||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
isLoading={updateProduct.isPending}
|
||||
existingImages={productData.images || []}
|
||||
/>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -26,12 +27,6 @@ interface User {
|
|||
isEligibleForNotif: boolean;
|
||||
}
|
||||
|
||||
const extractKeyFromUrl = (url: string): string => {
|
||||
const u = new URL(url);
|
||||
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||
return decodeURIComponent(rawKey);
|
||||
};
|
||||
|
||||
export default function SendNotifications() {
|
||||
const router = useRouter();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
|
|
@ -46,8 +41,7 @@ export default function SendNotifications() {
|
|||
search: searchQuery,
|
||||
});
|
||||
|
||||
// Generate upload URLs mutation
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
// Send notification mutation
|
||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||
|
|
@ -127,28 +121,8 @@ export default function SendNotifications() {
|
|||
|
||||
// Upload image if selected
|
||||
if (selectedImage) {
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'notification',
|
||||
mimeTypes: [selectedImage.mimeType],
|
||||
});
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
const uploadUrl = uploadUrls[0];
|
||||
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||
|
||||
// Upload image
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: selectedImage.blob,
|
||||
headers: {
|
||||
'Content-Type': selectedImage.mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
}
|
||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
||||
imageUrl = key;
|
||||
}
|
||||
|
||||
// Send notification
|
||||
|
|
@ -256,15 +230,15 @@ export default function SendNotifications() {
|
|||
{/* Submit Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSend}
|
||||
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
|
||||
disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0}
|
||||
style={tw`${
|
||||
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
|
||||
sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0
|
||||
? 'bg-gray-300'
|
||||
: 'bg-blue-600'
|
||||
} rounded-xl py-4 items-center shadow-sm`}
|
||||
>
|
||||
<MyText style={tw`text-white font-bold text-base`}>
|
||||
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||
{isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
|
|
|||
443
apps/admin-ui/app/(drawer)/stocking-schedules/index.tsx
Normal file
443
apps/admin-ui/app/(drawer)/stocking-schedules/index.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, Alert, FlatList, TouchableOpacity } from 'react-native';
|
||||
import {
|
||||
theme,
|
||||
AppContainer,
|
||||
MyText,
|
||||
tw,
|
||||
useManualRefresh,
|
||||
useMarkDataFetchers,
|
||||
MyTouchableOpacity,
|
||||
RawBottomDialog,
|
||||
BottomDialog,
|
||||
} from 'common-ui';
|
||||
import { trpc } from '../../../src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Ionicons, Entypo } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import AvailabilityScheduleForm from '../../../components/AvailabilityScheduleForm';
|
||||
|
||||
interface Schedule {
|
||||
id: number;
|
||||
scheduleName: string;
|
||||
time: string;
|
||||
action: 'in' | 'out';
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
productIds: number[];
|
||||
groupIds: number[];
|
||||
productCount: number;
|
||||
groupCount: number;
|
||||
}
|
||||
|
||||
const ScheduleItem = ({
|
||||
schedule,
|
||||
onDelete,
|
||||
index,
|
||||
onViewProducts,
|
||||
onViewGroups,
|
||||
onReplicate,
|
||||
}: {
|
||||
schedule: Schedule;
|
||||
onDelete: (id: number) => void;
|
||||
index: number;
|
||||
onViewProducts: (productIds: number[]) => void;
|
||||
onViewGroups: (groupIds: number[]) => void;
|
||||
onReplicate: (schedule: Schedule) => void;
|
||||
}) => {
|
||||
const isIn = schedule.action === 'in';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={tw``}>
|
||||
<View style={tw`p-6`}>
|
||||
{/* Top Header: Name & Action Badge */}
|
||||
<View style={tw`flex-row justify-between items-start mb-4`}>
|
||||
<View style={tw`flex-row items-center flex-1`}>
|
||||
<View
|
||||
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="schedule"
|
||||
size={24}
|
||||
color={theme.colors.brand600}
|
||||
/>
|
||||
</View>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyText
|
||||
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
|
||||
>
|
||||
Schedule Name
|
||||
</MyText>
|
||||
<MyText
|
||||
style={tw`text-xl font-black text-slate-900`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{schedule.scheduleName}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<View
|
||||
style={[
|
||||
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
|
||||
{ backgroundColor: isIn ? '#F0FDF4' : '#FFF1F2' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
tw`w-1.5 h-1.5 rounded-full mr-2`,
|
||||
{ backgroundColor: isIn ? '#10B981' : '#E11D48' },
|
||||
]}
|
||||
/>
|
||||
<MyText
|
||||
style={[
|
||||
tw`text-[10px] font-black uppercase tracking-tighter`,
|
||||
{ color: isIn ? '#10B981' : '#E11D48' },
|
||||
]}
|
||||
>
|
||||
{isIn ? 'In Stock' : 'Out of Stock'}
|
||||
</MyText>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={tw`p-1`}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Entypo name="dots-three-vertical" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Menu Dialog */}
|
||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>{schedule.scheduleName}</MyText>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
onReplicate(schedule);
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-800`}>Replicate items</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
Alert.alert('Coming Soon', 'Edit functionality will be available soon');
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="edit" size={20} color="#4B5563" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-800`}>Edit</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
onDelete(schedule.id);
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="delete" size={20} color="#E11D48" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-red-500`}>Delete</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => setMenuOpen(false)}
|
||||
style={tw`py-4 mt-2`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialIcons name="close" size={20} color="#6B7280" style={tw`mr-3`} />
|
||||
<MyText style={tw`text-base text-gray-600`}>Cancel</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Middle: Time Banner */}
|
||||
<View
|
||||
style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-4 border border-slate-100`}
|
||||
>
|
||||
<View
|
||||
style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}
|
||||
>
|
||||
<MaterialIcons name="access-time" size={20} color="#64748B" />
|
||||
</View>
|
||||
<View style={tw`ml-4 flex-1`}>
|
||||
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
||||
{schedule.time}
|
||||
</MyText>
|
||||
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
||||
Daily at this time
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats & Actions */}
|
||||
<View style={tw`flex-row items-center justify-between`}>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => onViewProducts(schedule.productIds)}
|
||||
style={tw`flex-row items-center mr-4`}
|
||||
>
|
||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
||||
{schedule.productCount} Products
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
{schedule.groupCount > 0 && (
|
||||
<MyTouchableOpacity
|
||||
onPress={() => onViewGroups(schedule.groupIds)}
|
||||
style={tw`flex-row items-center`}
|
||||
>
|
||||
<MaterialIcons name="category" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
||||
{schedule.groupCount} Groups
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StockingSchedules() {
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.productAvailabilitySchedules.getAll.useQuery();
|
||||
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
const deleteSchedule = trpc.admin.productAvailabilitySchedules.delete.useMutation();
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogType, setDialogType] = useState<'products' | 'groups'>('products');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
// Replication state
|
||||
const [replicatingSchedule, setReplicatingSchedule] = useState<Schedule | null>(null);
|
||||
|
||||
useManualRefresh(refetch);
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
Alert.alert(
|
||||
'Delete Schedule',
|
||||
'Are you sure you want to delete this schedule? This action cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteSchedule.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to delete schedule');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewProducts = (productIds: number[]) => {
|
||||
setDialogType('products');
|
||||
setSelectedIds(productIds);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewGroups = (groupIds: number[]) => {
|
||||
setDialogType('groups');
|
||||
setSelectedIds(groupIds);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleReplicate = (schedule: Schedule) => {
|
||||
setReplicatingSchedule(schedule);
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowCreateForm(false);
|
||||
setReplicatingSchedule(null);
|
||||
};
|
||||
|
||||
// Get product/group names from IDs
|
||||
const getProductNames = () => {
|
||||
const allProducts = productsData?.products || [];
|
||||
return selectedIds.map(id => {
|
||||
const product = allProducts.find(p => p.id === id);
|
||||
return product?.name || `Product #${id}`;
|
||||
});
|
||||
};
|
||||
|
||||
const getGroupNames = () => {
|
||||
const allGroups = groupsData?.groups || [];
|
||||
return selectedIds.map(id => {
|
||||
const group = allGroups.find(g => g.id === id);
|
||||
return group?.groupName || `Group #${id}`;
|
||||
});
|
||||
};
|
||||
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<AvailabilityScheduleForm
|
||||
onClose={handleCloseForm}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
handleCloseForm();
|
||||
}}
|
||||
initialProductIds={replicatingSchedule?.productIds}
|
||||
initialGroupIds={replicatingSchedule?.groupIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading schedules...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Error loading schedules</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 bg-white h-full`}>
|
||||
<ScrollView
|
||||
style={tw`flex-1`}
|
||||
contentContainerStyle={tw`pt-2 pb-32`}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{schedules && schedules.length === 0 ? (
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<View
|
||||
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
|
||||
>
|
||||
<Ionicons name="time-outline" size={48} color="#94A3B8" />
|
||||
</View>
|
||||
<MyText
|
||||
style={tw`text-slate-900 text-xl font-black tracking-tight`}
|
||||
>
|
||||
No Schedules Yet
|
||||
</MyText>
|
||||
<MyText
|
||||
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
|
||||
>
|
||||
Start by creating your first availability schedule using the
|
||||
button below.
|
||||
</MyText>
|
||||
</View>
|
||||
) : (
|
||||
schedules?.map((schedule, index) => (
|
||||
<React.Fragment key={schedule.id}>
|
||||
<ScheduleItem
|
||||
schedule={schedule}
|
||||
index={index}
|
||||
onDelete={handleDelete}
|
||||
onViewProducts={handleViewProducts}
|
||||
onViewGroups={handleViewGroups}
|
||||
onReplicate={handleReplicate}
|
||||
/>
|
||||
{index < schedules.length - 1 && (
|
||||
<View style={tw`h-px bg-slate-200 w-full`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</AppContainer>
|
||||
<MyTouchableOpacity
|
||||
onPress={handleCreate}
|
||||
activeOpacity={0.95}
|
||||
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#1570EF', '#194185']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
||||
>
|
||||
<MaterialIcons name="add" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
|
||||
{/* Products/Groups Dialog */}
|
||||
<RawBottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>
|
||||
{dialogType === 'products' ? 'Products' : 'Groups'}
|
||||
</MyText>
|
||||
<FlatList
|
||||
data={dialogType === 'products' ? getProductNames() : getGroupNames()}
|
||||
keyExtractor={(item, index) => index.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={tw`py-3 border-b border-gray-100`}>
|
||||
<MyText style={tw`text-base text-gray-800`}>{item}</MyText>
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={tw`max-h-80`}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`py-8 items-center`}>
|
||||
<MyText style={tw`text-gray-500`}>
|
||||
No {dialogType} found
|
||||
</MyText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</RawBottomDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
interface AddressPlaceFormProps {
|
||||
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
|
||||
const { data: zones } = trpc.admin.address.getZones.useQuery()
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
placeName: Yup.string().required('Place name is required'),
|
||||
zoneId: Yup.number().optional(),
|
||||
})
|
||||
|
||||
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
|
||||
<Formik
|
||||
initialValues={{ placeName: '', zoneId: null as number | null }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Place Name"
|
||||
value={values.placeName}
|
||||
onChangeText={handleChange('placeName')}
|
||||
error={!!(touched.placeName && errors.placeName)}
|
||||
/>
|
||||
<BottomDropdown
|
||||
label="Zone (Optional)"
|
||||
value={values.zoneId as any}
|
||||
options={zoneOptions}
|
||||
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
|
||||
placeholder="Select Zone"
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressPlaceForm
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, tw } from 'common-ui'
|
||||
|
||||
interface AddressZoneFormProps {
|
||||
onSubmit: (values: { zoneName: string }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
|
||||
const validationSchema = Yup.object({
|
||||
zoneName: Yup.string().required('Zone name is required'),
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
|
||||
<Formik
|
||||
initialValues={{ zoneName: '' }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Zone Name"
|
||||
value={values.zoneName}
|
||||
onChangeText={handleChange('zoneName')}
|
||||
error={!!(touched.zoneName && errors.zoneName)}
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressZoneForm
|
||||
237
apps/admin-ui/components/AvailabilityScheduleForm.tsx
Normal file
237
apps/admin-ui/components/AvailabilityScheduleForm.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useFormik } from 'formik';
|
||||
import { MyText, tw, MyTextInput, MyTouchableOpacity, DateTimePickerMod } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
|
||||
interface AvailabilityScheduleFormProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
initialProductIds?: number[];
|
||||
initialGroupIds?: number[];
|
||||
}
|
||||
|
||||
const AvailabilityScheduleForm: React.FC<AvailabilityScheduleFormProps> = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
initialProductIds,
|
||||
initialGroupIds,
|
||||
}) => {
|
||||
const createSchedule = trpc.admin.productAvailabilitySchedules.create.useMutation();
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
// Map groups data to match ProductsSelector types (convert price from string to number)
|
||||
const groups = (groupsData?.groups || []).map(group => ({
|
||||
...group,
|
||||
products: group.products.map(product => ({
|
||||
...product,
|
||||
price: parseFloat(product.price as unknown as string) || 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
scheduleName: '',
|
||||
timeDate: null as Date | null,
|
||||
action: 'in' as 'in' | 'out',
|
||||
productIds: initialProductIds || ([] as number[]),
|
||||
groupIds: initialGroupIds || ([] as number[]),
|
||||
},
|
||||
validate: (values) => {
|
||||
const errors: {[key: string]: string} = {};
|
||||
|
||||
if (!values.scheduleName.trim()) {
|
||||
errors.scheduleName = 'Schedule name is required';
|
||||
}
|
||||
|
||||
if (!values.timeDate) {
|
||||
errors.timeDate = 'Time is required';
|
||||
}
|
||||
|
||||
if (!values.action) {
|
||||
errors.action = 'Action is required';
|
||||
}
|
||||
|
||||
if (values.productIds.length === 0) {
|
||||
errors.productIds = 'At least one product must be selected';
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
// Convert Date to HH:MM string
|
||||
const hours = values.timeDate!.getHours().toString().padStart(2, '0');
|
||||
const minutes = values.timeDate!.getMinutes().toString().padStart(2, '0');
|
||||
const timeString = `${hours}:${minutes}`;
|
||||
|
||||
await createSchedule.mutateAsync({
|
||||
scheduleName: values.scheduleName,
|
||||
time: timeString,
|
||||
action: values.action,
|
||||
productIds: values.productIds,
|
||||
groupIds: values.groupIds,
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Schedule created successfully');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create schedule');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const actionOptions = [
|
||||
{ label: 'In Stock', value: 'in' },
|
||||
{ label: 'Out of Stock', value: 'out' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
{/* Header */}
|
||||
<View style={tw`flex-row items-center justify-between p-4 border-b border-gray-200 bg-white`}>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>
|
||||
Create Availability Schedule
|
||||
</MyText>
|
||||
<MyTouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color="#6B7280" />
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={tw`flex-1 p-4`} showsVerticalScrollIndicator={false}>
|
||||
{/* Schedule Name */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Schedule Name
|
||||
</MyText>
|
||||
<MyTextInput
|
||||
placeholder="Enter schedule name"
|
||||
value={formik.values.scheduleName}
|
||||
onChangeText={formik.handleChange('scheduleName')}
|
||||
onBlur={formik.handleBlur('scheduleName')}
|
||||
style={tw`border rounded-lg p-3 ${
|
||||
formik.touched.scheduleName && formik.errors.scheduleName
|
||||
? 'border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{formik.touched.scheduleName && formik.errors.scheduleName && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.scheduleName}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Time */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Time
|
||||
</MyText>
|
||||
<DateTimePickerMod
|
||||
value={formik.values.timeDate}
|
||||
setValue={(date) => formik.setFieldValue('timeDate', date)}
|
||||
timeOnly={true}
|
||||
showLabels={false}
|
||||
/>
|
||||
{formik.touched.timeDate && formik.errors.timeDate && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.timeDate}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Action
|
||||
</MyText>
|
||||
<View style={tw`flex-row gap-3`}>
|
||||
{actionOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => formik.setFieldValue('action', option.value)}
|
||||
style={tw`flex-1 flex-row items-center p-4 rounded-lg border ${
|
||||
formik.values.action === option.value
|
||||
? 'bg-blue-50 border-blue-500'
|
||||
: 'bg-white border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
style={tw`w-5 h-5 rounded-full border-2 mr-3 items-center justify-center ${
|
||||
formik.values.action === option.value
|
||||
? 'border-blue-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{formik.values.action === option.value && (
|
||||
<View style={tw`w-3 h-3 rounded-full bg-blue-500`} />
|
||||
)}
|
||||
</View>
|
||||
<MyText
|
||||
style={tw`font-medium ${
|
||||
formik.values.action === option.value
|
||||
? 'text-blue-700'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Products and Groups */}
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
Products & Groups
|
||||
</MyText>
|
||||
<ProductsSelector
|
||||
value={formik.values.productIds}
|
||||
onChange={(value) => formik.setFieldValue('productIds', value)}
|
||||
groups={groups}
|
||||
selectedGroupIds={formik.values.groupIds}
|
||||
onGroupChange={(groupIds) => formik.setFieldValue('groupIds', groupIds)}
|
||||
showGroups={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products for this schedule"
|
||||
/>
|
||||
{formik.touched.productIds && formik.errors.productIds && (
|
||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
||||
{formik.errors.productIds}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Spacer for bottom padding */}
|
||||
<View style={tw`h-24`} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<View style={tw`p-4 border-t border-gray-200 bg-white flex-row gap-3`}>
|
||||
<MyTouchableOpacity
|
||||
onPress={onClose}
|
||||
style={tw`flex-1 py-3 px-4 rounded-lg border border-gray-300 items-center`}
|
||||
>
|
||||
<MyText style={tw`font-medium text-gray-700`}>Cancel</MyText>
|
||||
</MyTouchableOpacity>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => formik.handleSubmit()}
|
||||
disabled={formik.isSubmitting}
|
||||
style={tw`flex-1 py-3 px-4 rounded-lg bg-blue-600 items-center ${
|
||||
formik.isSubmitting ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<MyText style={tw`font-medium text-white`}>
|
||||
{formik.isSubmitting ? 'Creating...' : 'Create Schedule'}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailabilityScheduleForm;
|
||||
|
|
@ -8,6 +8,7 @@ import ProductsSelector from './ProductsSelector';
|
|||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
||||
|
||||
export interface BannerFormData {
|
||||
name: string;
|
||||
|
|
@ -52,14 +53,7 @@ export default function BannerForm({
|
|||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
// Fetch products for dropdown
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const products = productsData?.products || [];
|
||||
|
||||
|
||||
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -97,37 +91,15 @@ export default function BannerForm({
|
|||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store', // Using 'store' for now
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
// Upload image
|
||||
const uploadUrl = uploadUrls[0];
|
||||
const { blob, mimeType } = selectedImages[0];
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
const { key, presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||
imageUrl = presignedUrl;
|
||||
}
|
||||
|
||||
imageUrl = uploadUrl;
|
||||
}
|
||||
|
||||
// Call onSubmit with form values and imageUrl
|
||||
await onSubmit(values, imageUrl);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
Alert.alert('Error', 'Failed to upload image');
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -239,15 +211,15 @@ export default function BannerForm({
|
|||
|
||||
<MyTouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isSubmitting || !isValid || !dirty}
|
||||
disabled={isSubmitting || isUploading || !isValid || !dirty}
|
||||
style={tw`flex-1 rounded-lg py-4 items-center ${
|
||||
isSubmitting || !isValid || !dirty
|
||||
isSubmitting || isUploading || !isValid || !dirty
|
||||
? 'bg-blue-400'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>
|
||||
{isSubmitting ? 'Saving...' : submitButtonText}
|
||||
{isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
|
|||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
|
||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
|
||||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
||||
|
||||
export interface StoreFormData {
|
||||
name: string;
|
||||
|
|
@ -15,6 +16,12 @@ export interface StoreFormData {
|
|||
products: number[];
|
||||
}
|
||||
|
||||
interface StoreImage {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
isExisting: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFormRef {
|
||||
// Add methods if needed
|
||||
}
|
||||
|
|
@ -27,6 +34,11 @@ interface StoreFormProps {
|
|||
storeId?: number;
|
||||
}
|
||||
|
||||
// Extend Formik values with images array
|
||||
interface FormikStoreValues extends StoreFormData {
|
||||
images: StoreImage[];
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required'),
|
||||
description: Yup.string(),
|
||||
|
|
@ -40,9 +52,23 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
// Build initial form values with images array
|
||||
const buildInitialValues = (): FormikStoreValues => {
|
||||
const images: StoreImage[] = [];
|
||||
if (initialValues.imageUrl) {
|
||||
images.push({
|
||||
uri: initialValues.imageUrl,
|
||||
mimeType: 'image/jpeg',
|
||||
isExisting: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...initialValues,
|
||||
images,
|
||||
};
|
||||
};
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
|
||||
|
||||
// For edit mode, pre-select products belonging to this store
|
||||
const initialSelectedProducts = useMemo(() => {
|
||||
|
|
@ -54,7 +80,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
|
||||
useEffect(() => {
|
||||
setFormInitialValues({
|
||||
...initialValues,
|
||||
...buildInitialValues(),
|
||||
products: initialSelectedProducts,
|
||||
});
|
||||
}, [initialValues, initialSelectedProducts]);
|
||||
|
|
@ -64,41 +90,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
value: staff.id,
|
||||
})) || [];
|
||||
|
||||
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setSelectedImages([]);
|
||||
setDisplayImages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const blobPromises = files.map(async (asset) => {
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
||||
});
|
||||
|
||||
const blobArray = await Promise.all(blobPromises);
|
||||
setSelectedImages(blobArray);
|
||||
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
||||
},
|
||||
multiple: false, // Single image for stores
|
||||
});
|
||||
|
||||
const handleRemoveImage = (uri: string) => {
|
||||
const index = displayImages.findIndex(img => img.uri === uri);
|
||||
if (index !== -1) {
|
||||
const newDisplay = displayImages.filter((_, i) => i !== index);
|
||||
const newFiles = selectedImages.filter((_, i) => i !== index);
|
||||
setDisplayImages(newDisplay);
|
||||
setSelectedImages(newFiles);
|
||||
}
|
||||
};
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
return (
|
||||
<Formik
|
||||
|
|
@ -108,51 +100,78 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||
// Image picker that adds to Formik field
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const newImages: StoreImage[] = files.map((asset) => ({
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
isExisting: false,
|
||||
}));
|
||||
|
||||
// Add to Formik images field
|
||||
const currentImages = values.images || [];
|
||||
setFieldValue('images', [...currentImages, ...newImages]);
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Remove image - works for both existing and new
|
||||
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
|
||||
const currentImages = values.images || [];
|
||||
const removedImage = currentImages.find(img => img.uri === image.uri);
|
||||
const newImages = currentImages.filter(img => img.uri !== image.uri);
|
||||
|
||||
setFieldValue('images', newImages);
|
||||
|
||||
// If we removed an existing image, also clear the imageUrl
|
||||
if (removedImage?.isExisting) {
|
||||
setFieldValue('imageUrl', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store',
|
||||
mimeTypes,
|
||||
});
|
||||
// Get new images that need to be uploaded
|
||||
const newImages = values.images.filter(img => !img.isExisting);
|
||||
|
||||
// Upload images
|
||||
for (let i = 0; i < uploadUrls.length; i++) {
|
||||
const uploadUrl = uploadUrls[i];
|
||||
const { blob, mimeType } = selectedImages[i];
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
if (newImages.length > 0) {
|
||||
// Upload the first new image (single image for stores)
|
||||
const image = newImages[0];
|
||||
const response = await fetch(image.uri);
|
||||
const imageBlob = await response.blob();
|
||||
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
|
||||
imageUrl = key;
|
||||
} else {
|
||||
// Check if there's an existing image remaining
|
||||
const existingImage = values.images.find(img => img.isExisting);
|
||||
if (existingImage) {
|
||||
imageUrl = existingImage.uri;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key from first upload URL
|
||||
// const u = new URL(uploadUrls[0]);
|
||||
// const rawKey = u.pathname.replace(/^\/+/, "");
|
||||
// imageUrl = decodeURIComponent(rawKey);
|
||||
imageUrl = uploadUrls[0];
|
||||
}
|
||||
|
||||
// Submit form with imageUrl
|
||||
onSubmit({ ...values, imageUrl });
|
||||
// Submit form with imageUrl (without images array)
|
||||
const { images, ...submitValues } = values;
|
||||
onSubmit({ ...submitValues, imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
Alert.alert('Error', 'Failed to upload image');
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare images for ImageUploaderNeo (convert to expected format)
|
||||
const imagesForUploader = (values.images || []).map(img => ({
|
||||
uri: img.uri,
|
||||
mimeType: img.mimeType,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View>
|
||||
<MyTextInput
|
||||
|
|
@ -193,22 +212,21 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
/>
|
||||
<View style={tw`mb-6`}>
|
||||
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
||||
onAddImage={handleImagePick}
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={imagesForUploader}
|
||||
onUploadImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading || generateUploadUrls.isPending}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
"distribution": "internal",
|
||||
"autoIncrement": true
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
|
|
|
|||
118
apps/admin-ui/hooks/useUploadToObjectStorage.ts
Normal file
118
apps/admin-ui/hooks/useUploadToObjectStorage.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useState } from 'react';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
|
||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
|
||||
|
||||
interface UploadInput {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface UploadBatchInput {
|
||||
images: UploadInput[];
|
||||
contextString: ContextString;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
keys: string[];
|
||||
presignedUrls: string[];
|
||||
}
|
||||
|
||||
export function useUploadToObjectStorage() {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
|
||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setProgress({ completed: 0, total: input.images.length });
|
||||
|
||||
try {
|
||||
const { images, contextString } = input;
|
||||
|
||||
if (images.length === 0) {
|
||||
return { keys: [], presignedUrls: [] };
|
||||
}
|
||||
|
||||
// 1. Get presigned URLs from backend (one call for all images)
|
||||
const mimeTypes = images.map(img => img.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString,
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
if (uploadUrls.length !== images.length) {
|
||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
||||
}
|
||||
|
||||
// 2. Upload all images in parallel
|
||||
const uploadPromises = images.map(async (image, index) => {
|
||||
const presignedUrl = uploadUrls[index];
|
||||
const { blob, mimeType } = image;
|
||||
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
||||
|
||||
return {
|
||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
||||
presignedUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// Use Promise.all - if any fails, entire batch fails
|
||||
const results = await Promise.all(uploadPromises);
|
||||
|
||||
return {
|
||||
keys: results.map(r => r.key),
|
||||
presignedUrls: results.map(r => r.presignedUrl),
|
||||
};
|
||||
} catch (err) {
|
||||
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
||||
setError(uploadError);
|
||||
throw uploadError;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType }],
|
||||
contextString,
|
||||
});
|
||||
return {
|
||||
key: result.keys[0],
|
||||
presignedUrl: result.presignedUrls[0],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploadSingle,
|
||||
isUploading,
|
||||
error,
|
||||
progress,
|
||||
isPending: generateUploadUrls.isPending
|
||||
};
|
||||
}
|
||||
|
||||
function extractKeyFromPresignedUrl(url: string): string {
|
||||
const u = new URL(url);
|
||||
let rawKey = u.pathname.replace(/^\/+/, '');
|
||||
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
||||
return decodeURIComponent(rawKey);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from '../../services/axios-admin-ui';
|
||||
|
||||
// Types
|
||||
export interface CreateProductPayload {
|
||||
name: string;
|
||||
shortDescription?: string;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
incrementStep?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
deals?: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface UpdateProductPayload {
|
||||
name: string;
|
||||
shortDescription?: string;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
incrementStep?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
deals?: {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescription?: string | null;
|
||||
longDescription?: string;
|
||||
unitId: number;
|
||||
storeId: number;
|
||||
price: number;
|
||||
marketPrice?: number;
|
||||
productQuantity?: number;
|
||||
isOutOfStock?: boolean;
|
||||
images?: string[];
|
||||
createdAt: string;
|
||||
unit?: {
|
||||
id: number;
|
||||
shortNotation: string;
|
||||
fullName: string;
|
||||
};
|
||||
deals?: {
|
||||
id: number;
|
||||
quantity: string;
|
||||
price: string;
|
||||
validTill: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CreateProductResponse {
|
||||
product: Product;
|
||||
deals?: any[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
||||
const response = await axios.post('/av/products', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
||||
const response = await axios.put(`/av/products/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useCreateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createProductApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateProductApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from '../../services/axios-admin-ui';
|
||||
|
||||
// Types
|
||||
export interface CreateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateTagPayload {
|
||||
tagName: string;
|
||||
tagDescription?: string;
|
||||
imageUrl?: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: number[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateTagResponse {
|
||||
tag: Tag;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GetTagsResponse {
|
||||
tags: Tag[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
// API functions
|
||||
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
|
||||
const response = await axios.post('/av/product-tags', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
|
||||
const response = await axios.put(`/av/product-tags/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
|
||||
const response = await axios.delete(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagsApi = async (): Promise<GetTagsResponse> => {
|
||||
const response = await axios.get('/av/product-tags');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
|
||||
const response = await axios.get(`/av/product-tags/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export const useCreateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTagApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: getTagsApi,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetTag = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['tags', id],
|
||||
queryFn: () => getTagApi(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
|
@ -7,7 +7,7 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
|
|||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
|
|
@ -38,7 +38,7 @@ export interface ProductFormRef {
|
|||
interface ProductFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialValues: ProductFormData;
|
||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
||||
isLoading: boolean;
|
||||
existingImages?: string[];
|
||||
}
|
||||
|
|
@ -60,8 +60,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
existingImages = []
|
||||
}, ref) => {
|
||||
const { theme } = useTheme();
|
||||
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
||||
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]);
|
||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||
const storeOptions = storesData?.stores.map(store => ({
|
||||
|
|
@ -69,8 +70,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
value: store.id,
|
||||
})) || [];
|
||||
|
||||
const { data: tagsData } = useGetTags();
|
||||
const tagOptions = tagsData?.tags.map(tag => ({
|
||||
const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
|
||||
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
|
||||
label: tag.tagName,
|
||||
value: tag.id.toString(),
|
||||
})) || [];
|
||||
|
|
@ -83,23 +84,62 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
}, [existingImages]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => setImages(prev => [...prev, ...files]),
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.isArray(assets) ? assets : [assets];
|
||||
const imageData = await Promise.all(
|
||||
files.map(async (asset) => {
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
uri: asset.uri
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setNewImages(prev => [...prev, ...imageData]);
|
||||
},
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
// Calculate which existing images were deleted
|
||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||
|
||||
// Display images for ImageUploader component
|
||||
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
let imageKeys: string[] = [];
|
||||
|
||||
// Upload new images if any
|
||||
if (newImages.length > 0) {
|
||||
const result = await upload({
|
||||
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
||||
contextString: 'product_info',
|
||||
});
|
||||
imageKeys = result.keys;
|
||||
}
|
||||
|
||||
onSubmit(values, imageKeys, deletedImages);
|
||||
} catch (error) {
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
|
||||
}
|
||||
}}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([]);
|
||||
setNewImages([]);
|
||||
setExistingImagesState([]);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -143,9 +183,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
{mode === 'create' && (
|
||||
<ImageUploader
|
||||
images={images}
|
||||
images={displayImages}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -166,9 +206,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
<View style={{ marginBottom: 16 }}>
|
||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||
<ImageUploader
|
||||
images={images}
|
||||
images={displayImages}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -355,11 +395,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
||||
|
||||
interface StoreOption {
|
||||
id: number;
|
||||
|
|
@ -23,7 +24,7 @@ interface TagFormProps {
|
|||
mode: 'create' | 'edit';
|
||||
initialValues: TagFormData;
|
||||
existingImageUrl?: string;
|
||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void;
|
||||
isLoading: boolean;
|
||||
stores?: StoreOption[];
|
||||
}
|
||||
|
|
@ -36,24 +37,35 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
isLoading,
|
||||
stores = [],
|
||||
}, ref) => {
|
||||
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
||||
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
existingImageUrl && setImage({uri:existingImageUrl})
|
||||
}, [initialValues.isDashboardTag]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => {
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setNewImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setImage(files || null)
|
||||
const asset = Array.isArray(assets) ? assets[0] : assets;
|
||||
const response = await fetch(asset.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
setNewImage({
|
||||
blob,
|
||||
mimeType: asset.mimeType || 'image/jpeg',
|
||||
uri: asset.uri
|
||||
});
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
tagName: Yup.string()
|
||||
.required('Tag name is required')
|
||||
|
|
@ -63,18 +75,44 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
.max(500, 'Description must be less than 500 characters'),
|
||||
});
|
||||
|
||||
// Display images for ImageUploader
|
||||
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
|
||||
const existingImages = existingImageUrl ? [existingImageUrl] : [];
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
let deleteExistingImage = false;
|
||||
|
||||
// Handle image upload
|
||||
if (newImage) {
|
||||
const result = await uploadSingle(newImage.blob, newImage.mimeType, 'product_info');
|
||||
imageKey = result.key;
|
||||
// If we're uploading a new image and there's an existing one, mark it for deletion
|
||||
if (existingImageUrl) {
|
||||
deleteExistingImage = true;
|
||||
}
|
||||
} else if (mode === 'edit' && !newImage && existingImageUrl) {
|
||||
// In edit mode, if no new image and existing was removed
|
||||
// This would need UI to explicitly remove image
|
||||
// For now, we don't support explicit deletion without replacement
|
||||
}
|
||||
|
||||
onSubmit(values, imageKey, deleteExistingImage);
|
||||
} catch (error) {
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
||||
}
|
||||
}}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImage(null);
|
||||
|
||||
setNewImage(null);
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -107,11 +145,15 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||
</MyText>
|
||||
|
||||
|
||||
<ImageUploader
|
||||
images={image ? [image] : []}
|
||||
images={displayImages}
|
||||
existingImageUrls={mode === 'edit' ? existingImages : []}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={() => setImage(null)}
|
||||
onRemoveImage={() => setNewImage(null)}
|
||||
onRemoveExistingImage={mode === 'edit' ? () => {
|
||||
// In edit mode, this would trigger deletion of existing image
|
||||
// But we need to implement this logic in the parent
|
||||
} : undefined}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
@ -122,7 +164,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
onPress={() => {
|
||||
const newValue = !isDashboardTagChecked;
|
||||
setIsDashboardTagChecked(newValue);
|
||||
formikSetFieldValue('isDashboardTag', newValue);
|
||||
setFieldValue('isDashboardTag', newValue);
|
||||
}}
|
||||
/>
|
||||
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
||||
|
|
@ -143,7 +185,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
||||
formikSetFieldValue('relatedStores', numericValues);
|
||||
setFieldValue('relatedStores', numericValues);
|
||||
}}
|
||||
multiple={true}
|
||||
/>
|
||||
|
|
@ -151,11 +193,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isLoading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
|
|||
import { Entypo } from '@expo/vector-icons';
|
||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useDeleteTag } from '../api-hooks/tag.api';
|
||||
import { trpc } from '../trpc-client';
|
||||
|
||||
export interface TagMenuProps {
|
||||
tagId: number;
|
||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
||||
const deleteTag = trpc.admin.tag.deleteTag.useMutation();
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true);
|
||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
};
|
||||
|
||||
const performDelete = () => {
|
||||
deleteTag(tagId, {
|
||||
deleteTag.mutate({ id: tagId }, {
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Tag deleted successfully');
|
||||
onDeleteSuccess?.();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
ENV_MODE=PROD
|
||||
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||
SQLITE_DB_PATH='./sqlite.db'
|
||||
DB_DIALECT='sqlite'
|
||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||
|
||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||
PHONE_PE_CLIENT_VERSION=1
|
||||
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
|
||||
|
|
@ -17,10 +20,14 @@ S3_REGION=apac
|
|||
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
||||
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
||||
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||
S3_BUCKET_NAME=meatfarmer
|
||||
S3_BUCKET_NAME=meatfarmer-dev
|
||||
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||
ASSETS_DOMAIN=https://assets.freshyo.in/
|
||||
ASSETS_DOMAIN=https://assets2.freshyo.in/
|
||||
API_CACHE_KEY=api-cache-dev
|
||||
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
|
||||
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
|
||||
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
|
||||
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
||||
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
||||
APP_URL=http://localhost:4000
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,11 +1,6 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import postgresConfig from '../db-helper-postgres/drizzle.config'
|
||||
import sqliteConfig from '../db-helper-sqlite/drizzle.config'
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
export default process.env.DB_DIALECT === 'sqlite'
|
||||
? sqliteConfig
|
||||
: postgresConfig
|
||||
|
|
|
|||
14
apps/backend/drizzle/0077_wakeful_norrin_radd.sql
Normal file
14
apps/backend/drizzle/0077_wakeful_norrin_radd.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TYPE "public"."product_availability_action" AS ENUM('in', 'out');--> statement-breakpoint
|
||||
CREATE TABLE "mf"."product_availability_schedules" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."product_availability_schedules_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"time" varchar(10) NOT NULL,
|
||||
"schedule_name" varchar(255) NOT NULL,
|
||||
"action" "product_availability_action" NOT NULL,
|
||||
"product_ids" integer[] DEFAULT '{}' NOT NULL,
|
||||
"group_ids" integer[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_updated" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "product_availability_schedules_schedule_name_unique" UNIQUE("schedule_name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mf"."product_info" ADD COLUMN "scheduled_availability" boolean DEFAULT true NOT NULL;
|
||||
3965
apps/backend/drizzle/meta/0077_snapshot.json
Normal file
3965
apps/backend/drizzle/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -540,6 +540,13 @@
|
|||
"when": 1772637259874,
|
||||
"tag": "0076_sturdy_wolverine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1773927855512,
|
||||
"tag": "0077_wakeful_norrin_radd",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
515
apps/backend/drizzle/sqlite/0000_goofy_oracle.sql
Normal file
515
apps/backend/drizzle/sqlite/0000_goofy_oracle.sql
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
CREATE TABLE `address_areas` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`place_name` text NOT NULL,
|
||||
`zone_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `address_zones` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`zone_name` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `addresses` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`phone` text NOT NULL,
|
||||
`address_line1` text NOT NULL,
|
||||
`address_line2` text,
|
||||
`city` text NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
`pincode` text NOT NULL,
|
||||
`is_default` integer DEFAULT false NOT NULL,
|
||||
`latitude` real,
|
||||
`longitude` real,
|
||||
`google_maps_url` text,
|
||||
`admin_latitude` real,
|
||||
`admin_longitude` real,
|
||||
`zone_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `cart_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_user_product` ON `cart_items` (`user_id`,`product_id`);--> statement-breakpoint
|
||||
CREATE TABLE `complaints` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`complaint_body` text NOT NULL,
|
||||
`images` text,
|
||||
`response` text,
|
||||
`is_resolved` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `coupon_applicable_products` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_product` ON `coupon_applicable_products` (`coupon_id`,`product_id`);--> statement-breakpoint
|
||||
CREATE TABLE `coupon_applicable_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_user` ON `coupon_applicable_users` (`coupon_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `coupon_usage` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`coupon_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`order_item_id` integer,
|
||||
`used_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_item_id`) REFERENCES `order_items`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `coupons` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`coupon_code` text NOT NULL,
|
||||
`is_user_based` integer DEFAULT false NOT NULL,
|
||||
`discount_percent` text,
|
||||
`flat_discount` text,
|
||||
`min_order` text,
|
||||
`product_ids` text,
|
||||
`created_by` integer,
|
||||
`max_value` text,
|
||||
`is_apply_for_all` integer DEFAULT false NOT NULL,
|
||||
`valid_till` integer,
|
||||
`max_limit_for_user` integer,
|
||||
`is_invalidated` integer DEFAULT false NOT NULL,
|
||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_coupon_code` ON `coupons` (`coupon_code`);--> statement-breakpoint
|
||||
CREATE TABLE `delivery_slot_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`delivery_time` integer NOT NULL,
|
||||
`freeze_time` integer NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`is_flash` integer DEFAULT false NOT NULL,
|
||||
`is_capacity_full` integer DEFAULT false NOT NULL,
|
||||
`delivery_sequence` text DEFAULT '{}',
|
||||
`group_ids` text DEFAULT '[]'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `home_banners` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`image_url` text NOT NULL,
|
||||
`description` text,
|
||||
`product_ids` text,
|
||||
`redirect_url` text,
|
||||
`serial_num` integer,
|
||||
`is_active` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `key_val_store` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `notif_creds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`last_verified` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notif_creds_token_unique` ON `notif_creds` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `notifications` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`type` text,
|
||||
`is_read` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `order_items` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`discounted_price` text,
|
||||
`is_packaged` integer DEFAULT false NOT NULL,
|
||||
`is_package_verified` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `order_status` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`is_packaged` integer DEFAULT false NOT NULL,
|
||||
`is_delivered` integer DEFAULT false NOT NULL,
|
||||
`is_cancelled` integer DEFAULT false NOT NULL,
|
||||
`cancel_reason` text,
|
||||
`is_cancelled_by_admin` integer,
|
||||
`payment_state` text DEFAULT 'pending' NOT NULL,
|
||||
`cancellation_user_notes` text,
|
||||
`cancellation_admin_notes` text,
|
||||
`cancellation_reviewed` integer DEFAULT false NOT NULL,
|
||||
`cancellation_reviewed_at` integer,
|
||||
`refund_coupon_id` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`refund_coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `orders` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`address_id` integer NOT NULL,
|
||||
`slot_id` integer,
|
||||
`is_cod` integer DEFAULT false NOT NULL,
|
||||
`is_online_payment` integer DEFAULT false NOT NULL,
|
||||
`payment_info_id` integer,
|
||||
`total_amount` text NOT NULL,
|
||||
`delivery_charge` text DEFAULT '0' NOT NULL,
|
||||
`readable_id` integer NOT NULL,
|
||||
`admin_notes` text,
|
||||
`user_notes` text,
|
||||
`order_group_id` text,
|
||||
`order_group_proportion` text,
|
||||
`is_flash_delivery` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`address_id`) REFERENCES `addresses`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`payment_info_id`) REFERENCES `payment_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `payment_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`gateway` text NOT NULL,
|
||||
`order_id` text,
|
||||
`token` text,
|
||||
`merchant_order_id` text NOT NULL,
|
||||
`payload` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `payment_info_merchant_order_id_unique` ON `payment_info` (`merchant_order_id`);--> statement-breakpoint
|
||||
CREATE TABLE `payments` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`gateway` text NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`token` text,
|
||||
`merchant_order_id` text NOT NULL,
|
||||
`payload` text,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `payments_merchant_order_id_unique` ON `payments` (`merchant_order_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_availability_schedules` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`time` text NOT NULL,
|
||||
`schedule_name` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`product_ids` text DEFAULT '[]' NOT NULL,
|
||||
`group_ids` text DEFAULT '[]' NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_availability_schedules_schedule_name_unique` ON `product_availability_schedules` (`schedule_name`);--> statement-breakpoint
|
||||
CREATE TABLE `product_categories` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_group_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`group_name` text NOT NULL,
|
||||
`description` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_group_membership` (
|
||||
`product_id` integer NOT NULL,
|
||||
`group_id` integer NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `product_group_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_group_membership_pk` ON `product_group_membership` (`product_id`,`group_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`short_description` text,
|
||||
`long_description` text,
|
||||
`unit_id` integer NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`market_price` text,
|
||||
`images` text,
|
||||
`is_out_of_stock` integer DEFAULT false NOT NULL,
|
||||
`is_suspended` integer DEFAULT false NOT NULL,
|
||||
`is_flash_available` integer DEFAULT false NOT NULL,
|
||||
`flash_price` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`increment_step` real DEFAULT 1 NOT NULL,
|
||||
`product_quantity` real DEFAULT 1 NOT NULL,
|
||||
`store_id` integer,
|
||||
`scheduled_availability` integer DEFAULT true NOT NULL,
|
||||
FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`store_id`) REFERENCES `store_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_reviews` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`review_body` text NOT NULL,
|
||||
`image_urls` text DEFAULT '[]',
|
||||
`review_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`ratings` real NOT NULL,
|
||||
`admin_response` text,
|
||||
`admin_response_images` text DEFAULT '[]',
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
CONSTRAINT "rating_check" CHECK("product_reviews"."ratings" >= 1 AND "product_reviews"."ratings" <= 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `product_slots` (
|
||||
`product_id` integer NOT NULL,
|
||||
`slot_id` integer NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_slot_pk` ON `product_slots` (`product_id`,`slot_id`);--> statement-breakpoint
|
||||
CREATE TABLE `product_tag_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`tag_name` text NOT NULL,
|
||||
`tag_description` text,
|
||||
`image_url` text,
|
||||
`is_dashboard_tag` integer DEFAULT false NOT NULL,
|
||||
`related_stores` text DEFAULT '[]',
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `product_tag_info_tag_name_unique` ON `product_tag_info` (`tag_name`);--> statement-breakpoint
|
||||
CREATE TABLE `product_tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
`assigned_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `product_tag_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_product_tag` ON `product_tags` (`product_id`,`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `refunds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`order_id` integer NOT NULL,
|
||||
`refund_amount` text,
|
||||
`refund_status` text DEFAULT 'none',
|
||||
`merchant_refund_id` text,
|
||||
`refund_processed_at` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reserved_coupons` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`secret_code` text NOT NULL,
|
||||
`coupon_code` text NOT NULL,
|
||||
`discount_percent` text,
|
||||
`flat_discount` text,
|
||||
`min_order` text,
|
||||
`product_ids` text,
|
||||
`max_value` text,
|
||||
`valid_till` integer,
|
||||
`max_limit_for_user` integer,
|
||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
||||
`is_redeemed` integer DEFAULT false NOT NULL,
|
||||
`redeemed_by` integer,
|
||||
`redeemed_at` integer,
|
||||
`created_by` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`redeemed_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `reserved_coupons_secret_code_unique` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_secret_code` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
||||
CREATE TABLE `special_deals` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`product_id` integer NOT NULL,
|
||||
`quantity` text NOT NULL,
|
||||
`price` text NOT NULL,
|
||||
`valid_till` integer NOT NULL,
|
||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `staff_permissions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`permission_name` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_permission_name` ON `staff_permissions` (`permission_name`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_role_permissions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`staff_role_id` integer NOT NULL,
|
||||
`staff_permission_id` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`staff_permission_id`) REFERENCES `staff_permissions`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_role_permission` ON `staff_role_permissions` (`staff_role_id`,`staff_permission_id`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_roles` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`role_name` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_role_name` ON `staff_roles` (`role_name`);--> statement-breakpoint
|
||||
CREATE TABLE `staff_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`password` text NOT NULL,
|
||||
`staff_role_id` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `store_info` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`image_url` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`owner` integer NOT NULL,
|
||||
FOREIGN KEY (`owner`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `units` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`short_notation` text NOT NULL,
|
||||
`full_name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_short_notation` ON `units` (`short_notation`);--> statement-breakpoint
|
||||
CREATE TABLE `unlogged_user_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`last_verified` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unlogged_user_tokens_token_unique` ON `unlogged_user_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `upload_url_status` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_creds` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`user_password` text NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_details` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`bio` text,
|
||||
`date_of_birth` integer,
|
||||
`gender` text,
|
||||
`occupation` text,
|
||||
`profile_image` text,
|
||||
`is_suspended` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`updated_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_details_user_id_unique` ON `user_details` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user_incidents` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`order_id` integer,
|
||||
`date_added` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`admin_comment` text,
|
||||
`added_by` integer,
|
||||
`negativity_score` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_notifications` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`image_url` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`applicable_users` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text,
|
||||
`email` text,
|
||||
`mobile` text,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `unique_email` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `vendor_snippets` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`snippet_code` text NOT NULL,
|
||||
`slot_id` integer,
|
||||
`is_permanent` integer DEFAULT false NOT NULL,
|
||||
`product_ids` text NOT NULL,
|
||||
`valid_till` integer,
|
||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `vendor_snippets_snippet_code_unique` ON `vendor_snippets` (`snippet_code`);
|
||||
3574
apps/backend/drizzle/sqlite/meta/0000_snapshot.json
Normal file
3574
apps/backend/drizzle/sqlite/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
apps/backend/drizzle/sqlite/meta/_journal.json
Normal file
13
apps/backend/drizzle/sqlite/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1774244805277,
|
||||
"tag": "0000_goofy_oracle",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
189
apps/backend/index-express.ts
Executable file
189
apps/backend/index-express.ts
Executable file
|
|
@ -0,0 +1,189 @@
|
|||
import 'dotenv/config';
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
// import bodyParser from "body-parser";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/db/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils';
|
||||
|
||||
seed()
|
||||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174'
|
||||
}));
|
||||
|
||||
|
||||
signedUrlCache.loadFromDisk();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Middleware to log all request URLs
|
||||
app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
//cors middleware
|
||||
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Allow requests from any origin (for production, replace * with your domain)
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Allow specific headers clients can send
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
||||
);
|
||||
|
||||
// Allow specific HTTP methods
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
|
||||
// Allow credentials if needed (optional)
|
||||
// res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Handle preflight (OPTIONS) requests quickly
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
app.use('/api/trpc', createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = await verifyToken(token);
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if ('staffId' in decoded) {
|
||||
const staffPayload = decoded as StaffJWTPayload;
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.id, staffPayload.staffId),
|
||||
});
|
||||
|
||||
if (staff) {
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userPayload = decoded as UserJWTPayload;
|
||||
// This is a regular user token
|
||||
user = {
|
||||
userId: userPayload.userId,
|
||||
name: userPayload.name,
|
||||
email: userPayload.email,
|
||||
mobile: userPayload.mobile,
|
||||
};
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userPayload.userId),
|
||||
});
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
}
|
||||
}
|
||||
return { req, res, user, staffUser };
|
||||
},
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
userId: ctx?.user?.userId,
|
||||
stack: error.stack,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
app.use('/api', mainRouter)
|
||||
|
||||
const fallbackUiDirCandidates = [
|
||||
path.resolve(__dirname, '../fallback-ui/dist'),
|
||||
path.resolve(__dirname, '../../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), '../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), './apps/fallback-ui/dist')
|
||||
]
|
||||
|
||||
const fallbackUiDir =
|
||||
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
|
||||
fallbackUiDirCandidates[0]
|
||||
|
||||
|
||||
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
|
||||
// const fallbackUiMountPath = '/admin-web'
|
||||
const fallbackUiMountPath = '/';
|
||||
|
||||
if (fs.existsSync(fallbackUiIndex)) {
|
||||
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
const fallbackUiRegex = new RegExp(
|
||||
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
|
||||
)
|
||||
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
})
|
||||
app.get(/.*/, (req,res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
})
|
||||
} else {
|
||||
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
||||
}
|
||||
|
||||
// Serve /assets/public folder at /assets route
|
||||
const assetsPublicDir = path.resolve(__dirname, './assets/public');
|
||||
if (fs.existsSync(assetsPublicDir)) {
|
||||
app.use('/assets', express.static(assetsPublicDir));
|
||||
console.log('Serving /assets from', assetsPublicDir);
|
||||
} else {
|
||||
console.warn('Assets public folder not found at', assetsPublicDir);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
const status = err.statusCode || err.status || 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
res.status(status).json({ message });
|
||||
});
|
||||
|
||||
app.listen(4000, () => {
|
||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||
});
|
||||
276
apps/backend/index.ts
Executable file → Normal file
276
apps/backend/index.ts
Executable file → Normal file
|
|
@ -1,185 +1,167 @@
|
|||
import 'dotenv/config';
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
// import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import jwt from 'jsonwebtoken'
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/db/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { serve } from 'bun'
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/src/trpc/router'
|
||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache'
|
||||
import { seed } from '@/src/db/seed'
|
||||
import initFunc from '@/src/lib/init'
|
||||
import '@/src/jobs/jobs-index'
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs'
|
||||
|
||||
// Initialize
|
||||
seed()
|
||||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = express();
|
||||
const app = new Hono()
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174'
|
||||
}));
|
||||
// CORS middleware
|
||||
app.use('*', cors({
|
||||
origin: ['http://localhost:5174', 'http://localhost:5173'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
||||
credentials: true
|
||||
}))
|
||||
|
||||
// Request logging
|
||||
app.use('*', logger())
|
||||
|
||||
signedUrlCache.loadFromDisk();
|
||||
// Health check
|
||||
app.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
message: 'Hello world'
|
||||
})
|
||||
})
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Middleware to log all request URLs
|
||||
app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
//cors middleware
|
||||
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Allow requests from any origin (for production, replace * with your domain)
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Allow specific headers clients can send
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
||||
);
|
||||
|
||||
// Allow specific HTTP methods
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
|
||||
// Allow credentials if needed (optional)
|
||||
// res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Handle preflight (OPTIONS) requests quickly
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
app.use('/api/trpc', createExpressMiddleware({
|
||||
// tRPC handler with context
|
||||
app.use('/api/trpc/*', async (c) => {
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req: c.req.raw,
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
createContext: async ({ req }) => {
|
||||
let user = null
|
||||
let staffUser = null
|
||||
const authHeader = req.headers.get('authorization')
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||
const decoded = await verifyToken(token)
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if (decoded.staffId) {
|
||||
// This is a staff token, verify staff exists
|
||||
if ('staffId' in decoded) {
|
||||
const staffPayload = decoded as StaffJWTPayload
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.id, decoded.staffId),
|
||||
});
|
||||
|
||||
where: eq(staffUsers.id, staffPayload.staffId)
|
||||
})
|
||||
if (staff) {
|
||||
user=staffUser
|
||||
staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
staffUser = { id: staff.id, name: staff.name }
|
||||
}
|
||||
} else {
|
||||
const userPayload = decoded as UserJWTPayload
|
||||
user = {
|
||||
userId: userPayload.userId,
|
||||
name: userPayload.name,
|
||||
email: userPayload.email,
|
||||
mobile: userPayload.mobile
|
||||
}
|
||||
|
||||
// This is a regular user token
|
||||
user = decoded;
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, user.userId),
|
||||
});
|
||||
where: eq(userDetails.userId, userPayload.userId)
|
||||
})
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account suspended',
|
||||
});
|
||||
message: 'Account suspended'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid token, both user and staffUser remain null
|
||||
} catch {
|
||||
// Invalid token
|
||||
}
|
||||
}
|
||||
return { req, res, user, staffUser };
|
||||
|
||||
return { req, res: c.res, user, staffUser }
|
||||
},
|
||||
onError({ error, path, type, ctx }) {
|
||||
console.error('🚨 tRPC Error :', {
|
||||
onError: ({ error, path, ctx }) => {
|
||||
console.error('🚨 tRPC Error:', {
|
||||
path,
|
||||
type,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
userId: ctx?.user?.userId,
|
||||
stack: error.stack,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
app.use('/api', mainRouter)
|
||||
|
||||
const fallbackUiDirCandidates = [
|
||||
path.resolve(__dirname, '../fallback-ui/dist'),
|
||||
path.resolve(__dirname, '../../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), '../fallback-ui/dist'),
|
||||
path.resolve(process.cwd(), './apps/fallback-ui/dist')
|
||||
]
|
||||
|
||||
const fallbackUiDir =
|
||||
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
|
||||
fallbackUiDirCandidates[0]
|
||||
|
||||
|
||||
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
|
||||
// const fallbackUiMountPath = '/admin-web'
|
||||
const fallbackUiMountPath = '/';
|
||||
|
||||
if (fs.existsSync(fallbackUiIndex)) {
|
||||
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
|
||||
const fallbackUiRegex = new RegExp(
|
||||
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
|
||||
)
|
||||
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
userId: ctx?.user?.userId
|
||||
})
|
||||
app.get(/.*/, (req,res) => {
|
||||
res.sendFile(fallbackUiIndex)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
||||
// Global error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
const status = err.statusCode || err.status || 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
res.status(status).json({ message });
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
console.error('Error:', err)
|
||||
|
||||
app.listen(4000, '::', () => {
|
||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||
});
|
||||
const status = err instanceof TRPCError
|
||||
? (err.code === 'UNAUTHORIZED' ? 401 : 500)
|
||||
: 500
|
||||
|
||||
const message = err.message || 'Internal Server Error'
|
||||
|
||||
return c.json({ message }, status)
|
||||
})
|
||||
|
||||
// Start server
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: 4000,
|
||||
hostname: '0.0.0.0'
|
||||
})
|
||||
|
||||
console.log('🚀 Server running on http://localhost:4000')
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@
|
|||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"migrate": "drizzle-kit generate:pg",
|
||||
"migrate": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
||||
"migrate:pg": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
||||
"migrate:sqlite": "drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
|
||||
"generate:pg": "bunx drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
||||
"generate:sqlite": "bunx drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
|
||||
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"build2": "rimraf ./dist && tsc",
|
||||
"db:push": "drizzle-kit push:pg",
|
||||
"db:push": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
|
||||
"db:push:pg": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
|
||||
"db:push:sqlite": "drizzle-kit push --config ../db-helper-sqlite/drizzle.config.ts",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"dev2": "tsx watch index.ts",
|
||||
"dev_node": "tsx watch index.ts",
|
||||
"dev:express": "bun --watch index-express.ts",
|
||||
"dev:hono": "bun --watch index.ts",
|
||||
"dev": "bun --watch index.ts",
|
||||
"start": "bun index.ts",
|
||||
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
|
||||
"docker:push": "docker push mohdshafiuddin54/health_petal:latest"
|
||||
},
|
||||
|
|
@ -26,8 +33,6 @@
|
|||
"@turf/turf": "^7.2.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.63.0",
|
||||
|
|
@ -36,18 +41,16 @@
|
|||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"expo-server-sdk": "^4.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"hono": "^4.6.3",
|
||||
"jose": "^5.10.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"razorpay": "^2.9.6",
|
||||
"redis": "^5.9.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/bun": "^1.1.10",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
|
|
|
|||
BIN
apps/backend/sqlite.db
Normal file
BIN
apps/backend/sqlite.db
Normal file
Binary file not shown.
|
|
@ -1,19 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
||||
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply staff authentication to all admin routes
|
||||
router.use(authenticateStaff);
|
||||
|
||||
// Product routes
|
||||
router.use("/products", productRouter);
|
||||
|
||||
// Tag routes
|
||||
router.use("/product-tags", tagRouter);
|
||||
|
||||
const avRouter = router;
|
||||
|
||||
export default avRouter;
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index";
|
||||
import { productTagInfo } from "@/src/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||
|
||||
/**
|
||||
* Create a new product tag
|
||||
*/
|
||||
export const createTag = async (req: Request, res: Response) => {
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
if (!tagName) {
|
||||
throw new ApiError("Tag name is required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate tag name
|
||||
const existingTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.tagName, tagName.trim()),
|
||||
});
|
||||
|
||||
if (existingTag) {
|
||||
throw new ApiError("A tag with this name already exists", 400);
|
||||
}
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
// Handle image upload if file is provided
|
||||
if (req.file) {
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||
}
|
||||
|
||||
// Parse relatedStores if it's a string (from FormData)
|
||||
let parsedRelatedStores: number[] = [];
|
||||
if (relatedStores) {
|
||||
try {
|
||||
parsedRelatedStores = typeof relatedStores === 'string'
|
||||
? JSON.parse(relatedStores)
|
||||
: relatedStores;
|
||||
} catch (e) {
|
||||
parsedRelatedStores = [];
|
||||
}
|
||||
}
|
||||
|
||||
const [newTag] = await db
|
||||
.insert(productTagInfo)
|
||||
.values({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag: isDashboardTag || false,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(201).json({
|
||||
tag: newTag,
|
||||
message: "Tag created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all product tags
|
||||
*/
|
||||
export const getAllTags = async (req: Request, res: Response) => {
|
||||
const tags = await db
|
||||
.select()
|
||||
.from(productTagInfo)
|
||||
.orderBy(productTagInfo.tagName);
|
||||
|
||||
// Generate signed URLs for tag images
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
}))
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
tags: tagsWithSignedUrls,
|
||||
message: "Tags retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single product tag by ID
|
||||
*/
|
||||
export const getTagById = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate signed URL for tag image
|
||||
const tagWithSignedUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
tag: tagWithSignedUrl,
|
||||
message: "Tag retrieved successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product tag
|
||||
*/
|
||||
export const updateTag = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||
|
||||
// Get the current tag to check for existing image
|
||||
const currentTag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
let imageUrl = currentTag.imageUrl;
|
||||
|
||||
// Handle image upload if new file is provided
|
||||
if (req.file) {
|
||||
// Delete old image if it exists
|
||||
if (currentTag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete old image:", error);
|
||||
// Continue with update even if delete fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Upload new image
|
||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||
console.log('file', key)
|
||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||
}
|
||||
|
||||
// Parse relatedStores if it's a string (from FormData)
|
||||
let parsedRelatedStores: number[] | undefined;
|
||||
if (relatedStores !== undefined) {
|
||||
try {
|
||||
parsedRelatedStores = typeof relatedStores === 'string'
|
||||
? JSON.parse(relatedStores)
|
||||
: relatedStores;
|
||||
} catch (e) {
|
||||
parsedRelatedStores = [];
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedTag] = await db
|
||||
.update(productTagInfo)
|
||||
.set({
|
||||
tagName: tagName?.trim(),
|
||||
tagDescription,
|
||||
imageUrl,
|
||||
isDashboardTag,
|
||||
relatedStores: parsedRelatedStores,
|
||||
})
|
||||
.where(eq(productTagInfo.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
tag: updatedTag,
|
||||
message: "Tag updated successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a product tag
|
||||
*/
|
||||
export const deleteTag = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if tag exists
|
||||
const tag = await db.query.productTagInfo.findFirst({
|
||||
where: eq(productTagInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Delete image from S3 if it exists
|
||||
if (tag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(tag.imageUrl);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete image from S3:", error);
|
||||
// Continue with deletion even if image delete fails
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This will fail if tag is still assigned to products due to foreign key constraint
|
||||
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
|
||||
|
||||
// Reinitialize stores to reflect changes in cache
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Tag deleted successfully",
|
||||
});
|
||||
};
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index";
|
||||
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error";
|
||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||
import type { SpecialDeal } from "@/src/db/types";
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||
|
||||
type CreateDeal = {
|
||||
quantity: number;
|
||||
price: number;
|
||||
validTill: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new product
|
||||
*/
|
||||
export const createProduct = async (req: Request, res: Response) => {
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const existingProduct = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.name, name.trim()),
|
||||
});
|
||||
|
||||
if (existingProduct) {
|
||||
throw new ApiError("A product with this name already exists", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unit = await db.query.units.findFirst({
|
||||
where: eq(units.id, unitId),
|
||||
});
|
||||
|
||||
if (!unit) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Extract images from req.files
|
||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images && Array.isArray(images)) {
|
||||
const imageUploadPromises = images.map((file, index) => {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||
});
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||
}
|
||||
|
||||
// Create product
|
||||
const productData: any = {
|
||||
name,
|
||||
shortDescription,
|
||||
longDescription,
|
||||
unitId,
|
||||
storeId,
|
||||
price,
|
||||
marketPrice,
|
||||
incrementStep: incrementStep || 1,
|
||||
productQuantity: productQuantity || 1,
|
||||
isSuspended: isSuspended || false,
|
||||
isFlashAvailable: isFlashAvailable || false,
|
||||
images: uploadedImageUrls,
|
||||
};
|
||||
|
||||
if (flashPrice) {
|
||||
productData.flashPrice = parseFloat(flashPrice);
|
||||
}
|
||||
|
||||
const [newProduct] = await db
|
||||
.insert(productInfo)
|
||||
.values(productData)
|
||||
.returning();
|
||||
|
||||
// Handle deals if provided
|
||||
let createdDeals: SpecialDeal[] = [];
|
||||
if (deals && Array.isArray(deals)) {
|
||||
const dealInserts = deals.map((deal: CreateDeal) => ({
|
||||
productId: newProduct.id,
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
|
||||
createdDeals = await db
|
||||
.insert(specialDeals)
|
||||
.values(dealInserts)
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
if (tagIds && Array.isArray(tagIds)) {
|
||||
const tagAssociations = tagIds.map((tagId: number) => ({
|
||||
productId: newProduct.id,
|
||||
tagId,
|
||||
}));
|
||||
|
||||
await db.insert(productTags).values(tagAssociations);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(201).json({
|
||||
product: newProduct,
|
||||
deals: createdDeals,
|
||||
message: "Product created successfully",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a product
|
||||
*/
|
||||
export const updateProduct = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
||||
|
||||
|
||||
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
||||
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
||||
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unit = await db.query.units.findFirst({
|
||||
where: eq(units.id, unitId),
|
||||
});
|
||||
|
||||
if (!unit) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
// Get current product to handle image updates
|
||||
const currentProduct = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, parseInt(id)),
|
||||
});
|
||||
|
||||
if (!currentProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle image deletions
|
||||
let currentImages = (currentProduct.images as string[]) || [];
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
// Convert signed URLs to original S3 URLs for comparison
|
||||
const originalUrlsToDelete = imagesToDelete
|
||||
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
||||
.filter(Boolean); // Remove nulls
|
||||
|
||||
// Find which stored images match the ones to delete
|
||||
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
||||
originalUrlsToDelete.includes(storedUrl)
|
||||
);
|
||||
|
||||
// Delete the matching images from S3
|
||||
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
// Remove deleted images from current images array
|
||||
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
||||
}
|
||||
|
||||
// Extract new images from req.files
|
||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||
let uploadedImageUrls: string[] = [];
|
||||
|
||||
if (images && Array.isArray(images)) {
|
||||
const imageUploadPromises = images.map((file, index) => {
|
||||
const key = `product-images/${Date.now()}-${index}`;
|
||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||
});
|
||||
|
||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||
}
|
||||
|
||||
// Combine remaining current images with new uploaded images
|
||||
const finalImages = [...currentImages, ...uploadedImageUrls];
|
||||
|
||||
const updateData: any = {
|
||||
name,
|
||||
shortDescription,
|
||||
longDescription,
|
||||
unitId,
|
||||
storeId,
|
||||
price,
|
||||
marketPrice,
|
||||
incrementStep: incrementStep || 1,
|
||||
productQuantity: productQuantity || 1,
|
||||
isSuspended: isSuspended || false,
|
||||
images: finalImages.length > 0 ? finalImages : undefined,
|
||||
};
|
||||
|
||||
if (isFlashAvailable !== undefined) {
|
||||
updateData.isFlashAvailable = isFlashAvailable;
|
||||
}
|
||||
|
||||
if (flashPrice !== undefined) {
|
||||
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
||||
}
|
||||
|
||||
const [updatedProduct] = await db
|
||||
.update(productInfo)
|
||||
.set(updateData)
|
||||
.where(eq(productInfo.id, parseInt(id)))
|
||||
.returning();
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle deals if provided
|
||||
if (deals && Array.isArray(deals)) {
|
||||
// Get existing deals
|
||||
const existingDeals = await db.query.specialDeals.findMany({
|
||||
where: eq(specialDeals.productId, parseInt(id)),
|
||||
});
|
||||
|
||||
// Create maps for comparison
|
||||
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
||||
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
||||
|
||||
// Find deals to add, update, and remove
|
||||
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
return !existingDealsMap.has(key);
|
||||
});
|
||||
|
||||
const dealsToRemove = existingDeals.filter(deal => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
return !newDealsMap.has(key);
|
||||
});
|
||||
|
||||
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
const existing = existingDealsMap.get(key);
|
||||
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
||||
});
|
||||
|
||||
// Remove old deals
|
||||
if (dealsToRemove.length > 0) {
|
||||
await db.delete(specialDeals).where(
|
||||
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
||||
);
|
||||
}
|
||||
|
||||
// Add new deals
|
||||
if (dealsToAdd.length > 0) {
|
||||
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
||||
productId: parseInt(id),
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
await db.insert(specialDeals).values(dealInserts);
|
||||
}
|
||||
|
||||
// Update existing deals
|
||||
for (const deal of dealsToUpdate) {
|
||||
const key = `${deal.quantity}-${deal.price}`;
|
||||
const existingDeal = existingDealsMap.get(key);
|
||||
if (existingDeal) {
|
||||
await db.update(specialDeals)
|
||||
.set({ validTill: new Date(deal.validTill) })
|
||||
.where(eq(specialDeals.id, existingDeal.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tag assignments if provided
|
||||
// if (tagIds && Array.isArray(tagIds)) {
|
||||
if (tagIds && Boolean(tagIds)) {
|
||||
// Remove existing tags
|
||||
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
||||
|
||||
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
||||
// Add new tags
|
||||
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
||||
productId: parseInt(id),
|
||||
tagId,
|
||||
}));
|
||||
|
||||
await db.insert(productTags).values(tagAssociations);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
|
||||
return res.status(200).json({
|
||||
product: updatedProduct,
|
||||
message: "Product updated successfully",
|
||||
});
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
||||
import uploadHandler from '@/src/lib/upload-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Product routes
|
||||
router.post("/", uploadHandler.array('images'), createProduct);
|
||||
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
||||
import uploadHandler from '@/src/lib/upload-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Tag routes
|
||||
router.post("/", uploadHandler.single('image'), createTag);
|
||||
router.get("/", getAllTags);
|
||||
router.get("/:id", getTagById);
|
||||
router.put("/:id", uploadHandler.single('image'), updateTag);
|
||||
router.delete("/:id", deleteTag);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,85 +1,19 @@
|
|||
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
||||
import { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
||||
import { Context } from 'hono'
|
||||
|
||||
/**
|
||||
* Get next delivery date for a product
|
||||
*/
|
||||
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
||||
const result = await db
|
||||
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
||||
.from(productSlots)
|
||||
.innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
|
||||
.where(
|
||||
and(
|
||||
eq(productSlots.productId, productId),
|
||||
eq(deliverySlotInfo.isActive, true),
|
||||
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
|
||||
)
|
||||
)
|
||||
.orderBy(deliverySlotInfo.deliveryTime)
|
||||
.limit(1);
|
||||
|
||||
|
||||
return result[0]?.deliveryTime || null;
|
||||
};
|
||||
import { getProductsSummaryData } from '@/src/db/common-product'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
|
||||
/**
|
||||
* Get all products summary for dropdown
|
||||
*/
|
||||
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||
export const getAllProductsSummary = async (c: Context) => {
|
||||
try {
|
||||
const { tagId } = req.query;
|
||||
const tagIdNum = tagId ? parseInt(tagId as string) : null;
|
||||
const tagId = c.req.query('tagId')
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null
|
||||
|
||||
let productIds: number[] | null = null;
|
||||
const productsWithUnits = await getProductsSummaryData(tagIdNum)
|
||||
|
||||
// If tagId is provided, get products that have this tag
|
||||
if (tagIdNum) {
|
||||
const taggedProducts = await db
|
||||
.select({ productId: productTags.productId })
|
||||
.from(productTags)
|
||||
.where(eq(productTags.tagId, tagIdNum));
|
||||
|
||||
productIds = taggedProducts.map(tp => tp.productId);
|
||||
}
|
||||
|
||||
let whereCondition = undefined;
|
||||
|
||||
// Filter by product IDs if tag filtering is applied
|
||||
if (productIds && productIds.length > 0) {
|
||||
whereCondition = inArray(productInfo.id, productIds);
|
||||
} else if (tagIdNum) {
|
||||
// If tagId was provided but no products found, return empty array
|
||||
return res.status(200).json({
|
||||
products: [],
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const productsWithUnits = await db
|
||||
.select({
|
||||
id: productInfo.id,
|
||||
name: productInfo.name,
|
||||
shortDescription: productInfo.shortDescription,
|
||||
price: productInfo.price,
|
||||
marketPrice: productInfo.marketPrice,
|
||||
images: productInfo.images,
|
||||
isOutOfStock: productInfo.isOutOfStock,
|
||||
unitShortNotation: units.shortNotation,
|
||||
productQuantity: productInfo.productQuantity,
|
||||
})
|
||||
.from(productInfo)
|
||||
.innerJoin(units, eq(productInfo.unitId, units.id))
|
||||
.where(whereCondition);
|
||||
|
||||
// Generate signed URLs for product images
|
||||
const formattedProducts = await Promise.all(
|
||||
productsWithUnits.map(async (product) => {
|
||||
const nextDeliveryDate = await getNextDeliveryDate(product.id);
|
||||
return {
|
||||
const formattedProducts = productsWithUnits.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
shortDescription: product.shortDescription,
|
||||
|
|
@ -88,18 +22,16 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
|
|||
unit: product.unitShortNotation,
|
||||
productQuantity: product.productQuantity,
|
||||
isOutOfStock: product.isOutOfStock,
|
||||
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||
nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
|
||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||
};
|
||||
})
|
||||
);
|
||||
}))
|
||||
|
||||
return res.status(200).json({
|
||||
return c.json({
|
||||
products: formattedProducts,
|
||||
count: formattedProducts.length,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Get products summary error:", error);
|
||||
return res.status(500).json({ error: "Failed to fetch products summary" });
|
||||
console.error('Get products summary error:', error)
|
||||
return c.json({ error: 'Failed to fetch products summary' }, 500)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Router } from "express";
|
||||
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||
import { Hono } from 'hono'
|
||||
import { getAllProductsSummary } from '@/src/apis/common-apis/apis/common-product.controller'
|
||||
|
||||
const router = Router();
|
||||
const app = new Hono()
|
||||
|
||||
router.get("/summary", getAllProductsSummary);
|
||||
// GET /summary - Get all products summary
|
||||
app.get('/summary', getAllProductsSummary)
|
||||
|
||||
|
||||
const commonProductsRouter= router;
|
||||
export default commonProductsRouter;
|
||||
export default app
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Router } from "express";
|
||||
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
||||
import { Hono } from 'hono'
|
||||
import commonProductsRouter from '@/src/apis/common-apis/apis/common-product.router'
|
||||
|
||||
const router = Router();
|
||||
const app = new Hono()
|
||||
|
||||
router.use('/products', commonProductsRouter)
|
||||
// Mount product routes at /products
|
||||
app.route('/products', commonProductsRouter)
|
||||
|
||||
const commonRouter = router;
|
||||
|
||||
export default commonRouter;
|
||||
export default app
|
||||
|
|
|
|||
10
apps/backend/src/db/common-product.ts
Normal file
10
apps/backend/src/db/common-product.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { getProductsSummaryData as getProductsSummaryDataPostgres } from '@db-helper-postgres/apis/common-apis/common-product'
|
||||
import { getProductsSummaryData as getProductsSummaryDataSqlite } from '@db-helper-sqlite/apis/common-apis/common-product'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
const getProductsSummaryData = dialect === 'sqlite'
|
||||
? getProductsSummaryDataSqlite
|
||||
: getProductsSummaryDataPostgres
|
||||
|
||||
export { getProductsSummaryData }
|
||||
14
apps/backend/src/db/db_index.ts
Executable file → Normal file
14
apps/backend/src/db/db_index.ts
Executable file → Normal file
|
|
@ -1,8 +1,10 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import path from "path"
|
||||
import * as schema from "@/src/db/schema"
|
||||
import { db as postgresDb } from '@db-helper-postgres/db/db_index'
|
||||
import { db as sqliteDb } from '@db-helper-sqlite/db/db_index'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
type Db = typeof DB_DIALECT_TYPE extends 'sqlite' ? typeof sqliteDb : typeof postgresDb
|
||||
|
||||
const db = (dialect === 'sqlite' ? sqliteDb : postgresDb) as Db
|
||||
|
||||
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
|
||||
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
|
||||
export { db }
|
||||
|
|
|
|||
1
apps/backend/src/db/schema-postgres.ts
Normal file
1
apps/backend/src/db/schema-postgres.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@/db-helper-postgres/db/schema'
|
||||
1
apps/backend/src/db/schema-sqlite.ts
Normal file
1
apps/backend/src/db/schema-sqlite.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@/db-helper-sqlite/db/schema'
|
||||
690
apps/backend/src/db/schema.ts
Executable file → Normal file
690
apps/backend/src/db/schema.ts
Executable file → Normal file
|
|
@ -1,689 +1 @@
|
|||
import { pgTable, pgSchema, integer, varchar, date, boolean, timestamp, numeric, jsonb, pgEnum, unique, real, text, check, decimal } from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
const mf = pgSchema('mf');
|
||||
|
||||
|
||||
|
||||
export const users = mf.table('users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }),
|
||||
email: varchar({ length: 255 }),
|
||||
mobile: varchar({ length: 255 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_email: unique('unique_email').on(t.email),
|
||||
}));
|
||||
|
||||
export const userDetails = mf.table('user_details', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id).unique(),
|
||||
bio: varchar('bio', { length: 500 }),
|
||||
dateOfBirth: date('date_of_birth'),
|
||||
gender: varchar('gender', { length: 20 }),
|
||||
occupation: varchar('occupation', { length: 100 }),
|
||||
profileImage: varchar('profile_image', { length: 500 }),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const userCreds = mf.table('user_creds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
userPassword: varchar('user_password', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addresses = mf.table('addresses', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
phone: varchar('phone', { length: 15 }).notNull(),
|
||||
addressLine1: varchar('address_line1', { length: 255 }).notNull(),
|
||||
addressLine2: varchar('address_line2', { length: 255 }),
|
||||
city: varchar('city', { length: 100 }).notNull(),
|
||||
state: varchar('state', { length: 100 }).notNull(),
|
||||
pincode: varchar('pincode', { length: 10 }).notNull(),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
latitude: real('latitude'),
|
||||
longitude: real('longitude'),
|
||||
googleMapsUrl: varchar('google_maps_url', { length: 500 }),
|
||||
adminLatitude: real('admin_latitude'),
|
||||
adminLongitude: real('admin_longitude'),
|
||||
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addressZones = mf.table('address_zones', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
zoneName: varchar('zone_name', { length: 255 }).notNull(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const addressAreas = mf.table('address_areas', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
placeName: varchar('place_name', { length: 255 }).notNull(),
|
||||
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const staffUsers = mf.table('staff_users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
password: varchar({ length: 255 }).notNull(),
|
||||
staffRoleId: integer('staff_role_id').references(() => staffRoles.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const storeInfo = mf.table('store_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
owner: integer('owner').notNull().references(() => staffUsers.id),
|
||||
});
|
||||
|
||||
export const units = mf.table('units', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
shortNotation: varchar('short_notation', { length: 50 }).notNull(),
|
||||
fullName: varchar('full_name', { length: 100 }).notNull(),
|
||||
}, (t) => ({
|
||||
unq_short_notation: unique('unique_short_notation').on(t.shortNotation),
|
||||
}));
|
||||
|
||||
export const productInfo = mf.table('product_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
shortDescription: varchar('short_description', { length: 500 }),
|
||||
longDescription: varchar('long_description', { length: 1000 }),
|
||||
unitId: integer('unit_id').notNull().references(() => units.id),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
marketPrice: numeric('market_price', { precision: 10, scale: 2 }),
|
||||
images: jsonb('images'),
|
||||
isOutOfStock: boolean('is_out_of_stock').notNull().default(false),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
isFlashAvailable: boolean('is_flash_available').notNull().default(false),
|
||||
flashPrice: numeric('flash_price', { precision: 10, scale: 2 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
incrementStep: real('increment_step').notNull().default(1),
|
||||
productQuantity: real('product_quantity').notNull().default(1),
|
||||
storeId: integer('store_id').references(() => storeInfo.id),
|
||||
});
|
||||
|
||||
export const productGroupInfo = mf.table('product_group_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
groupName: varchar('group_name', { length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productGroupMembership = mf.table('product_group_membership', {
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
groupId: integer('group_id').notNull().references(() => productGroupInfo.id),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
pk: unique('product_group_membership_pk').on(t.productId, t.groupId),
|
||||
}));
|
||||
|
||||
export const homeBanners = mf.table('home_banners', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
imageUrl: varchar('image_url', { length: 500 }).notNull(),
|
||||
description: varchar('description', { length: 500 }),
|
||||
productIds: integer('product_ids').array(),
|
||||
redirectUrl: varchar('redirect_url', { length: 500 }),
|
||||
serialNum: integer('serial_num'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
lastUpdated: timestamp('last_updated').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productReviews = mf.table('product_reviews', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
reviewBody: text('review_body').notNull(),
|
||||
imageUrls: jsonb('image_urls').$defaultFn(() => []),
|
||||
reviewTime: timestamp('review_time').notNull().defaultNow(),
|
||||
ratings: real('ratings').notNull(),
|
||||
adminResponse: text('admin_response'),
|
||||
adminResponseImages: jsonb('admin_response_images').$defaultFn(() => []),
|
||||
}, (t) => ({
|
||||
ratingCheck: check('rating_check', sql`${t.ratings} >= 1 AND ${t.ratings} <= 5`),
|
||||
}));
|
||||
|
||||
export const uploadStatusEnum = pgEnum('upload_status', ['pending', 'claimed']);
|
||||
|
||||
export const staffRoleEnum = pgEnum('staff_role', ['super_admin', 'admin', 'marketer', 'delivery_staff']);
|
||||
|
||||
export const staffPermissionEnum = pgEnum('staff_permission', ['crud_product', 'make_coupon', 'crud_staff_users']);
|
||||
|
||||
export const uploadUrlStatus = mf.table('upload_url_status', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
key: varchar('key', { length: 500 }).notNull(),
|
||||
status: uploadStatusEnum('status').notNull().default('pending'),
|
||||
});
|
||||
|
||||
export const productTagInfo = mf.table('product_tag_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
tagName: varchar('tag_name', { length: 100 }).notNull().unique(),
|
||||
tagDescription: varchar('tag_description', { length: 500 }),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
isDashboardTag: boolean('is_dashboard_tag').notNull().default(false),
|
||||
relatedStores: jsonb('related_stores').$defaultFn(() => []),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productTags = mf.table('product_tags', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
tagId: integer('tag_id').notNull().references(() => productTagInfo.id),
|
||||
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_product_tag: unique('unique_product_tag').on(t.productId, t.tagId),
|
||||
}));
|
||||
|
||||
export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
deliveryTime: timestamp('delivery_time').notNull(),
|
||||
freezeTime: timestamp('freeze_time').notNull(),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
isFlash: boolean('is_flash').notNull().default(false),
|
||||
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
|
||||
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||
});
|
||||
|
||||
export const vendorSnippets = mf.table('vendor_snippets', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
|
||||
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||
isPermanent: boolean('is_permanent').notNull().default(false),
|
||||
productIds: integer('product_ids').array().notNull(),
|
||||
validTill: timestamp('valid_till'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
||||
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
||||
}));
|
||||
|
||||
export const productSlots = mf.table('product_slots', {
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id),
|
||||
}, (t) => ({
|
||||
pk: unique('product_slot_pk').on(t.productId, t.slotId),
|
||||
}));
|
||||
|
||||
export const specialDeals = mf.table('special_deals', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
validTill: timestamp('valid_till').notNull(),
|
||||
});
|
||||
|
||||
export const orders = mf.table('orders', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
addressId: integer('address_id').notNull().references(() => addresses.id),
|
||||
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||
isCod: boolean('is_cod').notNull().default(false),
|
||||
isOnlinePayment: boolean('is_online_payment').notNull().default(false),
|
||||
paymentInfoId: integer('payment_info_id').references(() => paymentInfoTable.id),
|
||||
totalAmount: numeric('total_amount', { precision: 10, scale: 2 }).notNull(),
|
||||
deliveryCharge: numeric('delivery_charge', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
readableId: integer('readable_id').notNull(),
|
||||
adminNotes: text('admin_notes'),
|
||||
userNotes: text('user_notes'),
|
||||
orderGroupId: varchar('order_group_id', { length: 255 }),
|
||||
orderGroupProportion: decimal('order_group_proportion', { precision: 10, scale: 4 }),
|
||||
isFlashDelivery: boolean('is_flash_delivery').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const orderItems = mf.table('order_items', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: varchar('quantity', { length: 50 }).notNull(),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
discountedPrice: numeric('discounted_price', { precision: 10, scale: 2 }),
|
||||
is_packaged: boolean('is_packaged').notNull().default(false),
|
||||
is_package_verified: boolean('is_package_verified').notNull().default(false),
|
||||
});
|
||||
|
||||
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'success', 'cod', 'failed']);
|
||||
|
||||
export const orderStatus = mf.table('order_status', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderTime: timestamp('order_time').notNull().defaultNow(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
isPackaged: boolean('is_packaged').notNull().default(false),
|
||||
isDelivered: boolean('is_delivered').notNull().default(false),
|
||||
isCancelled: boolean('is_cancelled').notNull().default(false),
|
||||
cancelReason: varchar('cancel_reason', { length: 255 }),
|
||||
isCancelledByAdmin: boolean('is_cancelled_by_admin'),
|
||||
paymentStatus: paymentStatusEnum('payment_state').notNull().default('pending'),
|
||||
cancellationUserNotes: text('cancellation_user_notes'),
|
||||
cancellationAdminNotes: text('cancellation_admin_notes'),
|
||||
cancellationReviewed: boolean('cancellation_reviewed').notNull().default(false),
|
||||
cancellationReviewedAt: timestamp('cancellation_reviewed_at'),
|
||||
refundCouponId: integer('refund_coupon_id').references(() => coupons.id),
|
||||
});
|
||||
|
||||
export const paymentInfoTable = mf.table('payment_info', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
status: varchar({ length: 50 }).notNull(),
|
||||
gateway: varchar({ length: 50 }).notNull(),
|
||||
orderId: varchar('order_id', { length: 500 }),
|
||||
token: varchar({ length: 500 }),
|
||||
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||
payload: jsonb('payload'),
|
||||
});
|
||||
|
||||
export const payments = mf.table('payments', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
status: varchar({ length: 50 }).notNull(),
|
||||
gateway: varchar({ length: 50 }).notNull(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
token: varchar({ length: 500 }),
|
||||
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||
payload: jsonb('payload'),
|
||||
});
|
||||
|
||||
export const refunds = mf.table('refunds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||
refundAmount: numeric('refund_amount', { precision: 10, scale: 2 }),
|
||||
refundStatus: varchar('refund_status', { length: 50 }).default('none'),
|
||||
merchantRefundId: varchar('merchant_refund_id', { length: 255 }),
|
||||
refundProcessedAt: timestamp('refund_processed_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const keyValStore = mf.table('key_val_store', {
|
||||
key: varchar('key', { length: 255 }).primaryKey(),
|
||||
value: jsonb('value'),
|
||||
});
|
||||
|
||||
export const notifications = mf.table('notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
title: varchar({ length: 255 }).notNull(),
|
||||
body: varchar({ length: 512 }).notNull(),
|
||||
type: varchar({ length: 50 }),
|
||||
isRead: boolean('is_read').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const productCategories = mf.table('product_categories', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: varchar({ length: 500 }),
|
||||
});
|
||||
|
||||
export const cartItems = mf.table('cart_items', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_user_product: unique('unique_user_product').on(t.userId, t.productId),
|
||||
}));
|
||||
|
||||
export const complaints = mf.table('complaints', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
complaintBody: varchar('complaint_body', { length: 1000 }).notNull(),
|
||||
images: jsonb('images'),
|
||||
response: varchar('response', { length: 1000 }),
|
||||
isResolved: boolean('is_resolved').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const coupons = mf.table('coupons', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponCode: varchar('coupon_code', { length: 50 }).notNull().unique('unique_coupon_code'),
|
||||
isUserBased: boolean('is_user_based').notNull().default(false),
|
||||
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||
productIds: jsonb('product_ids'),
|
||||
createdBy: integer('created_by').references(() => staffUsers.id),
|
||||
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||
isApplyForAll: boolean('is_apply_for_all').notNull().default(false),
|
||||
validTill: timestamp('valid_till'),
|
||||
maxLimitForUser: integer('max_limit_for_user'),
|
||||
isInvalidated: boolean('is_invalidated').notNull().default(false),
|
||||
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const couponUsage = mf.table('coupon_usage', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
orderItemId: integer('order_item_id').references(() => orderItems.id),
|
||||
usedAt: timestamp('used_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const couponApplicableUsers = mf.table('coupon_applicable_users', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
}, (t) => ({
|
||||
unq_coupon_user: unique('unique_coupon_user').on(t.couponId, t.userId),
|
||||
}));
|
||||
|
||||
export const couponApplicableProducts = mf.table('coupon_applicable_products', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||
}, (t) => ({
|
||||
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
|
||||
}));
|
||||
|
||||
export const userIncidents = mf.table('user_incidents', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
orderId: integer('order_id').references(() => orders.id),
|
||||
dateAdded: timestamp('date_added').notNull().defaultNow(),
|
||||
adminComment: text('admin_comment'),
|
||||
addedBy: integer('added_by').references(() => staffUsers.id),
|
||||
negativityScore: integer('negativity_score'),
|
||||
});
|
||||
|
||||
export const reservedCoupons = mf.table('reserved_coupons', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
|
||||
couponCode: varchar('coupon_code', { length: 50 }).notNull(),
|
||||
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||
productIds: jsonb('product_ids'),
|
||||
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||
validTill: timestamp('valid_till'),
|
||||
maxLimitForUser: integer('max_limit_for_user'),
|
||||
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||
isRedeemed: boolean('is_redeemed').notNull().default(false),
|
||||
redeemedBy: integer('redeemed_by').references(() => users.id),
|
||||
redeemedAt: timestamp('redeemed_at'),
|
||||
createdBy: integer('created_by').notNull().references(() => staffUsers.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_secret_code: unique('unique_secret_code').on(t.secretCode),
|
||||
}));
|
||||
|
||||
export const notifCreds = mf.table('notif_creds', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
token: varchar({ length: 500 }).notNull().unique(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
userId: integer('user_id').notNull().references(() => users.id),
|
||||
lastVerified: timestamp('last_verified'),
|
||||
});
|
||||
|
||||
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
token: varchar({ length: 500 }).notNull().unique(),
|
||||
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||
lastVerified: timestamp('last_verified'),
|
||||
});
|
||||
|
||||
export const userNotifications = mf.table('user_notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
body: text('body').notNull(),
|
||||
applicableUsers: jsonb('applicable_users'),
|
||||
});
|
||||
|
||||
export const staffRoles = mf.table('staff_roles', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
roleName: staffRoleEnum('role_name').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_role_name: unique('unique_role_name').on(t.roleName),
|
||||
}));
|
||||
|
||||
export const staffPermissions = mf.table('staff_permissions', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
permissionName: staffPermissionEnum('permission_name').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_permission_name: unique('unique_permission_name').on(t.permissionName),
|
||||
}));
|
||||
|
||||
export const staffRolePermissions = mf.table('staff_role_permissions', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
staffRoleId: integer('staff_role_id').notNull().references(() => staffRoles.id),
|
||||
staffPermissionId: integer('staff_permission_id').notNull().references(() => staffPermissions.id),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
unq_role_permission: unique('unique_role_permission').on(t.staffRoleId, t.staffPermissionId),
|
||||
}));
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many, one }) => ({
|
||||
addresses: many(addresses),
|
||||
orders: many(orders),
|
||||
notifications: many(notifications),
|
||||
cartItems: many(cartItems),
|
||||
userCreds: one(userCreds),
|
||||
coupons: many(coupons),
|
||||
couponUsages: many(couponUsage),
|
||||
applicableCoupons: many(couponApplicableUsers),
|
||||
userDetails: one(userDetails),
|
||||
notifCreds: many(notifCreds),
|
||||
userIncidents: many(userIncidents),
|
||||
}));
|
||||
|
||||
export const userCredsRelations = relations(userCreds, ({ one }) => ({
|
||||
user: one(users, { fields: [userCreds.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const staffUsersRelations = relations(staffUsers, ({ one, many }) => ({
|
||||
role: one(staffRoles, { fields: [staffUsers.staffRoleId], references: [staffRoles.id] }),
|
||||
coupons: many(coupons),
|
||||
stores: many(storeInfo),
|
||||
}));
|
||||
|
||||
export const addressesRelations = relations(addresses, ({ one, many }) => ({
|
||||
user: one(users, { fields: [addresses.userId], references: [users.id] }),
|
||||
orders: many(orders),
|
||||
zone: one(addressZones, { fields: [addresses.zoneId], references: [addressZones.id] }),
|
||||
}));
|
||||
|
||||
export const unitsRelations = relations(units, ({ many }) => ({
|
||||
products: many(productInfo),
|
||||
}));
|
||||
|
||||
export const productInfoRelations = relations(productInfo, ({ one, many }) => ({
|
||||
unit: one(units, { fields: [productInfo.unitId], references: [units.id] }),
|
||||
store: one(storeInfo, { fields: [productInfo.storeId], references: [storeInfo.id] }),
|
||||
productSlots: many(productSlots),
|
||||
specialDeals: many(specialDeals),
|
||||
orderItems: many(orderItems),
|
||||
cartItems: many(cartItems),
|
||||
tags: many(productTags),
|
||||
applicableCoupons: many(couponApplicableProducts),
|
||||
reviews: many(productReviews),
|
||||
groups: many(productGroupMembership),
|
||||
}));
|
||||
|
||||
export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({
|
||||
products: many(productTags),
|
||||
}));
|
||||
|
||||
export const productTagsRelations = relations(productTags, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productTags.productId], references: [productInfo.id] }),
|
||||
tag: one(productTagInfo, { fields: [productTags.tagId], references: [productTagInfo.id] }),
|
||||
}));
|
||||
|
||||
export const deliverySlotInfoRelations = relations(deliverySlotInfo, ({ many }) => ({
|
||||
productSlots: many(productSlots),
|
||||
orders: many(orders),
|
||||
vendorSnippets: many(vendorSnippets),
|
||||
}));
|
||||
|
||||
export const productSlotsRelations = relations(productSlots, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productSlots.productId], references: [productInfo.id] }),
|
||||
slot: one(deliverySlotInfo, { fields: [productSlots.slotId], references: [deliverySlotInfo.id] }),
|
||||
}));
|
||||
|
||||
export const specialDealsRelations = relations(specialDeals, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [specialDeals.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
user: one(users, { fields: [orders.userId], references: [users.id] }),
|
||||
address: one(addresses, { fields: [orders.addressId], references: [addresses.id] }),
|
||||
slot: one(deliverySlotInfo, { fields: [orders.slotId], references: [deliverySlotInfo.id] }),
|
||||
orderItems: many(orderItems),
|
||||
payment: one(payments),
|
||||
paymentInfo: one(paymentInfoTable, { fields: [orders.paymentInfoId], references: [paymentInfoTable.id] }),
|
||||
orderStatus: many(orderStatus),
|
||||
refunds: many(refunds),
|
||||
couponUsages: many(couponUsage),
|
||||
userIncidents: many(userIncidents),
|
||||
}));
|
||||
|
||||
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
||||
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
|
||||
product: one(productInfo, { fields: [orderItems.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const orderStatusRelations = relations(orderStatus, ({ one }) => ({
|
||||
order: one(orders, { fields: [orderStatus.orderId], references: [orders.id] }),
|
||||
user: one(users, { fields: [orderStatus.userId], references: [users.id] }),
|
||||
refundCoupon: one(coupons, { fields: [orderStatus.refundCouponId], references: [coupons.id] }),
|
||||
}));
|
||||
|
||||
export const paymentInfoRelations = relations(paymentInfoTable, ({ one }) => ({
|
||||
order: one(orders, { fields: [paymentInfoTable.id], references: [orders.paymentInfoId] }),
|
||||
}));
|
||||
|
||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||
order: one(orders, { fields: [payments.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const refundsRelations = relations(refunds, ({ one }) => ({
|
||||
order: one(orders, { fields: [refunds.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, { fields: [notifications.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const productCategoriesRelations = relations(productCategories, ({}) => ({}));
|
||||
|
||||
export const cartItemsRelations = relations(cartItems, ({ one }) => ({
|
||||
user: one(users, { fields: [cartItems.userId], references: [users.id] }),
|
||||
product: one(productInfo, { fields: [cartItems.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const complaintsRelations = relations(complaints, ({ one }) => ({
|
||||
user: one(users, { fields: [complaints.userId], references: [users.id] }),
|
||||
order: one(orders, { fields: [complaints.orderId], references: [orders.id] }),
|
||||
}));
|
||||
|
||||
export const couponsRelations = relations(coupons, ({ one, many }) => ({
|
||||
creator: one(staffUsers, { fields: [coupons.createdBy], references: [staffUsers.id] }),
|
||||
usages: many(couponUsage),
|
||||
applicableUsers: many(couponApplicableUsers),
|
||||
applicableProducts: many(couponApplicableProducts),
|
||||
}));
|
||||
|
||||
export const couponUsageRelations = relations(couponUsage, ({ one }) => ({
|
||||
user: one(users, { fields: [couponUsage.userId], references: [users.id] }),
|
||||
coupon: one(coupons, { fields: [couponUsage.couponId], references: [coupons.id] }),
|
||||
order: one(orders, { fields: [couponUsage.orderId], references: [orders.id] }),
|
||||
orderItem: one(orderItems, { fields: [couponUsage.orderItemId], references: [orderItems.id] }),
|
||||
}));
|
||||
|
||||
export const userDetailsRelations = relations(userDetails, ({ one }) => ({
|
||||
user: one(users, { fields: [userDetails.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
||||
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
|
||||
// No relations needed for now
|
||||
}));
|
||||
|
||||
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
||||
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||
products: many(productInfo),
|
||||
}));
|
||||
|
||||
export const couponApplicableUsersRelations = relations(couponApplicableUsers, ({ one }) => ({
|
||||
coupon: one(coupons, { fields: [couponApplicableUsers.couponId], references: [coupons.id] }),
|
||||
user: one(users, { fields: [couponApplicableUsers.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const couponApplicableProductsRelations = relations(couponApplicableProducts, ({ one }) => ({
|
||||
coupon: one(coupons, { fields: [couponApplicableProducts.couponId], references: [coupons.id] }),
|
||||
product: one(productInfo, { fields: [couponApplicableProducts.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const reservedCouponsRelations = relations(reservedCoupons, ({ one }) => ({
|
||||
redeemedUser: one(users, { fields: [reservedCoupons.redeemedBy], references: [users.id] }),
|
||||
creator: one(staffUsers, { fields: [reservedCoupons.createdBy], references: [staffUsers.id] }),
|
||||
}));
|
||||
|
||||
export const productReviewsRelations = relations(productReviews, ({ one }) => ({
|
||||
user: one(users, { fields: [productReviews.userId], references: [users.id] }),
|
||||
product: one(productInfo, { fields: [productReviews.productId], references: [productInfo.id] }),
|
||||
}));
|
||||
|
||||
export const addressZonesRelations = relations(addressZones, ({ many }) => ({
|
||||
addresses: many(addresses),
|
||||
areas: many(addressAreas),
|
||||
}));
|
||||
|
||||
export const addressAreasRelations = relations(addressAreas, ({ one }) => ({
|
||||
zone: one(addressZones, { fields: [addressAreas.zoneId], references: [addressZones.id] }),
|
||||
}));
|
||||
|
||||
export const productGroupInfoRelations = relations(productGroupInfo, ({ many }) => ({
|
||||
memberships: many(productGroupMembership),
|
||||
}));
|
||||
|
||||
export const productGroupMembershipRelations = relations(productGroupMembership, ({ one }) => ({
|
||||
product: one(productInfo, { fields: [productGroupMembership.productId], references: [productInfo.id] }),
|
||||
group: one(productGroupInfo, { fields: [productGroupMembership.groupId], references: [productGroupInfo.id] }),
|
||||
}));
|
||||
|
||||
export const homeBannersRelations = relations(homeBanners, ({}) => ({
|
||||
// Relations for productIds array would be more complex, skipping for now
|
||||
}));
|
||||
|
||||
export const staffRolesRelations = relations(staffRoles, ({ many }) => ({
|
||||
staffUsers: many(staffUsers),
|
||||
rolePermissions: many(staffRolePermissions),
|
||||
}));
|
||||
|
||||
export const staffPermissionsRelations = relations(staffPermissions, ({ many }) => ({
|
||||
rolePermissions: many(staffRolePermissions),
|
||||
}));
|
||||
|
||||
export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({
|
||||
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
||||
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
||||
}));
|
||||
|
||||
export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
||||
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
|
||||
}));
|
||||
export * from './schema-sqlite'
|
||||
|
|
|
|||
|
|
@ -1,138 +1,8 @@
|
|||
import { db } from "@/src/db/db_index"
|
||||
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
|
||||
import { eq } from "drizzle-orm";
|
||||
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { seed as seedPostgres } from '@db-helper-postgres/db/seed'
|
||||
import { seed as seedSqlite } from '@db-helper-sqlite/db/seed'
|
||||
|
||||
export async function seed() {
|
||||
console.log("Seeding database...");
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
// Seed units individually
|
||||
const unitsToSeed = [
|
||||
{ shortNotation: "Kg", fullName: "Kilogram" },
|
||||
{ shortNotation: "L", fullName: "Litre" },
|
||||
{ shortNotation: "Dz", fullName: "Dozen" },
|
||||
{ shortNotation: "Pc", fullName: "Unit Piece" },
|
||||
];
|
||||
const seedImpl = dialect === 'sqlite' ? seedSqlite : seedPostgres
|
||||
|
||||
for (const unit of unitsToSeed) {
|
||||
const existingUnit = await db.query.units.findFirst({
|
||||
where: eq(units.shortNotation, unit.shortNotation),
|
||||
});
|
||||
if (!existingUnit) {
|
||||
await db.insert(units).values(unit);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed staff roles individually
|
||||
const rolesToSeed = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const;
|
||||
|
||||
for (const roleName of rolesToSeed) {
|
||||
const existingRole = await db.query.staffRoles.findFirst({
|
||||
where: eq(staffRoles.roleName, roleName),
|
||||
});
|
||||
if (!existingRole) {
|
||||
await db.insert(staffRoles).values({ roleName });
|
||||
}
|
||||
}
|
||||
|
||||
// Seed staff permissions individually
|
||||
const permissionsToSeed = ['crud_product', 'make_coupon', 'crud_staff_users'] as const;
|
||||
|
||||
for (const permissionName of permissionsToSeed) {
|
||||
const existingPermission = await db.query.staffPermissions.findFirst({
|
||||
where: eq(staffPermissions.permissionName, permissionName),
|
||||
});
|
||||
if (!existingPermission) {
|
||||
await db.insert(staffPermissions).values({ permissionName });
|
||||
}
|
||||
}
|
||||
|
||||
// Seed role-permission assignments
|
||||
await db.transaction(async (tx) => {
|
||||
// Get role IDs
|
||||
const superAdminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'super_admin') });
|
||||
const adminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'admin') });
|
||||
const marketerRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'marketer') });
|
||||
|
||||
// Get permission IDs
|
||||
const crudProductPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_product') });
|
||||
const makeCouponPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'make_coupon') });
|
||||
const crudStaffUsersPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_staff_users') });
|
||||
|
||||
// Assign all permissions to super_admin
|
||||
[crudProductPerm, makeCouponPerm, crudStaffUsersPerm].forEach(async (perm) => {
|
||||
if (superAdminRole && perm) {
|
||||
const existingSuperAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, superAdminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||
});
|
||||
if (!existingSuperAdminPerm) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: superAdminRole.id,
|
||||
staffPermissionId: perm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Assign all permissions to admin
|
||||
[crudProductPerm, makeCouponPerm].forEach(async (perm) => {
|
||||
if (adminRole && perm) {
|
||||
const existingAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, adminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||
});
|
||||
if (!existingAdminPerm) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: adminRole.id,
|
||||
staffPermissionId: perm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Assign make_coupon to marketer
|
||||
if (marketerRole && makeCouponPerm) {
|
||||
const existingMarketerCoupon = await tx.query.staffRolePermissions.findFirst({
|
||||
where: eq(staffRolePermissions.staffRoleId, marketerRole.id) && eq(staffRolePermissions.staffPermissionId, makeCouponPerm.id),
|
||||
});
|
||||
if (!existingMarketerCoupon) {
|
||||
await tx.insert(staffRolePermissions).values({
|
||||
staffRoleId: marketerRole.id,
|
||||
staffPermissionId: makeCouponPerm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Seed key-val store constants using CONST_KEYS
|
||||
const constantsToSeed = [
|
||||
{ key: CONST_KEYS.readableOrderId, value: 0 },
|
||||
{ key: CONST_KEYS.minRegularOrderValue, value: minOrderValue },
|
||||
{ key: CONST_KEYS.freeDeliveryThreshold, value: minOrderValue },
|
||||
{ key: CONST_KEYS.deliveryCharge, value: deliveryCharge },
|
||||
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
|
||||
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
|
||||
{ key: CONST_KEYS.popularItems, value: [] },
|
||||
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
||||
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
||||
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
|
||||
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
||||
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
||||
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||
];
|
||||
|
||||
for (const constant of constantsToSeed) {
|
||||
const existing = await db.query.keyValStore.findFirst({
|
||||
where: eq(keyValStore.key, constant.key),
|
||||
});
|
||||
if (!existing) {
|
||||
await db.insert(keyValStore).values({
|
||||
key: constant.key,
|
||||
value: constant.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seeding completed.");
|
||||
}
|
||||
export const seed = async () => seedImpl()
|
||||
|
|
|
|||
1
apps/backend/src/db/sqlite-casts.ts
Normal file
1
apps/backend/src/db/sqlite-casts.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@/db-helper-sqlite/db/sqlite-casts'
|
||||
|
|
@ -1,47 +1,58 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type {
|
||||
users,
|
||||
addresses,
|
||||
units,
|
||||
productInfo,
|
||||
deliverySlotInfo,
|
||||
productSlots,
|
||||
specialDeals,
|
||||
orders,
|
||||
orderItems,
|
||||
payments,
|
||||
notifications,
|
||||
productCategories,
|
||||
cartItems,
|
||||
coupons,
|
||||
} from "@/src/db/schema";
|
||||
User as PostgresUser,
|
||||
Address as PostgresAddress,
|
||||
Unit as PostgresUnit,
|
||||
ProductInfo as PostgresProductInfo,
|
||||
DeliverySlotInfo as PostgresDeliverySlotInfo,
|
||||
ProductSlot as PostgresProductSlot,
|
||||
SpecialDeal as PostgresSpecialDeal,
|
||||
Order as PostgresOrder,
|
||||
OrderItem as PostgresOrderItem,
|
||||
Payment as PostgresPayment,
|
||||
Notification as PostgresNotification,
|
||||
ProductCategory as PostgresProductCategory,
|
||||
CartItem as PostgresCartItem,
|
||||
Coupon as PostgresCoupon,
|
||||
ProductWithUnit as PostgresProductWithUnit,
|
||||
OrderWithItems as PostgresOrderWithItems,
|
||||
CartItemWithProduct as PostgresCartItemWithProduct,
|
||||
} from '@db-helper-postgres/db/types'
|
||||
import type {
|
||||
User as SqliteUser,
|
||||
Address as SqliteAddress,
|
||||
Unit as SqliteUnit,
|
||||
ProductInfo as SqliteProductInfo,
|
||||
DeliverySlotInfo as SqliteDeliverySlotInfo,
|
||||
ProductSlot as SqliteProductSlot,
|
||||
SpecialDeal as SqliteSpecialDeal,
|
||||
Order as SqliteOrder,
|
||||
OrderItem as SqliteOrderItem,
|
||||
Payment as SqlitePayment,
|
||||
Notification as SqliteNotification,
|
||||
ProductCategory as SqliteProductCategory,
|
||||
CartItem as SqliteCartItem,
|
||||
Coupon as SqliteCoupon,
|
||||
ProductWithUnit as SqliteProductWithUnit,
|
||||
OrderWithItems as SqliteOrderWithItems,
|
||||
CartItemWithProduct as SqliteCartItemWithProduct,
|
||||
} from '@db-helper-sqlite/db/types'
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Address = InferSelectModel<typeof addresses>;
|
||||
export type Unit = InferSelectModel<typeof units>;
|
||||
export type ProductInfo = InferSelectModel<typeof productInfo>;
|
||||
export type DeliverySlotInfo = InferSelectModel<typeof deliverySlotInfo>;
|
||||
export type ProductSlot = InferSelectModel<typeof productSlots>;
|
||||
export type SpecialDeal = InferSelectModel<typeof specialDeals>;
|
||||
export type Order = InferSelectModel<typeof orders>;
|
||||
export type OrderItem = InferSelectModel<typeof orderItems>;
|
||||
export type Payment = InferSelectModel<typeof payments>;
|
||||
export type Notification = InferSelectModel<typeof notifications>;
|
||||
export type ProductCategory = InferSelectModel<typeof productCategories>;
|
||||
export type CartItem = InferSelectModel<typeof cartItems>;
|
||||
export type Coupon = InferSelectModel<typeof coupons>;
|
||||
type UseSqlite = typeof DB_DIALECT_TYPE extends 'sqlite' ? true : false
|
||||
|
||||
// Combined types
|
||||
export type ProductWithUnit = ProductInfo & {
|
||||
unit: Unit;
|
||||
};
|
||||
|
||||
export type OrderWithItems = Order & {
|
||||
items: (OrderItem & { product: ProductInfo })[];
|
||||
address: Address;
|
||||
slot: DeliverySlotInfo;
|
||||
};
|
||||
|
||||
export type CartItemWithProduct = CartItem & {
|
||||
product: ProductInfo;
|
||||
};
|
||||
export type User = UseSqlite extends true ? SqliteUser : PostgresUser
|
||||
export type Address = UseSqlite extends true ? SqliteAddress : PostgresAddress
|
||||
export type Unit = UseSqlite extends true ? SqliteUnit : PostgresUnit
|
||||
export type ProductInfo = UseSqlite extends true ? SqliteProductInfo : PostgresProductInfo
|
||||
export type DeliverySlotInfo = UseSqlite extends true ? SqliteDeliverySlotInfo : PostgresDeliverySlotInfo
|
||||
export type ProductSlot = UseSqlite extends true ? SqliteProductSlot : PostgresProductSlot
|
||||
export type SpecialDeal = UseSqlite extends true ? SqliteSpecialDeal : PostgresSpecialDeal
|
||||
export type Order = UseSqlite extends true ? SqliteOrder : PostgresOrder
|
||||
export type OrderItem = UseSqlite extends true ? SqliteOrderItem : PostgresOrderItem
|
||||
export type Payment = UseSqlite extends true ? SqlitePayment : PostgresPayment
|
||||
export type Notification = UseSqlite extends true ? SqliteNotification : PostgresNotification
|
||||
export type ProductCategory = UseSqlite extends true ? SqliteProductCategory : PostgresProductCategory
|
||||
export type CartItem = UseSqlite extends true ? SqliteCartItem : PostgresCartItem
|
||||
export type Coupon = UseSqlite extends true ? SqliteCoupon : PostgresCoupon
|
||||
export type ProductWithUnit = UseSqlite extends true ? SqliteProductWithUnit : PostgresProductWithUnit
|
||||
export type OrderWithItems = UseSqlite extends true ? SqliteOrderWithItems : PostgresOrderWithItems
|
||||
export type CartItemWithProduct = UseSqlite extends true ? SqliteCartItemWithProduct : PostgresCartItemWithProduct
|
||||
|
|
|
|||
14
apps/backend/src/db/upload-url.ts
Normal file
14
apps/backend/src/db/upload-url.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { claimUploadUrlStatus as claimUploadUrlStatusPostgres, createUploadUrlStatus as createUploadUrlStatusPostgres } from '@db-helper-postgres/lib/upload-url'
|
||||
import { claimUploadUrlStatus as claimUploadUrlStatusSqlite, createUploadUrlStatus as createUploadUrlStatusSqlite } from '@db-helper-sqlite/lib/upload-url'
|
||||
|
||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
||||
|
||||
const createUploadUrlStatus = dialect === 'sqlite'
|
||||
? createUploadUrlStatusSqlite
|
||||
: createUploadUrlStatusPostgres
|
||||
|
||||
const claimUploadUrlStatus = dialect === 'sqlite'
|
||||
? claimUploadUrlStatusSqlite
|
||||
: claimUploadUrlStatusPostgres
|
||||
|
||||
export { claimUploadUrlStatus, createUploadUrlStatus }
|
||||
|
|
@ -1,17 +1,9 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
||||
|
||||
const runCombinedJob = async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
console.log('Starting combined job: payments and refunds check');
|
||||
|
||||
// Run payment check
|
||||
// await checkPendingPayments();
|
||||
|
||||
// Run refund check
|
||||
// await checkRefundStatuses();
|
||||
|
||||
console.log('Starting combined job');
|
||||
console.log('Combined job completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error in combined job:', error);
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
|
||||
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
||||
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
|
||||
|
||||
interface PendingPaymentRecord {
|
||||
payment: typeof payments.$inferSelect;
|
||||
order: typeof orders.$inferSelect;
|
||||
slot: typeof deliverySlotInfo.$inferSelect;
|
||||
}
|
||||
|
||||
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||
// Construct message from record data
|
||||
const message = `Payment pending for order ORD${record.order.id}. Please complete before orders close time.`;
|
||||
|
||||
// TODO: Implement notification sending logic using record.order.userId, record.order.id, message
|
||||
console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`);
|
||||
};
|
||||
|
||||
export const checkRefundStatuses = async () => {
|
||||
try {
|
||||
const initiatedRefunds = await db
|
||||
.select()
|
||||
.from(refunds)
|
||||
.where(and(
|
||||
eq(refunds.refundStatus, 'initiated'),
|
||||
isNotNull(refunds.merchantRefundId)
|
||||
));
|
||||
|
||||
// Process refunds concurrently using Promise.allSettled
|
||||
const promises = initiatedRefunds.map(async (refund) => {
|
||||
if (!refund.merchantRefundId) return;
|
||||
|
||||
try {
|
||||
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
|
||||
|
||||
if (razorpayRefund.status === 'processed') {
|
||||
await db
|
||||
.update(refunds)
|
||||
.set({ refundStatus: 'success', refundProcessedAt: new Date() })
|
||||
.where(eq(refunds.id, refund.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking refund ${refund.id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all promises to complete
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error('Error in checkRefundStatuses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPendingPayments = async () => {
|
||||
try {
|
||||
const pendingPayments = await db
|
||||
.select({
|
||||
payment: payments,
|
||||
order: orders,
|
||||
slot: deliverySlotInfo,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(orders, eq(payments.orderId, orders.id))
|
||||
.innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id))
|
||||
.where(and(
|
||||
eq(payments.status, 'pending'),
|
||||
gt(deliverySlotInfo.freezeTime, new Date()) // Freeze time not passed
|
||||
));
|
||||
|
||||
for (const record of pendingPayments) {
|
||||
createPaymentNotification(record);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking pending payments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||
import { inArray, eq } from 'drizzle-orm';
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { computeConstants } from '@/src/lib/const-store'
|
||||
|
||||
|
||||
const MUTTON_ITEMS = [
|
||||
12, //Lamb mutton
|
||||
14, // Mutton Boti
|
||||
35, //Mutton Kheema
|
||||
84, //Mutton Brain
|
||||
4, //Mutton
|
||||
86, //Mutton Chops
|
||||
87, //Mutton Soup bones
|
||||
85 //Mutton paya
|
||||
];
|
||||
|
||||
|
||||
|
||||
export const startAutomatedJobs = () => {
|
||||
// Job to disable flash delivery for mutton at 12 PM daily
|
||||
cron.schedule('0 12 * * *', async () => {
|
||||
try {
|
||||
console.log('Disabling flash delivery for products at 12 PM');
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ isFlashAvailable: false })
|
||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||
console.log('Flash delivery disabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disabling flash delivery:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to enable flash delivery for mutton at 6 AM daily
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
try {
|
||||
console.log('Enabling flash delivery for products at 5 AM');
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ isFlashAvailable: true })
|
||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||
console.log('Flash delivery enabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error enabling flash delivery:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to disable flash delivery feature at 9 PM daily
|
||||
cron.schedule('0 21 * * *', async () => {
|
||||
try {
|
||||
console.log('Disabling flash delivery feature at 9 PM');
|
||||
await db
|
||||
.update(keyValStore)
|
||||
.set({ value: false })
|
||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature disabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disabling flash delivery feature:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Job to enable flash delivery feature at 6 AM daily
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
try {
|
||||
console.log('Enabling flash delivery feature at 6 AM');
|
||||
await db
|
||||
.update(keyValStore)
|
||||
.set({ value: true })
|
||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature enabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error enabling flash delivery feature:', error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Automated jobs scheduled');
|
||||
};
|
||||
|
||||
// Optional: Call on import if desired, or export and call in main app
|
||||
// startAutomatedJobs();
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import express from 'express';
|
||||
const catchAsync =
|
||||
(fn: express.RequestHandler) =>
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) =>
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
export default catchAsync;
|
||||
|
|
@ -1,46 +1,54 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||
import { s3Url } from "@/src/lib/env-exporter"
|
||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from '@/src/lib/s3-client'
|
||||
import { assetsDomain, s3Url } from '@/src/lib/env-exporter'
|
||||
|
||||
function extractS3Key(url: string): string | null {
|
||||
try {
|
||||
// Check if this is a signed URL first and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(url) || url;
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(url) || url
|
||||
|
||||
// Find the index of '.com/' in the URL
|
||||
// const comIndex = originalUrl.indexOf(".com/");
|
||||
const baseUrlIndex = originalUrl.indexOf(s3Url);
|
||||
const baseUrlIndex = originalUrl.indexOf(s3Url)
|
||||
|
||||
// If '.com/' is found, return everything after it
|
||||
if (baseUrlIndex !== -1) {
|
||||
return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/'
|
||||
return originalUrl.substring(baseUrlIndex + s3Url.length) // +5 to skip '.com/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error extracting key from URL:", error);
|
||||
console.error('Error extracting key from URL:', error)
|
||||
}
|
||||
|
||||
// Return null if the pattern isn't found or there was an error
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export async function deleteS3Image(imageUrl: string) {
|
||||
try {
|
||||
// First check if this is a signed URL and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||
|
||||
const key = extractS3Key(originalUrl || "");
|
||||
let key: string | null = ''
|
||||
|
||||
|
||||
if (!key) {
|
||||
throw new Error("Invalid image URL format");
|
||||
if (imageUrl.includes(assetsDomain)) {
|
||||
key = imageUrl.replace(assetsDomain, '')
|
||||
}
|
||||
const deleteS3 = await deleteImageUtil({keys: [key] });
|
||||
else if (imageUrl.startsWith('http')) {
|
||||
// First check if this is a signed URL and get the original if it is
|
||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl
|
||||
|
||||
key = extractS3Key(originalUrl || '')
|
||||
}
|
||||
|
||||
else {
|
||||
key = imageUrl
|
||||
}
|
||||
if (!key) {
|
||||
throw new Error('Invalid image URL format')
|
||||
}
|
||||
const deleteS3 = await deleteImageUtil({ keys: [key] })
|
||||
if (!deleteS3) {
|
||||
throw new Error("Failed to delete image from S3");
|
||||
throw new Error('Failed to delete image from S3')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting image from S3:", error);
|
||||
console.error('Error deleting image from S3:', error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export const s3Region = process.env.S3_REGION as string
|
|||
|
||||
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||
|
||||
export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
||||
|
||||
export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
||||
|
||||
export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
||||
|
||||
export const s3Url = process.env.S3_URL as string
|
||||
|
||||
export const redisUrl = process.env.REDIS_URL as string
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
|
|||
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
||||
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
||||
import { deleteOrders } from '@/src/lib/delete-orders'
|
||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
||||
import { verifyProductsAvailabilityBySchedule } from './manage-scheduled-availability'
|
||||
|
||||
/**
|
||||
* Initialize all application services
|
||||
|
|
@ -18,6 +20,7 @@ export const initFunc = async (): Promise<void> => {
|
|||
try {
|
||||
console.log('Starting application initialization...');
|
||||
|
||||
await verifyProductsAvailabilityBySchedule(false);
|
||||
await Promise.all([
|
||||
initializeAllStores(),
|
||||
initializeUserNegativityStore(),
|
||||
|
|
@ -25,6 +28,10 @@ export const initFunc = async (): Promise<void> => {
|
|||
startCancellationHandler(),
|
||||
]);
|
||||
|
||||
// Create all cache files after stores are initialized
|
||||
await createAllCacheFiles();
|
||||
console.log('Cache files created successfully');
|
||||
|
||||
console.log('Application initialization completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Application initialization failed:', error);
|
||||
|
|
|
|||
72
apps/backend/src/lib/jwt-utils.ts
Normal file
72
apps/backend/src/lib/jwt-utils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { SignJWT, jwtVerify, errors, JWTPayload } from 'jose';
|
||||
import { jwtSecret } from './env-exporter';
|
||||
|
||||
// JWT Payload Types
|
||||
export interface UserJWTPayload extends JWTPayload {
|
||||
userId: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface StaffJWTPayload extends JWTPayload {
|
||||
staffId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Convert string secret to Uint8Array
|
||||
const getSecret = () => {
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT secret not configured');
|
||||
}
|
||||
return new TextEncoder().encode(jwtSecret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign a JWT token
|
||||
* Compatible with tokens signed by jsonwebtoken library
|
||||
*/
|
||||
export const signToken = async (
|
||||
payload: Record<string, unknown>,
|
||||
expiresIn: string = '7d'
|
||||
): Promise<string> => {
|
||||
const secret = getSecret();
|
||||
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
* Compatible with tokens signed by jsonwebtoken library
|
||||
*/
|
||||
export const verifyToken = async (token: string): Promise<JWTPayload> => {
|
||||
try {
|
||||
const secret = getSecret();
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof errors.JWTExpired) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
if (error instanceof errors.JWTInvalid) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error is a JWT-related error
|
||||
*/
|
||||
export const isJWTError = (error: unknown): boolean => {
|
||||
return error instanceof errors.JOSEError ||
|
||||
(error instanceof Error && (
|
||||
error.message.includes('token') ||
|
||||
error.message.includes('JWT')
|
||||
));
|
||||
};
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { db } from "@/src/db/db_index"
|
||||
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||
import { sendPushNotificationsMany } from '@/src/lib/expo-service'
|
||||
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
// Core notification dispatch methods (renamed for clarity)
|
||||
export async function dispatchBulkNotification({
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import Razorpay from "razorpay";
|
||||
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { payments } from "@/src/db/schema"
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
export class RazorpayPaymentService {
|
||||
private static instance = new Razorpay({
|
||||
key_id: razorpayId,
|
||||
key_secret: razorpaySecret,
|
||||
});
|
||||
|
||||
static async createOrder(orderId: number, amount: string) {
|
||||
// Create Razorpay order
|
||||
const razorpayOrder = await this.instance.orders.create({
|
||||
amount: parseFloat(amount) * 100, // Convert to paisa
|
||||
currency: 'INR',
|
||||
receipt: `order_${orderId}`,
|
||||
notes: {
|
||||
customerOrderId: orderId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return razorpayOrder;
|
||||
}
|
||||
|
||||
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
|
||||
// Use transaction if provided, otherwise use db
|
||||
const dbInstance = tx || db;
|
||||
|
||||
// Insert payment record
|
||||
const [payment] = await dbInstance
|
||||
.insert(payments)
|
||||
.values({
|
||||
status: 'pending',
|
||||
gateway: 'razorpay',
|
||||
orderId,
|
||||
token: orderId.toString(),
|
||||
merchantOrderId: razorpayOrder.id,
|
||||
payload: razorpayOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
static async initiateRefund(paymentId: string, amount: number) {
|
||||
const refund = await this.instance.payments.refund(paymentId, {
|
||||
amount,
|
||||
});
|
||||
return refund;
|
||||
}
|
||||
|
||||
static async fetchRefund(refundId: string) {
|
||||
const refund = await this.instance.refunds.fetch(refundId);
|
||||
return refund;
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/lib/retry.ts
Normal file
23
apps/backend/src/lib/retry.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export async function retryWithExponentialBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delayMs: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
delayMs *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { db } from "@/src/db/db_index"
|
||||
|
||||
/**
|
||||
* Constants for role names to avoid hardcoding and typos
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client,
|
|||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import signedUrlCache from "@/src/lib/signed-url-cache"
|
||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
||||
import { uploadUrlStatus } from "@/src/db/schema"
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/db/upload-url'
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: s3Region,
|
||||
|
|
@ -161,10 +159,7 @@ export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expi
|
|||
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
||||
try {
|
||||
// Insert record into upload_url_status
|
||||
await db.insert(uploadUrlStatus).values({
|
||||
key: key,
|
||||
status: 'pending',
|
||||
});
|
||||
await createUploadUrlStatus(key)
|
||||
|
||||
// Generate signed upload URL
|
||||
const command = new PutObjectCommand({
|
||||
|
|
@ -201,19 +196,13 @@ export function extractKeyFromPresignedUrl(url: string): string {
|
|||
|
||||
export async function claimUploadUrl(url: string): Promise<void> {
|
||||
try {
|
||||
const semiKey = extractKeyFromPresignedUrl(url);
|
||||
const key = s3BucketName+'/'+ semiKey
|
||||
let semiKey:string = ''
|
||||
|
||||
// Update status to 'claimed' if currently 'pending'
|
||||
const result = await db
|
||||
.update(uploadUrlStatus)
|
||||
.set({ status: 'claimed' })
|
||||
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('Upload URL not found or already claimed');
|
||||
}
|
||||
if(url.startsWith('http'))
|
||||
semiKey = extractKeyFromPresignedUrl(url);
|
||||
else
|
||||
semiKey = url
|
||||
await claimUploadUrlStatus(semiKey)
|
||||
} catch (error) {
|
||||
console.error('Error claiming upload URL:', error);
|
||||
throw new Error('Failed to claim upload URL');
|
||||
|
|
|
|||
132
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
132
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
|
|
@ -1,8 +1,3 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
|
|
@ -16,18 +11,7 @@ class SignedURLCache {
|
|||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
console.log('SignedURLCache: Initialized (in-memory only)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,7 +94,7 @@ class SignedURLCache {
|
|||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
this.saveToDisk();
|
||||
console.log('SignedURLCache: Cleared all entries');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -145,119 +129,27 @@ class SignedURLCache {
|
|||
}
|
||||
|
||||
/**
|
||||
* Save the cache to disk
|
||||
* Get cache statistics
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
try {
|
||||
// Remove expired entries before saving
|
||||
const removedCount = this.clearExpired();
|
||||
|
||||
// Convert Maps to serializable objects
|
||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
serializedOriginalToSigned[originalUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
getStats(): { totalEntries: number } {
|
||||
return {
|
||||
totalEntries: this.originalToSignedCache.size
|
||||
};
|
||||
}
|
||||
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
serializedSignedToOriginal[signedUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const serializedCache = {
|
||||
originalToSigned: serializedOriginalToSigned,
|
||||
signedToOriginal: serializedSignedToOriginal
|
||||
};
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(
|
||||
CACHE_FILE_PATH,
|
||||
JSON.stringify(serializedCache),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||
} catch (error) {
|
||||
console.error('Error saving SignedURLCache to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from disk
|
||||
* Stub methods for backward compatibility - do nothing in in-memory mode
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
// No-op: In-memory cache only
|
||||
}
|
||||
|
||||
loadFromDisk(): void {
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
// No-op: In-memory cache only
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
const signedUrlCache = new SignedURLCache();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default signedUrlCache;
|
||||
263
apps/backend/src/lib/signed-url-cache.ts.txt
Executable file
263
apps/backend/src/lib/signed-url-cache.ts.txt
Executable file
|
|
@ -0,0 +1,263 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||
|
||||
// Interface for cache entries with TTL
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAt: number; // Timestamp when this entry expires
|
||||
}
|
||||
|
||||
class SignedURLCache {
|
||||
private originalToSignedCache: Map<string, CacheEntry>;
|
||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
||||
|
||||
constructor() {
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
console.log('creating the directory')
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
else {
|
||||
console.log('the directory is already present')
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL from the cache using an original URL as the key
|
||||
*/
|
||||
get(originalUrl: string): string | undefined {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
// Also remove from reverse mapping if it exists
|
||||
this.signedToOriginalCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original URL from the cache using a signed URL as the key
|
||||
*/
|
||||
getOriginalUrl(signedUrl: string): string | undefined {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// If no entry or entry has expired, return undefined
|
||||
if (!entry || Date.now() > entry.expiresAt) {
|
||||
if (entry) {
|
||||
// Remove expired entry
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// Also remove from primary mapping if it exists
|
||||
this.originalToSignedCache.delete(entry.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache with a TTL (Time To Live)
|
||||
* @param originalUrl The original S3 URL
|
||||
* @param signedUrl The signed URL
|
||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
||||
*/
|
||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
|
||||
const entry: CacheEntry = {
|
||||
value: signedUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
const reverseEntry: CacheEntry = {
|
||||
value: originalUrl,
|
||||
expiresAt
|
||||
};
|
||||
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
||||
}
|
||||
|
||||
has(originalUrl: string): boolean {
|
||||
const entry = this.originalToSignedCache.get(originalUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
hasSignedUrl(signedUrl: string): boolean {
|
||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||
|
||||
// Entry exists and hasn't expired
|
||||
return !!entry && Date.now() <= entry.expiresAt;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.originalToSignedCache.clear();
|
||||
this.signedToOriginalCache.clear();
|
||||
this.saveToDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all expired entries from the cache
|
||||
* @returns The number of expired entries that were removed
|
||||
*/
|
||||
clearExpired(): number {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
// Clear expired entries from original to signed cache
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.originalToSignedCache.delete(originalUrl);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear expired entries from signed to original cache
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.signedToOriginalCache.delete(signedUrl);
|
||||
// No need to increment removedCount as we've already counted these in the first loop
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to disk
|
||||
*/
|
||||
saveToDisk(): void {
|
||||
try {
|
||||
// Remove expired entries before saving
|
||||
const removedCount = this.clearExpired();
|
||||
|
||||
// Convert Maps to serializable objects
|
||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||
|
||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||
serializedOriginalToSigned[originalUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||
serializedSignedToOriginal[signedUrl] = {
|
||||
value: entry.value,
|
||||
expiresAt: entry.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
const serializedCache = {
|
||||
originalToSigned: serializedOriginalToSigned,
|
||||
signedToOriginal: serializedSignedToOriginal
|
||||
};
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(
|
||||
CACHE_FILE_PATH,
|
||||
JSON.stringify(serializedCache),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||
} catch (error) {
|
||||
console.error('Error saving SignedURLCache to disk:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from disk
|
||||
*/
|
||||
loadFromDisk(): void {
|
||||
try {
|
||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||
// Read from file
|
||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||
|
||||
// Parse the data
|
||||
const parsedData = JSON.parse(data) as {
|
||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||
};
|
||||
|
||||
// Only load entries that haven't expired yet
|
||||
const now = Date.now();
|
||||
let loadedCount = 0;
|
||||
let expiredCount = 0;
|
||||
|
||||
// Load original to signed mappings
|
||||
if (parsedData.originalToSigned) {
|
||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.originalToSignedCache.set(originalUrl, entry);
|
||||
loadedCount++;
|
||||
} else {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load signed to original mappings
|
||||
if (parsedData.signedToOriginal) {
|
||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||
if (now <= entry.expiresAt) {
|
||||
this.signedToOriginalCache.set(signedUrl, entry);
|
||||
// Don't increment loadedCount as these are pairs of what we already counted
|
||||
} else {
|
||||
// Don't increment expiredCount as these are pairs of what we already counted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||
} else {
|
||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SignedURLCache from disk:', error);
|
||||
// Start with empty caches if loading fails
|
||||
this.originalToSignedCache = new Map();
|
||||
this.signedToOriginalCache = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance to be used throughout the application
|
||||
const signedUrlCache = new SignedURLCache();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||
signedUrlCache.saveToDisk();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
export default signedUrlCache;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import multerParent from 'multer';
|
||||
const uploadHandler = multerParent({
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10 MB
|
||||
}
|
||||
});
|
||||
|
||||
export default uploadHandler
|
||||
|
|
@ -1,65 +1,34 @@
|
|||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import avRouter from "@/src/apis/admin-apis/apis/av-router"
|
||||
import { ApiError } from "@/src/lib/api-error"
|
||||
import v1Router from "@/src/v1-router"
|
||||
import testController from "@/src/test-controller"
|
||||
import { authenticateUser } from "@/src/middleware/auth.middleware"
|
||||
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
|
||||
import uploadHandler from "@/src/lib/upload-handler"
|
||||
import { Hono } from 'hono'
|
||||
import { authenticateUser } from '@/src/middleware/auth.middleware'
|
||||
import v1Router from '@/src/v1-router'
|
||||
|
||||
|
||||
const router = Router();
|
||||
// Note: This router is kept for compatibility during migration
|
||||
// Most routes have been moved to tRPC
|
||||
const router = new Hono()
|
||||
|
||||
// Health check endpoints (no auth required)
|
||||
router.get('/health', (req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
// Note: These are also defined in index.ts, keeping for compatibility
|
||||
router.get('/health', (c) => {
|
||||
return c.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
message: 'Hello world'
|
||||
});
|
||||
});
|
||||
router.get('/seed', (req:Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/seed', (c) => {
|
||||
return c.json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// Mount v1 routes (REST API)
|
||||
router.route('/v1', v1Router)
|
||||
|
||||
// Apply authentication middleware to all subsequent routes
|
||||
router.use(authenticateUser);
|
||||
router.use('*', authenticateUser)
|
||||
|
||||
router.use('/v1', v1Router);
|
||||
// router.use('/av', avRouter);
|
||||
router.use('/test', testController);
|
||||
|
||||
// User REST APIs
|
||||
router.post('/uv/complaints/raise',
|
||||
uploadHandler.array('images', 5),
|
||||
raiseComplaint
|
||||
);
|
||||
|
||||
// Global error handling middleware
|
||||
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.message,
|
||||
details: err.details,
|
||||
statusCode: err.statusCode
|
||||
});
|
||||
}
|
||||
|
||||
// Handle unknown errors
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
statusCode: 500
|
||||
});
|
||||
});
|
||||
|
||||
const mainRouter = router;
|
||||
|
||||
export default mainRouter;
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
};
|
||||
staffUser?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authenticateUser = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new ApiError('Authorization token required', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
console.log(req.headers)
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||
|
||||
// Check if this is a staff token (has staffId)
|
||||
if (decoded.staffId) {
|
||||
// This is a staff token, verify staff exists
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.id, decoded.staffId),
|
||||
});
|
||||
|
||||
if (!staff) {
|
||||
throw new ApiError('Invalid staff token', 401);
|
||||
}
|
||||
|
||||
req.staffUser = {
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
};
|
||||
} else {
|
||||
// This is a regular user token
|
||||
req.user = decoded;
|
||||
|
||||
// Check if user is suspended
|
||||
const details = await db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, decoded.userId),
|
||||
});
|
||||
|
||||
if (details?.isSuspended) {
|
||||
throw new ApiError('Account suspended', 403);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,67 +1,67 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { verifyToken, isJWTError, UserJWTPayload } from '@/src/lib/jwt-utils'
|
||||
|
||||
// Extend the Request interface to include user property
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
// Type for Hono context variables
|
||||
type Variables = {
|
||||
user: UserJWTPayload
|
||||
}
|
||||
|
||||
export const verifyToken = (req: Request, res: Response, next: NextFunction) => {
|
||||
/**
|
||||
* Hono middleware to verify JWT token and attach user to context
|
||||
*/
|
||||
export const verifyTokenMiddleware = createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
const authHeader = c.req.header('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new ApiError('Access denied. No token provided', 401);
|
||||
throw new ApiError('Access denied. No token provided', 401)
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const token = authHeader.split(' ')[1]
|
||||
|
||||
if (!token) {
|
||||
throw new ApiError('Access denied. Invalid token format', 401);
|
||||
throw new ApiError('Access denied. Invalid token format', 401)
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
const decoded = await verifyToken(token) as UserJWTPayload
|
||||
|
||||
// Add user info to context
|
||||
c.set('user', decoded)
|
||||
|
||||
// Add user info to request
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
await next()
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
next(new ApiError('Invalid Auth Credentials', 401));
|
||||
} else {
|
||||
next(error);
|
||||
if (isJWTError(error)) {
|
||||
throw new ApiError('Invalid Auth Credentials', 401)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
// Keep old name for backward compatibility
|
||||
export { verifyTokenMiddleware as verifyToken }
|
||||
|
||||
/**
|
||||
* Hono middleware to require specific roles
|
||||
*/
|
||||
export const requireRole = (roles: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('Authentication required', 401);
|
||||
return createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
||||
const user = c.get('user')
|
||||
|
||||
if (!user) {
|
||||
throw new ApiError('Authentication required', 401)
|
||||
}
|
||||
|
||||
// Check if user has any of the required roles
|
||||
const userRoles = req.user.roles || [];
|
||||
const hasPermission = roles.some(role => userRoles.includes(role));
|
||||
const userRoles = user.roles || []
|
||||
const hasPermission = roles.some(role => userRoles.includes(role))
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ApiError('Access denied. Insufficient permissions', 403);
|
||||
throw new ApiError('Access denied. Insufficient permissions', 403)
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
await next()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,405 +0,0 @@
|
|||
import { db } from '@/src/db/db_index'
|
||||
import {
|
||||
orders,
|
||||
orderItems,
|
||||
orderStatus,
|
||||
addresses,
|
||||
productInfo,
|
||||
paymentInfoTable,
|
||||
coupons,
|
||||
couponUsage,
|
||||
payments,
|
||||
cartItems,
|
||||
refunds,
|
||||
units,
|
||||
userDetails,
|
||||
} from '@/src/db/schema'
|
||||
import { eq, and, inArray, desc, gte } from 'drizzle-orm'
|
||||
|
||||
// ============ User/Auth Queries ============
|
||||
|
||||
/**
|
||||
* Get user details by user ID
|
||||
*/
|
||||
export async function getUserDetails(userId: number) {
|
||||
return db.query.userDetails.findFirst({
|
||||
where: eq(userDetails.userId, userId),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Address Queries ============
|
||||
|
||||
/**
|
||||
* Get user address by ID
|
||||
*/
|
||||
export async function getUserAddress(userId: number, addressId: number) {
|
||||
return db.query.addresses.findFirst({
|
||||
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Product Queries ============
|
||||
|
||||
/**
|
||||
* Get product by ID
|
||||
*/
|
||||
export async function getProductById(productId: number) {
|
||||
return db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, productId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple products by IDs with unit info
|
||||
*/
|
||||
export async function getProductsByIdsWithUnits(productIds: number[]) {
|
||||
return db
|
||||
.select({
|
||||
id: productInfo.id,
|
||||
name: productInfo.name,
|
||||
shortDescription: productInfo.shortDescription,
|
||||
price: productInfo.price,
|
||||
images: productInfo.images,
|
||||
isOutOfStock: productInfo.isOutOfStock,
|
||||
unitShortNotation: units.shortNotation,
|
||||
incrementStep: productInfo.incrementStep,
|
||||
})
|
||||
.from(productInfo)
|
||||
.innerJoin(units, eq(productInfo.unitId, units.id))
|
||||
.where(and(inArray(productInfo.id, productIds), eq(productInfo.isSuspended, false)))
|
||||
.orderBy(desc(productInfo.createdAt))
|
||||
}
|
||||
|
||||
// ============ Coupon Queries ============
|
||||
|
||||
/**
|
||||
* Get coupon with usages for user
|
||||
*/
|
||||
export async function getCouponWithUsages(couponId: number, userId: number) {
|
||||
return db.query.coupons.findFirst({
|
||||
where: eq(coupons.id, couponId),
|
||||
with: {
|
||||
usages: { where: eq(couponUsage.userId, userId) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert coupon usage
|
||||
*/
|
||||
export async function insertCouponUsage(data: {
|
||||
userId: number
|
||||
couponId: number
|
||||
orderId: number
|
||||
orderItemId: number | null
|
||||
usedAt: Date
|
||||
}) {
|
||||
return db.insert(couponUsage).values(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coupon usages for order
|
||||
*/
|
||||
export async function getCouponUsagesForOrder(orderId: number) {
|
||||
return db.query.couponUsage.findMany({
|
||||
where: eq(couponUsage.orderId, orderId),
|
||||
with: {
|
||||
coupon: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Cart Queries ============
|
||||
|
||||
/**
|
||||
* Delete cart items for user by product IDs
|
||||
*/
|
||||
export async function deleteCartItems(userId: number, productIds: number[]) {
|
||||
return db.delete(cartItems).where(
|
||||
and(
|
||||
eq(cartItems.userId, userId),
|
||||
inArray(cartItems.productId, productIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Payment Info Queries ============
|
||||
|
||||
/**
|
||||
* Create payment info
|
||||
*/
|
||||
export async function createPaymentInfo(data: {
|
||||
status: string
|
||||
gateway: string
|
||||
merchantOrderId: string
|
||||
}) {
|
||||
return db.insert(paymentInfoTable).values(data).returning()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ Order Queries ============
|
||||
|
||||
/**
|
||||
* Insert multiple orders
|
||||
*/
|
||||
export async function insertOrders(ordersData: any[]) {
|
||||
return db.insert(orders).values(ordersData).returning()
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple order items
|
||||
*/
|
||||
export async function insertOrderItems(itemsData: any[]) {
|
||||
return db.insert(orderItems).values(itemsData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple order statuses
|
||||
*/
|
||||
export async function insertOrderStatuses(statusesData: any[]) {
|
||||
return db.insert(orderStatus).values(statusesData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user orders with all relations
|
||||
*/
|
||||
export async function getUserOrdersWithRelations(userId: number, limit: number, offset: number) {
|
||||
return db.query.orders.findMany({
|
||||
where: eq(orders.userId, userId),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
slot: true,
|
||||
paymentInfo: true,
|
||||
orderStatus: true,
|
||||
refunds: true,
|
||||
},
|
||||
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Count user orders
|
||||
*/
|
||||
export async function countUserOrders(userId: number) {
|
||||
return db.$count(orders, eq(orders.userId, userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with all relations
|
||||
*/
|
||||
export async function getOrderByIdWithRelations(orderId: number) {
|
||||
return db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
slot: true,
|
||||
paymentInfo: true,
|
||||
orderStatus: {
|
||||
with: {
|
||||
refundCoupon: true,
|
||||
},
|
||||
},
|
||||
refunds: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with order status
|
||||
*/
|
||||
export async function getOrderWithStatus(orderId: number) {
|
||||
return db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
orderStatus: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order status to cancelled
|
||||
*/
|
||||
export async function updateOrderStatusToCancelled(
|
||||
statusId: number,
|
||||
data: {
|
||||
isCancelled: boolean
|
||||
cancelReason: string
|
||||
cancellationUserNotes: string
|
||||
cancellationReviewed: boolean
|
||||
}
|
||||
) {
|
||||
return db
|
||||
.update(orderStatus)
|
||||
.set(data)
|
||||
.where(eq(orderStatus.id, statusId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert refund record
|
||||
*/
|
||||
export async function insertRefund(data: { orderId: number; refundStatus: string }) {
|
||||
return db.insert(refunds).values(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order notes
|
||||
*/
|
||||
export async function updateOrderNotes(orderId: number, userNotes: string | null) {
|
||||
return db
|
||||
.update(orders)
|
||||
.set({ userNotes })
|
||||
.where(eq(orders.id, orderId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent delivered orders for user
|
||||
*/
|
||||
export async function getRecentDeliveredOrders(
|
||||
userId: number,
|
||||
since: Date,
|
||||
limit: number
|
||||
) {
|
||||
return db
|
||||
.select({ id: orders.id })
|
||||
.from(orders)
|
||||
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.userId, userId),
|
||||
eq(orderStatus.isDelivered, true),
|
||||
gte(orders.createdAt, since)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(orders.createdAt))
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order items by order IDs
|
||||
*/
|
||||
export async function getOrderItemsByOrderIds(orderIds: number[]) {
|
||||
return db
|
||||
.select({ productId: orderItems.productId })
|
||||
.from(orderItems)
|
||||
.where(inArray(orderItems.orderId, orderIds))
|
||||
}
|
||||
|
||||
// ============ Transaction Helper ============
|
||||
|
||||
/**
|
||||
* Execute function within a database transaction
|
||||
*/
|
||||
export async function withTransaction<T>(fn: (tx: any) => Promise<T>): Promise<T> {
|
||||
return db.transaction(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel order with refund record in a transaction
|
||||
*/
|
||||
export async function cancelOrderWithRefund(
|
||||
statusId: number,
|
||||
orderId: number,
|
||||
isCod: boolean,
|
||||
reason: string
|
||||
): Promise<{ orderId: number }> {
|
||||
return db.transaction(async (tx) => {
|
||||
// Update order status
|
||||
await tx
|
||||
.update(orderStatus)
|
||||
.set({
|
||||
isCancelled: true,
|
||||
cancelReason: reason,
|
||||
cancellationUserNotes: reason,
|
||||
cancellationReviewed: false,
|
||||
})
|
||||
.where(eq(orderStatus.id, statusId))
|
||||
|
||||
// Insert refund record
|
||||
const refundStatus = isCod ? "na" : "pending"
|
||||
await tx.insert(refunds).values({
|
||||
orderId,
|
||||
refundStatus,
|
||||
})
|
||||
|
||||
return { orderId }
|
||||
})
|
||||
}
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* Create orders with payment info in a transaction
|
||||
*/
|
||||
export async function createOrdersWithPayment(
|
||||
ordersData: any[],
|
||||
paymentMethod: "online" | "cod",
|
||||
totalWithDelivery: number,
|
||||
razorpayOrderCreator?: (paymentInfoId: number, amount: string) => Promise<any>,
|
||||
paymentRecordInserter?: (paymentInfoId: number, razorpayOrder: any, tx: Tx) => Promise<any>
|
||||
): Promise<typeof orders.$inferSelect[]> {
|
||||
return db.transaction(async (tx) => {
|
||||
let sharedPaymentInfoId: number | null = null
|
||||
if (paymentMethod === "online") {
|
||||
const [paymentInfo] = await tx
|
||||
.insert(paymentInfoTable)
|
||||
.values({
|
||||
status: "pending",
|
||||
gateway: "razorpay",
|
||||
merchantOrderId: `multi_order_${Date.now()}`,
|
||||
})
|
||||
.returning()
|
||||
sharedPaymentInfoId = paymentInfo.id
|
||||
}
|
||||
|
||||
const ordersToInsert: Omit<typeof orders.$inferInsert, "id">[] = ordersData.map(
|
||||
(od) => ({
|
||||
...od.order,
|
||||
paymentInfoId: sharedPaymentInfoId,
|
||||
})
|
||||
)
|
||||
|
||||
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning()
|
||||
|
||||
const allOrderItems: Omit<typeof orderItems.$inferInsert, "id">[] = []
|
||||
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, "id">[] = []
|
||||
|
||||
insertedOrders.forEach((order: typeof orders.$inferSelect, index: number) => {
|
||||
const od = ordersData[index]
|
||||
od.orderItems.forEach((item: any) => {
|
||||
allOrderItems.push({ ...item, orderId: order.id as number })
|
||||
})
|
||||
allOrderStatuses.push({
|
||||
...od.orderStatus,
|
||||
orderId: order.id as number,
|
||||
})
|
||||
})
|
||||
|
||||
await tx.insert(orderItems).values(allOrderItems)
|
||||
await tx.insert(orderStatus).values(allOrderStatuses)
|
||||
|
||||
if (paymentMethod === "online" && sharedPaymentInfoId && razorpayOrderCreator && paymentRecordInserter) {
|
||||
const razorpayOrder = await razorpayOrderCreator(
|
||||
sharedPaymentInfoId,
|
||||
totalWithDelivery.toString()
|
||||
)
|
||||
await paymentRecordInserter(
|
||||
sharedPaymentInfoId,
|
||||
razorpayOrder,
|
||||
tx
|
||||
)
|
||||
}
|
||||
|
||||
return insertedOrders
|
||||
})
|
||||
}
|
||||
|
|
@ -4,6 +4,11 @@ import { initializeProducts } from '@/src/stores/product-store'
|
|||
import { initializeProductTagStore } from '@/src/stores/product-tag-store'
|
||||
import { initializeSlotStore } from '@/src/stores/slot-store'
|
||||
import { initializeBannerStore } from '@/src/stores/banner-store'
|
||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
||||
|
||||
// const STORE_INIT_DELAY_MS = 3 * 60 * 1000
|
||||
const STORE_INIT_DELAY_MS = 0.5 * 60 * 1000
|
||||
let storeInitializationTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
/**
|
||||
* Initialize all application stores
|
||||
|
|
@ -29,8 +34,27 @@ export const initializeAllStores = async (): Promise<void> => {
|
|||
]);
|
||||
|
||||
console.log('All application stores initialized successfully');
|
||||
|
||||
// Regenerate all cache files (fire-and-forget)
|
||||
createAllCacheFiles().catch(error => {
|
||||
console.error('Failed to regenerate cache files during store initialization:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Application stores initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleStoreInitialization = (): void => {
|
||||
if (storeInitializationTimeout) {
|
||||
clearTimeout(storeInitializationTimeout)
|
||||
storeInitializationTimeout = null
|
||||
}
|
||||
|
||||
storeInitializationTimeout = setTimeout(() => {
|
||||
storeInitializationTimeout = null
|
||||
initializeAllStores().catch(error => {
|
||||
console.error('Scheduled store initialization failed:', error)
|
||||
})
|
||||
}, STORE_INIT_DELAY_MS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Health check passed',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { addressZones, addressAreas } from '@/src/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
|
||||
const addressRouter = router({
|
||||
getZones: protectedProcedure.query(async () => {
|
||||
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
|
||||
return zones
|
||||
}),
|
||||
|
||||
getAreas: protectedProcedure.query(async () => {
|
||||
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
|
||||
return areas
|
||||
}),
|
||||
|
||||
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
|
||||
|
||||
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
|
||||
return {zone: zone};
|
||||
}),
|
||||
|
||||
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
|
||||
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
|
||||
return {area};
|
||||
}),
|
||||
|
||||
// TODO: Add update and delete mutations if needed
|
||||
});
|
||||
|
||||
export default addressRouter;
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
import { router } from '@/src/trpc/trpc-index'
|
||||
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
|
||||
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
|
||||
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
|
||||
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
|
||||
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
|
||||
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
|
||||
|
|
@ -10,15 +9,15 @@ import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
|
|||
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
|
||||
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
|
||||
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
|
||||
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
|
||||
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
||||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
|
||||
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
|
||||
|
||||
export const adminRouter = router({
|
||||
complaint: complaintRouter,
|
||||
coupon: couponRouter,
|
||||
cancelledOrders: cancelledOrdersRouter,
|
||||
order: orderRouter,
|
||||
vendorSnippets: vendorSnippetsRouter,
|
||||
slots: slotsRouter,
|
||||
|
|
@ -26,10 +25,11 @@ export const adminRouter = router({
|
|||
staffUser: staffUserRouter,
|
||||
store: storeRouter,
|
||||
payments: adminPaymentsRouter,
|
||||
address: addressRouter,
|
||||
banner: bannerRouter,
|
||||
user: userRouter,
|
||||
const: constRouter,
|
||||
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
|
||||
tag: tagRouter,
|
||||
});
|
||||
|
||||
export type AdminRouter = typeof adminRouter;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { homeBanners } from '@/src/db/schema'
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
|
||||
import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
import { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client'
|
||||
import { ApiError } from '@/src/lib/api-error';
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const bannerRouter = router({
|
||||
// Get all banners
|
||||
getBanners: protectedProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
|
||||
const banners = await db.query.homeBanners.findMany({
|
||||
orderBy: desc(homeBanners.createdAt), // Order by creation date instead
|
||||
// Removed product relationship since we now use productIds array
|
||||
});
|
||||
const banners = await bannerDbService.getAllBanners()
|
||||
|
||||
// Convert S3 keys to signed URLs for client
|
||||
const bannersWithSignedUrls = await Promise.all(
|
||||
|
|
@ -24,16 +18,14 @@ export const bannerRouter = router({
|
|||
try {
|
||||
return {
|
||||
...banner,
|
||||
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl,
|
||||
// Ensure productIds is always an array
|
||||
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
|
||||
productIds: banner.productIds || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||
return {
|
||||
...banner,
|
||||
imageUrl: banner.imageUrl, // Keep original on error
|
||||
// Ensure productIds is always an array
|
||||
imageUrl: banner.imageUrl,
|
||||
productIds: banner.productIds || [],
|
||||
};
|
||||
}
|
||||
|
|
@ -43,10 +35,8 @@ export const bannerRouter = router({
|
|||
return {
|
||||
banners: bannersWithSignedUrls,
|
||||
};
|
||||
}
|
||||
catch(e:any) {
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
|
||||
throw new ApiError(e.message);
|
||||
}
|
||||
}),
|
||||
|
|
@ -55,23 +45,17 @@ export const bannerRouter = router({
|
|||
getBanner: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const banner = await db.query.homeBanners.findFirst({
|
||||
where: eq(homeBanners.id, input.id),
|
||||
// Removed product relationship since we now use productIds array
|
||||
});
|
||||
const banner = await bannerDbService.getBannerById(input.id)
|
||||
|
||||
if (banner) {
|
||||
try {
|
||||
// Convert S3 key to signed URL for client
|
||||
if (banner.imageUrl) {
|
||||
banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl);
|
||||
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||
// Keep original imageUrl on error
|
||||
}
|
||||
|
||||
// Ensure productIds is always an array (handle migration compatibility)
|
||||
if (!banner.productIds) {
|
||||
banner.productIds = [];
|
||||
}
|
||||
|
|
@ -84,32 +68,31 @@ export const bannerRouter = router({
|
|||
createBanner: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
imageUrl: z.string().url(),
|
||||
imageUrl: z.string(),
|
||||
description: z.string().optional(),
|
||||
productIds: z.array(z.number()).optional(),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
// serialNum removed completely
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
||||
const [banner] = await db.insert(homeBanners).values({
|
||||
|
||||
const banner = await bannerDbService.createBanner({
|
||||
name: input.name,
|
||||
imageUrl: imageUrl,
|
||||
description: input.description,
|
||||
productIds: input.productIds || [],
|
||||
redirectUrl: input.redirectUrl,
|
||||
serialNum: 999, // Default value, not used
|
||||
isActive: false, // Default to inactive
|
||||
}).returning();
|
||||
serialNum: 999,
|
||||
isActive: false,
|
||||
})
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return banner;
|
||||
} catch (error) {
|
||||
console.error('Error creating banner:', error);
|
||||
throw error; // Re-throw to maintain tRPC error handling
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -127,31 +110,21 @@ export const bannerRouter = router({
|
|||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
|
||||
const { id, ...updateData } = input;
|
||||
const incomingProductIds = input.productIds;
|
||||
// Extract S3 key from presigned URL if imageUrl is provided
|
||||
const processedData = {
|
||||
...updateData,
|
||||
...(updateData.imageUrl && {
|
||||
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
|
||||
}),
|
||||
};
|
||||
|
||||
// Handle serialNum null case
|
||||
const finalData: any = { ...processedData };
|
||||
if ('serialNum' in finalData && finalData.serialNum === null) {
|
||||
// Set to null explicitly
|
||||
finalData.serialNum = null;
|
||||
const processedData: any = { ...updateData }
|
||||
|
||||
if (updateData.imageUrl) {
|
||||
processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl)
|
||||
}
|
||||
|
||||
const [banner] = await db.update(homeBanners)
|
||||
.set({ ...finalData, lastUpdated: new Date(), })
|
||||
.where(eq(homeBanners.id, id))
|
||||
.returning();
|
||||
if ('serialNum' in processedData && processedData.serialNum === null) {
|
||||
processedData.serialNum = null;
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
const banner = await bannerDbService.updateBannerById(id, processedData)
|
||||
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return banner;
|
||||
} catch (error) {
|
||||
|
|
@ -164,10 +137,9 @@ export const bannerRouter = router({
|
|||
deleteBanner: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
|
||||
await bannerDbService.deleteBannerById(input.id)
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
const updateCancellationReviewSchema = z.object({
|
||||
orderId: z.number(),
|
||||
cancellationReviewed: z.boolean(),
|
||||
adminNotes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateRefundSchema = z.object({
|
||||
orderId: z.number(),
|
||||
isRefundDone: z.boolean(),
|
||||
});
|
||||
|
||||
export const cancelledOrdersRouter = router({
|
||||
getAll: protectedProcedure
|
||||
.query(async () => {
|
||||
// First get cancelled order statuses with order details
|
||||
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
|
||||
where: eq(orderStatus.isCancelled, true),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
refunds: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(orderStatus.orderTime)],
|
||||
});
|
||||
|
||||
const filteredStatuses = cancelledOrderStatuses.filter(status => {
|
||||
return status.order.isCod || status.paymentStatus === 'success';
|
||||
});
|
||||
|
||||
return filteredStatuses.map(status => {
|
||||
const refund = status.order.refunds[0];
|
||||
return {
|
||||
id: status.order.id,
|
||||
readableId: status.order.id,
|
||||
customerName: `${status.order.user.name}`,
|
||||
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
|
||||
totalAmount: status.order.totalAmount,
|
||||
cancellationReviewed: status.cancellationReviewed || false,
|
||||
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||
adminNotes: status.order.adminNotes,
|
||||
cancelReason: status.cancelReason,
|
||||
paymentMode: status.order.isCod ? 'COD' : 'Online',
|
||||
paymentStatus: status.paymentStatus || 'pending',
|
||||
items: status.order.orderItems.map(item => ({
|
||||
name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
unit: item.product.unit?.shortNotation,
|
||||
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
|
||||
})),
|
||||
createdAt: status.order.createdAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
updateReview: protectedProcedure
|
||||
.input(updateCancellationReviewSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { orderId, cancellationReviewed, adminNotes } = input;
|
||||
|
||||
const result = await db.update(orderStatus)
|
||||
.set({
|
||||
cancellationReviewed,
|
||||
cancellationAdminNotes: adminNotes || null,
|
||||
cancellationReviewedAt: new Date(),
|
||||
})
|
||||
.where(eq(orderStatus.orderId, orderId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Cancellation record not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Get cancelled order with full details
|
||||
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
|
||||
where: eq(orderStatus.id, id),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
|
||||
throw new Error("Cancelled order not found");
|
||||
}
|
||||
|
||||
// Get refund details separately
|
||||
const refund = await db.query.refunds.findFirst({
|
||||
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
|
||||
});
|
||||
|
||||
const order = cancelledOrderStatus.order;
|
||||
|
||||
// Format the response similar to the getAll method
|
||||
const formattedOrder = {
|
||||
id: order.id,
|
||||
readableId: order.id,
|
||||
customerName: order.user.name,
|
||||
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
|
||||
totalAmount: order.totalAmount,
|
||||
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
|
||||
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
|
||||
cancelReason: cancelledOrderStatus.cancelReason || null,
|
||||
items: order.orderItems.map((item: any) => ({
|
||||
name: item.product.name,
|
||||
quantity: item.quantity,
|
||||
price: parseFloat(item.price.toString()),
|
||||
unit: item.product.unit?.shortNotation || 'unit',
|
||||
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||
image: item.product.images?.[0] || null,
|
||||
})),
|
||||
createdAt: order.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
return { order: formattedOrder };
|
||||
}),
|
||||
|
||||
updateRefund: protectedProcedure
|
||||
.input(updateRefundSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { orderId, isRefundDone } = input;
|
||||
|
||||
const refundStatus = isRefundDone ? 'processed' : 'none';
|
||||
const result = await db.update(refunds)
|
||||
.set({
|
||||
refundStatus,
|
||||
refundProcessedAt: isRefundDone ? new Date() : null,
|
||||
})
|
||||
.where(eq(refunds.orderId, orderId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Cancellation record not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { complaints, users } from '@/src/db/schema'
|
||||
import { eq, desc, lt, and } from 'drizzle-orm';
|
||||
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const complaintRouter = router({
|
||||
getAll: protectedProcedure
|
||||
|
|
@ -14,27 +12,7 @@ export const complaintRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { cursor, limit } = input;
|
||||
|
||||
let whereCondition = cursor
|
||||
? lt(complaints.id, cursor)
|
||||
: undefined;
|
||||
|
||||
const complaintsData = await db
|
||||
.select({
|
||||
id: complaints.id,
|
||||
complaintBody: complaints.complaintBody,
|
||||
userId: complaints.userId,
|
||||
orderId: complaints.orderId,
|
||||
isResolved: complaints.isResolved,
|
||||
createdAt: complaints.createdAt,
|
||||
userName: users.name,
|
||||
userMobile: users.mobile,
|
||||
images: complaints.images,
|
||||
})
|
||||
.from(complaints)
|
||||
.leftJoin(users, eq(complaints.userId, users.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(complaints.id))
|
||||
.limit(limit + 1);
|
||||
const complaintsData = await complaintDbService.getComplaints(cursor, limit);
|
||||
|
||||
const hasMore = complaintsData.length > limit;
|
||||
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
|
||||
|
|
@ -42,7 +20,7 @@ export const complaintRouter = router({
|
|||
const complaintsWithSignedImages = await Promise.all(
|
||||
complaintsToReturn.map(async (c) => {
|
||||
const signedImages = c.images
|
||||
? await generateSignedUrlsFromS3Urls(c.images as string[])
|
||||
? scaffoldAssetUrl(c.images as string[])
|
||||
: [];
|
||||
|
||||
return {
|
||||
|
|
@ -70,10 +48,7 @@ export const complaintRouter = router({
|
|||
resolve: protectedProcedure
|
||||
.input(z.object({ id: z.string(), response: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db
|
||||
.update(complaints)
|
||||
.set({ isResolved: true, response: input.response })
|
||||
.where(eq(complaints.id, parseInt(input.id)));
|
||||
await complaintDbService.resolveComplaint(parseInt(input.id), input.response);
|
||||
|
||||
return { message: 'Complaint resolved successfully' };
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { keyValStore } from '@/src/db/schema'
|
||||
import { computeConstants } from '@/src/lib/const-store'
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const constRouter = router({
|
||||
getConstants: protectedProcedure
|
||||
.query(async () => {
|
||||
|
||||
const constants = await db.select().from(keyValStore);
|
||||
const constants = await constantDbService.getAllConstants();
|
||||
|
||||
const resp = constants.map(c => ({
|
||||
key: c.key,
|
||||
|
|
@ -38,23 +36,14 @@ export const constRouter = router({
|
|||
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { key, value } of constants) {
|
||||
await tx.insert(keyValStore)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({
|
||||
target: keyValStore.key,
|
||||
set: { value },
|
||||
});
|
||||
}
|
||||
});
|
||||
const updatedCount = await constantDbService.upsertConstants(constants);
|
||||
|
||||
// Refresh all constants in Redis after database update
|
||||
await computeConstants();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: constants.length,
|
||||
updatedCount,
|
||||
keys: constants.map(c => c.key),
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
|
||||
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
|
||||
import dayjs from 'dayjs';
|
||||
import { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const createCouponBodySchema = z.object({
|
||||
couponCode: z.string().optional(),
|
||||
|
|
@ -51,10 +49,7 @@ export const couponRouter = router({
|
|||
|
||||
// If applicableUsers is provided, verify users exist
|
||||
if (applicableUsers && applicableUsers.length > 0) {
|
||||
const existingUsers = await db.query.users.findMany({
|
||||
where: inArray(users.id, applicableUsers),
|
||||
columns: { id: true },
|
||||
});
|
||||
const existingUsers = await couponDbService.getUsersByIds(applicableUsers);
|
||||
if (existingUsers.length !== applicableUsers.length) {
|
||||
throw new Error("Some applicable users not found");
|
||||
}
|
||||
|
|
@ -69,56 +64,40 @@ export const couponRouter = router({
|
|||
// Generate coupon code if not provided
|
||||
let finalCouponCode = couponCode;
|
||||
if (!finalCouponCode) {
|
||||
// Generate a unique coupon code
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
finalCouponCode = `MF${timestamp}${random}`;
|
||||
}
|
||||
|
||||
// Check if coupon code already exists
|
||||
const existingCoupon = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, finalCouponCode),
|
||||
});
|
||||
|
||||
const existingCoupon = await couponDbService.getCouponByCode(finalCouponCode);
|
||||
if (existingCoupon) {
|
||||
throw new Error("Coupon code already exists");
|
||||
}
|
||||
|
||||
const result = await db.insert(coupons).values({
|
||||
const coupon = await couponDbService.createCoupon({
|
||||
couponCode: finalCouponCode,
|
||||
isUserBased: isUserBased || false,
|
||||
discountPercent: discountPercent?.toString(),
|
||||
flatDiscount: flatDiscount?.toString(),
|
||||
minOrder: minOrder?.toString(),
|
||||
discountPercent: discountPercent?.toString() || null,
|
||||
flatDiscount: flatDiscount?.toString() || null,
|
||||
minOrder: minOrder?.toString() || null,
|
||||
productIds: productIds || null,
|
||||
createdBy: staffUserId,
|
||||
maxValue: maxValue?.toString(),
|
||||
maxValue: maxValue?.toString() || null,
|
||||
isApplyForAll: isApplyForAll || false,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||
maxLimitForUser: maxLimitForUser,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
||||
maxLimitForUser: maxLimitForUser || null,
|
||||
exclusiveApply: exclusiveApply || false,
|
||||
}).returning();
|
||||
|
||||
const coupon = result[0];
|
||||
});
|
||||
|
||||
// Insert applicable users
|
||||
if (applicableUsers && applicableUsers.length > 0) {
|
||||
await db.insert(couponApplicableUsers).values(
|
||||
applicableUsers.map(userId => ({
|
||||
couponId: coupon.id,
|
||||
userId,
|
||||
}))
|
||||
);
|
||||
await couponDbService.addApplicableUsers(coupon.id, applicableUsers);
|
||||
}
|
||||
|
||||
// Insert applicable products
|
||||
if (applicableProducts && applicableProducts.length > 0) {
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
applicableProducts.map(productId => ({
|
||||
couponId: coupon.id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
||||
}
|
||||
|
||||
return coupon;
|
||||
|
|
@ -133,39 +112,7 @@ export const couponRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { cursor, limit, search } = input;
|
||||
|
||||
let whereCondition = undefined;
|
||||
const conditions = [];
|
||||
|
||||
if (cursor) {
|
||||
conditions.push(lt(coupons.id, cursor));
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
conditions.push(like(coupons.couponCode, `%${search}%`));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereCondition = and(...conditions);
|
||||
}
|
||||
|
||||
const result = await db.query.coupons.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
creator: true,
|
||||
applicableUsers: {
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
applicableProducts: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (coupons, { desc }) => [desc(coupons.createdAt)],
|
||||
limit: limit + 1,
|
||||
});
|
||||
const result = await couponDbService.getAllCoupons({ cursor, limit, search });
|
||||
|
||||
const hasMore = result.length > limit;
|
||||
const couponsList = hasMore ? result.slice(0, limit) : result;
|
||||
|
|
@ -177,24 +124,7 @@ export const couponRouter = router({
|
|||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const couponId = input.id;
|
||||
|
||||
const result = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.id, couponId),
|
||||
with: {
|
||||
creator: true,
|
||||
applicableUsers: {
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
applicableProducts: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await couponDbService.getCouponById(input.id);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Coupon not found");
|
||||
|
|
@ -227,7 +157,7 @@ export const couponRouter = router({
|
|||
|
||||
// If updating to user-based, applicableUsers is required
|
||||
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
|
||||
const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
|
||||
const existingCount = await couponDbService.countApplicableUsers(id);
|
||||
if (existingCount === 0) {
|
||||
throw new Error("applicableUsers is required for user-based coupons");
|
||||
}
|
||||
|
|
@ -235,17 +165,14 @@ export const couponRouter = router({
|
|||
|
||||
// If applicableUsers is provided, verify users exist
|
||||
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
|
||||
const existingUsers = await db.query.users.findMany({
|
||||
where: inArray(users.id, updates.applicableUsers),
|
||||
columns: { id: true },
|
||||
});
|
||||
const existingUsers = await couponDbService.getUsersByIds(updates.applicableUsers);
|
||||
if (existingUsers.length !== updates.applicableUsers.length) {
|
||||
throw new Error("Some applicable users not found");
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = { ...updates };
|
||||
delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
|
||||
delete updateData.applicableUsers;
|
||||
if (updates.discountPercent !== undefined) {
|
||||
updateData.discountPercent = updates.discountPercent?.toString();
|
||||
}
|
||||
|
|
@ -262,60 +189,31 @@ export const couponRouter = router({
|
|||
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
|
||||
}
|
||||
|
||||
const result = await db.update(coupons)
|
||||
.set(updateData)
|
||||
.where(eq(coupons.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Coupon not found");
|
||||
}
|
||||
|
||||
console.log('updated coupon successfully')
|
||||
const result = await couponDbService.updateCoupon(id, updateData);
|
||||
|
||||
// Update applicable users: delete existing and insert new
|
||||
if (updates.applicableUsers !== undefined) {
|
||||
await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
|
||||
await couponDbService.removeAllApplicableUsers(id);
|
||||
if (updates.applicableUsers.length > 0) {
|
||||
await db.insert(couponApplicableUsers).values(
|
||||
updates.applicableUsers.map(userId => ({
|
||||
couponId: id,
|
||||
userId,
|
||||
}))
|
||||
);
|
||||
await couponDbService.addApplicableUsers(id, updates.applicableUsers);
|
||||
}
|
||||
}
|
||||
|
||||
// Update applicable products: delete existing and insert new
|
||||
if (updates.applicableProducts !== undefined) {
|
||||
await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
|
||||
await couponDbService.removeAllApplicableProducts(id);
|
||||
if (updates.applicableProducts.length > 0) {
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
updates.applicableProducts.map(productId => ({
|
||||
couponId: id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
await couponDbService.addApplicableProducts(id, updates.applicableProducts);
|
||||
}
|
||||
}
|
||||
|
||||
return result[0];
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const result = await db.update(coupons)
|
||||
.set({ isInvalidated: true })
|
||||
.where(eq(coupons.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Coupon not found");
|
||||
}
|
||||
|
||||
await couponDbService.invalidateCoupon(input.id);
|
||||
return { message: "Coupon invalidated successfully" };
|
||||
}),
|
||||
|
||||
|
|
@ -328,14 +226,9 @@ export const couponRouter = router({
|
|||
return { valid: false, message: "Invalid coupon code" };
|
||||
}
|
||||
|
||||
const coupon = await db.query.coupons.findFirst({
|
||||
where: and(
|
||||
eq(coupons.couponCode, code.toUpperCase()),
|
||||
eq(coupons.isInvalidated, false)
|
||||
),
|
||||
});
|
||||
const coupon = await couponDbService.getCouponByCode(code.toUpperCase());
|
||||
|
||||
if (!coupon) {
|
||||
if (!coupon || coupon.isInvalidated) {
|
||||
return { valid: false, message: "Coupon not found or invalidated" };
|
||||
}
|
||||
|
||||
|
|
@ -383,73 +276,39 @@ export const couponRouter = router({
|
|||
}),
|
||||
|
||||
generateCancellationCoupon: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
orderId: z.number(),
|
||||
})
|
||||
)
|
||||
.input(z.object({ orderId: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { orderId } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Find the order with user and order status information
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
user: true,
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
const order = await couponDbService.getOrderByIdWithUserAndStatus(orderId);
|
||||
|
||||
if (!order) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
|
||||
// Check if order is cancelled (check if any status entry has isCancelled: true)
|
||||
// const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
|
||||
// if (!isOrderCancelled) {
|
||||
// throw new Error("Order is not cancelled");
|
||||
// }
|
||||
|
||||
// // Check if payment method is COD
|
||||
// if (order.isCod) {
|
||||
// throw new Error("Can't generate refund coupon for CoD Order");
|
||||
// }
|
||||
|
||||
// Verify user exists
|
||||
if (!order.user) {
|
||||
throw new Error("User not found for this order");
|
||||
}
|
||||
|
||||
// Generate coupon code: first 3 letters of user name or mobile + orderId
|
||||
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
|
||||
const couponCode = `${userNamePrefix}${orderId}`;
|
||||
|
||||
// Check if coupon code already exists
|
||||
const existingCoupon = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, couponCode),
|
||||
});
|
||||
|
||||
const existingCoupon = await couponDbService.getCouponByCode(couponCode);
|
||||
if (existingCoupon) {
|
||||
throw new Error("Coupon code already exists");
|
||||
}
|
||||
|
||||
// Get order total amount
|
||||
const orderAmount = parseFloat(order.totalAmount);
|
||||
|
||||
// Calculate expiry date (30 days from now)
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||
|
||||
// Create the coupon and update order status in a transaction
|
||||
const coupon = await db.transaction(async (tx) => {
|
||||
// Create the coupon
|
||||
const result = await tx.insert(coupons).values({
|
||||
const coupon = await couponDbService.withTransaction(async (tx) => {
|
||||
const newCoupon = await couponDbService.createCoupon({
|
||||
couponCode,
|
||||
isUserBased: true,
|
||||
flatDiscount: orderAmount.toString(),
|
||||
|
|
@ -459,22 +318,12 @@ export const couponRouter = router({
|
|||
maxLimitForUser: 1,
|
||||
createdBy: staffUserId,
|
||||
isApplyForAll: false,
|
||||
}).returning();
|
||||
|
||||
const coupon = result[0];
|
||||
|
||||
// Insert applicable users
|
||||
await tx.insert(couponApplicableUsers).values({
|
||||
couponId: coupon.id,
|
||||
userId: order.userId,
|
||||
});
|
||||
|
||||
// Update order_status with refund coupon ID
|
||||
await tx.update(orderStatus)
|
||||
.set({ refundCouponId: coupon.id })
|
||||
.where(eq(orderStatus.orderId, orderId));
|
||||
await couponDbService.addApplicableUsers(newCoupon.id, [order.userId]);
|
||||
await couponDbService.updateOrderStatusRefundCoupon(orderId, newCoupon.id);
|
||||
|
||||
return coupon;
|
||||
return newCoupon;
|
||||
});
|
||||
|
||||
return coupon;
|
||||
|
|
@ -487,100 +336,52 @@ export const couponRouter = router({
|
|||
search: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { cursor, limit, search } = input;
|
||||
const result = await couponDbService.getReservedCoupons(input);
|
||||
|
||||
let whereCondition = undefined;
|
||||
const conditions = [];
|
||||
|
||||
if (cursor) {
|
||||
conditions.push(lt(reservedCoupons.id, cursor));
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
conditions.push(or(
|
||||
like(reservedCoupons.secretCode, `%${search}%`),
|
||||
like(reservedCoupons.couponCode, `%${search}%`)
|
||||
));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereCondition = and(...conditions);
|
||||
}
|
||||
|
||||
const result = await db.query.reservedCoupons.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
redeemedUser: true,
|
||||
creator: true,
|
||||
},
|
||||
orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
|
||||
limit: limit + 1, // Fetch one extra to check if there's more
|
||||
});
|
||||
|
||||
const hasMore = result.length > limit;
|
||||
const coupons = hasMore ? result.slice(0, limit) : result;
|
||||
const hasMore = result.length > input.limit;
|
||||
const coupons = hasMore ? result.slice(0, input.limit) : result;
|
||||
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
|
||||
|
||||
return {
|
||||
coupons,
|
||||
nextCursor,
|
||||
};
|
||||
return { coupons, nextCursor };
|
||||
}),
|
||||
|
||||
createReservedCoupon: protectedProcedure
|
||||
.input(createCouponBodySchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
|
||||
const { couponCode, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input;
|
||||
|
||||
// Validation: ensure at least one discount type is provided
|
||||
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
|
||||
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
|
||||
}
|
||||
|
||||
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Generate secret code if not provided (use couponCode as base)
|
||||
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||
|
||||
// Check if secret code already exists
|
||||
const existing = await db.query.reservedCoupons.findFirst({
|
||||
where: eq(reservedCoupons.secretCode, secretCode),
|
||||
});
|
||||
|
||||
const existing = await couponDbService.getCouponByCode(secretCode);
|
||||
if (existing) {
|
||||
throw new Error("Secret code already exists");
|
||||
}
|
||||
|
||||
const result = await db.insert(reservedCoupons).values({
|
||||
const coupon = await couponDbService.createReservedCoupon({
|
||||
secretCode,
|
||||
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
|
||||
discountPercent: discountPercent?.toString(),
|
||||
flatDiscount: flatDiscount?.toString(),
|
||||
minOrder: minOrder?.toString(),
|
||||
productIds,
|
||||
maxValue: maxValue?.toString(),
|
||||
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||
maxLimitForUser,
|
||||
discountPercent: discountPercent?.toString() || null,
|
||||
flatDiscount: flatDiscount?.toString() || null,
|
||||
minOrder: minOrder?.toString() || null,
|
||||
productIds: productIds || null,
|
||||
maxValue: maxValue?.toString() || null,
|
||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
||||
maxLimitForUser: maxLimitForUser || null,
|
||||
exclusiveApply: exclusiveApply || false,
|
||||
createdBy: staffUserId,
|
||||
}).returning();
|
||||
});
|
||||
|
||||
const coupon = result[0];
|
||||
|
||||
// Insert applicable products if provided
|
||||
if (applicableProducts && applicableProducts.length > 0) {
|
||||
await db.insert(couponApplicableProducts).values(
|
||||
applicableProducts.map(productId => ({
|
||||
couponId: coupon.id,
|
||||
productId,
|
||||
}))
|
||||
);
|
||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
||||
}
|
||||
|
||||
return coupon;
|
||||
|
|
@ -593,27 +394,11 @@ export const couponRouter = router({
|
|||
offset: z.number().min(0).default(0),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { search, limit } = input;
|
||||
const { search, limit, offset } = input;
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (search && search.trim()) {
|
||||
whereCondition = or(
|
||||
like(users.name, `%${search}%`),
|
||||
like(users.mobile, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
const userList = await db.query.users.findMany({
|
||||
where: whereCondition,
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
mobile: true,
|
||||
},
|
||||
limit: limit,
|
||||
offset: input.offset,
|
||||
orderBy: (users, { asc }) => [asc(users.name)],
|
||||
});
|
||||
const userList = search
|
||||
? await couponDbService.getUsersBySearch(search, limit, offset)
|
||||
: await couponDbService.getUsersByIds([]);
|
||||
|
||||
return {
|
||||
users: userList.map(user => ({
|
||||
|
|
@ -625,75 +410,55 @@ export const couponRouter = router({
|
|||
}),
|
||||
|
||||
createCoupon: protectedProcedure
|
||||
.input(z.object({
|
||||
mobile: z.string().min(1, 'Mobile number is required'),
|
||||
}))
|
||||
.input(z.object({ mobile: z.string().min(1, 'Mobile number is required') }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { mobile } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Clean mobile number (remove non-digits)
|
||||
const cleanMobile = mobile.replace(/\D/g, '');
|
||||
|
||||
// Validate: exactly 10 digits
|
||||
if (cleanMobile.length !== 10) {
|
||||
throw new Error("Mobile number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// Check if user exists, create if not
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(users.mobile, cleanMobile),
|
||||
});
|
||||
let user = await couponDbService.getUserByMobile(cleanMobile);
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
const [newUser] = await db.insert(users).values({
|
||||
user = await couponDbService.createUser({
|
||||
name: null,
|
||||
email: null,
|
||||
mobile: cleanMobile,
|
||||
}).returning();
|
||||
user = newUser;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique coupon code
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
|
||||
|
||||
// Check if coupon code already exists (very unlikely but safe)
|
||||
const existingCode = await db.query.coupons.findFirst({
|
||||
where: eq(coupons.couponCode, couponCode),
|
||||
});
|
||||
|
||||
const existingCode = await couponDbService.getCouponByCode(couponCode);
|
||||
if (existingCode) {
|
||||
throw new Error("Generated coupon code already exists - please try again");
|
||||
}
|
||||
|
||||
// Create the coupon
|
||||
const [coupon] = await db.insert(coupons).values({
|
||||
const coupon = await couponDbService.createCoupon({
|
||||
couponCode,
|
||||
isUserBased: true,
|
||||
discountPercent: "20", // 20% discount
|
||||
minOrder: "1000", // ₹1000 minimum order
|
||||
maxValue: "500", // ₹500 maximum discount
|
||||
maxLimitForUser: 1, // One-time use
|
||||
discountPercent: "20",
|
||||
minOrder: "1000",
|
||||
maxValue: "500",
|
||||
maxLimitForUser: 1,
|
||||
isApplyForAll: false,
|
||||
exclusiveApply: false,
|
||||
createdBy: staffUserId,
|
||||
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
|
||||
}).returning();
|
||||
|
||||
// Associate coupon with user
|
||||
await db.insert(couponApplicableUsers).values({
|
||||
couponId: coupon.id,
|
||||
userId: user.id,
|
||||
validTill: dayjs().add(90, 'days').toDate(),
|
||||
});
|
||||
|
||||
await couponDbService.addApplicableUsers(coupon.id, [user.id]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
coupon: {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
|
||||
import { z } from "zod";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import {
|
||||
orders,
|
||||
orderItems,
|
||||
orderStatus,
|
||||
users,
|
||||
addresses,
|
||||
refunds,
|
||||
coupons,
|
||||
couponUsage,
|
||||
complaints,
|
||||
payments,
|
||||
} from "@/src/db/schema";
|
||||
import { eq, and, gte, lt, desc, SQL, inArray } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { ApiError } from "@/src/lib/api-error"
|
||||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import {
|
||||
sendOrderPackagedNotification,
|
||||
sendOrderDeliveredNotification,
|
||||
} from "@/src/lib/notif-job";
|
||||
import { publishCancellation } from "@/src/lib/post-order-handler"
|
||||
import { getMultipleUserNegativityScores } from "@/src/stores/user-negativity-store"
|
||||
} from '@/src/lib/notif-job'
|
||||
import { publishCancellation } from '@/src/lib/post-order-handler'
|
||||
import { getMultipleUserNegativityScores } from '@/src/stores/user-negativity-store'
|
||||
import { orderDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const updateOrderNotesSchema = z.object({
|
||||
orderId: z.number(),
|
||||
|
|
@ -89,19 +76,13 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { orderId, adminNotes } = input;
|
||||
|
||||
const result = await db
|
||||
.update(orders)
|
||||
.set({
|
||||
adminNotes: adminNotes || null,
|
||||
})
|
||||
.where(eq(orders.id, orderId))
|
||||
.returning();
|
||||
const result = await orderDbService.updateOrderNotes(orderId, adminNotes || null)
|
||||
|
||||
if (result.length === 0) {
|
||||
if (!result) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
return result;
|
||||
}),
|
||||
|
||||
getFullOrder: protectedProcedure
|
||||
|
|
@ -109,34 +90,14 @@ export const orderRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { orderId } = input;
|
||||
|
||||
const orderData = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
slot: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
paymentInfo: true,
|
||||
},
|
||||
});
|
||||
const orderData = await orderDbService.getOrderWithRelations(orderId)
|
||||
|
||||
if (!orderData) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
|
||||
// Get order status separately
|
||||
const statusRecord = await db.query.orderStatus.findFirst({
|
||||
where: eq(orderStatus.orderId, orderId),
|
||||
});
|
||||
const statusRecord = await orderDbService.getOrderStatusByOrderId(orderId)
|
||||
|
||||
let status: "pending" | "delivered" | "cancelled" = "pending";
|
||||
if (statusRecord?.isCancelled) {
|
||||
|
|
@ -148,9 +109,7 @@ export const orderRouter = router({
|
|||
// Get refund details if order is cancelled
|
||||
let refund = null;
|
||||
if (status === "cancelled") {
|
||||
refund = await db.query.refunds.findFirst({
|
||||
where: eq(refunds.orderId, orderId),
|
||||
});
|
||||
refund = await orderDbService.getRefundByOrderId(orderId)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -220,39 +179,14 @@ export const orderRouter = router({
|
|||
const { orderId } = input;
|
||||
|
||||
// Single optimized query with all relations
|
||||
const orderData = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
slot: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
paymentInfo: true,
|
||||
orderStatus: true, // Include in main query
|
||||
refunds: true, // Include in main query
|
||||
},
|
||||
});
|
||||
const orderData = await orderDbService.getOrderWithDetails(orderId)
|
||||
|
||||
if (!orderData) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
|
||||
// Get coupon usage for this specific order using new orderId field
|
||||
const couponUsageData = await db.query.couponUsage.findMany({
|
||||
where: eq(couponUsage.orderId, orderData.id), // Use new orderId field
|
||||
with: {
|
||||
coupon: true,
|
||||
},
|
||||
});
|
||||
const couponUsageData = await orderDbService.getCouponUsageByOrderId(orderData.id)
|
||||
|
||||
let couponData = null;
|
||||
if (couponUsageData.length > 0) {
|
||||
|
|
@ -388,27 +322,15 @@ export const orderRouter = router({
|
|||
const { orderId, isPackaged } = input;
|
||||
|
||||
// Update all order items to the specified packaged state
|
||||
await db
|
||||
.update(orderItems)
|
||||
.set({ is_packaged: isPackaged })
|
||||
.where(eq(orderItems.orderId, parseInt(orderId)));
|
||||
const parsedOrderId = parseInt(orderId)
|
||||
await orderDbService.updateOrderItemsPackaged(parsedOrderId, isPackaged)
|
||||
|
||||
// Also update the order status table for backward compatibility
|
||||
if (!isPackaged) {
|
||||
await db
|
||||
.update(orderStatus)
|
||||
.set({ isPackaged, isDelivered: false })
|
||||
.where(eq(orderStatus.orderId, parseInt(orderId)));
|
||||
} else {
|
||||
await db
|
||||
.update(orderStatus)
|
||||
.set({ isPackaged })
|
||||
.where(eq(orderStatus.orderId, parseInt(orderId)));
|
||||
}
|
||||
const currentStatus = await orderDbService.getOrderStatusByOrderId(parsedOrderId)
|
||||
const isDelivered = !isPackaged ? false : currentStatus?.isDelivered || false
|
||||
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, parseInt(orderId)),
|
||||
});
|
||||
await orderDbService.updateOrderStatusPackaged(parsedOrderId, isPackaged, isDelivered)
|
||||
|
||||
const order = await orderDbService.getOrderById(parsedOrderId)
|
||||
if (order) await sendOrderPackagedNotification(order.userId, orderId);
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -419,14 +341,10 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { orderId, isDelivered } = input;
|
||||
|
||||
await db
|
||||
.update(orderStatus)
|
||||
.set({ isDelivered })
|
||||
.where(eq(orderStatus.orderId, parseInt(orderId)));
|
||||
const parsedOrderId = parseInt(orderId)
|
||||
await orderDbService.updateOrderStatusDelivered(parsedOrderId, isDelivered)
|
||||
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, parseInt(orderId)),
|
||||
});
|
||||
const order = await orderDbService.getOrderById(parsedOrderId)
|
||||
if (order) await sendOrderDeliveredNotification(order.userId, orderId);
|
||||
|
||||
return { success: true };
|
||||
|
|
@ -438,9 +356,7 @@ export const orderRouter = router({
|
|||
const { orderItemId, isPackaged, isPackageVerified } = input;
|
||||
|
||||
// Validate that orderItem exists
|
||||
const orderItem = await db.query.orderItems.findFirst({
|
||||
where: eq(orderItems.id, orderItemId),
|
||||
});
|
||||
const orderItem = await orderDbService.getOrderItemById(orderItemId)
|
||||
|
||||
if (!orderItem) {
|
||||
throw new ApiError("Order item not found", 404);
|
||||
|
|
@ -456,10 +372,7 @@ export const orderRouter = router({
|
|||
}
|
||||
|
||||
// Update the order item
|
||||
await db
|
||||
.update(orderItems)
|
||||
.set(updateData)
|
||||
.where(eq(orderItems.id, orderItemId));
|
||||
await orderDbService.updateOrderItem(orderItemId, updateData)
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
|
@ -469,9 +382,7 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { orderId } = input;
|
||||
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
});
|
||||
const order = await orderDbService.getOrderById(orderId)
|
||||
|
||||
if (!order) {
|
||||
throw new Error('Order not found');
|
||||
|
|
@ -481,13 +392,7 @@ export const orderRouter = router({
|
|||
const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0');
|
||||
const newTotalAmount = currentTotalAmount - currentDeliveryCharge;
|
||||
|
||||
await db
|
||||
.update(orders)
|
||||
.set({
|
||||
deliveryCharge: '0',
|
||||
totalAmount: newTotalAmount.toString()
|
||||
})
|
||||
.where(eq(orders.id, orderId));
|
||||
await orderDbService.removeDeliveryCharge(orderId, newTotalAmount.toString())
|
||||
|
||||
return { success: true, message: 'Delivery charge removed' };
|
||||
}),
|
||||
|
|
@ -497,27 +402,10 @@ export const orderRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { slotId } = input;
|
||||
|
||||
const slotOrders = await db.query.orders.findMany({
|
||||
where: eq(orders.slotId, parseInt(slotId)),
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
slot: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
const slotOrders = await orderDbService.getOrdersBySlotId(parseInt(slotId))
|
||||
|
||||
const filteredOrders = slotOrders.filter((order) => {
|
||||
const statusRecord = order.orderStatus[0];
|
||||
const statusRecord = order.orderStatus?.[0];
|
||||
return (
|
||||
order.isCod ||
|
||||
(statusRecord && statusRecord.paymentStatus === "success")
|
||||
|
|
@ -525,7 +413,7 @@ export const orderRouter = router({
|
|||
});
|
||||
|
||||
const formattedOrders = filteredOrders.map((order) => {
|
||||
const statusRecord = order.orderStatus[0]; // assuming one status per order
|
||||
const statusRecord = order.orderStatus?.[0]; // assuming one status per order
|
||||
let status: "pending" | "delivered" | "cancelled" = "pending";
|
||||
if (statusRecord?.isCancelled) {
|
||||
status = "cancelled";
|
||||
|
|
@ -582,39 +470,14 @@ export const orderRouter = router({
|
|||
const start = dayjs().startOf("day").toDate();
|
||||
const end = dayjs().endOf("day").toDate();
|
||||
|
||||
let whereCondition = and(
|
||||
gte(orders.createdAt, start),
|
||||
lt(orders.createdAt, end)
|
||||
);
|
||||
|
||||
if (slotId) {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orders.slotId, parseInt(slotId))
|
||||
);
|
||||
}
|
||||
|
||||
const todaysOrders = await db.query.orders.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
slot: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
const todaysOrders = await orderDbService.getOrdersByDateRange(
|
||||
start,
|
||||
end,
|
||||
slotId ? parseInt(slotId) : undefined
|
||||
)
|
||||
|
||||
const filteredOrders = todaysOrders.filter((order) => {
|
||||
const statusRecord = order.orderStatus[0];
|
||||
const statusRecord = order.orderStatus?.[0];
|
||||
return (
|
||||
order.isCod ||
|
||||
(statusRecord && statusRecord.paymentStatus === "success")
|
||||
|
|
@ -622,7 +485,7 @@ export const orderRouter = router({
|
|||
});
|
||||
|
||||
const formattedOrders = filteredOrders.map((order) => {
|
||||
const statusRecord = order.orderStatus[0]; // assuming one status per order
|
||||
const statusRecord = order.orderStatus?.[0]; // assuming one status per order
|
||||
let status: "pending" | "delivered" | "cancelled" = "pending";
|
||||
if (statusRecord?.isCancelled) {
|
||||
status = "cancelled";
|
||||
|
|
@ -677,16 +540,9 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { addressId, latitude, longitude } = input;
|
||||
|
||||
const result = await db
|
||||
.update(addresses)
|
||||
.set({
|
||||
adminLatitude: latitude,
|
||||
adminLongitude: longitude,
|
||||
})
|
||||
.where(eq(addresses.id, addressId))
|
||||
.returning();
|
||||
const result = await orderDbService.updateAddressCoords(addressId, latitude, longitude)
|
||||
|
||||
if (result.length === 0) {
|
||||
if (!result) {
|
||||
throw new ApiError("Address not found", 404);
|
||||
}
|
||||
|
||||
|
|
@ -707,78 +563,15 @@ export const orderRouter = router({
|
|||
flashDeliveryFilter,
|
||||
} = input;
|
||||
|
||||
let whereCondition: SQL<unknown> | undefined = eq(orders.id, orders.id); // always true
|
||||
if (cursor) {
|
||||
whereCondition = and(whereCondition, lt(orders.id, cursor));
|
||||
}
|
||||
if (slotId) {
|
||||
whereCondition = and(whereCondition, eq(orders.slotId, slotId));
|
||||
}
|
||||
if (packagedFilter === "packaged") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isPackaged, true)
|
||||
);
|
||||
} else if (packagedFilter === "not_packaged") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isPackaged, false)
|
||||
);
|
||||
}
|
||||
if (deliveredFilter === "delivered") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isDelivered, true)
|
||||
);
|
||||
} else if (deliveredFilter === "not_delivered") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isDelivered, false)
|
||||
);
|
||||
}
|
||||
if (cancellationFilter === "cancelled") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isCancelled, true)
|
||||
);
|
||||
} else if (cancellationFilter === "not_cancelled") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orderStatus.isCancelled, false)
|
||||
);
|
||||
}
|
||||
if (flashDeliveryFilter === "flash") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orders.isFlashDelivery, true)
|
||||
);
|
||||
} else if (flashDeliveryFilter === "regular") {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(orders.isFlashDelivery, false)
|
||||
);
|
||||
}
|
||||
|
||||
const allOrders = await db.query.orders.findMany({
|
||||
where: whereCondition,
|
||||
orderBy: desc(orders.createdAt),
|
||||
limit: limit + 1, // fetch one extra to check if there's more
|
||||
with: {
|
||||
user: true,
|
||||
address: true,
|
||||
slot: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
const allOrders = await orderDbService.getAllOrdersWithFilters({
|
||||
cursor,
|
||||
limit,
|
||||
slotId,
|
||||
packagedFilter,
|
||||
deliveredFilter,
|
||||
cancellationFilter,
|
||||
flashDeliveryFilter,
|
||||
})
|
||||
|
||||
const hasMore = allOrders.length > limit;
|
||||
const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders;
|
||||
|
|
@ -787,7 +580,7 @@ export const orderRouter = router({
|
|||
const negativityScores = await getMultipleUserNegativityScores(userIds);
|
||||
|
||||
const filteredOrders = ordersToReturn.filter((order) => {
|
||||
const statusRecord = order.orderStatus[0];
|
||||
const statusRecord = order.orderStatus?.[0];
|
||||
return (
|
||||
order.isCod ||
|
||||
(statusRecord && statusRecord.paymentStatus === "success")
|
||||
|
|
@ -795,7 +588,7 @@ export const orderRouter = router({
|
|||
});
|
||||
|
||||
const formattedOrders = filteredOrders.map((order) => {
|
||||
const statusRecord = order.orderStatus[0];
|
||||
const statusRecord = order.orderStatus?.[0];
|
||||
let status: "pending" | "delivered" | "cancelled" = "pending";
|
||||
if (statusRecord?.isCancelled) {
|
||||
status = "cancelled";
|
||||
|
|
@ -868,21 +661,7 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const slotIds = input.slotIds;
|
||||
|
||||
const ordersList = await db.query.orders.findMany({
|
||||
where: inArray(orders.slotId, slotIds),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: true
|
||||
}
|
||||
},
|
||||
couponUsages: {
|
||||
with: {
|
||||
coupon: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
const ordersList = await orderDbService.getOrdersBySlotIds(slotIds)
|
||||
|
||||
const processedOrdersData = ordersList.map((order) => {
|
||||
|
||||
|
|
@ -921,19 +700,19 @@ export const orderRouter = router({
|
|||
})
|
||||
|
||||
const updatedOrderIds: number[] = [];
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { order, updatedOrderItems, newTotal } of processedOrdersData) {
|
||||
await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id));
|
||||
updatedOrderIds.push(order.id);
|
||||
|
||||
for (const item of updatedOrderItems) {
|
||||
await tx.update(orderItems).set({
|
||||
await orderDbService.updateOrdersAndItemsInTransaction(
|
||||
processedOrdersData.map((entry) => ({
|
||||
orderId: entry.order.id,
|
||||
totalAmount: entry.newTotal.toString(),
|
||||
items: entry.updatedOrderItems.map((item) => ({
|
||||
id: item.id,
|
||||
price: item.price,
|
||||
discountedPrice: item.discountedPrice
|
||||
}).where(eq(orderItems.id, item.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
discountedPrice: item.discountedPrice || item.price,
|
||||
})),
|
||||
}))
|
||||
)
|
||||
|
||||
processedOrdersData.forEach((entry) => updatedOrderIds.push(entry.order.id))
|
||||
|
||||
return { success: true, updatedOrders: updatedOrderIds, message: `Rebalanced ${updatedOrderIds.length} orders.` };
|
||||
}),
|
||||
|
|
@ -946,12 +725,7 @@ export const orderRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { orderId, reason } = input;
|
||||
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
with: {
|
||||
orderStatus: true,
|
||||
},
|
||||
});
|
||||
const order = await orderDbService.getOrderWithStatus(orderId)
|
||||
|
||||
if (!order) {
|
||||
throw new ApiError("Order not found", 404);
|
||||
|
|
@ -970,28 +744,13 @@ export const orderRouter = router({
|
|||
throw new ApiError("Cannot cancel delivered order", 400);
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(orderStatus)
|
||||
.set({
|
||||
isCancelled: true,
|
||||
isCancelledByAdmin: true,
|
||||
cancelReason: reason,
|
||||
cancellationAdminNotes: reason,
|
||||
cancellationReviewed: true,
|
||||
cancellationReviewedAt: new Date(),
|
||||
})
|
||||
.where(eq(orderStatus.id, status.id));
|
||||
await orderDbService.cancelOrderStatus(status.id, reason)
|
||||
|
||||
const refundStatus = order.isCod ? "na" : "pending";
|
||||
const refundStatus = order.isCod ? 'na' : 'pending'
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
orderId: order.id,
|
||||
refundStatus,
|
||||
});
|
||||
await orderDbService.createRefund(order.id, refundStatus)
|
||||
|
||||
return { orderId: order.id, userId: order.userId };
|
||||
});
|
||||
const result = { orderId: order.id, userId: order.userId }
|
||||
|
||||
// Publish to Redis for Telegram notification
|
||||
await publishCancellation(result.orderId, 'admin', reason);
|
||||
|
|
@ -1005,14 +764,5 @@ export const orderRouter = router({
|
|||
type RefundStatus = "success" | "pending" | "failed" | "none" | "na";
|
||||
|
||||
export async function deleteOrderById(orderId: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(orderItems).where(eq(orderItems.orderId, orderId));
|
||||
await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId));
|
||||
await tx.delete(payments).where(eq(payments.orderId, orderId));
|
||||
await tx.delete(refunds).where(eq(refunds.orderId, orderId));
|
||||
await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId));
|
||||
await tx.delete(complaints).where(eq(complaints.orderId, orderId));
|
||||
await tx.delete(orders).where(eq(orders.id, orderId));
|
||||
});
|
||||
await orderDbService.deleteOrderById(orderId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
|
||||
import { z } from "zod";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import {
|
||||
orders,
|
||||
orderStatus,
|
||||
payments,
|
||||
refunds,
|
||||
} from "@/src/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error"
|
||||
import { RazorpayPaymentService } from "@/src/lib/payments-utils"
|
||||
import { refundDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const initiateRefundSchema = z
|
||||
.object({
|
||||
|
|
@ -37,18 +29,14 @@ export const adminPaymentsRouter = router({
|
|||
const { orderId, refundPercent, refundAmount } = input;
|
||||
|
||||
// Validate order exists
|
||||
const order = await db.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
});
|
||||
const order = await refundDbService.getOrderById(orderId);
|
||||
|
||||
if (!order) {
|
||||
throw new ApiError("Order not found", 404);
|
||||
}
|
||||
|
||||
// Check if order is paid
|
||||
const orderStatusRecord = await db.query.orderStatus.findFirst({
|
||||
where: eq(orderStatus.orderId, orderId),
|
||||
});
|
||||
const orderStatusRecord = await refundDbService.getOrderStatusByOrderId(orderId);
|
||||
|
||||
if(order.isCod) {
|
||||
throw new ApiError("Order is a Cash On Delivery. Not eligible for refund")
|
||||
|
|
@ -76,54 +64,31 @@ export const adminPaymentsRouter = router({
|
|||
throw new ApiError("Invalid refund parameters", 400);
|
||||
}
|
||||
|
||||
let razorpayRefund = null;
|
||||
let merchantRefundId = null;
|
||||
let merchantRefundId = 'xxx'; //temporary suppressal
|
||||
|
||||
// Get payment record for online payments
|
||||
const payment = await db.query.payments.findFirst({
|
||||
where: and(
|
||||
eq(payments.orderId, orderId),
|
||||
eq(payments.status, "success")
|
||||
),
|
||||
});
|
||||
const payment = await refundDbService.getSuccessfulPaymentByOrderId(orderId);
|
||||
|
||||
if (!payment || payment.status !== "success") {
|
||||
throw new ApiError("Payment not found or not successful", 404);
|
||||
}
|
||||
|
||||
const payload = payment.payload as any;
|
||||
// Initiate Razorpay refund
|
||||
razorpayRefund = await RazorpayPaymentService.initiateRefund(
|
||||
payload.payment_id,
|
||||
Math.round(calculatedRefundAmount * 100) // Convert to paisa
|
||||
);
|
||||
merchantRefundId = razorpayRefund.id;
|
||||
|
||||
|
||||
|
||||
// Check if refund already exists for this order
|
||||
const existingRefund = await db.query.refunds.findFirst({
|
||||
where: eq(refunds.orderId, orderId),
|
||||
});
|
||||
const existingRefund = await refundDbService.getRefundByOrderId(orderId);
|
||||
|
||||
const refundStatus = "initiated";
|
||||
|
||||
if (existingRefund) {
|
||||
// Update existing refund
|
||||
await db
|
||||
.update(refunds)
|
||||
.set({
|
||||
await refundDbService.updateRefund(existingRefund.id, {
|
||||
refundAmount: calculatedRefundAmount.toString(),
|
||||
refundStatus,
|
||||
merchantRefundId,
|
||||
refundProcessedAt: order.isCod ? new Date() : null,
|
||||
})
|
||||
.where(eq(refunds.id, existingRefund.id));
|
||||
});
|
||||
} else {
|
||||
// Insert new refund
|
||||
await db
|
||||
.insert(refunds)
|
||||
.values({
|
||||
await refundDbService.createRefund({
|
||||
orderId,
|
||||
refundAmount: calculatedRefundAmount.toString(),
|
||||
refundStatus,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { refreshScheduleJobs } from '@/src/lib/automatedJobs';
|
||||
import { scheduleDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const createScheduleSchema = z.object({
|
||||
scheduleName: z.string().min(1, "Schedule name is required"),
|
||||
time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"),
|
||||
action: z.enum(['in', 'out']),
|
||||
productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"),
|
||||
groupIds: z.array(z.number().int().positive()).default([]),
|
||||
});
|
||||
|
||||
const updateScheduleSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
updates: createScheduleSchema.partial().extend({
|
||||
scheduleName: z.string().min(1).optional(),
|
||||
productIds: z.array(z.number().int().positive()).optional(),
|
||||
groupIds: z.array(z.number().int().positive()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const productAvailabilitySchedulesRouter = router({
|
||||
create: protectedProcedure
|
||||
.input(createScheduleSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { scheduleName, time, action, productIds, groupIds } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Check if schedule name already exists
|
||||
const existingSchedule = await scheduleDbService.getScheduleByName(scheduleName);
|
||||
if (existingSchedule) {
|
||||
throw new Error("Schedule name already exists");
|
||||
}
|
||||
|
||||
// Create schedule with arrays
|
||||
const scheduleResult = await scheduleDbService.createSchedule({
|
||||
scheduleName,
|
||||
time,
|
||||
action,
|
||||
productIds,
|
||||
groupIds,
|
||||
});
|
||||
|
||||
// Refresh cron jobs to include new schedule
|
||||
await refreshScheduleJobs();
|
||||
|
||||
return scheduleResult;
|
||||
}),
|
||||
|
||||
getAll: protectedProcedure
|
||||
.query(async () => {
|
||||
const schedules = await scheduleDbService.getAllSchedules();
|
||||
|
||||
return schedules.map(schedule => ({
|
||||
...schedule,
|
||||
productCount: schedule.productIds.length,
|
||||
groupCount: schedule.groupIds.length,
|
||||
}));
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const schedule = await scheduleDbService.getScheduleById(id);
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error("Schedule not found");
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateScheduleSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, updates } = input;
|
||||
|
||||
// Check if schedule exists
|
||||
const existingSchedule = await scheduleDbService.getScheduleById(id);
|
||||
if (!existingSchedule) {
|
||||
throw new Error("Schedule not found");
|
||||
}
|
||||
|
||||
// Check schedule name uniqueness if being updated
|
||||
if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) {
|
||||
const duplicateSchedule = await scheduleDbService.getScheduleByName(updates.scheduleName);
|
||||
if (duplicateSchedule) {
|
||||
throw new Error("Schedule name already exists");
|
||||
}
|
||||
}
|
||||
|
||||
// Update schedule
|
||||
const updateData: any = {};
|
||||
if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName;
|
||||
if (updates.time !== undefined) updateData.time = updates.time;
|
||||
if (updates.action !== undefined) updateData.action = updates.action;
|
||||
if (updates.productIds !== undefined) updateData.productIds = updates.productIds;
|
||||
if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds;
|
||||
|
||||
const result = await scheduleDbService.updateSchedule(id, updateData);
|
||||
|
||||
// Refresh cron jobs to reflect changes
|
||||
await refreshScheduleJobs();
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
await scheduleDbService.deleteSchedule(id);
|
||||
|
||||
// Refresh cron jobs to remove deleted schedule
|
||||
await refreshScheduleJobs();
|
||||
|
||||
return { message: "Schedule deleted successfully" };
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
|
||||
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
|
||||
import { productDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { imageUploadS3, generateSignedUrlsFromS3Urls, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
||||
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
||||
import { deleteS3Image } from '@/src/lib/delete-image'
|
||||
import type { SpecialDeal } from '@/src/db/types'
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
|
||||
|
||||
type CreateDeal = {
|
||||
quantity: number;
|
||||
|
|
@ -18,19 +16,13 @@ type CreateDeal = {
|
|||
export const productRouter = router({
|
||||
getProducts: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const products = await db.query.productInfo.findMany({
|
||||
orderBy: productInfo.name,
|
||||
with: {
|
||||
unit: true,
|
||||
store: true,
|
||||
},
|
||||
});
|
||||
const products = await productDbService.getAllProducts();
|
||||
|
||||
// Generate signed URLs for all product images
|
||||
const productsWithSignedUrls = await Promise.all(
|
||||
products.map(async (product) => ({
|
||||
...product,
|
||||
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -47,35 +39,22 @@ export const productRouter = router({
|
|||
.query(async ({ input, ctx }) => {
|
||||
const { id } = input;
|
||||
|
||||
const product = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, id),
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
});
|
||||
const product = await productDbService.getProductById(id);
|
||||
|
||||
if (!product) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Fetch special deals for this product
|
||||
const deals = await db.query.specialDeals.findMany({
|
||||
where: eq(specialDeals.productId, id),
|
||||
orderBy: specialDeals.quantity,
|
||||
});
|
||||
const deals = await productDbService.getDealsByProductId(id);
|
||||
|
||||
// Fetch associated tags for this product
|
||||
const productTagsData = await db.query.productTags.findMany({
|
||||
where: eq(productTags.productId, id),
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
});
|
||||
const productTagsData = await productDbService.getTagsByProductId(id);
|
||||
|
||||
// Generate signed URLs for product images
|
||||
const productWithSignedUrls = {
|
||||
...product,
|
||||
images: await generateSignedUrlsFromS3Urls((product.images as string[]) || []),
|
||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||
deals,
|
||||
tags: productTagsData.map(pt => pt.tag),
|
||||
};
|
||||
|
|
@ -92,23 +71,231 @@ export const productRouter = router({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const { id } = input;
|
||||
|
||||
const [deletedProduct] = await db
|
||||
.delete(productInfo)
|
||||
.where(eq(productInfo.id, id))
|
||||
.returning();
|
||||
const deletedProduct = await productDbService.deleteProduct(id);
|
||||
|
||||
if (!deletedProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: "Product deleted successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
createProduct: protectedProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
shortDescription: z.string().optional(),
|
||||
longDescription: z.string().optional(),
|
||||
unitId: z.number(),
|
||||
storeId: z.number(),
|
||||
price: z.number(),
|
||||
marketPrice: z.number().optional(),
|
||||
incrementStep: z.number().default(1),
|
||||
productQuantity: z.number().default(1),
|
||||
isSuspended: z.boolean().default(false),
|
||||
isFlashAvailable: z.boolean().default(false),
|
||||
flashPrice: z.number().optional(),
|
||||
deals: z.array(z.object({
|
||||
quantity: z.number(),
|
||||
price: z.number(),
|
||||
validTill: z.string(),
|
||||
})).optional(),
|
||||
tagIds: z.array(z.number()).optional(),
|
||||
imageKeys: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const {
|
||||
name, shortDescription, longDescription, unitId, storeId,
|
||||
price, marketPrice, incrementStep, productQuantity,
|
||||
isSuspended, isFlashAvailable, flashPrice,
|
||||
deals, tagIds, imageKeys
|
||||
} = input;
|
||||
|
||||
// Validation
|
||||
if (!name || !unitId || !storeId || !price) {
|
||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const allProducts = await productDbService.getAllProducts();
|
||||
const existingProduct = allProducts.find(p => p.name === name.trim());
|
||||
if (existingProduct) {
|
||||
throw new ApiError("A product with this name already exists", 400);
|
||||
}
|
||||
|
||||
// Check if unit exists
|
||||
const unit = await productDbService.getUnitById(unitId);
|
||||
if (!unit) {
|
||||
throw new ApiError("Invalid unit ID", 400);
|
||||
}
|
||||
|
||||
console.log(imageKeys)
|
||||
const newProduct = await productDbService.createProduct({
|
||||
name: name.trim(),
|
||||
shortDescription,
|
||||
longDescription,
|
||||
unitId,
|
||||
storeId,
|
||||
price: price.toString(),
|
||||
marketPrice: marketPrice?.toString(),
|
||||
incrementStep,
|
||||
productQuantity,
|
||||
isSuspended,
|
||||
isFlashAvailable,
|
||||
flashPrice: flashPrice?.toString(),
|
||||
images: imageKeys || [],
|
||||
});
|
||||
|
||||
// Handle deals
|
||||
if (deals && deals.length > 0) {
|
||||
const dealInserts = deals.map(deal => ({
|
||||
productId: newProduct.id,
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
await productDbService.createDeals(dealInserts);
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
const tagAssociations = tagIds.map(tagId => ({
|
||||
productId: newProduct.id,
|
||||
tagId,
|
||||
}));
|
||||
await productDbService.createTagAssociations(tagAssociations);
|
||||
}
|
||||
|
||||
// Claim upload URLs
|
||||
if (imageKeys && imageKeys.length > 0) {
|
||||
for (const key of imageKeys) {
|
||||
try {
|
||||
await claimUploadUrl(key);
|
||||
} catch (e) {
|
||||
console.warn("Failed to claim upload URL for key:", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
product: newProduct,
|
||||
message: "Product created successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
updateProduct: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(1).optional(),
|
||||
shortDescription: z.string().optional(),
|
||||
longDescription: z.string().optional(),
|
||||
unitId: z.number().optional(),
|
||||
storeId: z.number().optional(),
|
||||
price: z.number().optional(),
|
||||
marketPrice: z.number().optional(),
|
||||
incrementStep: z.number().optional(),
|
||||
productQuantity: z.number().optional(),
|
||||
isSuspended: z.boolean().optional(),
|
||||
isFlashAvailable: z.boolean().optional(),
|
||||
flashPrice: z.number().optional(),
|
||||
deals: z.array(z.object({
|
||||
quantity: z.number(),
|
||||
price: z.number(),
|
||||
validTill: z.string(),
|
||||
})).optional(),
|
||||
tagIds: z.array(z.number()).optional(),
|
||||
newImageKeys: z.array(z.string()).optional(),
|
||||
imagesToDelete: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
|
||||
|
||||
// Get current product
|
||||
const currentProduct = await productDbService.getProductById(id);
|
||||
if (!currentProduct) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
// Handle image deletions
|
||||
let currentImages = (currentProduct.images as string[]) || [];
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
for (const imageUrl of imagesToDelete) {
|
||||
try {
|
||||
await deleteS3Image(imageUrl);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete image:", imageUrl, e);
|
||||
}
|
||||
}
|
||||
currentImages = currentImages.filter(img => {
|
||||
// imagesToDelete.includes(img)
|
||||
const isRemoved = imagesToDelete.some(item => item.includes(img));
|
||||
return !isRemoved;
|
||||
});
|
||||
}
|
||||
|
||||
// Add new images
|
||||
if (newImageKeys && newImageKeys.length > 0) {
|
||||
currentImages = [...currentImages, ...newImageKeys];
|
||||
|
||||
for (const key of newImageKeys) {
|
||||
try {
|
||||
await claimUploadUrl(key);
|
||||
} catch (e) {
|
||||
console.warn("Failed to claim upload URL for key:", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update product - convert numeric fields to strings for PostgreSQL numeric type
|
||||
const { price, marketPrice, flashPrice, ...otherData } = updateData;
|
||||
const updatedProduct = await productDbService.updateProduct(id, {
|
||||
...otherData,
|
||||
...(price !== undefined && { price: price.toString() }),
|
||||
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
|
||||
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
|
||||
images: currentImages,
|
||||
});
|
||||
|
||||
// Handle deals update
|
||||
if (deals !== undefined) {
|
||||
await productDbService.deleteDealsByProductId(id);
|
||||
if (deals.length > 0) {
|
||||
const dealInserts = deals.map(deal => ({
|
||||
productId: id,
|
||||
quantity: deal.quantity.toString(),
|
||||
price: deal.price.toString(),
|
||||
validTill: new Date(deal.validTill),
|
||||
}));
|
||||
await productDbService.createDeals(dealInserts);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tags update
|
||||
if (tagIds !== undefined) {
|
||||
await productDbService.deleteTagAssociationsByProductId(id);
|
||||
if (tagIds.length > 0) {
|
||||
const tagAssociations = tagIds.map(tagId => ({
|
||||
productId: id,
|
||||
tagId,
|
||||
}));
|
||||
await productDbService.createTagAssociations(tagAssociations);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
product: updatedProduct,
|
||||
message: "Product updated successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
toggleOutOfStock: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
|
|
@ -116,24 +303,18 @@ export const productRouter = router({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const { id } = input;
|
||||
|
||||
const product = await db.query.productInfo.findFirst({
|
||||
where: eq(productInfo.id, id),
|
||||
});
|
||||
const product = await productDbService.getProductById(id);
|
||||
|
||||
if (!product) {
|
||||
throw new ApiError("Product not found", 404);
|
||||
}
|
||||
|
||||
const [updatedProduct] = await db
|
||||
.update(productInfo)
|
||||
.set({
|
||||
const updatedProduct = await productDbService.updateProduct(id, {
|
||||
isOutOfStock: !product.isOutOfStock,
|
||||
})
|
||||
.where(eq(productInfo.id, id))
|
||||
.returning();
|
||||
});
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
product: updatedProduct,
|
||||
|
|
@ -154,12 +335,7 @@ export const productRouter = router({
|
|||
}
|
||||
|
||||
// Get current associations
|
||||
const currentAssociations = await db.query.productSlots.findMany({
|
||||
where: eq(productSlots.slotId, parseInt(slotId)),
|
||||
columns: {
|
||||
productId: true,
|
||||
},
|
||||
});
|
||||
const currentAssociations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
|
||||
|
||||
const currentProductIds = currentAssociations.map(assoc => assoc.productId);
|
||||
const newProductIds = productIds.map((id: string) => parseInt(id));
|
||||
|
|
@ -170,26 +346,20 @@ export const productRouter = router({
|
|||
|
||||
// Remove associations for products that are no longer selected
|
||||
if (productsToRemove.length > 0) {
|
||||
await db.delete(productSlots).where(
|
||||
and(
|
||||
eq(productSlots.slotId, parseInt(slotId)),
|
||||
inArray(productSlots.productId, productsToRemove)
|
||||
)
|
||||
);
|
||||
for (const productId of productsToRemove) {
|
||||
await productDbService.deleteProductSlot(parseInt(slotId), productId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add associations for newly selected products
|
||||
if (productsToAdd.length > 0) {
|
||||
const newAssociations = productsToAdd.map(productId => ({
|
||||
productId,
|
||||
slotId: parseInt(slotId),
|
||||
}));
|
||||
|
||||
await db.insert(productSlots).values(newAssociations);
|
||||
for (const productId of productsToAdd) {
|
||||
await productDbService.createProductSlot(parseInt(slotId), productId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: "Slot products updated successfully",
|
||||
|
|
@ -205,12 +375,7 @@ export const productRouter = router({
|
|||
.query(async ({ input, ctx }) => {
|
||||
const { slotId } = input;
|
||||
|
||||
const associations = await db.query.productSlots.findMany({
|
||||
where: eq(productSlots.slotId, parseInt(slotId)),
|
||||
columns: {
|
||||
productId: true,
|
||||
},
|
||||
});
|
||||
const associations = await productDbService.getProductSlotsBySlotId(parseInt(slotId));
|
||||
|
||||
const productIds = associations.map(assoc => assoc.productId);
|
||||
|
||||
|
|
@ -235,13 +400,7 @@ export const productRouter = router({
|
|||
}
|
||||
|
||||
// Fetch all associations for the requested slots
|
||||
const associations = await db.query.productSlots.findMany({
|
||||
where: inArray(productSlots.slotId, slotIds),
|
||||
columns: {
|
||||
slotId: true,
|
||||
productId: true,
|
||||
},
|
||||
});
|
||||
const associations = await productDbService.getProductSlotsBySlotIds(slotIds);
|
||||
|
||||
// Group by slotId
|
||||
const result = associations.reduce((acc, assoc) => {
|
||||
|
|
@ -271,40 +430,19 @@ export const productRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { productId, limit, offset } = input;
|
||||
|
||||
const reviews = await db
|
||||
.select({
|
||||
id: productReviews.id,
|
||||
reviewBody: productReviews.reviewBody,
|
||||
ratings: productReviews.ratings,
|
||||
imageUrls: productReviews.imageUrls,
|
||||
reviewTime: productReviews.reviewTime,
|
||||
adminResponse: productReviews.adminResponse,
|
||||
adminResponseImages: productReviews.adminResponseImages,
|
||||
userName: users.name,
|
||||
})
|
||||
.from(productReviews)
|
||||
.innerJoin(users, eq(productReviews.userId, users.id))
|
||||
.where(eq(productReviews.productId, productId))
|
||||
.orderBy(desc(productReviews.reviewTime))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const reviews = await productDbService.getReviewsByProductId(productId, limit, offset);
|
||||
|
||||
// Generate signed URLs for images
|
||||
const reviewsWithSignedUrls = await Promise.all(
|
||||
reviews.map(async (review) => ({
|
||||
...review,
|
||||
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []),
|
||||
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []),
|
||||
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
|
||||
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
|
||||
}))
|
||||
);
|
||||
|
||||
// Check if more reviews exist
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql`count(*)` })
|
||||
.from(productReviews)
|
||||
.where(eq(productReviews.productId, productId));
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalCount = await productDbService.getReviewCountByProductId(productId);
|
||||
const hasMore = offset + limit < totalCount;
|
||||
|
||||
return { reviews: reviewsWithSignedUrls, hasMore };
|
||||
|
|
@ -320,14 +458,10 @@ export const productRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
|
||||
|
||||
const [updatedReview] = await db
|
||||
.update(productReviews)
|
||||
.set({
|
||||
const updatedReview = await productDbService.updateReview(reviewId, {
|
||||
adminResponse,
|
||||
adminResponseImages,
|
||||
})
|
||||
.where(eq(productReviews.id, reviewId))
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (!updatedReview) {
|
||||
throw new ApiError('Review not found', 404);
|
||||
|
|
@ -335,7 +469,6 @@ export const productRouter = router({
|
|||
|
||||
// Claim upload URLs
|
||||
if (uploadUrls && uploadUrls.length > 0) {
|
||||
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
|
||||
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
|
||||
}
|
||||
|
||||
|
|
@ -344,22 +477,13 @@ export const productRouter = router({
|
|||
|
||||
getGroups: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const groups = await db.query.productGroupInfo.findMany({
|
||||
with: {
|
||||
memberships: {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: desc(productGroupInfo.createdAt),
|
||||
});
|
||||
const groups = await productDbService.getAllGroups() as any[];
|
||||
|
||||
return {
|
||||
groups: groups.map(group => ({
|
||||
...group,
|
||||
products: group.memberships.map(m => m.product),
|
||||
productCount: group.memberships.length,
|
||||
products: group.memberships?.map((m: any) => m.product) || [],
|
||||
productCount: group.memberships?.length || 0,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
|
@ -373,13 +497,10 @@ export const productRouter = router({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const { group_name, description, product_ids } = input;
|
||||
|
||||
const [newGroup] = await db
|
||||
.insert(productGroupInfo)
|
||||
.values({
|
||||
const newGroup = await productDbService.createGroup({
|
||||
groupName: group_name,
|
||||
description,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (product_ids.length > 0) {
|
||||
const memberships = product_ids.map(productId => ({
|
||||
|
|
@ -387,11 +508,11 @@ export const productRouter = router({
|
|||
groupId: newGroup.id,
|
||||
}));
|
||||
|
||||
await db.insert(productGroupMembership).values(memberships);
|
||||
await productDbService.createGroupMemberships(memberships);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
group: newGroup,
|
||||
|
|
@ -413,11 +534,7 @@ export const productRouter = router({
|
|||
if (group_name !== undefined) updateData.groupName = group_name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
|
||||
const [updatedGroup] = await db
|
||||
.update(productGroupInfo)
|
||||
.set(updateData)
|
||||
.where(eq(productGroupInfo.id, id))
|
||||
.returning();
|
||||
const updatedGroup = await productDbService.updateGroup(id, updateData);
|
||||
|
||||
if (!updatedGroup) {
|
||||
throw new ApiError('Group not found', 404);
|
||||
|
|
@ -425,7 +542,7 @@ export const productRouter = router({
|
|||
|
||||
if (product_ids !== undefined) {
|
||||
// Delete existing memberships
|
||||
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
|
||||
await productDbService.deleteGroupMembershipsByGroupId(id);
|
||||
|
||||
// Insert new memberships
|
||||
if (product_ids.length > 0) {
|
||||
|
|
@ -434,12 +551,12 @@ export const productRouter = router({
|
|||
groupId: id,
|
||||
}));
|
||||
|
||||
await db.insert(productGroupMembership).values(memberships);
|
||||
await productDbService.createGroupMemberships(memberships);
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
group: updatedGroup,
|
||||
|
|
@ -455,20 +572,17 @@ export const productRouter = router({
|
|||
const { id } = input;
|
||||
|
||||
// Delete memberships first
|
||||
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
|
||||
await productDbService.deleteGroupMembershipsByGroupId(id);
|
||||
|
||||
// Delete group
|
||||
const [deletedGroup] = await db
|
||||
.delete(productGroupInfo)
|
||||
.where(eq(productGroupInfo.id, id))
|
||||
.returning();
|
||||
const deletedGroup = await productDbService.deleteGroup(id);
|
||||
|
||||
if (!deletedGroup) {
|
||||
throw new ApiError('Group not found', 404);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: 'Group deleted successfully',
|
||||
|
|
@ -494,37 +608,31 @@ export const productRouter = router({
|
|||
|
||||
// Validate that all productIds exist
|
||||
const productIds = updates.map(u => u.productId);
|
||||
const existingProducts = await db.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, productIds),
|
||||
columns: { id: true },
|
||||
});
|
||||
const allExist = await productDbService.validateProductIdsExist(productIds);
|
||||
|
||||
const existingIds = new Set(existingProducts.map(p => p.id));
|
||||
const invalidIds = productIds.filter(id => !existingIds.has(id));
|
||||
|
||||
if (invalidIds.length > 0) {
|
||||
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
|
||||
if (!allExist) {
|
||||
throw new ApiError('Some product IDs are invalid', 400);
|
||||
}
|
||||
|
||||
// Perform batch update
|
||||
const updatePromises = updates.map(async (update) => {
|
||||
const batchUpdates = updates.map(update => {
|
||||
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
|
||||
const updateData: any = {};
|
||||
if (price !== undefined) updateData.price = price;
|
||||
if (marketPrice !== undefined) updateData.marketPrice = marketPrice;
|
||||
if (flashPrice !== undefined) updateData.flashPrice = flashPrice;
|
||||
if (price !== undefined) updateData.price = price.toString();
|
||||
if (marketPrice !== undefined) updateData.marketPrice = marketPrice?.toString();
|
||||
if (flashPrice !== undefined) updateData.flashPrice = flashPrice?.toString();
|
||||
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
|
||||
|
||||
return db
|
||||
.update(productInfo)
|
||||
.set(updateData)
|
||||
.where(eq(productInfo.id, productId));
|
||||
return {
|
||||
productId,
|
||||
data: updateData,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
await productDbService.batchUpdateProducts(batchUpdates);
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: `Updated prices for ${updates.length} product(s)`,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "@/src/db/schema"
|
||||
import { eq, inArray, and, desc } from "drizzle-orm";
|
||||
import { ApiError } from "@/src/lib/api-error"
|
||||
import { appUrl } from "@/src/lib/env-exporter"
|
||||
import redisClient from "@/src/lib/redis-client"
|
||||
import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { slotDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
interface CachedDeliverySequence {
|
||||
[userId: string]: number[];
|
||||
|
|
@ -57,50 +55,29 @@ const getDeliverySequenceSchema = z.object({
|
|||
|
||||
const updateDeliverySequenceSchema = z.object({
|
||||
id: z.number(),
|
||||
// deliverySequence: z.array(z.number()),
|
||||
deliverySequence: z.any(),
|
||||
});
|
||||
|
||||
export const slotsRouter = router({
|
||||
// Exact replica of GET /av/slots
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.staffUser?.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
}
|
||||
|
||||
const slots = await db.query.deliverySlotInfo
|
||||
.findMany({
|
||||
where: eq(deliverySlotInfo.isActive, true),
|
||||
orderBy: desc(deliverySlotInfo.deliveryTime),
|
||||
with: {
|
||||
productSlots: {
|
||||
with: {
|
||||
product: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((slots) =>
|
||||
slots.map((slot) => ({
|
||||
const slots = await slotDbService.getAllSlots();
|
||||
|
||||
const transformedSlots = slots.map((slot) => ({
|
||||
...slot,
|
||||
deliverySequence: slot.deliverySequence as number[],
|
||||
products: slot.productSlots.map((ps) => ps.product),
|
||||
}))
|
||||
);
|
||||
products: slot.productSlots.map((ps: any) => ps.product),
|
||||
}));
|
||||
|
||||
return {
|
||||
slots,
|
||||
count: slots.length,
|
||||
slots: transformedSlots,
|
||||
count: transformedSlots.length,
|
||||
};
|
||||
}),
|
||||
|
||||
// Exact replica of POST /av/products/slots/product-ids
|
||||
getSlotsProductIds: protectedProcedure
|
||||
.input(z.object({ slotIds: z.array(z.number()) }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
|
@ -121,25 +98,16 @@ export const slotsRouter = router({
|
|||
return {};
|
||||
}
|
||||
|
||||
// Fetch all associations for the requested slots
|
||||
const associations = await db.query.productSlots.findMany({
|
||||
where: inArray(productSlots.slotId, slotIds),
|
||||
columns: {
|
||||
slotId: true,
|
||||
productId: true,
|
||||
},
|
||||
});
|
||||
const associations = await slotDbService.getProductSlotsBySlotIds(slotIds);
|
||||
|
||||
// Group by slotId
|
||||
const result = associations.reduce((acc, assoc) => {
|
||||
const result = associations.reduce((acc: Record<number, number[]>, assoc) => {
|
||||
if (!acc[assoc.slotId]) {
|
||||
acc[assoc.slotId] = [];
|
||||
}
|
||||
acc[assoc.slotId].push(assoc.productId);
|
||||
return acc;
|
||||
}, {} as Record<number, number[]>);
|
||||
}, {});
|
||||
|
||||
// Ensure all requested slots have entries (even if empty)
|
||||
slotIds.forEach((slotId) => {
|
||||
if (!result[slotId]) {
|
||||
result[slotId] = [];
|
||||
|
|
@ -149,14 +117,8 @@ export const slotsRouter = router({
|
|||
return result;
|
||||
}),
|
||||
|
||||
// Exact replica of PUT /av/products/slots/:slotId/products
|
||||
updateSlotProducts: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
slotId: z.number(),
|
||||
productIds: z.array(z.number()),
|
||||
})
|
||||
)
|
||||
.input(z.object({ slotId: z.number(), productIds: z.array(z.number()) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.staffUser?.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
|
|
@ -171,51 +133,22 @@ export const slotsRouter = router({
|
|||
});
|
||||
}
|
||||
|
||||
// Get current associations
|
||||
const currentAssociations = await db.query.productSlots.findMany({
|
||||
where: eq(productSlots.slotId, slotId),
|
||||
columns: {
|
||||
productId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentProductIds = currentAssociations.map(
|
||||
(assoc) => assoc.productId
|
||||
);
|
||||
const currentAssociations = await slotDbService.getProductSlotsBySlotId(slotId);
|
||||
const currentProductIds = currentAssociations.map((assoc) => assoc.productId);
|
||||
const newProductIds = productIds;
|
||||
|
||||
// Find products to add and remove
|
||||
const productsToAdd = newProductIds.filter(
|
||||
(id) => !currentProductIds.includes(id)
|
||||
);
|
||||
const productsToRemove = currentProductIds.filter(
|
||||
(id) => !newProductIds.includes(id)
|
||||
);
|
||||
const productsToAdd = newProductIds.filter((id) => !currentProductIds.includes(id));
|
||||
const productsToRemove = currentProductIds.filter((id) => !newProductIds.includes(id));
|
||||
|
||||
// Remove associations for products that are no longer selected
|
||||
if (productsToRemove.length > 0) {
|
||||
await db
|
||||
.delete(productSlots)
|
||||
.where(
|
||||
and(
|
||||
eq(productSlots.slotId, slotId),
|
||||
inArray(productSlots.productId, productsToRemove)
|
||||
)
|
||||
);
|
||||
for (const productId of productsToRemove) {
|
||||
await slotDbService.deleteProductSlot(slotId, productId);
|
||||
}
|
||||
|
||||
// Add associations for newly selected products
|
||||
if (productsToAdd.length > 0) {
|
||||
const newAssociations = productsToAdd.map((productId) => ({
|
||||
productId,
|
||||
slotId,
|
||||
}));
|
||||
|
||||
await db.insert(productSlots).values(newAssociations);
|
||||
for (const productId of productsToAdd) {
|
||||
await slotDbService.createProductSlot(slotId, productId);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
message: "Slot products updated successfully",
|
||||
|
|
@ -233,58 +166,43 @@ export const slotsRouter = router({
|
|||
|
||||
const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
||||
|
||||
// Validate required fields
|
||||
if (!deliveryTime || !freezeTime) {
|
||||
throw new ApiError("Delivery time and orders close time are required", 400);
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Create slot
|
||||
const [newSlot] = await tx
|
||||
.insert(deliverySlotInfo)
|
||||
.values({
|
||||
const result = await slotDbService.withTransaction(async (tx) => {
|
||||
const newSlot = await slotDbService.createSlot({
|
||||
deliveryTime: new Date(deliveryTime),
|
||||
freezeTime: new Date(freezeTime),
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: groupIds !== undefined ? groupIds : [],
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
// Insert product associations if provided
|
||||
if (productIds && productIds.length > 0) {
|
||||
const associations = productIds.map((productId) => ({
|
||||
productId,
|
||||
slotId: newSlot.id,
|
||||
}));
|
||||
await tx.insert(productSlots).values(associations);
|
||||
for (const productId of productIds) {
|
||||
await slotDbService.createProductSlot(newSlot.id, productId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create vendor snippets if provided
|
||||
let createdSnippets: any[] = [];
|
||||
if (snippets && snippets.length > 0) {
|
||||
for (const snippet of snippets) {
|
||||
// Validate products exist
|
||||
const products = await tx.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, snippet.productIds),
|
||||
});
|
||||
if (products.length !== snippet.productIds.length) {
|
||||
const productsValid = await slotDbService.validateProductsExist(snippet.productIds);
|
||||
if (!productsValid) {
|
||||
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
|
||||
}
|
||||
|
||||
// Check if snippet name already exists
|
||||
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, snippet.name),
|
||||
});
|
||||
if (existingSnippet) {
|
||||
const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name);
|
||||
if (codeExists) {
|
||||
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
|
||||
}
|
||||
|
||||
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
||||
const createdSnippet = await slotDbService.createVendorSnippet({
|
||||
snippetCode: snippet.name,
|
||||
slotId: newSlot.id,
|
||||
productIds: snippet.productIds,
|
||||
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
||||
}).returning();
|
||||
});
|
||||
|
||||
createdSnippets.push(createdSnippet);
|
||||
}
|
||||
|
|
@ -297,8 +215,7 @@ export const slotsRouter = router({
|
|||
};
|
||||
});
|
||||
|
||||
// Reinitialize stores to reflect changes (outside transaction)
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
|
@ -308,9 +225,7 @@ export const slotsRouter = router({
|
|||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
}
|
||||
|
||||
const slots = await db.query.deliverySlotInfo.findMany({
|
||||
where: eq(deliverySlotInfo.isActive, true),
|
||||
});
|
||||
const slots = await slotDbService.getActiveSlots();
|
||||
|
||||
return {
|
||||
slots,
|
||||
|
|
@ -327,23 +242,7 @@ export const slotsRouter = router({
|
|||
|
||||
const { id } = input;
|
||||
|
||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||
where: eq(deliverySlotInfo.id, id),
|
||||
with: {
|
||||
productSlots: {
|
||||
with: {
|
||||
product: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
images: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vendorSnippets: true,
|
||||
},
|
||||
});
|
||||
const slot = await slotDbService.getSlotById(id);
|
||||
|
||||
if (!slot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
|
|
@ -354,8 +253,8 @@ export const slotsRouter = router({
|
|||
...slot,
|
||||
deliverySequence: slot.deliverySequence as number[],
|
||||
groupIds: slot.groupIds as number[],
|
||||
products: slot.productSlots.map((ps) => ps.product),
|
||||
vendorSnippets: slot.vendorSnippets?.map(snippet => ({
|
||||
products: slot.productSlots.map((ps: any) => ps.product),
|
||||
vendorSnippets: slot.vendorSnippets?.map((snippet: any) => ({
|
||||
...snippet,
|
||||
accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`
|
||||
})),
|
||||
|
|
@ -369,81 +268,60 @@ export const slotsRouter = router({
|
|||
if (!ctx.staffUser?.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
||||
}
|
||||
try{
|
||||
try {
|
||||
const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input;
|
||||
|
||||
if (!deliveryTime || !freezeTime) {
|
||||
throw new ApiError("Delivery time and orders close time are required", 400);
|
||||
}
|
||||
|
||||
// Filter groupIds to only include valid (existing) groups
|
||||
let validGroupIds = groupIds;
|
||||
if (groupIds && groupIds.length > 0) {
|
||||
const existingGroups = await db.query.productGroupInfo.findMany({
|
||||
where: inArray(productGroupInfo.id, groupIds),
|
||||
columns: { id: true },
|
||||
});
|
||||
validGroupIds = existingGroups.map(g => g.id);
|
||||
const existingGroups = await slotDbService.getGroupsByIds(groupIds);
|
||||
validGroupIds = existingGroups.map((g: any) => g.id);
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [updatedSlot] = await tx
|
||||
.update(deliverySlotInfo)
|
||||
.set({
|
||||
const result = await slotDbService.withTransaction(async (tx) => {
|
||||
const updatedSlot = await slotDbService.updateSlot(id, {
|
||||
deliveryTime: new Date(deliveryTime),
|
||||
freezeTime: new Date(freezeTime),
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
groupIds: validGroupIds !== undefined ? validGroupIds : [],
|
||||
})
|
||||
.where(eq(deliverySlotInfo.id, id))
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (!updatedSlot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
}
|
||||
|
||||
// Update product associations
|
||||
if (productIds !== undefined) {
|
||||
// Delete existing associations
|
||||
await tx.delete(productSlots).where(eq(productSlots.slotId, id));
|
||||
await slotDbService.deleteProductSlotsBySlotId(id);
|
||||
|
||||
// Insert new associations
|
||||
if (productIds.length > 0) {
|
||||
const associations = productIds.map((productId) => ({
|
||||
productId,
|
||||
slotId: id,
|
||||
}));
|
||||
await tx.insert(productSlots).values(associations);
|
||||
for (const productId of productIds) {
|
||||
await slotDbService.createProductSlot(id, productId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create vendor snippets if provided
|
||||
let createdSnippets: any[] = [];
|
||||
if (snippets && snippets.length > 0) {
|
||||
for (const snippet of snippets) {
|
||||
// Validate products exist
|
||||
const products = await tx.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, snippet.productIds),
|
||||
});
|
||||
if (products.length !== snippet.productIds.length) {
|
||||
const productsValid = await slotDbService.validateProductsExist(snippet.productIds);
|
||||
if (!productsValid) {
|
||||
throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400);
|
||||
}
|
||||
|
||||
// Check if snippet name already exists
|
||||
const existingSnippet = await tx.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, snippet.name),
|
||||
});
|
||||
if (existingSnippet) {
|
||||
const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name);
|
||||
if (codeExists) {
|
||||
throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400);
|
||||
}
|
||||
|
||||
const [createdSnippet] = await tx.insert(vendorSnippets).values({
|
||||
const createdSnippet = await slotDbService.createVendorSnippet({
|
||||
snippetCode: snippet.name,
|
||||
slotId: id,
|
||||
productIds: snippet.productIds,
|
||||
validTill: snippet.validTill ? new Date(snippet.validTill) : undefined,
|
||||
|
||||
}).returning();
|
||||
});
|
||||
|
||||
createdSnippets.push(createdSnippet);
|
||||
}
|
||||
|
|
@ -456,13 +334,11 @@ export const slotsRouter = router({
|
|||
};
|
||||
});
|
||||
|
||||
// Reinitialize stores to reflect changes (outside transaction)
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch(e) {
|
||||
console.log(e)
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new ApiError("Unable to Update Slot");
|
||||
}
|
||||
}),
|
||||
|
|
@ -476,18 +352,13 @@ export const slotsRouter = router({
|
|||
|
||||
const { id } = input;
|
||||
|
||||
const [deletedSlot] = await db
|
||||
.update(deliverySlotInfo)
|
||||
.set({ isActive: false })
|
||||
.where(eq(deliverySlotInfo.id, id))
|
||||
.returning();
|
||||
const deletedSlot = await slotDbService.deactivateSlot(id);
|
||||
|
||||
if (!deletedSlot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
message: "Slot deleted successfully",
|
||||
|
|
@ -496,8 +367,7 @@ export const slotsRouter = router({
|
|||
|
||||
getDeliverySequence: protectedProcedure
|
||||
.input(getDeliverySequenceSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
const slotId = parseInt(id);
|
||||
const cacheKey = getSlotSequenceKey(slotId);
|
||||
|
|
@ -507,19 +377,14 @@ export const slotsRouter = router({
|
|||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence;
|
||||
console.log('sending cached response')
|
||||
|
||||
console.log('sending cached response');
|
||||
return { deliverySequence: validated };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Redis cache read/validation failed, falling back to DB:', error);
|
||||
// Continue to DB fallback
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||
where: eq(deliverySlotInfo.id, slotId),
|
||||
});
|
||||
const slot = await slotDbService.getSlotById(slotId);
|
||||
|
||||
if (!slot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
|
|
@ -527,7 +392,6 @@ export const slotsRouter = router({
|
|||
|
||||
const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence;
|
||||
|
||||
// Cache the validated result
|
||||
try {
|
||||
const validated = cachedSequenceSchema.parse(sequence);
|
||||
await redisClient.set(cacheKey, JSON.stringify(validated), 3600);
|
||||
|
|
@ -547,20 +411,12 @@ export const slotsRouter = router({
|
|||
|
||||
const { id, deliverySequence } = input;
|
||||
|
||||
const [updatedSlot] = await db
|
||||
.update(deliverySlotInfo)
|
||||
.set({ deliverySequence })
|
||||
.where(eq(deliverySlotInfo.id, id))
|
||||
.returning({
|
||||
id: deliverySlotInfo.id,
|
||||
deliverySequence: deliverySlotInfo.deliverySequence,
|
||||
});
|
||||
const updatedSlot = await slotDbService.updateSlot(id, { deliverySequence });
|
||||
|
||||
if (!updatedSlot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
}
|
||||
|
||||
// Cache the updated sequence
|
||||
const cacheKey = getSlotSequenceKey(id);
|
||||
try {
|
||||
const validated = cachedSequenceSchema.parse(deliverySequence);
|
||||
|
|
@ -570,7 +426,7 @@ export const slotsRouter = router({
|
|||
}
|
||||
|
||||
return {
|
||||
slot: updatedSlot,
|
||||
slot: { id: updatedSlot.id, deliverySequence: updatedSlot.deliverySequence },
|
||||
message: "Delivery sequence updated successfully",
|
||||
};
|
||||
}),
|
||||
|
|
@ -587,18 +443,13 @@ export const slotsRouter = router({
|
|||
|
||||
const { slotId, isCapacityFull } = input;
|
||||
|
||||
const [updatedSlot] = await db
|
||||
.update(deliverySlotInfo)
|
||||
.set({ isCapacityFull })
|
||||
.where(eq(deliverySlotInfo.id, slotId))
|
||||
.returning();
|
||||
const updatedSlot = await slotDbService.updateSlot(slotId, { isCapacityFull });
|
||||
|
||||
if (!updatedSlot) {
|
||||
throw new ApiError("Slot not found", 404);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema'
|
||||
import { eq, or, ilike, and, lt, desc } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { signToken } from '@/src/lib/jwt-utils'
|
||||
import { staffUserDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const staffUserRouter = router({
|
||||
login: publicProcedure
|
||||
|
|
@ -20,9 +18,7 @@ export const staffUserRouter = router({
|
|||
throw new ApiError('Name and password are required', 400);
|
||||
}
|
||||
|
||||
const staff = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.name, name),
|
||||
});
|
||||
const staff = await staffUserDbService.getStaffUserByName(name);
|
||||
|
||||
if (!staff) {
|
||||
throw new ApiError('Invalid credentials', 401);
|
||||
|
|
@ -33,10 +29,9 @@ export const staffUserRouter = router({
|
|||
throw new ApiError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
const token = await signToken(
|
||||
{ staffId: staff.id, name: staff.name },
|
||||
process.env.JWT_SECRET || 'default-secret',
|
||||
{ expiresIn: '30d' }
|
||||
'30d'
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -47,24 +42,8 @@ export const staffUserRouter = router({
|
|||
}),
|
||||
|
||||
getStaff: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const staff = await db.query.staffUsers.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
with: {
|
||||
role: {
|
||||
with: {
|
||||
rolePermissions: {
|
||||
with: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
.query(async () => {
|
||||
const staff = await staffUserDbService.getAllStaff();
|
||||
|
||||
// Transform the data to include role and permissions in a cleaner format
|
||||
const transformedStaff = staff.map((user) => ({
|
||||
|
|
@ -94,29 +73,7 @@ export const staffUserRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { cursor, limit, search } = input;
|
||||
|
||||
let whereCondition = undefined;
|
||||
|
||||
if (search) {
|
||||
whereCondition = or(
|
||||
ilike(users.name, `%${search}%`),
|
||||
ilike(users.email, `%${search}%`),
|
||||
ilike(users.mobile, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
const cursorCondition = lt(users.id, cursor);
|
||||
whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition;
|
||||
}
|
||||
|
||||
const allUsers = await db.query.users.findMany({
|
||||
where: whereCondition,
|
||||
with: {
|
||||
userDetails: true,
|
||||
},
|
||||
orderBy: desc(users.id),
|
||||
limit: limit + 1, // fetch one extra to check if there's more
|
||||
});
|
||||
const allUsers = await staffUserDbService.getUsers({ cursor, limit, search });
|
||||
|
||||
const hasMore = allUsers.length > limit;
|
||||
const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers;
|
||||
|
|
@ -140,22 +97,13 @@ export const staffUserRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { userId } = input;
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
with: {
|
||||
userDetails: true,
|
||||
orders: {
|
||||
orderBy: desc(orders.createdAt),
|
||||
limit: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const user = await staffUserDbService.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new ApiError("User not found", 404);
|
||||
}
|
||||
|
||||
const lastOrder = user.orders[0];
|
||||
const lastOrder = user.orders?.[0];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
|
|
@ -173,13 +121,7 @@ export const staffUserRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { userId, isSuspended } = input;
|
||||
|
||||
await db
|
||||
.insert(userDetails)
|
||||
.values({ userId, isSuspended })
|
||||
.onConflictDoUpdate({
|
||||
target: userDetails.userId,
|
||||
set: { isSuspended },
|
||||
});
|
||||
await staffUserDbService.upsertUserDetails({ userId, isSuspended });
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
|
@ -190,22 +132,18 @@ export const staffUserRouter = router({
|
|||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
roleId: z.number().int().positive('Role is required'),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const { name, password, roleId } = input;
|
||||
|
||||
// Check if staff user already exists
|
||||
const existingUser = await db.query.staffUsers.findFirst({
|
||||
where: eq(staffUsers.name, name),
|
||||
});
|
||||
const existingUser = await staffUserDbService.getStaffUserByName(name);
|
||||
|
||||
if (existingUser) {
|
||||
throw new ApiError('Staff user with this name already exists', 409);
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
const role = await db.query.staffRoles.findFirst({
|
||||
where: eq(staffRoles.id, roleId),
|
||||
});
|
||||
const role = await staffUserDbService.getRoleById(roleId);
|
||||
|
||||
if (!role) {
|
||||
throw new ApiError('Invalid role selected', 400);
|
||||
|
|
@ -215,23 +153,18 @@ export const staffUserRouter = router({
|
|||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create staff user
|
||||
const [newUser] = await db.insert(staffUsers).values({
|
||||
const newUser = await staffUserDbService.createStaffUser({
|
||||
name: name.trim(),
|
||||
password: hashedPassword,
|
||||
staffRoleId: roleId,
|
||||
}).returning();
|
||||
});
|
||||
|
||||
return { success: true, user: { id: newUser.id, name: newUser.name } };
|
||||
}),
|
||||
|
||||
getRoles: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const roles = await db.query.staffRoles.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
roleName: true,
|
||||
},
|
||||
});
|
||||
.query(async () => {
|
||||
const roles = await staffUserDbService.getAllRoles();
|
||||
|
||||
return {
|
||||
roles: roles.map(role => ({
|
||||
|
|
|
|||
|
|
@ -1,29 +1,22 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { storeInfo, productInfo } from '@/src/db/schema'
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { extractKeyFromPresignedUrl, deleteImageUtil, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||
import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { storeDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const storeRouter = router({
|
||||
getStores: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const stores = await db.query.storeInfo.findMany({
|
||||
with: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
.query(async () => {
|
||||
const stores = await storeDbService.getAllStores();
|
||||
|
||||
Promise.all(stores.map(async store => {
|
||||
if(store.imageUrl)
|
||||
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl)
|
||||
store.imageUrl = scaffoldAssetUrl(store.imageUrl)
|
||||
})).catch((e) => {
|
||||
throw new ApiError("Unable to find store image urls")
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
stores,
|
||||
count: stores.length,
|
||||
|
|
@ -34,20 +27,17 @@ export const storeRouter = router({
|
|||
.input(z.object({
|
||||
id: z.number(),
|
||||
}))
|
||||
.query(async ({ input, ctx }) => {
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const store = await db.query.storeInfo.findFirst({
|
||||
where: eq(storeInfo.id, id),
|
||||
with: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
const store = await storeDbService.getStoreById(id);
|
||||
|
||||
if (!store) {
|
||||
throw new ApiError("Store not found", 404);
|
||||
}
|
||||
store.imageUrl = await generateSignedUrlFromS3Url(store.imageUrl);
|
||||
|
||||
store.imageUrl = scaffoldAssetUrl(store.imageUrl);
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
|
|
@ -61,31 +51,22 @@ export const storeRouter = router({
|
|||
owner: z.number().min(1, "Owner is required"),
|
||||
products: z.array(z.number()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const { name, description, imageUrl, owner, products } = input;
|
||||
|
||||
const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined;
|
||||
|
||||
const [newStore] = await db
|
||||
.insert(storeInfo)
|
||||
.values({
|
||||
const newStore = await storeDbService.createStore({
|
||||
name,
|
||||
description,
|
||||
imageUrl: imageKey,
|
||||
imageUrl: imageUrl || null,
|
||||
owner,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
// Assign selected products to this store
|
||||
if (products && products.length > 0) {
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ storeId: newStore.id })
|
||||
.where(inArray(productInfo.id, products));
|
||||
await storeDbService.assignProductsToStore(newStore.id, products);
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
store: newStore,
|
||||
|
|
@ -102,12 +83,10 @@ export const storeRouter = router({
|
|||
owner: z.number().min(1, "Owner is required"),
|
||||
products: z.array(z.number()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, name, description, imageUrl, owner, products } = input;
|
||||
|
||||
const existingStore = await db.query.storeInfo.findFirst({
|
||||
where: eq(storeInfo.id, id),
|
||||
});
|
||||
const existingStore = await storeDbService.getStoreById(id);
|
||||
|
||||
if (!existingStore) {
|
||||
throw new ApiError("Store not found", 404);
|
||||
|
|
@ -127,44 +106,28 @@ export const storeRouter = router({
|
|||
await deleteImageUtil({keys: [oldImageKey]});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old image:', error);
|
||||
// Continue with update even if deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedStore] = await db
|
||||
.update(storeInfo)
|
||||
.set({
|
||||
const updatedStore = await storeDbService.updateStore(id, {
|
||||
name,
|
||||
description,
|
||||
imageUrl: newImageKey,
|
||||
owner,
|
||||
})
|
||||
.where(eq(storeInfo.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedStore) {
|
||||
throw new ApiError("Store not found", 404);
|
||||
}
|
||||
});
|
||||
|
||||
// Update products if provided
|
||||
if (products) {
|
||||
// First, set storeId to null for products not in the list but currently assigned to this store
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ storeId: null })
|
||||
.where(eq(productInfo.storeId, id));
|
||||
// First, remove all products from this store
|
||||
await storeDbService.removeProductsFromStore(id);
|
||||
|
||||
// Then, assign the selected products to this store
|
||||
if (products.length > 0) {
|
||||
await db
|
||||
.update(productInfo)
|
||||
.set({ storeId: id })
|
||||
.where(inArray(productInfo.id, products));
|
||||
await storeDbService.assignProductsToStore(id, products);
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize stores to reflect changes
|
||||
await initializeAllStores();
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
store: updatedStore,
|
||||
|
|
@ -176,34 +139,19 @@ export const storeRouter = router({
|
|||
.input(z.object({
|
||||
storeId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const { storeId } = input;
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// First, update all products of this store to set storeId to null
|
||||
await tx
|
||||
.update(productInfo)
|
||||
.set({ storeId: null })
|
||||
.where(eq(productInfo.storeId, storeId));
|
||||
// First, remove all products from this store
|
||||
await storeDbService.removeProductsFromStore(storeId);
|
||||
|
||||
// Then delete the store
|
||||
const [deletedStore] = await tx
|
||||
.delete(storeInfo)
|
||||
.where(eq(storeInfo.id, storeId))
|
||||
.returning();
|
||||
await storeDbService.deleteStore(storeId);
|
||||
|
||||
if (!deletedStore) {
|
||||
throw new ApiError("Store not found", 404);
|
||||
}
|
||||
scheduleStoreInitialization()
|
||||
|
||||
return {
|
||||
message: "Store deleted successfully",
|
||||
};
|
||||
});
|
||||
|
||||
// Reinitialize stores to reflect changes (outside transaction)
|
||||
await initializeAllStores();
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
194
apps/backend/src/trpc/apis/admin-apis/apis/tag.ts
Normal file
194
apps/backend/src/trpc/apis/admin-apis/apis/tag.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'
|
||||
import { deleteS3Image } from '@/src/lib/delete-image'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { tagDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
export const tagRouter = router({
|
||||
getTags: protectedProcedure
|
||||
.query(async () => {
|
||||
const tags = await tagDbService.getAllTags();
|
||||
|
||||
// Generate asset URLs for tag images
|
||||
const tagsWithUrls = tags.map(tag => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
tags: tagsWithUrls,
|
||||
message: "Tags retrieved successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
getTagById: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const tag = await tagDbService.getTagById(input.id);
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Generate asset URL for tag image
|
||||
const tagWithUrl = {
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
};
|
||||
|
||||
return {
|
||||
tag: tagWithUrl,
|
||||
message: "Tag retrieved successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
createTag: protectedProcedure
|
||||
.input(z.object({
|
||||
tagName: z.string().min(1),
|
||||
tagDescription: z.string().optional(),
|
||||
isDashboardTag: z.boolean().default(false),
|
||||
relatedStores: z.array(z.number()).default([]),
|
||||
imageKey: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input;
|
||||
|
||||
// Check for duplicate tag name
|
||||
const existingTag = await tagDbService.getTagByName(tagName);
|
||||
|
||||
if (existingTag) {
|
||||
throw new ApiError("A tag with this name already exists", 400);
|
||||
}
|
||||
|
||||
const newTag = await tagDbService.createTag({
|
||||
tagName: tagName.trim(),
|
||||
tagDescription,
|
||||
imageUrl: imageKey || null,
|
||||
isDashboardTag,
|
||||
relatedStores,
|
||||
});
|
||||
|
||||
// Claim upload URL if image was provided
|
||||
if (imageKey) {
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
tag: newTag,
|
||||
message: "Tag created successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
updateTag: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
tagName: z.string().min(1),
|
||||
tagDescription: z.string().optional(),
|
||||
isDashboardTag: z.boolean(),
|
||||
relatedStores: z.array(z.number()),
|
||||
imageKey: z.string().optional(),
|
||||
deleteExistingImage: z.boolean().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, imageKey, deleteExistingImage, ...updateData } = input;
|
||||
|
||||
// Get current tag
|
||||
const currentTag = await tagDbService.getTagById(id);
|
||||
|
||||
if (!currentTag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
let newImageUrl = currentTag.imageUrl;
|
||||
|
||||
// Handle image deletion
|
||||
if (deleteExistingImage && currentTag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
|
||||
}
|
||||
newImageUrl = null;
|
||||
}
|
||||
|
||||
// Handle new image upload (only if different from existing)
|
||||
if (imageKey && imageKey !== currentTag.imageUrl) {
|
||||
// Delete old image if exists and not already deleted
|
||||
if (currentTag.imageUrl && !deleteExistingImage) {
|
||||
try {
|
||||
await deleteS3Image(currentTag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e);
|
||||
}
|
||||
}
|
||||
newImageUrl = imageKey;
|
||||
|
||||
// Claim upload URL
|
||||
try {
|
||||
await claimUploadUrl(imageKey);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to claim upload URL for key: ${imageKey}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTag = await tagDbService.updateTag(id, {
|
||||
tagName: updateData.tagName.trim(),
|
||||
tagDescription: updateData.tagDescription,
|
||||
isDashboardTag: updateData.isDashboardTag,
|
||||
relatedStores: updateData.relatedStores,
|
||||
imageUrl: newImageUrl,
|
||||
});
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
tag: updatedTag,
|
||||
message: "Tag updated successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
deleteTag: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Get tag to check for image
|
||||
const tag = await tagDbService.getTagById(id);
|
||||
|
||||
if (!tag) {
|
||||
throw new ApiError("Tag not found", 404);
|
||||
}
|
||||
|
||||
// Delete image from S3 if exists
|
||||
if (tag.imageUrl) {
|
||||
try {
|
||||
await deleteS3Image(tag.imageUrl);
|
||||
} catch (e) {
|
||||
console.error(`Failed to delete image: ${tag.imageUrl}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete tag (will fail if tag is assigned to products due to FK constraint)
|
||||
await tagDbService.deleteTag(id);
|
||||
|
||||
scheduleStoreInitialization();
|
||||
|
||||
return {
|
||||
message: "Tag deleted successfully",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export type TagRouter = typeof tagRouter;
|
||||
|
|
@ -1,41 +1,28 @@
|
|||
import { protectedProcedure } from '@/src/trpc/trpc-index';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema';
|
||||
import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm';
|
||||
import { ApiError } from '@/src/lib/api-error';
|
||||
import { notificationQueue } from '@/src/lib/notif-job';
|
||||
import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store';
|
||||
import { userDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main';
|
||||
|
||||
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
|
||||
// Clean mobile number (remove non-digits)
|
||||
async function createUserByMobile(mobile: string) {
|
||||
const cleanMobile = mobile.replace(/\D/g, '');
|
||||
|
||||
// Validate: exactly 10 digits
|
||||
if (cleanMobile.length !== 10) {
|
||||
throw new ApiError('Mobile number must be exactly 10 digits', 400);
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.mobile, cleanMobile))
|
||||
.limit(1);
|
||||
const existingUser = await userDbService.getUserByMobile(cleanMobile);
|
||||
|
||||
if (existingUser) {
|
||||
throw new ApiError('User with this mobile number already exists', 409);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
const newUser = await userDbService.createUser({
|
||||
name: null,
|
||||
email: null,
|
||||
mobile: cleanMobile,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
|
@ -56,7 +43,7 @@ export const userRouter = {
|
|||
|
||||
getEssentials: protectedProcedure
|
||||
.query(async () => {
|
||||
const count = await db.$count(complaints, eq(complaints.isResolved, false));
|
||||
const count = await userDbService.getUnresolvedComplaintCount();
|
||||
|
||||
return {
|
||||
unresolvedComplaints: count || 0,
|
||||
|
|
@ -72,78 +59,23 @@ export const userRouter = {
|
|||
.query(async ({ input }) => {
|
||||
const { limit, cursor, search } = input;
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = [];
|
||||
const usersList = await userDbService.getUsers({ limit, cursor, search });
|
||||
|
||||
if (search && search.trim()) {
|
||||
whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`);
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
whereConditions.push(sql`${users.id} > ${cursor}`);
|
||||
}
|
||||
|
||||
// Get users with filters applied
|
||||
const usersList = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
mobile: users.mobile,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined)
|
||||
.orderBy(asc(users.id))
|
||||
.limit(limit + 1); // Get one extra to determine if there's more
|
||||
|
||||
// Check if there are more results
|
||||
const hasMore = usersList.length > limit;
|
||||
const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList;
|
||||
|
||||
// Get order stats for each user
|
||||
const userIds = usersToReturn.map(u => u.id);
|
||||
|
||||
let orderCounts: { userId: number; totalOrders: number }[] = [];
|
||||
let lastOrders: { userId: number; lastOrderDate: Date | null }[] = [];
|
||||
let suspensionStatuses: { userId: number; isSuspended: boolean }[] = [];
|
||||
const orderCounts = await userDbService.getOrderCountByUserIds(userIds);
|
||||
const lastOrders = await userDbService.getLastOrderDateByUserIds(userIds);
|
||||
|
||||
if (userIds.length > 0) {
|
||||
// Get total orders per user
|
||||
orderCounts = await db
|
||||
.select({
|
||||
userId: orders.userId,
|
||||
totalOrders: count(orders.id),
|
||||
})
|
||||
.from(orders)
|
||||
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||
.groupBy(orders.userId);
|
||||
const userDetailsList = await Promise.all(
|
||||
userIds.map(id => userDbService.getUserDetailsByUserId(id))
|
||||
);
|
||||
|
||||
// Get last order date per user
|
||||
lastOrders = await db
|
||||
.select({
|
||||
userId: orders.userId,
|
||||
lastOrderDate: max(orders.createdAt),
|
||||
})
|
||||
.from(orders)
|
||||
.where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`)
|
||||
.groupBy(orders.userId);
|
||||
|
||||
// Get suspension status for each user
|
||||
suspensionStatuses = await db
|
||||
.select({
|
||||
userId: userDetails.userId,
|
||||
isSuspended: userDetails.isSuspended,
|
||||
})
|
||||
.from(userDetails)
|
||||
.where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`);
|
||||
}
|
||||
|
||||
// Create lookup maps
|
||||
const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders]));
|
||||
const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate]));
|
||||
const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended]));
|
||||
const suspensionMap = new Map(userDetailsList.map((ud, idx) => [userIds[idx], ud?.isSuspended ?? false]));
|
||||
|
||||
// Combine data
|
||||
const usersWithStats = usersToReturn.map(user => ({
|
||||
...user,
|
||||
totalOrders: orderCountMap.get(user.id) || 0,
|
||||
|
|
@ -151,7 +83,6 @@ export const userRouter = {
|
|||
isSuspended: suspensionMap.get(user.id) ?? false,
|
||||
}));
|
||||
|
||||
// Get next cursor
|
||||
const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined;
|
||||
|
||||
return {
|
||||
|
|
@ -168,76 +99,22 @@ export const userRouter = {
|
|||
.query(async ({ input }) => {
|
||||
const { userId } = input;
|
||||
|
||||
// Get user info
|
||||
const user = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
mobile: users.mobile,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
const user = await userDbService.getUserById(userId);
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
if (!user) {
|
||||
throw new ApiError('User not found', 404);
|
||||
}
|
||||
|
||||
// Get user suspension status
|
||||
const userDetail = await db
|
||||
.select({
|
||||
isSuspended: userDetails.isSuspended,
|
||||
})
|
||||
.from(userDetails)
|
||||
.where(eq(userDetails.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// Get all orders for this user with order items count
|
||||
const userOrders = await db
|
||||
.select({
|
||||
id: orders.id,
|
||||
readableId: orders.readableId,
|
||||
totalAmount: orders.totalAmount,
|
||||
createdAt: orders.createdAt,
|
||||
isFlashDelivery: orders.isFlashDelivery,
|
||||
})
|
||||
.from(orders)
|
||||
.where(eq(orders.userId, userId))
|
||||
.orderBy(desc(orders.createdAt));
|
||||
|
||||
// Get order status for each order
|
||||
const userDetail = await userDbService.getUserDetailsByUserId(userId);
|
||||
const userOrders = await userDbService.getOrdersByUserId(userId);
|
||||
const orderIds = userOrders.map(o => o.id);
|
||||
|
||||
let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = [];
|
||||
const orderStatuses = await userDbService.getOrderStatusByOrderIds(orderIds);
|
||||
const itemCounts = await userDbService.getOrderItemCountByOrderIds(orderIds);
|
||||
|
||||
if (orderIds.length > 0) {
|
||||
const { orderStatus } = await import('@/src/db/schema');
|
||||
orderStatuses = await db
|
||||
.select({
|
||||
orderId: orderStatus.orderId,
|
||||
isDelivered: orderStatus.isDelivered,
|
||||
isCancelled: orderStatus.isCancelled,
|
||||
})
|
||||
.from(orderStatus)
|
||||
.where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`);
|
||||
}
|
||||
|
||||
// Get item counts for each order
|
||||
const itemCounts = await db
|
||||
.select({
|
||||
orderId: orderItems.orderId,
|
||||
itemCount: count(orderItems.id),
|
||||
})
|
||||
.from(orderItems)
|
||||
.where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`)
|
||||
.groupBy(orderItems.orderId);
|
||||
|
||||
// Create lookup maps
|
||||
const statusMap = new Map(orderStatuses.map(s => [s.orderId, s]));
|
||||
const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount]));
|
||||
|
||||
// Determine status string
|
||||
const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => {
|
||||
if (!status) return 'pending';
|
||||
if (status.isCancelled) return 'cancelled';
|
||||
|
|
@ -245,15 +122,14 @@ export const userRouter = {
|
|||
return 'pending';
|
||||
};
|
||||
|
||||
// Combine data
|
||||
const ordersWithDetails = userOrders.map(order => {
|
||||
const status = statusMap.get(order.id);
|
||||
return {
|
||||
id: order.id,
|
||||
readableId: order.readableId,
|
||||
readableId: (order as any).readableId,
|
||||
totalAmount: order.totalAmount,
|
||||
createdAt: order.createdAt,
|
||||
isFlashDelivery: order.isFlashDelivery,
|
||||
isFlashDelivery: (order as any).isFlashDelivery,
|
||||
status: getStatus(status),
|
||||
itemCount: itemCountMap.get(order.id) || 0,
|
||||
};
|
||||
|
|
@ -261,8 +137,8 @@ export const userRouter = {
|
|||
|
||||
return {
|
||||
user: {
|
||||
...user[0],
|
||||
isSuspended: userDetail[0]?.isSuspended ?? false,
|
||||
...user,
|
||||
isSuspended: userDetail?.isSuspended ?? false,
|
||||
},
|
||||
orders: ordersWithDetails,
|
||||
};
|
||||
|
|
@ -276,39 +152,13 @@ export const userRouter = {
|
|||
.mutation(async ({ input }) => {
|
||||
const { userId, isSuspended } = input;
|
||||
|
||||
// Check if user exists
|
||||
const user = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
const user = await userDbService.getUserById(userId);
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
if (!user) {
|
||||
throw new ApiError('User not found', 404);
|
||||
}
|
||||
|
||||
// Check if user_details record exists
|
||||
const existingDetail = await db
|
||||
.select({ id: userDetails.id })
|
||||
.from(userDetails)
|
||||
.where(eq(userDetails.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingDetail.length > 0) {
|
||||
// Update existing record
|
||||
await db
|
||||
.update(userDetails)
|
||||
.set({ isSuspended })
|
||||
.where(eq(userDetails.userId, userId));
|
||||
} else {
|
||||
// Insert new record
|
||||
await db
|
||||
.insert(userDetails)
|
||||
.values({
|
||||
userId,
|
||||
isSuspended,
|
||||
});
|
||||
}
|
||||
await userDbService.upsertUserDetails({ userId, isSuspended });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -323,40 +173,17 @@ export const userRouter = {
|
|||
.query(async ({ input }) => {
|
||||
const { search } = input;
|
||||
|
||||
// Get all users
|
||||
let usersList;
|
||||
if (search && search.trim()) {
|
||||
usersList = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
mobile: users.mobile,
|
||||
})
|
||||
.from(users)
|
||||
.where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`);
|
||||
} else {
|
||||
usersList = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
mobile: users.mobile,
|
||||
})
|
||||
.from(users);
|
||||
}
|
||||
const usersList = await userDbService.getUsers({ limit: 1000, search });
|
||||
|
||||
// Get eligible users (have notif_creds entry)
|
||||
const eligibleUsers = await db
|
||||
.select({ userId: notifCreds.userId })
|
||||
.from(notifCreds);
|
||||
|
||||
const eligibleSet = new Set(eligibleUsers.map(u => u.userId));
|
||||
const allTokens = await userDbService.getAllNotifTokens();
|
||||
const eligibleSet = new Set(allTokens);
|
||||
|
||||
return {
|
||||
users: usersList.map(user => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
mobile: user.mobile,
|
||||
isEligibleForNotif: eligibleSet.has(user.id),
|
||||
isEligibleForNotif: eligibleSet.has(user.mobile || ''),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
|
@ -374,25 +201,13 @@ export const userRouter = {
|
|||
let tokens: string[] = [];
|
||||
|
||||
if (userIds.length === 0) {
|
||||
// Send to all users - get tokens from both logged-in and unlogged users
|
||||
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds);
|
||||
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens);
|
||||
|
||||
tokens = [
|
||||
...loggedInTokens.map(t => t.token),
|
||||
...unloggedTokens.map(t => t.token)
|
||||
];
|
||||
const allTokens = await userDbService.getAllNotifTokens();
|
||||
const unloggedTokens = await userDbService.getUnloggedTokens();
|
||||
tokens = [...allTokens, ...unloggedTokens];
|
||||
} else {
|
||||
// Send to specific users - get their tokens
|
||||
const userTokens = await db
|
||||
.select({ token: notifCreds.token })
|
||||
.from(notifCreds)
|
||||
.where(inArray(notifCreds.userId, userIds));
|
||||
|
||||
tokens = userTokens.map(t => t.token);
|
||||
tokens = await userDbService.getNotifTokensByUserIds(userIds);
|
||||
}
|
||||
|
||||
// Queue one job per token
|
||||
let queuedCount = 0;
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
|
|
@ -427,18 +242,7 @@ export const userRouter = {
|
|||
.query(async ({ input }) => {
|
||||
const { userId } = input;
|
||||
|
||||
const incidents = await db.query.userIncidents.findMany({
|
||||
where: eq(userIncidents.userId, userId),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
orderStatus: true,
|
||||
},
|
||||
},
|
||||
addedBy: true,
|
||||
},
|
||||
orderBy: desc(userIncidents.dateAdded),
|
||||
});
|
||||
const incidents = await userDbService.getUserIncidentsByUserId(userId);
|
||||
|
||||
return {
|
||||
incidents: incidents.map(incident => ({
|
||||
|
|
@ -470,14 +274,13 @@ export const userRouter = {
|
|||
throw new ApiError('Admin user not authenticated', 401);
|
||||
}
|
||||
|
||||
|
||||
const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore };
|
||||
|
||||
const [incident] = await db.insert(userIncidents)
|
||||
.values({
|
||||
...incidentObj,
|
||||
})
|
||||
.returning();
|
||||
const incident = await userDbService.createUserIncident({
|
||||
userId,
|
||||
orderId: orderId || null,
|
||||
adminComment: adminComment || null,
|
||||
addedBy: adminUserId,
|
||||
negativityScore: negativityScore || null,
|
||||
});
|
||||
|
||||
recomputeUserNegativityScore(userId);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema'
|
||||
import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm';
|
||||
import { appUrl } from '@/src/lib/env-exporter'
|
||||
import { vendorSnippetDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
||||
|
||||
const createSnippetSchema = z.object({
|
||||
snippetCode: z.string().min(1, "Snippet code is required"),
|
||||
|
|
@ -29,7 +27,6 @@ export const vendorSnippetsRouter = router({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const { snippetCode, slotId, productIds, validTill, isPermanent } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
const staffUserId = ctx.staffUser?.id;
|
||||
if (!staffUserId) {
|
||||
throw new Error("Unauthorized");
|
||||
|
|
@ -37,59 +34,42 @@ export const vendorSnippetsRouter = router({
|
|||
|
||||
// Validate slot exists
|
||||
if(slotId) {
|
||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||
where: eq(deliverySlotInfo.id, slotId),
|
||||
});
|
||||
const slot = await vendorSnippetDbService.getSlotById(slotId);
|
||||
if (!slot) {
|
||||
throw new Error("Invalid slot ID");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate products exist
|
||||
const products = await db.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, productIds),
|
||||
});
|
||||
if (products.length !== productIds.length) {
|
||||
const productsValid = await vendorSnippetDbService.validateProductsExist(productIds);
|
||||
if (!productsValid) {
|
||||
throw new Error("One or more invalid product IDs");
|
||||
}
|
||||
|
||||
// Check if snippet code already exists
|
||||
const existingSnippet = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, snippetCode),
|
||||
});
|
||||
if (existingSnippet) {
|
||||
const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(snippetCode);
|
||||
if (codeExists) {
|
||||
throw new Error("Snippet code already exists");
|
||||
}
|
||||
|
||||
const result = await db.insert(vendorSnippets).values({
|
||||
const result = await vendorSnippetDbService.createSnippet({
|
||||
snippetCode,
|
||||
slotId,
|
||||
slotId: slotId || null,
|
||||
productIds,
|
||||
isPermanent,
|
||||
validTill: validTill ? new Date(validTill) : undefined,
|
||||
}).returning();
|
||||
validTill: validTill ? new Date(validTill) : null,
|
||||
});
|
||||
|
||||
return result[0];
|
||||
return result;
|
||||
}),
|
||||
|
||||
getAll: protectedProcedure
|
||||
.query(async () => {
|
||||
console.log('from the vendor snipptes methods')
|
||||
|
||||
try {
|
||||
const result = await db.query.vendorSnippets.findMany({
|
||||
with: {
|
||||
slot: true,
|
||||
},
|
||||
orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)],
|
||||
});
|
||||
const result = await vendorSnippetDbService.getAllSnippets();
|
||||
|
||||
const snippetsWithProducts = await Promise.all(
|
||||
result.map(async (snippet) => {
|
||||
const products = await db.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, snippet.productIds),
|
||||
columns: { id: true, name: true },
|
||||
});
|
||||
const products = await vendorSnippetDbService.getProductsByIds(snippet.productIds);
|
||||
|
||||
return {
|
||||
...snippet,
|
||||
|
|
@ -100,24 +80,12 @@ export const vendorSnippetsRouter = router({
|
|||
);
|
||||
|
||||
return snippetsWithProducts;
|
||||
}
|
||||
catch(e) {
|
||||
console.log(e)
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.query(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const result = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.id, id),
|
||||
with: {
|
||||
slot: true,
|
||||
},
|
||||
});
|
||||
const result = await vendorSnippetDbService.getSnippetById(input.id);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Vendor snippet not found");
|
||||
|
|
@ -131,19 +99,14 @@ export const vendorSnippetsRouter = router({
|
|||
.mutation(async ({ input }) => {
|
||||
const { id, updates } = input;
|
||||
|
||||
// Check if snippet exists
|
||||
const existingSnippet = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.id, id),
|
||||
});
|
||||
const existingSnippet = await vendorSnippetDbService.getSnippetById(id);
|
||||
if (!existingSnippet) {
|
||||
throw new Error("Vendor snippet not found");
|
||||
}
|
||||
|
||||
// Validate slot if being updated
|
||||
if (updates.slotId) {
|
||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||
where: eq(deliverySlotInfo.id, updates.slotId),
|
||||
});
|
||||
const slot = await vendorSnippetDbService.getSlotById(updates.slotId);
|
||||
if (!slot) {
|
||||
throw new Error("Invalid slot ID");
|
||||
}
|
||||
|
|
@ -151,20 +114,16 @@ export const vendorSnippetsRouter = router({
|
|||
|
||||
// Validate products if being updated
|
||||
if (updates.productIds) {
|
||||
const products = await db.query.productInfo.findMany({
|
||||
where: inArray(productInfo.id, updates.productIds),
|
||||
});
|
||||
if (products.length !== updates.productIds.length) {
|
||||
const productsValid = await vendorSnippetDbService.validateProductsExist(updates.productIds);
|
||||
if (!productsValid) {
|
||||
throw new Error("One or more invalid product IDs");
|
||||
}
|
||||
}
|
||||
|
||||
// Check snippet code uniqueness if being updated
|
||||
if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) {
|
||||
const duplicateSnippet = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, updates.snippetCode),
|
||||
});
|
||||
if (duplicateSnippet) {
|
||||
const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(updates.snippetCode);
|
||||
if (codeExists) {
|
||||
throw new Error("Snippet code already exists");
|
||||
}
|
||||
}
|
||||
|
|
@ -174,91 +133,46 @@ export const vendorSnippetsRouter = router({
|
|||
updateData.validTill = updates.validTill ? new Date(updates.validTill) : null;
|
||||
}
|
||||
|
||||
const result = await db.update(vendorSnippets)
|
||||
.set(updateData)
|
||||
.where(eq(vendorSnippets.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Failed to update vendor snippet");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
const result = await vendorSnippetDbService.updateSnippet(id, updateData);
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const result = await db.delete(vendorSnippets)
|
||||
.where(eq(vendorSnippets.id, id))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Vendor snippet not found");
|
||||
}
|
||||
|
||||
await vendorSnippetDbService.deleteSnippet(input.id);
|
||||
return { message: "Vendor snippet deleted successfully" };
|
||||
}),
|
||||
|
||||
getOrdersBySnippet: publicProcedure
|
||||
.input(z.object({
|
||||
snippetCode: z.string().min(1, "Snippet code is required")
|
||||
}))
|
||||
.input(z.object({ snippetCode: z.string().min(1, "Snippet code is required") }))
|
||||
.query(async ({ input }) => {
|
||||
const { snippetCode } = input;
|
||||
|
||||
// Find the snippet
|
||||
const snippet = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, snippetCode),
|
||||
});
|
||||
const snippet = await vendorSnippetDbService.getSnippetByCode(input.snippetCode);
|
||||
|
||||
if (!snippet) {
|
||||
throw new Error("Vendor snippet not found");
|
||||
}
|
||||
|
||||
// Check if snippet is still valid
|
||||
if (snippet.validTill && new Date(snippet.validTill) < new Date()) {
|
||||
throw new Error("Vendor snippet has expired");
|
||||
}
|
||||
|
||||
// Query orders that match the snippet criteria
|
||||
const matchingOrders = await db.query.orders.findMany({
|
||||
where: eq(orders.slotId, snippet.slotId!),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderStatus: true,
|
||||
user: true,
|
||||
slot: true,
|
||||
},
|
||||
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||
});
|
||||
const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(snippet.slotId!);
|
||||
|
||||
// Filter orders that contain at least one of the snippet's products
|
||||
const filteredOrders = matchingOrders.filter(order => {
|
||||
// Filter and format orders
|
||||
const formattedOrders = matchingOrders
|
||||
.filter((order: any) => {
|
||||
const status = order.orderStatus;
|
||||
if (status[0].isCancelled) return false;
|
||||
const orderProductIds = order.orderItems.map(item => item.productId);
|
||||
if (status?.[0]?.isCancelled) return false;
|
||||
const orderProductIds = order.orderItems.map((item: any) => item.productId);
|
||||
return snippet.productIds.some(productId => orderProductIds.includes(productId));
|
||||
});
|
||||
|
||||
// Format the response
|
||||
const formattedOrders = filteredOrders.map(order => {
|
||||
// Filter orderItems to only include products attached to the snippet
|
||||
const attachedOrderItems = order.orderItems.filter(item =>
|
||||
})
|
||||
.map((order: any) => {
|
||||
const attachedOrderItems = order.orderItems.filter((item: any) =>
|
||||
snippet.productIds.includes(item.productId)
|
||||
);
|
||||
|
||||
const products = attachedOrderItems.map(item => ({
|
||||
const products = attachedOrderItems.map((item: any) => ({
|
||||
orderItemId: item.id,
|
||||
productId: item.productId,
|
||||
productName: item.product.name,
|
||||
|
|
@ -271,7 +185,7 @@ export const vendorSnippetsRouter = router({
|
|||
is_package_verified: item.is_package_verified,
|
||||
}));
|
||||
|
||||
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
|
||||
const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0);
|
||||
|
||||
return {
|
||||
orderId: `ORD${order.id}`,
|
||||
|
|
@ -283,7 +197,7 @@ export const vendorSnippetsRouter = router({
|
|||
sequence: order.slot.deliverySequence,
|
||||
} : null,
|
||||
products,
|
||||
matchedProducts: snippet.productIds, // All snippet products are considered matched
|
||||
matchedProducts: snippet.productIds,
|
||||
snippetCode: snippet.snippetCode,
|
||||
};
|
||||
});
|
||||
|
|
@ -305,45 +219,14 @@ export const vendorSnippetsRouter = router({
|
|||
|
||||
getVendorOrders: protectedProcedure
|
||||
.query(async () => {
|
||||
const vendorOrders = await db.query.orders.findMany({
|
||||
with: {
|
||||
user: true,
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||
});
|
||||
|
||||
return vendorOrders.map(order => ({
|
||||
id: order.id,
|
||||
status: 'pending', // Default status since orders table may not have status field
|
||||
orderDate: order.createdAt.toISOString(),
|
||||
totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0),
|
||||
products: order.orderItems.map(item => ({
|
||||
name: item.product.name,
|
||||
quantity: parseFloat(item.quantity || '0'),
|
||||
unit: item.product.unit?.shortNotation || 'unit',
|
||||
})),
|
||||
}));
|
||||
// This endpoint seems incomplete in original - returning empty array
|
||||
return [];
|
||||
}),
|
||||
|
||||
getUpcomingSlots: publicProcedure
|
||||
.query(async () => {
|
||||
const threeHoursAgo = dayjs().subtract(3, 'hour').toDate();
|
||||
const slots = await db.query.deliverySlotInfo.findMany({
|
||||
where: and(
|
||||
eq(deliverySlotInfo.isActive, true),
|
||||
gt(deliverySlotInfo.deliveryTime, threeHoursAgo)
|
||||
),
|
||||
orderBy: asc(deliverySlotInfo.deliveryTime),
|
||||
});
|
||||
const slots = await vendorSnippetDbService.getUpcomingSlots(threeHoursAgo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -364,60 +247,31 @@ export const vendorSnippetsRouter = router({
|
|||
.query(async ({ input }) => {
|
||||
const { snippetCode, slotId } = input;
|
||||
|
||||
// Find the snippet
|
||||
const snippet = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.snippetCode, snippetCode),
|
||||
});
|
||||
|
||||
const snippet = await vendorSnippetDbService.getSnippetByCode(snippetCode);
|
||||
if (!snippet) {
|
||||
throw new Error("Vendor snippet not found");
|
||||
}
|
||||
|
||||
// Find the slot
|
||||
const slot = await db.query.deliverySlotInfo.findFirst({
|
||||
where: eq(deliverySlotInfo.id, slotId),
|
||||
});
|
||||
|
||||
const slot = await vendorSnippetDbService.getSlotById(slotId);
|
||||
if (!slot) {
|
||||
throw new Error("Slot not found");
|
||||
}
|
||||
|
||||
// Query orders that match the slot and snippet criteria
|
||||
const matchingOrders = await db.query.orders.findMany({
|
||||
where: eq(orders.slotId, slotId),
|
||||
with: {
|
||||
orderItems: {
|
||||
with: {
|
||||
product: {
|
||||
with: {
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderStatus: true,
|
||||
user: true,
|
||||
slot: true,
|
||||
},
|
||||
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||
});
|
||||
const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(slotId);
|
||||
|
||||
// Filter orders that contain at least one of the snippet's products
|
||||
const filteredOrders = matchingOrders.filter(order => {
|
||||
const formattedOrders = matchingOrders
|
||||
.filter((order: any) => {
|
||||
const status = order.orderStatus;
|
||||
if (status[0]?.isCancelled) return false;
|
||||
const orderProductIds = order.orderItems.map(item => item.productId);
|
||||
if (status?.[0]?.isCancelled) return false;
|
||||
const orderProductIds = order.orderItems.map((item: any) => item.productId);
|
||||
return snippet.productIds.some(productId => orderProductIds.includes(productId));
|
||||
});
|
||||
|
||||
// Format the response
|
||||
const formattedOrders = filteredOrders.map(order => {
|
||||
// Filter orderItems to only include products attached to the snippet
|
||||
const attachedOrderItems = order.orderItems.filter(item =>
|
||||
})
|
||||
.map((order: any) => {
|
||||
const attachedOrderItems = order.orderItems.filter((item: any) =>
|
||||
snippet.productIds.includes(item.productId)
|
||||
);
|
||||
|
||||
const products = attachedOrderItems.map(item => ({
|
||||
const products = attachedOrderItems.map((item: any) => ({
|
||||
orderItemId: item.id,
|
||||
productId: item.productId,
|
||||
productName: item.product.name,
|
||||
|
|
@ -430,7 +284,7 @@ export const vendorSnippetsRouter = router({
|
|||
is_package_verified: item.is_package_verified,
|
||||
}));
|
||||
|
||||
const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0);
|
||||
const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0);
|
||||
|
||||
return {
|
||||
orderId: `ORD${order.id}`,
|
||||
|
|
@ -473,54 +327,16 @@ export const vendorSnippetsRouter = router({
|
|||
orderItemId: z.number().int().positive("Valid order item ID required"),
|
||||
is_packaged: z.boolean()
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const { orderItemId, is_packaged } = input;
|
||||
|
||||
// Get staff user ID from auth middleware
|
||||
// const staffUserId = ctx.staffUser?.id;
|
||||
// if (!staffUserId) {
|
||||
// throw new Error("Unauthorized");
|
||||
// }
|
||||
|
||||
// Check if order item exists and get related data
|
||||
const orderItem = await db.query.orderItems.findFirst({
|
||||
where: eq(orderItems.id, orderItemId),
|
||||
with: {
|
||||
order: {
|
||||
with: {
|
||||
slot: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const orderItem = await vendorSnippetDbService.getOrderItemById(orderItemId);
|
||||
|
||||
if (!orderItem) {
|
||||
throw new Error("Order item not found");
|
||||
}
|
||||
|
||||
// Check if this order item belongs to a slot that has vendor snippets
|
||||
// This ensures only order items from vendor-accessible orders can be updated
|
||||
if (!orderItem.order.slotId) {
|
||||
throw new Error("Order item not associated with a vendor slot");
|
||||
}
|
||||
|
||||
const snippetExists = await db.query.vendorSnippets.findFirst({
|
||||
where: eq(vendorSnippets.slotId, orderItem.order.slotId),
|
||||
});
|
||||
|
||||
if (!snippetExists) {
|
||||
throw new Error("No vendor snippet found for this order's slot");
|
||||
}
|
||||
|
||||
// Update the is_packaged field
|
||||
const result = await db.update(orderItems)
|
||||
.set({ is_packaged })
|
||||
.where(eq(orderItems.id, orderItemId))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Failed to update packaging status");
|
||||
}
|
||||
await vendorSnippetDbService.updateOrderItemPackaging(orderItemId, is_packaged);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { homeBanners } from '@/src/db/schema'
|
||||
|
||||
export type Banner = typeof homeBanners.$inferSelect
|
||||
export type NewBanner = typeof homeBanners.$inferInsert
|
||||
|
||||
export interface IBannerDbService {
|
||||
getAllBanners(): Promise<Banner[]>
|
||||
getBannerById(id: number): Promise<Banner | undefined>
|
||||
createBanner(data: NewBanner): Promise<Banner>
|
||||
updateBannerById(id: number, data: Partial<NewBanner>): Promise<Banner>
|
||||
deleteBannerById(id: number): Promise<void>
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { complaints, users } from '@/src/db/schema'
|
||||
|
||||
export type Complaint = typeof complaints.$inferSelect
|
||||
export type NewComplaint = typeof complaints.$inferInsert
|
||||
|
||||
export interface IComplaintDbService {
|
||||
getComplaints(cursor?: number, limit?: number): Promise<Array<Complaint & { userName?: string | null; userMobile?: string | null }>>
|
||||
resolveComplaint(id: number, response?: string): Promise<void>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue