Compare commits
No commits in common. "edge_redacted" and "main" have entirely different histories.
edge_redac
...
main
688 changed files with 40852 additions and 39624 deletions
|
|
@ -1,9 +0,0 @@
|
||||||
|
|
||||||
**/node_modules
|
|
||||||
**/dist
|
|
||||||
apps/users-ui/app
|
|
||||||
apps/users-ui/src
|
|
||||||
apps/admin-ui/app
|
|
||||||
apps/users-ui/src
|
|
||||||
**/package-lock.json
|
|
||||||
test/
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,14 +7,10 @@ yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
*.apk
|
|
||||||
**/appBinaries
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
test/appBinaries
|
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
|
||||||
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
|
||||||
- remove slots from products cache
|
|
||||||
- remove redundant product details like name, description etc from the slots api
|
|
||||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,36 +1,32 @@
|
||||||
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||||
|
|
||||||
# 1. ---- Base Bun image
|
# 1. ---- Base Node image
|
||||||
FROM oven/bun:1.3.10 AS base
|
FROM node:20-slim AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 2. ---- Pruner ----
|
# 2. ---- Pruner ----
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Copy config files first for better caching
|
# Copy config files first for better caching
|
||||||
COPY package.json turbo.json ./
|
COPY package.json package-lock.json turbo.json ./
|
||||||
COPY apps/backend/package.json ./apps/backend/
|
COPY apps/backend/package.json ./apps/backend/
|
||||||
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||||
COPY packages/shared/ ./packages/shared
|
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
RUN bun install -g turbo
|
RUN npm install -g turbo
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
|
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||||
# RUN find . -path "./node_modules" -prune -o -print
|
|
||||||
|
|
||||||
# 3. ---- Builder ----
|
# 3. ---- Builder ----
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Copy package files first to cache bun install
|
# Copy package files first to cache npm install
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
#RUN cat ./bun.lock
|
|
||||||
COPY --from=pruner /app/turbo.json .
|
COPY --from=pruner /app/turbo.json .
|
||||||
RUN bun install
|
RUN npm ci
|
||||||
# Copy source code after dependencies are installed
|
# Copy source code after dependencies are installed
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
|
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||||
RUN find . -path "./node_modules" -prune -o -print
|
|
||||||
|
|
||||||
# 4. ---- Runner ----
|
# 4. ---- Runner ----
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
@ -38,15 +34,12 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Copy package files and install production deps
|
# Copy package files and install production deps
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||||
RUN bun install --production
|
RUN npm ci --production --omit=dev
|
||||||
# Copy built applications
|
# Copy built applications
|
||||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||||
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
|
||||||
|
|
||||||
# RUN ls -R
|
|
||||||
RUN find . -path "./node_modules" -prune -o -print
|
|
||||||
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
|
RUN npm i -g bun
|
||||||
|
CMD ["bun", "apps/backend/dist/index.js"]
|
||||||
|
# CMD ["node", "apps/backend/dist/index.js"]
|
||||||
12
apps/admin-ui/.expo/types/router.d.ts
vendored
12
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -227,6 +227,7 @@ export default function Layout() {
|
||||||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||||
|
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||||
|
|
|
||||||
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
108
apps/admin-ui/app/(drawer)/address-management/index.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||||
|
import { BottomDialog , tw } from 'common-ui'
|
||||||
|
import { trpc } from '@/src/trpc-client'
|
||||||
|
import AddressZoneForm from '@/components/AddressZoneForm'
|
||||||
|
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||||
|
|
||||||
|
const AddressManagement: React.FC = () => {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
||||||
|
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
||||||
|
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
||||||
|
|
||||||
|
const createZone = trpc.admin.address.createZone.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchZones()
|
||||||
|
setDialogOpen(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createArea = trpc.admin.address.createArea.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchAreas()
|
||||||
|
setDialogOpen(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAddZone = () => {
|
||||||
|
setDialogType('zone')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPlace = () => {
|
||||||
|
setDialogType('place')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleZone = (zoneId: number) => {
|
||||||
|
setExpandedZones(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(zoneId)) {
|
||||||
|
newSet.delete(zoneId)
|
||||||
|
} else {
|
||||||
|
newSet.add(zoneId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAreas = areas?.reduce((acc, area) => {
|
||||||
|
if (area.zoneId) {
|
||||||
|
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
||||||
|
acc[area.zoneId].push(area)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Record<number, typeof areas[0][]>) || {}
|
||||||
|
|
||||||
|
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white`}>
|
||||||
|
<View style={tw`flex-row justify-between p-4`}>
|
||||||
|
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
||||||
|
<Text style={tw`text-white`}>Add Zone</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
||||||
|
<Text style={tw`text-white`}>Add Place</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={tw`flex-1 p-4`}>
|
||||||
|
{zones?.map(zone => (
|
||||||
|
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
||||||
|
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
||||||
|
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
||||||
|
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{expandedZones.has(zone.id) && (
|
||||||
|
<View style={tw`p-3`}>
|
||||||
|
{groupedAreas[zone.id]?.map(area => (
|
||||||
|
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||||
|
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={tw`mt-6`}>
|
||||||
|
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
||||||
|
{unzonedAreas.map(area => (
|
||||||
|
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||||
|
))}
|
||||||
|
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||||
|
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
||||||
|
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
||||||
|
</BottomDialog>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressManagement
|
||||||
|
|
@ -74,7 +74,7 @@ export default function Dashboard() {
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Manage Orderss',
|
title: 'Manage Orders',
|
||||||
icon: 'shopping-bag',
|
icon: 'shopping-bag',
|
||||||
description: 'View and manage customer orders',
|
description: 'View and manage customer orders',
|
||||||
route: '/(drawer)/manage-orders',
|
route: '/(drawer)/manage-orders',
|
||||||
|
|
@ -158,15 +158,6 @@ export default function Dashboard() {
|
||||||
iconColor: '#8B5CF6',
|
iconColor: '#8B5CF6',
|
||||||
iconBg: '#F3E8FF',
|
iconBg: '#F3E8FF',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Stocking Schedules',
|
|
||||||
icon: 'schedule',
|
|
||||||
description: 'Manage product stocking schedules',
|
|
||||||
route: '/(drawer)/stocking-schedules',
|
|
||||||
category: 'products',
|
|
||||||
iconColor: '#0EA5E9',
|
|
||||||
iconBg: '#E0F2FE',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Stores',
|
title: 'Stores',
|
||||||
icon: 'store',
|
icon: 'store',
|
||||||
|
|
@ -184,6 +175,15 @@ export default function Dashboard() {
|
||||||
category: 'marketing',
|
category: 'marketing',
|
||||||
iconColor: '#F97316',
|
iconColor: '#F97316',
|
||||||
iconBg: '#FFEDD5',
|
iconBg: '#FFEDD5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Address Management',
|
||||||
|
icon: 'location-on',
|
||||||
|
description: 'Manage service areas',
|
||||||
|
route: '/(drawer)/address-management',
|
||||||
|
category: 'settings',
|
||||||
|
iconColor: '#EAB308',
|
||||||
|
iconBg: '#FEF9C3',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'App Constants',
|
title: 'App Constants',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { AppContainer, MyText, tw } from 'common-ui';
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
import TagForm from '@/src/components/TagForm';
|
import TagForm from '@/src/components/TagForm';
|
||||||
|
import { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
interface TagFormData {
|
interface TagFormData {
|
||||||
|
|
@ -14,17 +15,36 @@ interface TagFormData {
|
||||||
|
|
||||||
export default function AddTag() {
|
export default function AddTag() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createTag = trpc.admin.tag.createTag.useMutation();
|
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
||||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||||
|
|
||||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||||
createTag.mutate({
|
const formData = new FormData();
|
||||||
tagName: values.tagName,
|
|
||||||
tagDescription: values.tagDescription,
|
// Add text fields
|
||||||
isDashboardTag: values.isDashboardTag,
|
formData.append('tagName', values.tagName);
|
||||||
relatedStores: values.relatedStores,
|
if (values.tagDescription) {
|
||||||
imageKey: imageKey,
|
formData.append('tagDescription', values.tagDescription);
|
||||||
}, {
|
}
|
||||||
|
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||||
|
|
||||||
|
// Add related stores
|
||||||
|
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||||
|
|
||||||
|
// Add image if uploaded
|
||||||
|
if (image?.uri) {
|
||||||
|
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||||
|
const match = /\.(\w+)$/.exec(filename);
|
||||||
|
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||||
|
|
||||||
|
formData.append('image', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: filename,
|
||||||
|
type,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTag(formData, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Tag created successfully', [
|
Alert.alert('Success', 'Tag created successfully', [
|
||||||
{
|
{
|
||||||
|
|
@ -56,7 +76,7 @@ export default function AddTag() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createTag.isPending}
|
isLoading={isCreating}
|
||||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, Alert } from 'react-native';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, MyText, tw } from 'common-ui';
|
import { AppContainer, MyText, tw } from 'common-ui';
|
||||||
import TagForm from '@/src/components/TagForm';
|
import TagForm from '@/src/components/TagForm';
|
||||||
|
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
interface TagFormData {
|
interface TagFormData {
|
||||||
|
|
@ -10,6 +11,7 @@ interface TagFormData {
|
||||||
tagDescription: string;
|
tagDescription: string;
|
||||||
isDashboardTag: boolean;
|
isDashboardTag: boolean;
|
||||||
relatedStores: number[];
|
relatedStores: number[];
|
||||||
|
existingImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditTag() {
|
export default function EditTag() {
|
||||||
|
|
@ -17,25 +19,39 @@ export default function EditTag() {
|
||||||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||||
|
|
||||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.tag.getTagById.useQuery(
|
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||||
{ id: tagIdNum! },
|
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||||
{ enabled: !!tagIdNum }
|
|
||||||
);
|
|
||||||
const updateTag = trpc.admin.tag.updateTag.useMutation();
|
|
||||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||||
|
|
||||||
const handleSubmit = (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => {
|
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||||
if (!tagIdNum) return;
|
if (!tagIdNum) return;
|
||||||
|
|
||||||
updateTag.mutate({
|
const formData = new FormData();
|
||||||
id: tagIdNum,
|
|
||||||
tagName: values.tagName,
|
// Add text fields
|
||||||
tagDescription: values.tagDescription,
|
formData.append('tagName', values.tagName);
|
||||||
isDashboardTag: values.isDashboardTag,
|
if (values.tagDescription) {
|
||||||
relatedStores: values.relatedStores,
|
formData.append('tagDescription', values.tagDescription);
|
||||||
imageKey: imageKey,
|
}
|
||||||
deleteExistingImage: deleteExistingImage,
|
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||||
}, {
|
|
||||||
|
// Add related stores
|
||||||
|
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||||
|
|
||||||
|
// Add image if uploaded
|
||||||
|
if (image?.uri) {
|
||||||
|
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||||
|
const match = /\.(\w+)$/.exec(filename);
|
||||||
|
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||||
|
|
||||||
|
formData.append('image', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: filename,
|
||||||
|
type,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTag({ id: tagIdNum, formData }, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Tag updated successfully', [
|
Alert.alert('Success', 'Tag updated successfully', [
|
||||||
{
|
{
|
||||||
|
|
@ -76,7 +92,8 @@ export default function EditTag() {
|
||||||
tagName: tag.tagName,
|
tagName: tag.tagName,
|
||||||
tagDescription: tag.tagDescription || '',
|
tagDescription: tag.tagDescription || '',
|
||||||
isDashboardTag: tag.isDashboardTag,
|
isDashboardTag: tag.isDashboardTag,
|
||||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
relatedStores: tag.relatedStores || [],
|
||||||
|
existingImageUrl: tag.imageUrl || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -89,7 +106,7 @@ export default function EditTag() {
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
existingImageUrl={tag.imageUrl || undefined}
|
existingImageUrl={tag.imageUrl || undefined}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={updateTag.isPending}
|
isLoading={isUpdating}
|
||||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,7 @@ import { useRouter } from 'expo-router';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||||
import { TagMenu } from '@/src/components/TagMenu';
|
import { TagMenu } from '@/src/components/TagMenu';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||||
|
|
||||||
interface Tag {
|
|
||||||
id: number;
|
|
||||||
tagName: string;
|
|
||||||
tagDescription: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores?: any;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagItemProps {
|
interface TagItemProps {
|
||||||
item: Tag;
|
item: Tag;
|
||||||
|
|
@ -70,7 +60,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
||||||
|
|
||||||
export default function ProductTags() {
|
export default function ProductTags() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.tag.getTags.useQuery();
|
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const tags = tagsData?.tags || [];
|
const tags = tagsData?.tags || [];
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import { AppContainer } from 'common-ui';
|
import { AppContainer } from 'common-ui';
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
import ProductForm from '@/src/components/ProductForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
||||||
|
|
||||||
const handleSubmit = (values: any, imageKeys?: string[]) => {
|
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
||||||
createProduct.mutate({
|
const payload: CreateProductPayload = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
|
|
@ -18,12 +18,37 @@ export default function AddProduct() {
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
productQuantity: values.productQuantity || 1,
|
||||||
isSuspended: values.isSuspended || false,
|
};
|
||||||
isFlashAvailable: values.isFlashAvailable || false,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
const formData = new FormData();
|
||||||
tagIds: values.tagIds || [],
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
imageKeys: imageKeys || [],
|
if (value !== undefined && value !== null) {
|
||||||
}, {
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append tag IDs
|
||||||
|
if (values.tagIds && values.tagIds.length > 0) {
|
||||||
|
values.tagIds.forEach((tagId: number) => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append images
|
||||||
|
if (images) {
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: `image-${index}.jpg`,
|
||||||
|
// type: 'image/jpeg',
|
||||||
|
type: image.mimeType as any,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createProduct(formData, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
Alert.alert('Success', 'Product created successfully!');
|
||||||
// Reset form or navigate
|
// Reset form or navigate
|
||||||
|
|
@ -56,7 +81,7 @@ export default function AddProduct() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createProduct.isPending}
|
isLoading={isCreating}
|
||||||
existingImages={[]}
|
existingImages={[]}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
||||||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../../../../hooks/useUploadToObjectStorage';
|
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
@ -27,7 +26,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||||
const { upload, isUploading } = useUploadToObjectStorage();
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -63,16 +62,30 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
|
|
||||||
const handleSubmit = async (adminResponse: string) => {
|
const handleSubmit = async (adminResponse: string) => {
|
||||||
try {
|
try {
|
||||||
let keys: string[] = [];
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
let generatedUrls: string[] = [];
|
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
|
||||||
const result = await upload({
|
|
||||||
images: selectedImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
|
||||||
contextString: 'review',
|
contextString: 'review',
|
||||||
|
mimeTypes,
|
||||||
});
|
});
|
||||||
keys = result.keys;
|
const keys = generatedUrls.map(url => {
|
||||||
generatedUrls = result.presignedUrls;
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, "");
|
||||||
|
const decodedKey = decodeURIComponent(rawKey);
|
||||||
|
const parts = decodedKey.split('/');
|
||||||
|
parts.shift();
|
||||||
|
return parts.join('/');
|
||||||
|
});
|
||||||
|
setUploadUrls(generatedUrls);
|
||||||
|
|
||||||
|
for (let i = 0; i < generatedUrls.length; i++) {
|
||||||
|
const uploadUrl = generatedUrls[i];
|
||||||
|
const { blob, mimeType } = selectedImages[i];
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: { 'Content-Type': mimeType },
|
||||||
|
});
|
||||||
|
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await respondToReview.mutateAsync({
|
await respondToReview.mutateAsync({
|
||||||
|
|
@ -89,6 +102,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
setDisplayImages([]);
|
setDisplayImages([]);
|
||||||
setUploadUrls([]);
|
setUploadUrls([]);
|
||||||
} catch (error:any) {
|
} catch (error:any) {
|
||||||
|
|
||||||
Alert.alert('Error', error.message || 'Failed to submit response.');
|
Alert.alert('Error', error.message || 'Failed to submit response.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -123,7 +137,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => formikSubmit()}
|
onPress={() => formikSubmit()}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
disabled={respondToReview.isPending || isUploading}
|
disabled={respondToReview.isPending}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#2563EB', '#1D4ED8']}
|
colors={['#2563EB', '#1D4ED8']}
|
||||||
|
|
@ -131,9 +145,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
style={tw`py-4 rounded-2xl items-center shadow-lg`}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{respondToReview.isPending ? (
|
||||||
<ActivityIndicator color="white" />
|
|
||||||
) : respondToReview.isPending ? (
|
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color="white" />
|
||||||
) : (
|
) : (
|
||||||
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, Text, Alert } from 'react-native';
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
|
||||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||||
|
import { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export default function EditProduct() {
|
export default function EditProduct() {
|
||||||
|
|
@ -10,18 +11,18 @@ export default function EditProduct() {
|
||||||
const productId = Number(id);
|
const productId = Number(id);
|
||||||
const productFormRef = useRef<ProductFormRef>(null);
|
const productFormRef = useRef<ProductFormRef>(null);
|
||||||
|
|
||||||
|
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||||
{ id: productId },
|
{ id: productId },
|
||||||
{ enabled: !!productId }
|
{ enabled: !!productId }
|
||||||
);
|
);
|
||||||
|
//
|
||||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||||
|
|
||||||
useManualRefresh(() => refetch());
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
const handleSubmit = (values: any, newImageKeys?: string[], imagesToDelete?: string[]) => {
|
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||||
updateProduct.mutate({
|
const payload = {
|
||||||
id: productId,
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
|
|
@ -31,9 +32,6 @@ export default function EditProduct() {
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
incrementStep: 1,
|
incrementStep: 1,
|
||||||
productQuantity: values.productQuantity || 1,
|
productQuantity: values.productQuantity || 1,
|
||||||
isSuspended: values.isSuspended,
|
|
||||||
isFlashAvailable: values.isFlashAvailable,
|
|
||||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
|
||||||
deals: values.deals?.filter((deal: any) =>
|
deals: values.deals?.filter((deal: any) =>
|
||||||
deal.quantity && deal.price && deal.validTill
|
deal.quantity && deal.price && deal.validTill
|
||||||
).map((deal: any) => ({
|
).map((deal: any) => ({
|
||||||
|
|
@ -41,12 +39,47 @@ export default function EditProduct() {
|
||||||
price: parseFloat(deal.price),
|
price: parseFloat(deal.price),
|
||||||
validTill: deal.validTill instanceof Date
|
validTill: deal.validTill instanceof Date
|
||||||
? deal.validTill.toISOString().split('T')[0]
|
? deal.validTill.toISOString().split('T')[0]
|
||||||
: deal.validTill,
|
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
||||||
})),
|
})),
|
||||||
tagIds: values.tagIds,
|
tagIds: values.tagIds,
|
||||||
newImageKeys: newImageKeys || [],
|
};
|
||||||
imagesToDelete: imagesToDelete || [],
|
|
||||||
}, {
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(payload).forEach(([key, value]) => {
|
||||||
|
if (key === 'deals' && Array.isArray(value)) {
|
||||||
|
formData.append(key, JSON.stringify(value));
|
||||||
|
} else if (key === 'tagIds' && Array.isArray(value)) {
|
||||||
|
value.forEach(tagId => {
|
||||||
|
formData.append('tagIds', tagId.toString());
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new images
|
||||||
|
if (newImages && newImages.length > 0) {
|
||||||
|
newImages.forEach((image, index) => {
|
||||||
|
if (image.uri) {
|
||||||
|
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
||||||
|
formData.append('images', {
|
||||||
|
uri: image.uri,
|
||||||
|
name: fileName,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add images to delete
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(
|
||||||
|
{ id: productId, formData },
|
||||||
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
Alert.alert('Success', 'Product updated successfully!');
|
Alert.alert('Success', 'Product updated successfully!');
|
||||||
// Clear newly added images after successful update
|
// Clear newly added images after successful update
|
||||||
|
|
@ -55,7 +88,8 @@ export default function EditProduct() {
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
Alert.alert('Error', error.message || 'Failed to update product');
|
Alert.alert('Error', error.message || 'Failed to update product');
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|
@ -91,7 +125,7 @@ export default function EditProduct() {
|
||||||
deals: productData.deals?.map(deal => ({
|
deals: productData.deals?.map(deal => ({
|
||||||
quantity: deal.quantity,
|
quantity: deal.quantity,
|
||||||
price: deal.price,
|
price: deal.price,
|
||||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||||
})) || [{ quantity: '', price: '', validTill: null }],
|
})) || [{ quantity: '', price: '', validTill: null }],
|
||||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||||
isSuspended: productData.isSuspended || false,
|
isSuspended: productData.isSuspended || false,
|
||||||
|
|
@ -107,7 +141,7 @@ export default function EditProduct() {
|
||||||
mode="edit"
|
mode="edit"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={updateProduct.isPending}
|
isLoading={isUpdating}
|
||||||
existingImages={productData.images || []}
|
existingImages={productData.images || []}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
} from 'common-ui';
|
} from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../../../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -27,6 +26,12 @@ interface User {
|
||||||
isEligibleForNotif: boolean;
|
isEligibleForNotif: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractKeyFromUrl = (url: string): string => {
|
||||||
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||||
|
return decodeURIComponent(rawKey);
|
||||||
|
};
|
||||||
|
|
||||||
export default function SendNotifications() {
|
export default function SendNotifications() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
|
|
@ -41,7 +46,8 @@ export default function SendNotifications() {
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
// Generate upload URLs mutation
|
||||||
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
// Send notification mutation
|
// Send notification mutation
|
||||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||||
|
|
@ -121,8 +127,28 @@ export default function SendNotifications() {
|
||||||
|
|
||||||
// Upload image if selected
|
// Upload image if selected
|
||||||
if (selectedImage) {
|
if (selectedImage) {
|
||||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
imageUrl = key;
|
contextString: 'notification',
|
||||||
|
mimeTypes: [selectedImage.mimeType],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadUrls.length > 0) {
|
||||||
|
const uploadUrl = uploadUrls[0];
|
||||||
|
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: selectedImage.blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': selectedImage.mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
|
|
@ -230,15 +256,15 @@ export default function SendNotifications() {
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0}
|
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0}
|
||||||
style={tw`${
|
style={tw`${
|
||||||
sendNotification.isPending || isUploading || title.trim().length === 0 || message.trim().length === 0
|
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
|
||||||
? 'bg-gray-300'
|
? 'bg-gray-300'
|
||||||
: 'bg-blue-600'
|
: 'bg-blue-600'
|
||||||
} rounded-xl py-4 items-center shadow-sm`}
|
} rounded-xl py-4 items-center shadow-sm`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white font-bold text-base`}>
|
<MyText style={tw`text-white font-bold text-base`}>
|
||||||
{isUploading ? 'Uploading...' : sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -1,443 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { View, ScrollView, Alert, FlatList, TouchableOpacity } from 'react-native';
|
|
||||||
import {
|
|
||||||
theme,
|
|
||||||
AppContainer,
|
|
||||||
MyText,
|
|
||||||
tw,
|
|
||||||
useManualRefresh,
|
|
||||||
useMarkDataFetchers,
|
|
||||||
MyTouchableOpacity,
|
|
||||||
RawBottomDialog,
|
|
||||||
BottomDialog,
|
|
||||||
} from 'common-ui';
|
|
||||||
import { trpc } from '../../../src/trpc-client';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { Ionicons, Entypo } from '@expo/vector-icons';
|
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
|
|
||||||
import AvailabilityScheduleForm from '../../../components/AvailabilityScheduleForm';
|
|
||||||
|
|
||||||
interface Schedule {
|
|
||||||
id: number;
|
|
||||||
scheduleName: string;
|
|
||||||
time: string;
|
|
||||||
action: 'in' | 'out';
|
|
||||||
createdAt: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
productIds: number[];
|
|
||||||
groupIds: number[];
|
|
||||||
productCount: number;
|
|
||||||
groupCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScheduleItem = ({
|
|
||||||
schedule,
|
|
||||||
onDelete,
|
|
||||||
index,
|
|
||||||
onViewProducts,
|
|
||||||
onViewGroups,
|
|
||||||
onReplicate,
|
|
||||||
}: {
|
|
||||||
schedule: Schedule;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
index: number;
|
|
||||||
onViewProducts: (productIds: number[]) => void;
|
|
||||||
onViewGroups: (groupIds: number[]) => void;
|
|
||||||
onReplicate: (schedule: Schedule) => void;
|
|
||||||
}) => {
|
|
||||||
const isIn = schedule.action === 'in';
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={tw``}>
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
{/* Top Header: Name & Action Badge */}
|
|
||||||
<View style={tw`flex-row justify-between items-start mb-4`}>
|
|
||||||
<View style={tw`flex-row items-center flex-1`}>
|
|
||||||
<View
|
|
||||||
style={tw`w-12 h-12 rounded-2xl bg-brand50 items-center justify-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name="schedule"
|
|
||||||
size={24}
|
|
||||||
color={theme.colors.brand600}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<MyText
|
|
||||||
style={tw`text-slate-400 text-[10px] font-black uppercase tracking-widest`}
|
|
||||||
>
|
|
||||||
Schedule Name
|
|
||||||
</MyText>
|
|
||||||
<MyText
|
|
||||||
style={tw`text-xl font-black text-slate-900`}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{schedule.scheduleName}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
tw`px-3 py-1.5 rounded-full flex-row items-center mr-2`,
|
|
||||||
{ backgroundColor: isIn ? '#F0FDF4' : '#FFF1F2' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
tw`w-1.5 h-1.5 rounded-full mr-2`,
|
|
||||||
{ backgroundColor: isIn ? '#10B981' : '#E11D48' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<MyText
|
|
||||||
style={[
|
|
||||||
tw`text-[10px] font-black uppercase tracking-tighter`,
|
|
||||||
{ color: isIn ? '#10B981' : '#E11D48' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isIn ? 'In Stock' : 'Out of Stock'}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setMenuOpen(true)}
|
|
||||||
style={tw`p-1`}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<Entypo name="dots-three-vertical" size={20} color="#9CA3AF" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Menu Dialog */}
|
|
||||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
<MyText style={tw`text-lg font-bold mb-4`}>{schedule.scheduleName}</MyText>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
onReplicate(schedule);
|
|
||||||
}}
|
|
||||||
style={tw`py-4 border-b border-gray-200`}
|
|
||||||
>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="content-copy" size={20} color="#4B5563" style={tw`mr-3`} />
|
|
||||||
<MyText style={tw`text-base text-gray-800`}>Replicate items</MyText>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
Alert.alert('Coming Soon', 'Edit functionality will be available soon');
|
|
||||||
}}
|
|
||||||
style={tw`py-4 border-b border-gray-200`}
|
|
||||||
>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="edit" size={20} color="#4B5563" style={tw`mr-3`} />
|
|
||||||
<MyText style={tw`text-base text-gray-800`}>Edit</MyText>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
onDelete(schedule.id);
|
|
||||||
}}
|
|
||||||
style={tw`py-4 border-b border-gray-200`}
|
|
||||||
>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="delete" size={20} color="#E11D48" style={tw`mr-3`} />
|
|
||||||
<MyText style={tw`text-base text-red-500`}>Delete</MyText>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setMenuOpen(false)}
|
|
||||||
style={tw`py-4 mt-2`}
|
|
||||||
>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MaterialIcons name="close" size={20} color="#6B7280" style={tw`mr-3`} />
|
|
||||||
<MyText style={tw`text-base text-gray-600`}>Cancel</MyText>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</BottomDialog>
|
|
||||||
|
|
||||||
{/* Middle: Time Banner */}
|
|
||||||
<View
|
|
||||||
style={tw`bg-slate-50 rounded-3xl p-4 flex-row items-center mb-4 border border-slate-100`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={tw`bg-white w-10 h-10 rounded-2xl items-center justify-center shadow-sm`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="access-time" size={20} color="#64748B" />
|
|
||||||
</View>
|
|
||||||
<View style={tw`ml-4 flex-1`}>
|
|
||||||
<MyText style={tw`text-slate-900 font-extrabold text-sm`}>
|
|
||||||
{schedule.time}
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-slate-500 text-[10px] font-bold uppercase`}>
|
|
||||||
Daily at this time
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Stats & Actions */}
|
|
||||||
<View style={tw`flex-row items-center justify-between`}>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<MyTouchableOpacity
|
|
||||||
onPress={() => onViewProducts(schedule.productIds)}
|
|
||||||
style={tw`flex-row items-center mr-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
|
||||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
|
||||||
{schedule.productCount} Products
|
|
||||||
</MyText>
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
{schedule.groupCount > 0 && (
|
|
||||||
<MyTouchableOpacity
|
|
||||||
onPress={() => onViewGroups(schedule.groupIds)}
|
|
||||||
style={tw`flex-row items-center`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="category" size={14} color="#94A3B8" />
|
|
||||||
<MyText style={tw`text-xs font-bold text-brand600 ml-1.5`}>
|
|
||||||
{schedule.groupCount} Groups
|
|
||||||
</MyText>
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StockingSchedules() {
|
|
||||||
const {
|
|
||||||
data: schedules,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = trpc.admin.productAvailabilitySchedules.getAll.useQuery();
|
|
||||||
|
|
||||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
|
||||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
|
||||||
|
|
||||||
const deleteSchedule = trpc.admin.productAvailabilitySchedules.delete.useMutation();
|
|
||||||
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
||||||
|
|
||||||
// Dialog state
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [dialogType, setDialogType] = useState<'products' | 'groups'>('products');
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
|
|
||||||
// Replication state
|
|
||||||
const [replicatingSchedule, setReplicatingSchedule] = useState<Schedule | null>(null);
|
|
||||||
|
|
||||||
useManualRefresh(refetch);
|
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
|
||||||
refetch();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
setShowCreateForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
|
||||||
Alert.alert(
|
|
||||||
'Delete Schedule',
|
|
||||||
'Are you sure you want to delete this schedule? This action cannot be undone.',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Delete',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
|
||||||
deleteSchedule.mutate(
|
|
||||||
{ id },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to delete schedule');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewProducts = (productIds: number[]) => {
|
|
||||||
setDialogType('products');
|
|
||||||
setSelectedIds(productIds);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewGroups = (groupIds: number[]) => {
|
|
||||||
setDialogType('groups');
|
|
||||||
setSelectedIds(groupIds);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReplicate = (schedule: Schedule) => {
|
|
||||||
setReplicatingSchedule(schedule);
|
|
||||||
setShowCreateForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
|
||||||
setShowCreateForm(false);
|
|
||||||
setReplicatingSchedule(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get product/group names from IDs
|
|
||||||
const getProductNames = () => {
|
|
||||||
const allProducts = productsData?.products || [];
|
|
||||||
return selectedIds.map(id => {
|
|
||||||
const product = allProducts.find(p => p.id === id);
|
|
||||||
return product?.name || `Product #${id}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGroupNames = () => {
|
|
||||||
const allGroups = groupsData?.groups || [];
|
|
||||||
return selectedIds.map(id => {
|
|
||||||
const group = allGroups.find(g => g.id === id);
|
|
||||||
return group?.groupName || `Group #${id}`;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showCreateForm) {
|
|
||||||
return (
|
|
||||||
<AvailabilityScheduleForm
|
|
||||||
onClose={handleCloseForm}
|
|
||||||
onSuccess={() => {
|
|
||||||
refetch();
|
|
||||||
handleCloseForm();
|
|
||||||
}}
|
|
||||||
initialProductIds={replicatingSchedule?.productIds}
|
|
||||||
initialGroupIds={replicatingSchedule?.groupIds}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Loading schedules...</MyText>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MyText style={tw`text-red-600`}>Error loading schedules</MyText>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppContainer>
|
|
||||||
<View style={tw`flex-1 bg-white h-full`}>
|
|
||||||
<ScrollView
|
|
||||||
style={tw`flex-1`}
|
|
||||||
contentContainerStyle={tw`pt-2 pb-32`}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{schedules && schedules.length === 0 ? (
|
|
||||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
|
||||||
<View
|
|
||||||
style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}
|
|
||||||
>
|
|
||||||
<Ionicons name="time-outline" size={48} color="#94A3B8" />
|
|
||||||
</View>
|
|
||||||
<MyText
|
|
||||||
style={tw`text-slate-900 text-xl font-black tracking-tight`}
|
|
||||||
>
|
|
||||||
No Schedules Yet
|
|
||||||
</MyText>
|
|
||||||
<MyText
|
|
||||||
style={tw`text-slate-500 text-center mt-2 font-medium px-8`}
|
|
||||||
>
|
|
||||||
Start by creating your first availability schedule using the
|
|
||||||
button below.
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
schedules?.map((schedule, index) => (
|
|
||||||
<React.Fragment key={schedule.id}>
|
|
||||||
<ScheduleItem
|
|
||||||
schedule={schedule}
|
|
||||||
index={index}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onViewProducts={handleViewProducts}
|
|
||||||
onViewGroups={handleViewGroups}
|
|
||||||
onReplicate={handleReplicate}
|
|
||||||
/>
|
|
||||||
{index < schedules.length - 1 && (
|
|
||||||
<View style={tw`h-px bg-slate-200 w-full`} />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
<MyTouchableOpacity
|
|
||||||
onPress={handleCreate}
|
|
||||||
activeOpacity={0.95}
|
|
||||||
style={tw`absolute bottom-8 right-6 shadow-2xl z-50`}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#1570EF', '#194185']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-brand300`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="add" size={32} color="white" />
|
|
||||||
</LinearGradient>
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
|
|
||||||
{/* Products/Groups Dialog */}
|
|
||||||
<RawBottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
<MyText style={tw`text-lg font-bold mb-4`}>
|
|
||||||
{dialogType === 'products' ? 'Products' : 'Groups'}
|
|
||||||
</MyText>
|
|
||||||
<FlatList
|
|
||||||
data={dialogType === 'products' ? getProductNames() : getGroupNames()}
|
|
||||||
keyExtractor={(item, index) => index.toString()}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View style={tw`py-3 border-b border-gray-100`}>
|
|
||||||
<MyText style={tw`text-base text-gray-800`}>{item}</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
style={tw`max-h-80`}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View style={tw`py-8 items-center`}>
|
|
||||||
<MyText style={tw`text-gray-500`}>
|
|
||||||
No {dialogType} found
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</RawBottomDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Formik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native'
|
||||||
|
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
|
||||||
|
import { trpc } from '@/src/trpc-client'
|
||||||
|
|
||||||
|
interface AddressPlaceFormProps {
|
||||||
|
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
|
||||||
|
const { data: zones } = trpc.admin.address.getZones.useQuery()
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
placeName: Yup.string().required('Place name is required'),
|
||||||
|
zoneId: Yup.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
|
||||||
|
<Formik
|
||||||
|
initialValues={{ placeName: '', zoneId: null as number | null }}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
onSubmit(values)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
|
||||||
|
<View>
|
||||||
|
<MyTextInput
|
||||||
|
label="Place Name"
|
||||||
|
value={values.placeName}
|
||||||
|
onChangeText={handleChange('placeName')}
|
||||||
|
error={!!(touched.placeName && errors.placeName)}
|
||||||
|
/>
|
||||||
|
<BottomDropdown
|
||||||
|
label="Zone (Optional)"
|
||||||
|
value={values.zoneId as any}
|
||||||
|
options={zoneOptions}
|
||||||
|
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
|
||||||
|
placeholder="Select Zone"
|
||||||
|
/>
|
||||||
|
<View style={tw`flex-row justify-between mt-4`}>
|
||||||
|
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||||
|
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||||
|
<Text style={tw`text-white`}>Create</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressPlaceForm
|
||||||
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Formik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native'
|
||||||
|
import { MyTextInput, tw } from 'common-ui'
|
||||||
|
|
||||||
|
interface AddressZoneFormProps {
|
||||||
|
onSubmit: (values: { zoneName: string }) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
zoneName: Yup.string().required('Zone name is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
|
||||||
|
<Formik
|
||||||
|
initialValues={{ zoneName: '' }}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
onSubmit(values)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ handleChange, handleSubmit, values, errors, touched }) => (
|
||||||
|
<View>
|
||||||
|
<MyTextInput
|
||||||
|
label="Zone Name"
|
||||||
|
value={values.zoneName}
|
||||||
|
onChangeText={handleChange('zoneName')}
|
||||||
|
error={!!(touched.zoneName && errors.zoneName)}
|
||||||
|
/>
|
||||||
|
<View style={tw`flex-row justify-between mt-4`}>
|
||||||
|
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||||
|
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||||
|
<Text style={tw`text-white`}>Create</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddressZoneForm
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { View, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
|
||||||
import { useFormik } from 'formik';
|
|
||||||
import { MyText, tw, MyTextInput, MyTouchableOpacity, DateTimePickerMod } from 'common-ui';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import ProductsSelector from './ProductsSelector';
|
|
||||||
import { trpc } from '../src/trpc-client';
|
|
||||||
|
|
||||||
interface AvailabilityScheduleFormProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
initialProductIds?: number[];
|
|
||||||
initialGroupIds?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AvailabilityScheduleForm: React.FC<AvailabilityScheduleFormProps> = ({
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
initialProductIds,
|
|
||||||
initialGroupIds,
|
|
||||||
}) => {
|
|
||||||
const createSchedule = trpc.admin.productAvailabilitySchedules.create.useMutation();
|
|
||||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
|
||||||
|
|
||||||
// Map groups data to match ProductsSelector types (convert price from string to number)
|
|
||||||
const groups = (groupsData?.groups || []).map(group => ({
|
|
||||||
...group,
|
|
||||||
products: group.products.map(product => ({
|
|
||||||
...product,
|
|
||||||
price: parseFloat(product.price as unknown as string) || 0,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const formik = useFormik({
|
|
||||||
initialValues: {
|
|
||||||
scheduleName: '',
|
|
||||||
timeDate: null as Date | null,
|
|
||||||
action: 'in' as 'in' | 'out',
|
|
||||||
productIds: initialProductIds || ([] as number[]),
|
|
||||||
groupIds: initialGroupIds || ([] as number[]),
|
|
||||||
},
|
|
||||||
validate: (values) => {
|
|
||||||
const errors: {[key: string]: string} = {};
|
|
||||||
|
|
||||||
if (!values.scheduleName.trim()) {
|
|
||||||
errors.scheduleName = 'Schedule name is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.timeDate) {
|
|
||||||
errors.timeDate = 'Time is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.action) {
|
|
||||||
errors.action = 'Action is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.productIds.length === 0) {
|
|
||||||
errors.productIds = 'At least one product must be selected';
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
onSubmit: async (values) => {
|
|
||||||
try {
|
|
||||||
// Convert Date to HH:MM string
|
|
||||||
const hours = values.timeDate!.getHours().toString().padStart(2, '0');
|
|
||||||
const minutes = values.timeDate!.getMinutes().toString().padStart(2, '0');
|
|
||||||
const timeString = `${hours}:${minutes}`;
|
|
||||||
|
|
||||||
await createSchedule.mutateAsync({
|
|
||||||
scheduleName: values.scheduleName,
|
|
||||||
time: timeString,
|
|
||||||
action: values.action,
|
|
||||||
productIds: values.productIds,
|
|
||||||
groupIds: values.groupIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
Alert.alert('Success', 'Schedule created successfully');
|
|
||||||
onSuccess();
|
|
||||||
onClose();
|
|
||||||
} catch (error: any) {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to create schedule');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionOptions = [
|
|
||||||
{ label: 'In Stock', value: 'in' },
|
|
||||||
{ label: 'Out of Stock', value: 'out' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={tw`flex-1 bg-white`}>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={tw`flex-row items-center justify-between p-4 border-b border-gray-200 bg-white`}>
|
|
||||||
<MyText style={tw`text-xl font-bold text-gray-900`}>
|
|
||||||
Create Availability Schedule
|
|
||||||
</MyText>
|
|
||||||
<MyTouchableOpacity onPress={onClose}>
|
|
||||||
<MaterialIcons name="close" size={24} color="#6B7280" />
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView style={tw`flex-1 p-4`} showsVerticalScrollIndicator={false}>
|
|
||||||
{/* Schedule Name */}
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
|
||||||
Schedule Name
|
|
||||||
</MyText>
|
|
||||||
<MyTextInput
|
|
||||||
placeholder="Enter schedule name"
|
|
||||||
value={formik.values.scheduleName}
|
|
||||||
onChangeText={formik.handleChange('scheduleName')}
|
|
||||||
onBlur={formik.handleBlur('scheduleName')}
|
|
||||||
style={tw`border rounded-lg p-3 ${
|
|
||||||
formik.touched.scheduleName && formik.errors.scheduleName
|
|
||||||
? 'border-red-500'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{formik.touched.scheduleName && formik.errors.scheduleName && (
|
|
||||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
|
||||||
{formik.errors.scheduleName}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
|
||||||
Time
|
|
||||||
</MyText>
|
|
||||||
<DateTimePickerMod
|
|
||||||
value={formik.values.timeDate}
|
|
||||||
setValue={(date) => formik.setFieldValue('timeDate', date)}
|
|
||||||
timeOnly={true}
|
|
||||||
showLabels={false}
|
|
||||||
/>
|
|
||||||
{formik.touched.timeDate && formik.errors.timeDate && (
|
|
||||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
|
||||||
{formik.errors.timeDate}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
|
||||||
Action
|
|
||||||
</MyText>
|
|
||||||
<View style={tw`flex-row gap-3`}>
|
|
||||||
{actionOptions.map((option) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={option.value}
|
|
||||||
onPress={() => formik.setFieldValue('action', option.value)}
|
|
||||||
style={tw`flex-1 flex-row items-center p-4 rounded-lg border ${
|
|
||||||
formik.values.action === option.value
|
|
||||||
? 'bg-blue-50 border-blue-500'
|
|
||||||
: 'bg-white border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={tw`w-5 h-5 rounded-full border-2 mr-3 items-center justify-center ${
|
|
||||||
formik.values.action === option.value
|
|
||||||
? 'border-blue-500'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formik.values.action === option.value && (
|
|
||||||
<View style={tw`w-3 h-3 rounded-full bg-blue-500`} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<MyText
|
|
||||||
style={tw`font-medium ${
|
|
||||||
formik.values.action === option.value
|
|
||||||
? 'text-blue-700'
|
|
||||||
: 'text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Products and Groups */}
|
|
||||||
<View style={tw`mb-4`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
|
||||||
Products & Groups
|
|
||||||
</MyText>
|
|
||||||
<ProductsSelector
|
|
||||||
value={formik.values.productIds}
|
|
||||||
onChange={(value) => formik.setFieldValue('productIds', value)}
|
|
||||||
groups={groups}
|
|
||||||
selectedGroupIds={formik.values.groupIds}
|
|
||||||
onGroupChange={(groupIds) => formik.setFieldValue('groupIds', groupIds)}
|
|
||||||
showGroups={true}
|
|
||||||
label="Select Products"
|
|
||||||
placeholder="Select products for this schedule"
|
|
||||||
/>
|
|
||||||
{formik.touched.productIds && formik.errors.productIds && (
|
|
||||||
<MyText style={tw`text-red-500 text-xs mt-1`}>
|
|
||||||
{formik.errors.productIds}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Spacer for bottom padding */}
|
|
||||||
<View style={tw`h-24`} />
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Footer Buttons */}
|
|
||||||
<View style={tw`p-4 border-t border-gray-200 bg-white flex-row gap-3`}>
|
|
||||||
<MyTouchableOpacity
|
|
||||||
onPress={onClose}
|
|
||||||
style={tw`flex-1 py-3 px-4 rounded-lg border border-gray-300 items-center`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`font-medium text-gray-700`}>Cancel</MyText>
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
<MyTouchableOpacity
|
|
||||||
onPress={() => formik.handleSubmit()}
|
|
||||||
disabled={formik.isSubmitting}
|
|
||||||
style={tw`flex-1 py-3 px-4 rounded-lg bg-blue-600 items-center ${
|
|
||||||
formik.isSubmitting ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`font-medium text-white`}>
|
|
||||||
{formik.isSubmitting ? 'Creating...' : 'Create Schedule'}
|
|
||||||
</MyText>
|
|
||||||
</MyTouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AvailabilityScheduleForm;
|
|
||||||
|
|
@ -8,7 +8,6 @@ import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
export interface BannerFormData {
|
export interface BannerFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -53,7 +52,14 @@ export default function BannerForm({
|
||||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
|
// Fetch products for dropdown
|
||||||
|
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||||
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -91,15 +97,37 @@ export default function BannerForm({
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
if (selectedImages.length > 0) {
|
||||||
|
// Generate upload URLs
|
||||||
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'store', // Using 'store' for now
|
||||||
|
mimeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
const uploadUrl = uploadUrls[0];
|
||||||
const { blob, mimeType } = selectedImages[0];
|
const { blob, mimeType } = selectedImages[0];
|
||||||
const { key, presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
|
||||||
imageUrl = presignedUrl;
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: blob,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageUrl = uploadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onSubmit with form values and imageUrl
|
||||||
await onSubmit(values, imageUrl);
|
await onSubmit(values, imageUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
Alert.alert('Error', 'Failed to upload image');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -211,15 +239,15 @@ export default function BannerForm({
|
||||||
|
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
disabled={isSubmitting || isUploading || !isValid || !dirty}
|
disabled={isSubmitting || !isValid || !dirty}
|
||||||
style={tw`flex-1 rounded-lg py-4 items-center ${
|
style={tw`flex-1 rounded-lg py-4 items-center ${
|
||||||
isSubmitting || isUploading || !isValid || !dirty
|
isSubmitting || !isValid || !dirty
|
||||||
? 'bg-blue-400'
|
? 'bg-blue-400'
|
||||||
: 'bg-blue-600'
|
: 'bg-blue-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white font-semibold`}>
|
<MyText style={tw`text-white font-semibold`}>
|
||||||
{isUploading ? 'Uploading...' : isSubmitting ? 'Saving...' : submitButtonText}
|
{isSubmitting ? 'Saving...' : submitButtonText}
|
||||||
</MyText>
|
</MyText>
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import React, { forwardRef, useState, useEffect, useMemo } from 'react';
|
||||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploaderNeo } from 'common-ui';
|
import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-ui';
|
||||||
import ProductsSelector from './ProductsSelector';
|
import ProductsSelector from './ProductsSelector';
|
||||||
import { trpc } from '../src/trpc-client';
|
import { trpc } from '../src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
export interface StoreFormData {
|
export interface StoreFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -16,12 +15,6 @@ export interface StoreFormData {
|
||||||
products: number[];
|
products: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoreImage {
|
|
||||||
uri: string;
|
|
||||||
mimeType: string;
|
|
||||||
isExisting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoreFormRef {
|
export interface StoreFormRef {
|
||||||
// Add methods if needed
|
// Add methods if needed
|
||||||
}
|
}
|
||||||
|
|
@ -34,11 +27,6 @@ interface StoreFormProps {
|
||||||
storeId?: number;
|
storeId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend Formik values with images array
|
|
||||||
interface FormikStoreValues extends StoreFormData {
|
|
||||||
images: StoreImage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationSchema = Yup.object().shape({
|
const validationSchema = Yup.object().shape({
|
||||||
name: Yup.string().required('Name is required'),
|
name: Yup.string().required('Name is required'),
|
||||||
description: Yup.string(),
|
description: Yup.string(),
|
||||||
|
|
@ -52,23 +40,9 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||||
|
|
||||||
// Build initial form values with images array
|
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues);
|
||||||
const buildInitialValues = (): FormikStoreValues => {
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||||
const images: StoreImage[] = [];
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||||
if (initialValues.imageUrl) {
|
|
||||||
images.push({
|
|
||||||
uri: initialValues.imageUrl,
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
isExisting: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...initialValues,
|
|
||||||
images,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formInitialValues, setFormInitialValues] = useState<FormikStoreValues>(buildInitialValues());
|
|
||||||
|
|
||||||
// For edit mode, pre-select products belonging to this store
|
// For edit mode, pre-select products belonging to this store
|
||||||
const initialSelectedProducts = useMemo(() => {
|
const initialSelectedProducts = useMemo(() => {
|
||||||
|
|
@ -80,7 +54,7 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormInitialValues({
|
setFormInitialValues({
|
||||||
...buildInitialValues(),
|
...initialValues,
|
||||||
products: initialSelectedProducts,
|
products: initialSelectedProducts,
|
||||||
});
|
});
|
||||||
}, [initialValues, initialSelectedProducts]);
|
}, [initialValues, initialSelectedProducts]);
|
||||||
|
|
@ -90,7 +64,41 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
value: staff.id,
|
value: staff.id,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
|
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||||
|
|
||||||
|
const handleImagePick = usePickImage({
|
||||||
|
setFile: async (assets: any) => {
|
||||||
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||||
|
setSelectedImages([]);
|
||||||
|
setDisplayImages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.isArray(assets) ? assets : [assets];
|
||||||
|
const blobPromises = files.map(async (asset) => {
|
||||||
|
const response = await fetch(asset.uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const blobArray = await Promise.all(blobPromises);
|
||||||
|
setSelectedImages(blobArray);
|
||||||
|
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
||||||
|
},
|
||||||
|
multiple: false, // Single image for stores
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveImage = (uri: string) => {
|
||||||
|
const index = displayImages.findIndex(img => img.uri === uri);
|
||||||
|
if (index !== -1) {
|
||||||
|
const newDisplay = displayImages.filter((_, i) => i !== index);
|
||||||
|
const newFiles = selectedImages.filter((_, i) => i !== index);
|
||||||
|
setDisplayImages(newDisplay);
|
||||||
|
setSelectedImages(newFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
|
|
@ -100,78 +108,51 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||||
// Image picker that adds to Formik field
|
|
||||||
const handleImagePick = usePickImage({
|
|
||||||
setFile: async (assets: any) => {
|
|
||||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.isArray(assets) ? assets : [assets];
|
|
||||||
const newImages: StoreImage[] = files.map((asset) => ({
|
|
||||||
uri: asset.uri,
|
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
|
||||||
isExisting: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Add to Formik images field
|
|
||||||
const currentImages = values.images || [];
|
|
||||||
setFieldValue('images', [...currentImages, ...newImages]);
|
|
||||||
},
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove image - works for both existing and new
|
|
||||||
const handleRemoveImage = (image: { uri: string; mimeType: string }) => {
|
|
||||||
const currentImages = values.images || [];
|
|
||||||
const removedImage = currentImages.find(img => img.uri === image.uri);
|
|
||||||
const newImages = currentImages.filter(img => img.uri !== image.uri);
|
|
||||||
|
|
||||||
setFieldValue('images', newImages);
|
|
||||||
|
|
||||||
// If we removed an existing image, also clear the imageUrl
|
|
||||||
if (removedImage?.isExisting) {
|
|
||||||
setFieldValue('imageUrl', undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
// Get new images that need to be uploaded
|
if (selectedImages.length > 0) {
|
||||||
const newImages = values.images.filter(img => !img.isExisting);
|
// Generate upload URLs
|
||||||
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||||
|
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||||
|
contextString: 'store',
|
||||||
|
mimeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
// Upload images
|
||||||
// Upload the first new image (single image for stores)
|
for (let i = 0; i < uploadUrls.length; i++) {
|
||||||
const image = newImages[0];
|
const uploadUrl = uploadUrls[i];
|
||||||
const response = await fetch(image.uri);
|
const { blob, mimeType } = selectedImages[i];
|
||||||
const imageBlob = await response.blob();
|
|
||||||
const { key } = await uploadSingle(imageBlob, image.mimeType, 'store');
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
imageUrl = key;
|
method: 'PUT',
|
||||||
} else {
|
body: blob,
|
||||||
// Check if there's an existing image remaining
|
headers: {
|
||||||
const existingImage = values.images.find(img => img.isExisting);
|
'Content-Type': mimeType,
|
||||||
if (existingImage) {
|
},
|
||||||
imageUrl = existingImage.uri;
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit form with imageUrl (without images array)
|
// Extract key from first upload URL
|
||||||
const { images, ...submitValues } = values;
|
// const u = new URL(uploadUrls[0]);
|
||||||
onSubmit({ ...submitValues, imageUrl });
|
// const rawKey = u.pathname.replace(/^\/+/, "");
|
||||||
|
// imageUrl = decodeURIComponent(rawKey);
|
||||||
|
imageUrl = uploadUrls[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form with imageUrl
|
||||||
|
onSubmit({ ...values, imageUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
Alert.alert('Error', 'Failed to upload image');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare images for ImageUploaderNeo (convert to expected format)
|
|
||||||
const imagesForUploader = (values.images || []).map(img => ({
|
|
||||||
uri: img.uri,
|
|
||||||
mimeType: img.mimeType,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
|
|
@ -212,21 +193,22 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
/>
|
/>
|
||||||
<View style={tw`mb-6`}>
|
<View style={tw`mb-6`}>
|
||||||
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
||||||
|
<ImageUploader
|
||||||
<ImageUploaderNeo
|
images={displayImages}
|
||||||
images={imagesForUploader}
|
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
||||||
onUploadImage={handleImagePick}
|
onAddImage={handleImagePick}
|
||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
|
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
||||||
allowMultiple={false}
|
allowMultiple={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading || isUploading}
|
disabled={isLoading || generateUploadUrls.isPending}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"distribution": "internal",
|
"developmentClient": true,
|
||||||
"autoIncrement": true
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { trpc } from '../src/trpc-client';
|
|
||||||
|
|
||||||
type ContextString = 'review' | 'product_info' | 'notification' | 'store';
|
|
||||||
|
|
||||||
interface UploadInput {
|
|
||||||
blob: Blob;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadBatchInput {
|
|
||||||
images: UploadInput[];
|
|
||||||
contextString: ContextString;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadResult {
|
|
||||||
keys: string[];
|
|
||||||
presignedUrls: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadToObjectStorage() {
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
|
|
||||||
|
|
||||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
|
||||||
|
|
||||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
|
||||||
setIsUploading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ completed: 0, total: input.images.length });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { images, contextString } = input;
|
|
||||||
|
|
||||||
if (images.length === 0) {
|
|
||||||
return { keys: [], presignedUrls: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get presigned URLs from backend (one call for all images)
|
|
||||||
const mimeTypes = images.map(img => img.mimeType);
|
|
||||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
|
||||||
contextString,
|
|
||||||
mimeTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadUrls.length !== images.length) {
|
|
||||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Upload all images in parallel
|
|
||||||
const uploadPromises = images.map(async (image, index) => {
|
|
||||||
const presignedUrl = uploadUrls[index];
|
|
||||||
const { blob, mimeType } = image;
|
|
||||||
|
|
||||||
const response = await fetch(presignedUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: blob,
|
|
||||||
headers: { 'Content-Type': mimeType },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
|
||||||
presignedUrl,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use Promise.all - if any fails, entire batch fails
|
|
||||||
const results = await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
return {
|
|
||||||
keys: results.map(r => r.key),
|
|
||||||
presignedUrls: results.map(r => r.presignedUrl),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const uploadError = err instanceof Error ? err : new Error('Upload failed');
|
|
||||||
setError(uploadError);
|
|
||||||
throw uploadError;
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
setProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadSingle = async (blob: Blob, mimeType: string, contextString: ContextString): Promise<{ key: string; presignedUrl: string }> => {
|
|
||||||
const result = await upload({
|
|
||||||
images: [{ blob, mimeType }],
|
|
||||||
contextString,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
key: result.keys[0],
|
|
||||||
presignedUrl: result.presignedUrls[0],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
upload,
|
|
||||||
uploadSingle,
|
|
||||||
isUploading,
|
|
||||||
error,
|
|
||||||
progress,
|
|
||||||
isPending: generateUploadUrls.isPending
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractKeyFromPresignedUrl(url: string): string {
|
|
||||||
const u = new URL(url);
|
|
||||||
let rawKey = u.pathname.replace(/^\/+/, '');
|
|
||||||
rawKey = rawKey.split('/').slice(1).join('/'); // make meatfarmer/product-images/asdf as product-images/asdf
|
|
||||||
return decodeURIComponent(rawKey);
|
|
||||||
}
|
|
||||||
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
111
apps/admin-ui/src/api-hooks/product.api.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from '../../services/axios-admin-ui';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface CreateProductPayload {
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
incrementStep?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
deals?: {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductPayload {
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
incrementStep?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
deals?: {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
longDescription?: string;
|
||||||
|
unitId: number;
|
||||||
|
storeId: number;
|
||||||
|
price: number;
|
||||||
|
marketPrice?: number;
|
||||||
|
productQuantity?: number;
|
||||||
|
isOutOfStock?: boolean;
|
||||||
|
images?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
unit?: {
|
||||||
|
id: number;
|
||||||
|
shortNotation: string;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
deals?: {
|
||||||
|
id: number;
|
||||||
|
quantity: string;
|
||||||
|
price: string;
|
||||||
|
validTill: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductResponse {
|
||||||
|
product: Product;
|
||||||
|
deals?: any[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
||||||
|
const response = await axios.post('/av/products', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
||||||
|
const response = await axios.put(`/av/products/${id}`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export const useCreateProduct = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createProductApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateProduct = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateProductApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal file
119
apps/admin-ui/src/api-hooks/tag.api.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios from '../../services/axios-admin-ui';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface CreateTagPayload {
|
||||||
|
tagName: string;
|
||||||
|
tagDescription?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
relatedStores?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTagPayload {
|
||||||
|
tagName: string;
|
||||||
|
tagDescription?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
relatedStores?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
tagName: string;
|
||||||
|
tagDescription: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
relatedStores: number[];
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTagResponse {
|
||||||
|
tag: Tag;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTagsResponse {
|
||||||
|
tags: Tag[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
|
||||||
|
const response = await axios.post('/av/product-tags', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
|
||||||
|
const response = await axios.put(`/av/product-tags/${id}`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
|
||||||
|
const response = await axios.delete(`/av/product-tags/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagsApi = async (): Promise<GetTagsResponse> => {
|
||||||
|
const response = await axios.get('/av/product-tags');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
|
||||||
|
const response = await axios.get(`/av/product-tags/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export const useCreateTag = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createTagApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateTag = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateTagApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteTag = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteTagApi,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetTags = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: getTagsApi,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetTag = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tags', id],
|
||||||
|
queryFn: () => getTagApi(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Formik, FieldArray } from 'formik';
|
import { Formik, FieldArray } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
@ -7,7 +7,7 @@ import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDel
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { trpc } from '../trpc-client';
|
import { trpc } from '../trpc-client';
|
||||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
import { useGetTags } from '../api-hooks/tag.api';
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -38,7 +38,7 @@ export interface ProductFormRef {
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: ProductFormData;
|
initialValues: ProductFormData;
|
||||||
onSubmit: (values: ProductFormData, imageKeys?: string[], imagesToDelete?: string[]) => void;
|
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
existingImages?: string[];
|
existingImages?: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -60,9 +60,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
existingImages = []
|
existingImages = []
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [newImages, setNewImages] = useState<{ blob: Blob; mimeType: string; uri: string }[]>([]);
|
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
||||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||||
const { upload, isUploading } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
const storeOptions = storesData?.stores.map(store => ({
|
||||||
|
|
@ -70,8 +69,8 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
value: store.id,
|
value: store.id,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const { data: tagsData } = trpc.admin.tag.getTags.useQuery();
|
const { data: tagsData } = useGetTags();
|
||||||
const tagOptions = tagsData?.tags.map((tag: { tagName: string; id: number }) => ({
|
const tagOptions = tagsData?.tags.map(tag => ({
|
||||||
label: tag.tagName,
|
label: tag.tagName,
|
||||||
value: tag.id.toString(),
|
value: tag.id.toString(),
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
@ -84,62 +83,23 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
}, [existingImages]);
|
}, [existingImages]);
|
||||||
|
|
||||||
const pickImage = usePickImage({
|
const pickImage = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: (files) => setImages(prev => [...prev, ...files]),
|
||||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = Array.isArray(assets) ? assets : [assets];
|
|
||||||
const imageData = await Promise.all(
|
|
||||||
files.map(async (asset) => {
|
|
||||||
const response = await fetch(asset.uri);
|
|
||||||
const blob = await response.blob();
|
|
||||||
return {
|
|
||||||
blob,
|
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
|
||||||
uri: asset.uri
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setNewImages(prev => [...prev, ...imageData]);
|
|
||||||
},
|
|
||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate which existing images were deleted
|
// Calculate which existing images were deleted
|
||||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||||
|
|
||||||
// Display images for ImageUploader component
|
|
||||||
const displayImages = newImages.map(img => ({ uri: img.uri }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={async (values) => {
|
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
||||||
try {
|
|
||||||
let imageKeys: string[] = [];
|
|
||||||
|
|
||||||
// Upload new images if any
|
|
||||||
if (newImages.length > 0) {
|
|
||||||
const result = await upload({
|
|
||||||
images: newImages.map(img => ({ blob: img.blob, mimeType: img.mimeType })),
|
|
||||||
contextString: 'product_info',
|
|
||||||
});
|
|
||||||
imageKeys = result.keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(values, imageKeys, deletedImages);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload images');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
// Clear form when screen comes into focus
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setNewImages([]);
|
setImages([]);
|
||||||
setExistingImagesState([]);
|
setExistingImagesState([]);
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
@ -183,9 +143,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={displayImages}
|
images={images}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -206,9 +166,9 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={displayImages}
|
images={images}
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={(uri) => setNewImages(prev => prev.filter(img => img.uri !== uri))}
|
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -395,11 +355,11 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading || isUploading}
|
disabled={isLoading}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isUploading ? 'Uploading Images...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Product' : 'Update Product')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
|
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { useUploadToObjectStorage } from '../../hooks/useUploadToObjectStorage';
|
|
||||||
|
|
||||||
interface StoreOption {
|
interface StoreOption {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -24,7 +23,7 @@ interface TagFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: TagFormData;
|
initialValues: TagFormData;
|
||||||
existingImageUrl?: string;
|
existingImageUrl?: string;
|
||||||
onSubmit: (values: TagFormData, imageKey?: string, deleteExistingImage?: boolean) => void;
|
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
stores?: StoreOption[];
|
stores?: StoreOption[];
|
||||||
}
|
}
|
||||||
|
|
@ -37,35 +36,24 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
isLoading,
|
isLoading,
|
||||||
stores = [],
|
stores = [],
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [newImage, setNewImage] = useState<{ blob: Blob; mimeType: string; uri: string } | null>(null);
|
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
||||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
|
||||||
|
|
||||||
// Update checkbox when initial values change
|
// Update checkbox when initial values change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||||
|
existingImageUrl && setImage({uri:existingImageUrl})
|
||||||
}, [initialValues.isDashboardTag]);
|
}, [initialValues.isDashboardTag]);
|
||||||
|
|
||||||
const pickImage = usePickImage({
|
const pickImage = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: (files) => {
|
||||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
||||||
setNewImage(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = Array.isArray(assets) ? assets[0] : assets;
|
setImage(files || null)
|
||||||
const response = await fetch(asset.uri);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
setNewImage({
|
|
||||||
blob,
|
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
|
||||||
uri: asset.uri
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
multiple: false,
|
multiple: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const validationSchema = Yup.object().shape({
|
const validationSchema = Yup.object().shape({
|
||||||
tagName: Yup.string()
|
tagName: Yup.string()
|
||||||
.required('Tag name is required')
|
.required('Tag name is required')
|
||||||
|
|
@ -75,44 +63,18 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
.max(500, 'Description must be less than 500 characters'),
|
.max(500, 'Description must be less than 500 characters'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Display images for ImageUploader
|
|
||||||
const displayImages = newImage ? [{ uri: newImage.uri }] : [];
|
|
||||||
const existingImages = existingImageUrl ? [existingImageUrl] : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||||
try {
|
|
||||||
let imageKey: string | undefined;
|
|
||||||
let deleteExistingImage = false;
|
|
||||||
|
|
||||||
// Handle image upload
|
|
||||||
if (newImage) {
|
|
||||||
const result = await uploadSingle(newImage.blob, newImage.mimeType, 'product_info');
|
|
||||||
imageKey = result.key;
|
|
||||||
// If we're uploading a new image and there's an existing one, mark it for deletion
|
|
||||||
if (existingImageUrl) {
|
|
||||||
deleteExistingImage = true;
|
|
||||||
}
|
|
||||||
} else if (mode === 'edit' && !newImage && existingImageUrl) {
|
|
||||||
// In edit mode, if no new image and existing was removed
|
|
||||||
// This would need UI to explicitly remove image
|
|
||||||
// For now, we don't support explicit deletion without replacement
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(values, imageKey, deleteExistingImage);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to upload image');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
// Clear form when screen comes into focus
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setNewImage(null);
|
setImage(null);
|
||||||
|
|
||||||
setIsDashboardTagChecked(false);
|
setIsDashboardTagChecked(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
@ -145,15 +107,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||||
</MyText>
|
</MyText>
|
||||||
|
|
||||||
|
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={displayImages}
|
images={image ? [image] : []}
|
||||||
existingImageUrls={mode === 'edit' ? existingImages : []}
|
|
||||||
onAddImage={pickImage}
|
onAddImage={pickImage}
|
||||||
onRemoveImage={() => setNewImage(null)}
|
onRemoveImage={() => setImage(null)}
|
||||||
onRemoveExistingImage={mode === 'edit' ? () => {
|
|
||||||
// In edit mode, this would trigger deletion of existing image
|
|
||||||
// But we need to implement this logic in the parent
|
|
||||||
} : undefined}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -164,7 +122,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const newValue = !isDashboardTagChecked;
|
const newValue = !isDashboardTagChecked;
|
||||||
setIsDashboardTagChecked(newValue);
|
setIsDashboardTagChecked(newValue);
|
||||||
setFieldValue('isDashboardTag', newValue);
|
formikSetFieldValue('isDashboardTag', newValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
<MyText style={tw`ml-3 text-gray-800`}>Mark as Dashboard Tag</MyText>
|
||||||
|
|
@ -185,7 +143,7 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
}))}
|
}))}
|
||||||
onValueChange={(selectedValues) => {
|
onValueChange={(selectedValues) => {
|
||||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
const numericValues = (selectedValues as string[]).map(v => parseInt(v));
|
||||||
setFieldValue('relatedStores', numericValues);
|
formikSetFieldValue('relatedStores', numericValues);
|
||||||
}}
|
}}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
|
|
@ -193,11 +151,11 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
disabled={isLoading || isUploading}
|
disabled={isLoading}
|
||||||
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-3 rounded-lg shadow-lg items-center ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{isUploading ? 'Uploading Image...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { Entypo } from '@expo/vector-icons';
|
import { Entypo } from '@expo/vector-icons';
|
||||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { trpc } from '../trpc-client';
|
import { useDeleteTag } from '../api-hooks/tag.api';
|
||||||
|
|
||||||
export interface TagMenuProps {
|
export interface TagMenuProps {
|
||||||
tagId: number;
|
tagId: number;
|
||||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const deleteTag = trpc.admin.tag.deleteTag.useMutation();
|
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
||||||
|
|
||||||
const handleOpenMenu = () => {
|
const handleOpenMenu = () => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const performDelete = () => {
|
const performDelete = () => {
|
||||||
deleteTag.mutate({ id: tagId }, {
|
deleteTag(tagId, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert('Success', 'Tag deleted successfully');
|
Alert.alert('Success', 'Tag deleted successfully');
|
||||||
onDeleteSuccess?.();
|
onDeleteSuccess?.();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
ENV_MODE=PROD
|
ENV_MODE=PROD
|
||||||
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
# DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||||
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||||
SQLITE_DB_PATH='./sqlite.db'
|
|
||||||
DB_DIALECT='sqlite'
|
|
||||||
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
|
|
||||||
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||||
PHONE_PE_CLIENT_VERSION=1
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
|
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
|
||||||
|
|
@ -20,14 +17,10 @@ S3_REGION=apac
|
||||||
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
||||||
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
||||||
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||||
S3_BUCKET_NAME=meatfarmer-dev
|
S3_BUCKET_NAME=meatfarmer
|
||||||
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||||
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||||
ASSETS_DOMAIN=https://assets2.freshyo.in/
|
ASSETS_DOMAIN=https://assets.freshyo.in/
|
||||||
API_CACHE_KEY=api-cache-dev
|
|
||||||
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
|
|
||||||
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
|
|
||||||
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
|
|
||||||
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
||||||
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
||||||
APP_URL=http://localhost:4000
|
APP_URL=http://localhost:4000
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,11 @@
|
||||||
import postgresConfig from '../db-helper-postgres/drizzle.config'
|
import 'dotenv/config';
|
||||||
import sqliteConfig from '../db-helper-sqlite/drizzle.config'
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
export default process.env.DB_DIALECT === 'sqlite'
|
export default defineConfig({
|
||||||
? sqliteConfig
|
out: './drizzle',
|
||||||
: postgresConfig
|
schema: './src/db/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
CREATE TYPE "public"."product_availability_action" AS ENUM('in', 'out');--> statement-breakpoint
|
|
||||||
CREATE TABLE "mf"."product_availability_schedules" (
|
|
||||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "mf"."product_availability_schedules_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
|
||||||
"time" varchar(10) NOT NULL,
|
|
||||||
"schedule_name" varchar(255) NOT NULL,
|
|
||||||
"action" "product_availability_action" NOT NULL,
|
|
||||||
"product_ids" integer[] DEFAULT '{}' NOT NULL,
|
|
||||||
"group_ids" integer[] DEFAULT '{}' NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"last_updated" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "product_availability_schedules_schedule_name_unique" UNIQUE("schedule_name")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "mf"."product_info" ADD COLUMN "scheduled_availability" boolean DEFAULT true NOT NULL;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -540,13 +540,6 @@
|
||||||
"when": 1772637259874,
|
"when": 1772637259874,
|
||||||
"tag": "0076_sturdy_wolverine",
|
"tag": "0076_sturdy_wolverine",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 77,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1773927855512,
|
|
||||||
"tag": "0077_wakeful_norrin_radd",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
CREATE TABLE `address_areas` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`place_name` text NOT NULL,
|
|
||||||
`zone_id` integer,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `address_zones` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`zone_name` text NOT NULL,
|
|
||||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `addresses` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`phone` text NOT NULL,
|
|
||||||
`address_line1` text NOT NULL,
|
|
||||||
`address_line2` text,
|
|
||||||
`city` text NOT NULL,
|
|
||||||
`state` text NOT NULL,
|
|
||||||
`pincode` text NOT NULL,
|
|
||||||
`is_default` integer DEFAULT false NOT NULL,
|
|
||||||
`latitude` real,
|
|
||||||
`longitude` real,
|
|
||||||
`google_maps_url` text,
|
|
||||||
`admin_latitude` real,
|
|
||||||
`admin_longitude` real,
|
|
||||||
`zone_id` integer,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`zone_id`) REFERENCES `address_zones`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `cart_items` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`quantity` text NOT NULL,
|
|
||||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_user_product` ON `cart_items` (`user_id`,`product_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `complaints` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`order_id` integer,
|
|
||||||
`complaint_body` text NOT NULL,
|
|
||||||
`images` text,
|
|
||||||
`response` text,
|
|
||||||
`is_resolved` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `coupon_applicable_products` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`coupon_id` integer NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_coupon_product` ON `coupon_applicable_products` (`coupon_id`,`product_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `coupon_applicable_users` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`coupon_id` integer NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_coupon_user` ON `coupon_applicable_users` (`coupon_id`,`user_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `coupon_usage` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`coupon_id` integer NOT NULL,
|
|
||||||
`order_id` integer,
|
|
||||||
`order_item_id` integer,
|
|
||||||
`used_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`order_item_id`) REFERENCES `order_items`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `coupons` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`coupon_code` text NOT NULL,
|
|
||||||
`is_user_based` integer DEFAULT false NOT NULL,
|
|
||||||
`discount_percent` text,
|
|
||||||
`flat_discount` text,
|
|
||||||
`min_order` text,
|
|
||||||
`product_ids` text,
|
|
||||||
`created_by` integer,
|
|
||||||
`max_value` text,
|
|
||||||
`is_apply_for_all` integer DEFAULT false NOT NULL,
|
|
||||||
`valid_till` integer,
|
|
||||||
`max_limit_for_user` integer,
|
|
||||||
`is_invalidated` integer DEFAULT false NOT NULL,
|
|
||||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_coupon_code` ON `coupons` (`coupon_code`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `delivery_slot_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`delivery_time` integer NOT NULL,
|
|
||||||
`freeze_time` integer NOT NULL,
|
|
||||||
`is_active` integer DEFAULT true NOT NULL,
|
|
||||||
`is_flash` integer DEFAULT false NOT NULL,
|
|
||||||
`is_capacity_full` integer DEFAULT false NOT NULL,
|
|
||||||
`delivery_sequence` text DEFAULT '{}',
|
|
||||||
`group_ids` text DEFAULT '[]'
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `home_banners` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`image_url` text NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`product_ids` text,
|
|
||||||
`redirect_url` text,
|
|
||||||
`serial_num` integer,
|
|
||||||
`is_active` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `key_val_store` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`value` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `notif_creds` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`token` text NOT NULL,
|
|
||||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`last_verified` integer,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `notif_creds_token_unique` ON `notif_creds` (`token`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `notifications` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`title` text NOT NULL,
|
|
||||||
`body` text NOT NULL,
|
|
||||||
`type` text,
|
|
||||||
`is_read` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `order_items` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`order_id` integer NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`quantity` text NOT NULL,
|
|
||||||
`price` text NOT NULL,
|
|
||||||
`discounted_price` text,
|
|
||||||
`is_packaged` integer DEFAULT false NOT NULL,
|
|
||||||
`is_package_verified` integer DEFAULT false NOT NULL,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `order_status` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`order_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`order_id` integer NOT NULL,
|
|
||||||
`is_packaged` integer DEFAULT false NOT NULL,
|
|
||||||
`is_delivered` integer DEFAULT false NOT NULL,
|
|
||||||
`is_cancelled` integer DEFAULT false NOT NULL,
|
|
||||||
`cancel_reason` text,
|
|
||||||
`is_cancelled_by_admin` integer,
|
|
||||||
`payment_state` text DEFAULT 'pending' NOT NULL,
|
|
||||||
`cancellation_user_notes` text,
|
|
||||||
`cancellation_admin_notes` text,
|
|
||||||
`cancellation_reviewed` integer DEFAULT false NOT NULL,
|
|
||||||
`cancellation_reviewed_at` integer,
|
|
||||||
`refund_coupon_id` integer,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`refund_coupon_id`) REFERENCES `coupons`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `orders` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`address_id` integer NOT NULL,
|
|
||||||
`slot_id` integer,
|
|
||||||
`is_cod` integer DEFAULT false NOT NULL,
|
|
||||||
`is_online_payment` integer DEFAULT false NOT NULL,
|
|
||||||
`payment_info_id` integer,
|
|
||||||
`total_amount` text NOT NULL,
|
|
||||||
`delivery_charge` text DEFAULT '0' NOT NULL,
|
|
||||||
`readable_id` integer NOT NULL,
|
|
||||||
`admin_notes` text,
|
|
||||||
`user_notes` text,
|
|
||||||
`order_group_id` text,
|
|
||||||
`order_group_proportion` text,
|
|
||||||
`is_flash_delivery` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`address_id`) REFERENCES `addresses`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`payment_info_id`) REFERENCES `payment_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `payment_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`status` text NOT NULL,
|
|
||||||
`gateway` text NOT NULL,
|
|
||||||
`order_id` text,
|
|
||||||
`token` text,
|
|
||||||
`merchant_order_id` text NOT NULL,
|
|
||||||
`payload` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `payment_info_merchant_order_id_unique` ON `payment_info` (`merchant_order_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `payments` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`status` text NOT NULL,
|
|
||||||
`gateway` text NOT NULL,
|
|
||||||
`order_id` integer NOT NULL,
|
|
||||||
`token` text,
|
|
||||||
`merchant_order_id` text NOT NULL,
|
|
||||||
`payload` text,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `payments_merchant_order_id_unique` ON `payments` (`merchant_order_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_availability_schedules` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`time` text NOT NULL,
|
|
||||||
`schedule_name` text NOT NULL,
|
|
||||||
`action` text NOT NULL,
|
|
||||||
`product_ids` text DEFAULT '[]' NOT NULL,
|
|
||||||
`group_ids` text DEFAULT '[]' NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`last_updated` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `product_availability_schedules_schedule_name_unique` ON `product_availability_schedules` (`schedule_name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_categories` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`description` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_group_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`group_name` text NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_group_membership` (
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`group_id` integer NOT NULL,
|
|
||||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`group_id`) REFERENCES `product_group_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `product_group_membership_pk` ON `product_group_membership` (`product_id`,`group_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`short_description` text,
|
|
||||||
`long_description` text,
|
|
||||||
`unit_id` integer NOT NULL,
|
|
||||||
`price` text NOT NULL,
|
|
||||||
`market_price` text,
|
|
||||||
`images` text,
|
|
||||||
`is_out_of_stock` integer DEFAULT false NOT NULL,
|
|
||||||
`is_suspended` integer DEFAULT false NOT NULL,
|
|
||||||
`is_flash_available` integer DEFAULT false NOT NULL,
|
|
||||||
`flash_price` text,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`increment_step` real DEFAULT 1 NOT NULL,
|
|
||||||
`product_quantity` real DEFAULT 1 NOT NULL,
|
|
||||||
`store_id` integer,
|
|
||||||
`scheduled_availability` integer DEFAULT true NOT NULL,
|
|
||||||
FOREIGN KEY (`unit_id`) REFERENCES `units`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`store_id`) REFERENCES `store_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_reviews` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`review_body` text NOT NULL,
|
|
||||||
`image_urls` text DEFAULT '[]',
|
|
||||||
`review_time` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`ratings` real NOT NULL,
|
|
||||||
`admin_response` text,
|
|
||||||
`admin_response_images` text DEFAULT '[]',
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
CONSTRAINT "rating_check" CHECK("product_reviews"."ratings" >= 1 AND "product_reviews"."ratings" <= 5)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_slots` (
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`slot_id` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `product_slot_pk` ON `product_slots` (`product_id`,`slot_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_tag_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`tag_name` text NOT NULL,
|
|
||||||
`tag_description` text,
|
|
||||||
`image_url` text,
|
|
||||||
`is_dashboard_tag` integer DEFAULT false NOT NULL,
|
|
||||||
`related_stores` text DEFAULT '[]',
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `product_tag_info_tag_name_unique` ON `product_tag_info` (`tag_name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `product_tags` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`tag_id` integer NOT NULL,
|
|
||||||
`assigned_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`tag_id`) REFERENCES `product_tag_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_product_tag` ON `product_tags` (`product_id`,`tag_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `refunds` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`order_id` integer NOT NULL,
|
|
||||||
`refund_amount` text,
|
|
||||||
`refund_status` text DEFAULT 'none',
|
|
||||||
`merchant_refund_id` text,
|
|
||||||
`refund_processed_at` integer,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `reserved_coupons` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`secret_code` text NOT NULL,
|
|
||||||
`coupon_code` text NOT NULL,
|
|
||||||
`discount_percent` text,
|
|
||||||
`flat_discount` text,
|
|
||||||
`min_order` text,
|
|
||||||
`product_ids` text,
|
|
||||||
`max_value` text,
|
|
||||||
`valid_till` integer,
|
|
||||||
`max_limit_for_user` integer,
|
|
||||||
`exclusive_apply` integer DEFAULT false NOT NULL,
|
|
||||||
`is_redeemed` integer DEFAULT false NOT NULL,
|
|
||||||
`redeemed_by` integer,
|
|
||||||
`redeemed_at` integer,
|
|
||||||
`created_by` integer NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`redeemed_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `reserved_coupons_secret_code_unique` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_secret_code` ON `reserved_coupons` (`secret_code`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `special_deals` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`product_id` integer NOT NULL,
|
|
||||||
`quantity` text NOT NULL,
|
|
||||||
`price` text NOT NULL,
|
|
||||||
`valid_till` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`product_id`) REFERENCES `product_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `staff_permissions` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`permission_name` text NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_permission_name` ON `staff_permissions` (`permission_name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `staff_role_permissions` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`staff_role_id` integer NOT NULL,
|
|
||||||
`staff_permission_id` integer NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`staff_permission_id`) REFERENCES `staff_permissions`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_role_permission` ON `staff_role_permissions` (`staff_role_id`,`staff_permission_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `staff_roles` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`role_name` text NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_role_name` ON `staff_roles` (`role_name`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `staff_users` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`password` text NOT NULL,
|
|
||||||
`staff_role_id` integer,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`staff_role_id`) REFERENCES `staff_roles`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `store_info` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`description` text,
|
|
||||||
`image_url` text,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`owner` integer NOT NULL,
|
|
||||||
FOREIGN KEY (`owner`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `units` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`short_notation` text NOT NULL,
|
|
||||||
`full_name` text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_short_notation` ON `units` (`short_notation`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `unlogged_user_tokens` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`token` text NOT NULL,
|
|
||||||
`added_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`last_verified` integer
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unlogged_user_tokens_token_unique` ON `unlogged_user_tokens` (`token`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `upload_url_status` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`key` text NOT NULL,
|
|
||||||
`status` text DEFAULT 'pending' NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `user_creds` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`user_password` text NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `user_details` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`bio` text,
|
|
||||||
`date_of_birth` integer,
|
|
||||||
`gender` text,
|
|
||||||
`occupation` text,
|
|
||||||
`profile_image` text,
|
|
||||||
`is_suspended` integer DEFAULT false NOT NULL,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `user_details_user_id_unique` ON `user_details` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `user_incidents` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`order_id` integer,
|
|
||||||
`date_added` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`admin_comment` text,
|
|
||||||
`added_by` integer,
|
|
||||||
`negativity_score` integer,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE no action,
|
|
||||||
FOREIGN KEY (`added_by`) REFERENCES `staff_users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `user_notifications` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`title` text NOT NULL,
|
|
||||||
`image_url` text,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
`body` text NOT NULL,
|
|
||||||
`applicable_users` text
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `users` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text,
|
|
||||||
`email` text,
|
|
||||||
`mobile` text,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `unique_email` ON `users` (`email`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `vendor_snippets` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`snippet_code` text NOT NULL,
|
|
||||||
`slot_id` integer,
|
|
||||||
`is_permanent` integer DEFAULT false NOT NULL,
|
|
||||||
`product_ids` text NOT NULL,
|
|
||||||
`valid_till` integer,
|
|
||||||
`created_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
|
|
||||||
FOREIGN KEY (`slot_id`) REFERENCES `delivery_slot_info`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `vendor_snippets_snippet_code_unique` ON `vendor_snippets` (`snippet_code`);
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "sqlite",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1774244805277,
|
|
||||||
"tag": "0000_goofy_oracle",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import 'dotenv/config';
|
|
||||||
import express, { NextFunction, Request, Response } from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
// import bodyParser from "body-parser";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
import { db } from '@/src/db/db_index';
|
|
||||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import mainRouter from '@/src/main-router';
|
|
||||||
import initFunc from '@/src/lib/init';
|
|
||||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
|
||||||
import { appRouter } from '@/src/trpc/router';
|
|
||||||
import { TRPCError } from '@trpc/server';
|
|
||||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
|
||||||
import { seed } from '@/src/db/seed';
|
|
||||||
import '@/src/jobs/jobs-index';
|
|
||||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
|
||||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils';
|
|
||||||
|
|
||||||
seed()
|
|
||||||
initFunc()
|
|
||||||
startAutomatedJobs()
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors({
|
|
||||||
origin: 'http://localhost:5174'
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
signedUrlCache.loadFromDisk();
|
|
||||||
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
// Middleware to log all request URLs
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
//cors middleware
|
|
||||||
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
||||||
// Allow requests from any origin (for production, replace * with your domain)
|
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
|
||||||
|
|
||||||
// Allow specific headers clients can send
|
|
||||||
res.header(
|
|
||||||
'Access-Control-Allow-Headers',
|
|
||||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Allow specific HTTP methods
|
|
||||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
||||||
|
|
||||||
// Allow credentials if needed (optional)
|
|
||||||
// res.header('Access-Control-Allow-Credentials', 'true');
|
|
||||||
|
|
||||||
// Handle preflight (OPTIONS) requests quickly
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return res.sendStatus(204);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
app.use('/api/trpc', createExpressMiddleware({
|
|
||||||
router: appRouter,
|
|
||||||
createContext: async ({ req, res }) => {
|
|
||||||
let user = null;
|
|
||||||
let staffUser = null;
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
try {
|
|
||||||
const decoded = await verifyToken(token);
|
|
||||||
|
|
||||||
// Check if this is a staff token (has staffId)
|
|
||||||
if ('staffId' in decoded) {
|
|
||||||
const staffPayload = decoded as StaffJWTPayload;
|
|
||||||
// This is a staff token, verify staff exists
|
|
||||||
const staff = await db.query.staffUsers.findFirst({
|
|
||||||
where: eq(staffUsers.id, staffPayload.staffId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (staff) {
|
|
||||||
staffUser = {
|
|
||||||
id: staff.id,
|
|
||||||
name: staff.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const userPayload = decoded as UserJWTPayload;
|
|
||||||
// This is a regular user token
|
|
||||||
user = {
|
|
||||||
userId: userPayload.userId,
|
|
||||||
name: userPayload.name,
|
|
||||||
email: userPayload.email,
|
|
||||||
mobile: userPayload.mobile,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if user is suspended
|
|
||||||
const details = await db.query.userDetails.findFirst({
|
|
||||||
where: eq(userDetails.userId, userPayload.userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (details?.isSuspended) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: 'Account suspended',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Invalid token, both user and staffUser remain null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { req, res, user, staffUser };
|
|
||||||
},
|
|
||||||
onError({ error, path, type, ctx }) {
|
|
||||||
console.error('🚨 tRPC Error :', {
|
|
||||||
path,
|
|
||||||
type,
|
|
||||||
code: error.code,
|
|
||||||
message: error.message,
|
|
||||||
userId: ctx?.user?.userId,
|
|
||||||
stack: error.stack,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.use('/api', mainRouter)
|
|
||||||
|
|
||||||
const fallbackUiDirCandidates = [
|
|
||||||
path.resolve(__dirname, '../fallback-ui/dist'),
|
|
||||||
path.resolve(__dirname, '../../fallback-ui/dist'),
|
|
||||||
path.resolve(process.cwd(), '../fallback-ui/dist'),
|
|
||||||
path.resolve(process.cwd(), './apps/fallback-ui/dist')
|
|
||||||
]
|
|
||||||
|
|
||||||
const fallbackUiDir =
|
|
||||||
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
|
|
||||||
fallbackUiDirCandidates[0]
|
|
||||||
|
|
||||||
|
|
||||||
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
|
|
||||||
// const fallbackUiMountPath = '/admin-web'
|
|
||||||
const fallbackUiMountPath = '/';
|
|
||||||
|
|
||||||
if (fs.existsSync(fallbackUiIndex)) {
|
|
||||||
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
|
|
||||||
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
|
|
||||||
const fallbackUiRegex = new RegExp(
|
|
||||||
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
|
|
||||||
)
|
|
||||||
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
|
|
||||||
res.sendFile(fallbackUiIndex)
|
|
||||||
})
|
|
||||||
app.get(/.*/, (req,res) => {
|
|
||||||
res.sendFile(fallbackUiIndex)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve /assets/public folder at /assets route
|
|
||||||
const assetsPublicDir = path.resolve(__dirname, './assets/public');
|
|
||||||
if (fs.existsSync(assetsPublicDir)) {
|
|
||||||
app.use('/assets', express.static(assetsPublicDir));
|
|
||||||
console.log('Serving /assets from', assetsPublicDir);
|
|
||||||
} else {
|
|
||||||
console.warn('Assets public folder not found at', assetsPublicDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global error handler
|
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
console.error(err);
|
|
||||||
const status = err.statusCode || err.status || 500;
|
|
||||||
const message = err.message || 'Internal Server Error';
|
|
||||||
res.status(status).json({ message });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(4000, () => {
|
|
||||||
console.log("Server is running on http://localhost:4000/api/mobile/");
|
|
||||||
});
|
|
||||||
270
apps/backend/index.ts
Normal file → Executable file
270
apps/backend/index.ts
Normal file → Executable file
|
|
@ -1,167 +1,185 @@
|
||||||
import { Hono } from 'hono'
|
import 'dotenv/config';
|
||||||
import { cors } from 'hono/cors'
|
import express, { NextFunction, Request, Response } from "express";
|
||||||
import { logger } from 'hono/logger'
|
import cors from "cors";
|
||||||
import { serve } from 'bun'
|
// import bodyParser from "body-parser";
|
||||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
import multer from "multer";
|
||||||
import { appRouter } from '@/src/trpc/router'
|
import path from "path";
|
||||||
import { verifyToken, UserJWTPayload, StaffJWTPayload } from '@/src/lib/jwt-utils'
|
import fs from "fs";
|
||||||
import { db } from '@/src/db/db_index'
|
import { db } from '@/src/db/db_index';
|
||||||
import { staffUsers, userDetails } from '@/src/db/schema'
|
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm';
|
||||||
import { TRPCError } from '@trpc/server'
|
import mainRouter from '@/src/main-router';
|
||||||
import signedUrlCache from '@/src/lib/signed-url-cache'
|
import initFunc from '@/src/lib/init';
|
||||||
import { seed } from '@/src/db/seed'
|
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||||
import initFunc from '@/src/lib/init'
|
import { appRouter } from '@/src/trpc/router';
|
||||||
import '@/src/jobs/jobs-index'
|
import { TRPCError } from '@trpc/server';
|
||||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||||
|
import { seed } from '@/src/db/seed';
|
||||||
|
import '@/src/jobs/jobs-index';
|
||||||
|
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||||
|
|
||||||
// Initialize
|
|
||||||
seed()
|
seed()
|
||||||
initFunc()
|
initFunc()
|
||||||
startAutomatedJobs()
|
startAutomatedJobs()
|
||||||
|
|
||||||
const app = new Hono()
|
const app = express();
|
||||||
|
|
||||||
// CORS middleware
|
app.use(cors({
|
||||||
app.use('*', cors({
|
origin: 'http://localhost:5174'
|
||||||
origin: ['http://localhost:5174', 'http://localhost:5173'],
|
}));
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
||||||
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
|
||||||
credentials: true
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Request logging
|
|
||||||
app.use('*', logger())
|
|
||||||
|
|
||||||
// Health check
|
signedUrlCache.loadFromDisk();
|
||||||
app.get('/health', (c) => {
|
|
||||||
return c.json({
|
|
||||||
status: 'OK',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
message: 'Hello world'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// tRPC handler with context
|
app.use(express.json());
|
||||||
app.use('/api/trpc/*', async (c) => {
|
app.use(express.urlencoded({ extended: true }));
|
||||||
const response = await fetchRequestHandler({
|
|
||||||
endpoint: '/api/trpc',
|
// Middleware to log all request URLs
|
||||||
req: c.req.raw,
|
app.use((req, res, next) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
//cors middleware
|
||||||
|
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
// Allow requests from any origin (for production, replace * with your domain)
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
// Allow specific headers clients can send
|
||||||
|
res.header(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allow specific HTTP methods
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
|
||||||
|
// Allow credentials if needed (optional)
|
||||||
|
// res.header('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
|
// Handle preflight (OPTIONS) requests quickly
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.use('/api/trpc', createExpressMiddleware({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req }) => {
|
createContext: async ({ req, res }) => {
|
||||||
let user = null
|
let user = null;
|
||||||
let staffUser = null
|
let staffUser = null;
|
||||||
const authHeader = req.headers.get('authorization')
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
const token = authHeader.substring(7)
|
const token = authHeader.substring(7);
|
||||||
try {
|
try {
|
||||||
const decoded = await verifyToken(token)
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||||
|
|
||||||
if ('staffId' in decoded) {
|
// Check if this is a staff token (has staffId)
|
||||||
const staffPayload = decoded as StaffJWTPayload
|
if (decoded.staffId) {
|
||||||
|
// This is a staff token, verify staff exists
|
||||||
const staff = await db.query.staffUsers.findFirst({
|
const staff = await db.query.staffUsers.findFirst({
|
||||||
where: eq(staffUsers.id, staffPayload.staffId)
|
where: eq(staffUsers.id, decoded.staffId),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (staff) {
|
if (staff) {
|
||||||
staffUser = { id: staff.id, name: staff.name }
|
user=staffUser
|
||||||
|
staffUser = {
|
||||||
|
id: staff.id,
|
||||||
|
name: staff.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const userPayload = decoded as UserJWTPayload
|
|
||||||
user = {
|
|
||||||
userId: userPayload.userId,
|
|
||||||
name: userPayload.name,
|
|
||||||
email: userPayload.email,
|
|
||||||
mobile: userPayload.mobile
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// This is a regular user token
|
||||||
|
user = decoded;
|
||||||
|
|
||||||
|
// Check if user is suspended
|
||||||
const details = await db.query.userDetails.findFirst({
|
const details = await db.query.userDetails.findFirst({
|
||||||
where: eq(userDetails.userId, userPayload.userId)
|
where: eq(userDetails.userId, user.userId),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (details?.isSuspended) {
|
if (details?.isSuspended) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'Account suspended'
|
message: 'Account suspended',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Invalid token
|
// Invalid token, both user and staffUser remain null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { req, res, user, staffUser };
|
||||||
return { req, res: c.res, user, staffUser }
|
|
||||||
},
|
},
|
||||||
onError: ({ error, path, ctx }) => {
|
onError({ error, path, type, ctx }) {
|
||||||
console.error('🚨 tRPC Error :', {
|
console.error('🚨 tRPC Error :', {
|
||||||
path,
|
path,
|
||||||
|
type,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
userId: ctx?.user?.userId
|
userId: ctx?.user?.userId,
|
||||||
})
|
stack: error.stack,
|
||||||
}
|
});
|
||||||
})
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
return response
|
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) => {
|
||||||
// Static files - Fallback UI
|
res.sendFile(fallbackUiIndex)
|
||||||
app.use('/*', async (c) => {
|
})
|
||||||
const url = new URL(c.req.url)
|
} else {
|
||||||
let filePath = url.pathname
|
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
|
||||||
|
|
||||||
// Default to index.html for root
|
|
||||||
if (filePath === '/') {
|
|
||||||
filePath = '/index.html'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to serve the file
|
// Serve /assets/public folder at /assets route
|
||||||
const file = Bun.file(`./fallback-ui/dist${filePath}`)
|
const assetsPublicDir = path.resolve(__dirname, './assets/public');
|
||||||
if (await file.exists()) {
|
if (fs.existsSync(assetsPublicDir)) {
|
||||||
return new Response(file)
|
app.use('/assets', express.static(assetsPublicDir));
|
||||||
|
console.log('Serving /assets from', assetsPublicDir);
|
||||||
|
} else {
|
||||||
|
console.warn('Assets public folder not found at', assetsPublicDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA fallback - serve index.html for any unmatched routes
|
|
||||||
const indexFile = Bun.file('./fallback-ui/dist/index.html')
|
|
||||||
if (await indexFile.exists()) {
|
|
||||||
return new Response(indexFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.notFound()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Static files - Assets
|
|
||||||
app.use('/assets/*', async (c) => {
|
|
||||||
const path = c.req.path.replace('/assets/', '')
|
|
||||||
const file = Bun.file(`./assets/public/${path}`)
|
|
||||||
|
|
||||||
if (await file.exists()) {
|
|
||||||
return new Response(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.notFound()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.onError((err, c) => {
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
console.error('Error:', err)
|
console.error(err);
|
||||||
|
const status = err.statusCode || err.status || 500;
|
||||||
|
const message = err.message || 'Internal Server Error';
|
||||||
|
res.status(status).json({ message });
|
||||||
|
});
|
||||||
|
|
||||||
const status = err instanceof TRPCError
|
app.listen(4000, '::', () => {
|
||||||
? (err.code === 'UNAUTHORIZED' ? 401 : 500)
|
console.log("Server is running on http://localhost:4000/api/mobile/");
|
||||||
: 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,21 +4,14 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"migrate": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
"migrate": "drizzle-kit generate:pg",
|
||||||
"migrate:pg": "drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
|
||||||
"migrate:sqlite": "drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
|
|
||||||
"generate:pg": "bunx drizzle-kit generate --config ../db-helper-postgres/drizzle.config.ts",
|
|
||||||
"generate:sqlite": "bunx drizzle-kit generate --config ../db-helper-sqlite/drizzle.config.ts",
|
|
||||||
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||||
"build2": "rimraf ./dist && tsc",
|
"build2": "rimraf ./dist && tsc",
|
||||||
"db:push": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
|
"db:push": "drizzle-kit push:pg",
|
||||||
"db:push:pg": "drizzle-kit push --config ../db-helper-postgres/drizzle.config.ts",
|
|
||||||
"db:push:sqlite": "drizzle-kit push --config ../db-helper-sqlite/drizzle.config.ts",
|
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
"dev:express": "bun --watch index-express.ts",
|
"dev2": "tsx watch index.ts",
|
||||||
"dev:hono": "bun --watch index.ts",
|
"dev_node": "tsx watch index.ts",
|
||||||
"dev": "bun --watch index.ts",
|
"dev": "bun --watch index.ts",
|
||||||
"start": "bun index.ts",
|
|
||||||
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
|
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
|
||||||
"docker:push": "docker push mohdshafiuddin54/health_petal:latest"
|
"docker:push": "docker push mohdshafiuddin54/health_petal:latest"
|
||||||
},
|
},
|
||||||
|
|
@ -33,6 +26,8 @@
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
|
|
@ -41,16 +36,18 @@
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"hono": "^4.6.3",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jose": "^5.10.0",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"razorpay": "^2.9.6",
|
||||||
"redis": "^5.9.0",
|
"redis": "^5.9.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.1.10",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
|
|
||||||
Binary file not shown.
19
apps/backend/src/apis/admin-apis/apis/av-router.ts
Executable file
19
apps/backend/src/apis/admin-apis/apis/av-router.ts
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
||||||
|
import productRouter from "@/src/apis/admin-apis/apis/product.router"
|
||||||
|
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Apply staff authentication to all admin routes
|
||||||
|
router.use(authenticateStaff);
|
||||||
|
|
||||||
|
// Product routes
|
||||||
|
router.use("/products", productRouter);
|
||||||
|
|
||||||
|
// Tag routes
|
||||||
|
router.use("/product-tags", tagRouter);
|
||||||
|
|
||||||
|
const avRouter = router;
|
||||||
|
|
||||||
|
export default avRouter;
|
||||||
222
apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts
Normal file
222
apps/backend/src/apis/admin-apis/apis/product-tags.controller.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { db } from "@/src/db/db_index";
|
||||||
|
import { productTagInfo } from "@/src/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
|
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
||||||
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
|
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product tag
|
||||||
|
*/
|
||||||
|
export const createTag = async (req: Request, res: Response) => {
|
||||||
|
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||||
|
|
||||||
|
if (!tagName) {
|
||||||
|
throw new ApiError("Tag name is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate tag name
|
||||||
|
const existingTag = await db.query.productTagInfo.findFirst({
|
||||||
|
where: eq(productTagInfo.tagName, tagName.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTag) {
|
||||||
|
throw new ApiError("A tag with this name already exists", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageUrl: string | null = null;
|
||||||
|
|
||||||
|
// Handle image upload if file is provided
|
||||||
|
if (req.file) {
|
||||||
|
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||||
|
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse relatedStores if it's a string (from FormData)
|
||||||
|
let parsedRelatedStores: number[] = [];
|
||||||
|
if (relatedStores) {
|
||||||
|
try {
|
||||||
|
parsedRelatedStores = typeof relatedStores === 'string'
|
||||||
|
? JSON.parse(relatedStores)
|
||||||
|
: relatedStores;
|
||||||
|
} catch (e) {
|
||||||
|
parsedRelatedStores = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newTag] = await db
|
||||||
|
.insert(productTagInfo)
|
||||||
|
.values({
|
||||||
|
tagName: tagName.trim(),
|
||||||
|
tagDescription,
|
||||||
|
imageUrl,
|
||||||
|
isDashboardTag: isDashboardTag || false,
|
||||||
|
relatedStores: parsedRelatedStores,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes in cache
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
tag: newTag,
|
||||||
|
message: "Tag created successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all product tags
|
||||||
|
*/
|
||||||
|
export const getAllTags = async (req: Request, res: Response) => {
|
||||||
|
const tags = await db
|
||||||
|
.select()
|
||||||
|
.from(productTagInfo)
|
||||||
|
.orderBy(productTagInfo.tagName);
|
||||||
|
|
||||||
|
// Generate signed URLs for tag images
|
||||||
|
const tagsWithSignedUrls = await Promise.all(
|
||||||
|
tags.map(async (tag) => ({
|
||||||
|
...tag,
|
||||||
|
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
tags: tagsWithSignedUrls,
|
||||||
|
message: "Tags retrieved successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single product tag by ID
|
||||||
|
*/
|
||||||
|
export const getTagById = async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const tag = await db.query.productTagInfo.findFirst({
|
||||||
|
where: eq(productTagInfo.id, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
throw new ApiError("Tag not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signed URL for tag image
|
||||||
|
const tagWithSignedUrl = {
|
||||||
|
...tag,
|
||||||
|
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
tag: tagWithSignedUrl,
|
||||||
|
message: "Tag retrieved successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product tag
|
||||||
|
*/
|
||||||
|
export const updateTag = async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
||||||
|
|
||||||
|
// Get the current tag to check for existing image
|
||||||
|
const currentTag = await db.query.productTagInfo.findFirst({
|
||||||
|
where: eq(productTagInfo.id, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentTag) {
|
||||||
|
throw new ApiError("Tag not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageUrl = currentTag.imageUrl;
|
||||||
|
|
||||||
|
// Handle image upload if new file is provided
|
||||||
|
if (req.file) {
|
||||||
|
// Delete old image if it exists
|
||||||
|
if (currentTag.imageUrl) {
|
||||||
|
try {
|
||||||
|
await deleteS3Image(currentTag.imageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete old image:", error);
|
||||||
|
// Continue with update even if delete fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Upload new image
|
||||||
|
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
||||||
|
console.log('file', key)
|
||||||
|
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse relatedStores if it's a string (from FormData)
|
||||||
|
let parsedRelatedStores: number[] | undefined;
|
||||||
|
if (relatedStores !== undefined) {
|
||||||
|
try {
|
||||||
|
parsedRelatedStores = typeof relatedStores === 'string'
|
||||||
|
? JSON.parse(relatedStores)
|
||||||
|
: relatedStores;
|
||||||
|
} catch (e) {
|
||||||
|
parsedRelatedStores = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedTag] = await db
|
||||||
|
.update(productTagInfo)
|
||||||
|
.set({
|
||||||
|
tagName: tagName?.trim(),
|
||||||
|
tagDescription,
|
||||||
|
imageUrl,
|
||||||
|
isDashboardTag,
|
||||||
|
relatedStores: parsedRelatedStores,
|
||||||
|
})
|
||||||
|
.where(eq(productTagInfo.id, parseInt(id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes in cache
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
tag: updatedTag,
|
||||||
|
message: "Tag updated successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a product tag
|
||||||
|
*/
|
||||||
|
export const deleteTag = async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check if tag exists
|
||||||
|
const tag = await db.query.productTagInfo.findFirst({
|
||||||
|
where: eq(productTagInfo.id, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
throw new ApiError("Tag not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete image from S3 if it exists
|
||||||
|
if (tag.imageUrl) {
|
||||||
|
try {
|
||||||
|
await deleteS3Image(tag.imageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete image from S3:", error);
|
||||||
|
// Continue with deletion even if image delete fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: This will fail if tag is still assigned to products due to foreign key constraint
|
||||||
|
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes in cache
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Tag deleted successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
303
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
303
apps/backend/src/apis/admin-apis/apis/product.controller.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { db } from "@/src/db/db_index";
|
||||||
|
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { ApiError } from "@/src/lib/api-error";
|
||||||
|
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
||||||
|
import { deleteS3Image } from "@/src/lib/delete-image";
|
||||||
|
import type { SpecialDeal } from "@/src/db/types";
|
||||||
|
import { initializeAllStores } from '@/src/stores/store-initializer';
|
||||||
|
|
||||||
|
type CreateDeal = {
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
validTill: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
export const createProduct = async (req: Request, res: Response) => {
|
||||||
|
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !unitId || !storeId || !price) {
|
||||||
|
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existingProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.name, name.trim()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProduct) {
|
||||||
|
throw new ApiError("A product with this name already exists", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if unit exists
|
||||||
|
const unit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.id, unitId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new ApiError("Invalid unit ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract images from req.files
|
||||||
|
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||||
|
let uploadedImageUrls: string[] = [];
|
||||||
|
|
||||||
|
if (images && Array.isArray(images)) {
|
||||||
|
const imageUploadPromises = images.map((file, index) => {
|
||||||
|
const key = `product-images/${Date.now()}-${index}`;
|
||||||
|
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
const productData: any = {
|
||||||
|
name,
|
||||||
|
shortDescription,
|
||||||
|
longDescription,
|
||||||
|
unitId,
|
||||||
|
storeId,
|
||||||
|
price,
|
||||||
|
marketPrice,
|
||||||
|
incrementStep: incrementStep || 1,
|
||||||
|
productQuantity: productQuantity || 1,
|
||||||
|
isSuspended: isSuspended || false,
|
||||||
|
isFlashAvailable: isFlashAvailable || false,
|
||||||
|
images: uploadedImageUrls,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flashPrice) {
|
||||||
|
productData.flashPrice = parseFloat(flashPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newProduct] = await db
|
||||||
|
.insert(productInfo)
|
||||||
|
.values(productData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Handle deals if provided
|
||||||
|
let createdDeals: SpecialDeal[] = [];
|
||||||
|
if (deals && Array.isArray(deals)) {
|
||||||
|
const dealInserts = deals.map((deal: CreateDeal) => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
|
||||||
|
createdDeals = await db
|
||||||
|
.insert(specialDeals)
|
||||||
|
.values(dealInserts)
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag assignments if provided
|
||||||
|
if (tagIds && Array.isArray(tagIds)) {
|
||||||
|
const tagAssociations = tagIds.map((tagId: number) => ({
|
||||||
|
productId: newProduct.id,
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
product: newProduct,
|
||||||
|
deals: createdDeals,
|
||||||
|
message: "Product created successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
export const updateProduct = async (req: Request, res: Response) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
||||||
|
|
||||||
|
|
||||||
|
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
||||||
|
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
||||||
|
|
||||||
|
if (!name || !unitId || !storeId || !price) {
|
||||||
|
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if unit exists
|
||||||
|
const unit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.id, unitId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new ApiError("Invalid unit ID", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current product to handle image updates
|
||||||
|
const currentProduct = await db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentProduct) {
|
||||||
|
throw new ApiError("Product not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle image deletions
|
||||||
|
let currentImages = (currentProduct.images as string[]) || [];
|
||||||
|
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||||
|
// Convert signed URLs to original S3 URLs for comparison
|
||||||
|
const originalUrlsToDelete = imagesToDelete
|
||||||
|
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
||||||
|
.filter(Boolean); // Remove nulls
|
||||||
|
|
||||||
|
// Find which stored images match the ones to delete
|
||||||
|
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
||||||
|
originalUrlsToDelete.includes(storedUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete the matching images from S3
|
||||||
|
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
// Remove deleted images from current images array
|
||||||
|
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract new images from req.files
|
||||||
|
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
||||||
|
let uploadedImageUrls: string[] = [];
|
||||||
|
|
||||||
|
if (images && Array.isArray(images)) {
|
||||||
|
const imageUploadPromises = images.map((file, index) => {
|
||||||
|
const key = `product-images/${Date.now()}-${index}`;
|
||||||
|
return imageUploadS3(file.buffer, file.mimetype, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine remaining current images with new uploaded images
|
||||||
|
const finalImages = [...currentImages, ...uploadedImageUrls];
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
shortDescription,
|
||||||
|
longDescription,
|
||||||
|
unitId,
|
||||||
|
storeId,
|
||||||
|
price,
|
||||||
|
marketPrice,
|
||||||
|
incrementStep: incrementStep || 1,
|
||||||
|
productQuantity: productQuantity || 1,
|
||||||
|
isSuspended: isSuspended || false,
|
||||||
|
images: finalImages.length > 0 ? finalImages : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFlashAvailable !== undefined) {
|
||||||
|
updateData.isFlashAvailable = isFlashAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flashPrice !== undefined) {
|
||||||
|
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedProduct] = await db
|
||||||
|
.update(productInfo)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(productInfo.id, parseInt(id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedProduct) {
|
||||||
|
throw new ApiError("Product not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deals if provided
|
||||||
|
if (deals && Array.isArray(deals)) {
|
||||||
|
// Get existing deals
|
||||||
|
const existingDeals = await db.query.specialDeals.findMany({
|
||||||
|
where: eq(specialDeals.productId, parseInt(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create maps for comparison
|
||||||
|
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
||||||
|
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
||||||
|
|
||||||
|
// Find deals to add, update, and remove
|
||||||
|
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
return !existingDealsMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealsToRemove = existingDeals.filter(deal => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
return !newDealsMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
const existing = existingDealsMap.get(key);
|
||||||
|
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old deals
|
||||||
|
if (dealsToRemove.length > 0) {
|
||||||
|
await db.delete(specialDeals).where(
|
||||||
|
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new deals
|
||||||
|
if (dealsToAdd.length > 0) {
|
||||||
|
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
||||||
|
productId: parseInt(id),
|
||||||
|
quantity: deal.quantity.toString(),
|
||||||
|
price: deal.price.toString(),
|
||||||
|
validTill: new Date(deal.validTill),
|
||||||
|
}));
|
||||||
|
await db.insert(specialDeals).values(dealInserts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing deals
|
||||||
|
for (const deal of dealsToUpdate) {
|
||||||
|
const key = `${deal.quantity}-${deal.price}`;
|
||||||
|
const existingDeal = existingDealsMap.get(key);
|
||||||
|
if (existingDeal) {
|
||||||
|
await db.update(specialDeals)
|
||||||
|
.set({ validTill: new Date(deal.validTill) })
|
||||||
|
.where(eq(specialDeals.id, existingDeal.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tag assignments if provided
|
||||||
|
// if (tagIds && Array.isArray(tagIds)) {
|
||||||
|
if (tagIds && Boolean(tagIds)) {
|
||||||
|
// Remove existing tags
|
||||||
|
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
||||||
|
|
||||||
|
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
||||||
|
// Add new tags
|
||||||
|
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
||||||
|
productId: parseInt(id),
|
||||||
|
tagId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(productTags).values(tagAssociations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize stores to reflect changes
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
product: updatedProduct,
|
||||||
|
message: "Product updated successfully",
|
||||||
|
});
|
||||||
|
};
|
||||||
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
11
apps/backend/src/apis/admin-apis/apis/product.router.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
||||||
|
import uploadHandler from '@/src/lib/upload-handler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Product routes
|
||||||
|
router.post("/", uploadHandler.array('images'), createProduct);
|
||||||
|
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
||||||
|
|
||||||
|
export default router;
|
||||||
14
apps/backend/src/apis/admin-apis/apis/tag.router.ts
Normal file
14
apps/backend/src/apis/admin-apis/apis/tag.router.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
||||||
|
import uploadHandler from '@/src/lib/upload-handler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Tag routes
|
||||||
|
router.post("/", uploadHandler.single('image'), createTag);
|
||||||
|
router.get("/", getAllTags);
|
||||||
|
router.get("/:id", getTagById);
|
||||||
|
router.put("/:id", uploadHandler.single('image'), updateTag);
|
||||||
|
router.delete("/:id", deleteTag);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1,19 +1,85 @@
|
||||||
import { Context } from 'hono'
|
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { db } from "@/src/db/db_index"
|
||||||
|
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||||
|
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
||||||
|
|
||||||
import { getProductsSummaryData } from '@/src/db/common-product'
|
/**
|
||||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
* Get next delivery date for a product
|
||||||
|
*/
|
||||||
|
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
||||||
|
const result = await db
|
||||||
|
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
||||||
|
.from(productSlots)
|
||||||
|
.innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(productSlots.productId, productId),
|
||||||
|
eq(deliverySlotInfo.isActive, true),
|
||||||
|
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(deliverySlotInfo.deliveryTime)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
|
||||||
|
return result[0]?.deliveryTime || null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all products summary for dropdown
|
* Get all products summary for dropdown
|
||||||
*/
|
*/
|
||||||
export const getAllProductsSummary = async (c: Context) => {
|
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const tagId = c.req.query('tagId')
|
const { tagId } = req.query;
|
||||||
const tagIdNum = tagId ? parseInt(tagId) : null
|
const tagIdNum = tagId ? parseInt(tagId as string) : null;
|
||||||
|
|
||||||
const productsWithUnits = await getProductsSummaryData(tagIdNum)
|
let productIds: number[] | null = null;
|
||||||
|
|
||||||
const formattedProducts = productsWithUnits.map((product) => ({
|
// If tagId is provided, get products that have this tag
|
||||||
|
if (tagIdNum) {
|
||||||
|
const taggedProducts = await db
|
||||||
|
.select({ productId: productTags.productId })
|
||||||
|
.from(productTags)
|
||||||
|
.where(eq(productTags.tagId, tagIdNum));
|
||||||
|
|
||||||
|
productIds = taggedProducts.map(tp => tp.productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let whereCondition = undefined;
|
||||||
|
|
||||||
|
// Filter by product IDs if tag filtering is applied
|
||||||
|
if (productIds && productIds.length > 0) {
|
||||||
|
whereCondition = inArray(productInfo.id, productIds);
|
||||||
|
} else if (tagIdNum) {
|
||||||
|
// If tagId was provided but no products found, return empty array
|
||||||
|
return res.status(200).json({
|
||||||
|
products: [],
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const productsWithUnits = await db
|
||||||
|
.select({
|
||||||
|
id: productInfo.id,
|
||||||
|
name: productInfo.name,
|
||||||
|
shortDescription: productInfo.shortDescription,
|
||||||
|
price: productInfo.price,
|
||||||
|
marketPrice: productInfo.marketPrice,
|
||||||
|
images: productInfo.images,
|
||||||
|
isOutOfStock: productInfo.isOutOfStock,
|
||||||
|
unitShortNotation: units.shortNotation,
|
||||||
|
productQuantity: productInfo.productQuantity,
|
||||||
|
})
|
||||||
|
.from(productInfo)
|
||||||
|
.innerJoin(units, eq(productInfo.unitId, units.id))
|
||||||
|
.where(whereCondition);
|
||||||
|
|
||||||
|
// Generate signed URLs for product images
|
||||||
|
const formattedProducts = await Promise.all(
|
||||||
|
productsWithUnits.map(async (product) => {
|
||||||
|
const nextDeliveryDate = await getNextDeliveryDate(product.id);
|
||||||
|
return {
|
||||||
id: product.id,
|
id: product.id,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
shortDescription: product.shortDescription,
|
shortDescription: product.shortDescription,
|
||||||
|
|
@ -22,16 +88,18 @@ export const getAllProductsSummary = async (c: Context) => {
|
||||||
unit: product.unitShortNotation,
|
unit: product.unitShortNotation,
|
||||||
productQuantity: product.productQuantity,
|
productQuantity: product.productQuantity,
|
||||||
isOutOfStock: product.isOutOfStock,
|
isOutOfStock: product.isOutOfStock,
|
||||||
nextDeliveryDate: product.nextDeliveryDate ? product.nextDeliveryDate.toISOString() : null,
|
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||||
images: scaffoldAssetUrl((product.images as string[]) || []),
|
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({
|
return res.status(200).json({
|
||||||
products: formattedProducts,
|
products: formattedProducts,
|
||||||
count: formattedProducts.length,
|
count: formattedProducts.length,
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get products summary error:', error)
|
console.error("Get products summary error:", error);
|
||||||
return c.json({ error: 'Failed to fetch products summary' }, 500)
|
return res.status(500).json({ error: "Failed to fetch products summary" });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { Hono } from 'hono'
|
import { Router } from "express";
|
||||||
import { getAllProductsSummary } from '@/src/apis/common-apis/apis/common-product.controller'
|
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||||
|
|
||||||
const app = new Hono()
|
const router = Router();
|
||||||
|
|
||||||
// GET /summary - Get all products summary
|
router.get("/summary", getAllProductsSummary);
|
||||||
app.get('/summary', getAllProductsSummary)
|
|
||||||
|
|
||||||
export default app
|
|
||||||
|
const commonProductsRouter= router;
|
||||||
|
export default commonProductsRouter;
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { Hono } from 'hono'
|
import { Router } from "express";
|
||||||
import commonProductsRouter from '@/src/apis/common-apis/apis/common-product.router'
|
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
||||||
|
|
||||||
const app = new Hono()
|
const router = Router();
|
||||||
|
|
||||||
// Mount product routes at /products
|
router.use('/products', commonProductsRouter)
|
||||||
app.route('/products', commonProductsRouter)
|
|
||||||
|
|
||||||
export default app
|
const commonRouter = router;
|
||||||
|
|
||||||
|
export default commonRouter;
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { getProductsSummaryData as getProductsSummaryDataPostgres } from '@db-helper-postgres/apis/common-apis/common-product'
|
|
||||||
import { getProductsSummaryData as getProductsSummaryDataSqlite } from '@db-helper-sqlite/apis/common-apis/common-product'
|
|
||||||
|
|
||||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
|
||||||
|
|
||||||
const getProductsSummaryData = dialect === 'sqlite'
|
|
||||||
? getProductsSummaryDataSqlite
|
|
||||||
: getProductsSummaryDataPostgres
|
|
||||||
|
|
||||||
export { getProductsSummaryData }
|
|
||||||
14
apps/backend/src/db/db_index.ts
Normal file → Executable file
14
apps/backend/src/db/db_index.ts
Normal file → Executable file
|
|
@ -1,10 +1,8 @@
|
||||||
import { db as postgresDb } from '@db-helper-postgres/db/db_index'
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
import { db as sqliteDb } from '@db-helper-sqlite/db/db_index'
|
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||||
|
import path from "path"
|
||||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
import * as schema from "@/src/db/schema"
|
||||||
|
|
||||||
type Db = typeof DB_DIALECT_TYPE extends 'sqlite' ? typeof sqliteDb : typeof postgresDb
|
|
||||||
|
|
||||||
const db = (dialect === 'sqlite' ? sqliteDb : postgresDb) as Db
|
|
||||||
|
|
||||||
|
const db = drizzle({ connection: process.env.DATABASE_URL!, casing: "snake_case", schema: schema })
|
||||||
|
// const db = drizzle('postgresql://postgres:postgres@localhost:2345/pooler');
|
||||||
export { db }
|
export { db }
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
* This was a one time script to change the composition of the signed urls
|
* This was a one time script to change the composition of the signed urls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from './db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import {
|
import {
|
||||||
userDetails,
|
userDetails,
|
||||||
productInfo,
|
productInfo,
|
||||||
productTagInfo,
|
productTagInfo,
|
||||||
complaints
|
complaints
|
||||||
} from './schema';
|
} from '@/src/db/schema';
|
||||||
import { eq, not, isNull } from 'drizzle-orm';
|
import { eq, not, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
|
const S3_DOMAIN = 'https://s3.sgp.io.cloud.ovh.net';
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '@/db-helper-postgres/db/schema'
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '@/db-helper-sqlite/db/schema'
|
|
||||||
690
apps/backend/src/db/schema.ts
Normal file → Executable file
690
apps/backend/src/db/schema.ts
Normal file → Executable file
|
|
@ -1 +1,689 @@
|
||||||
export * from './schema-sqlite'
|
import { pgTable, pgSchema, integer, varchar, date, boolean, timestamp, numeric, jsonb, pgEnum, unique, real, text, check, decimal } from "drizzle-orm/pg-core";
|
||||||
|
import { relations, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const mf = pgSchema('mf');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const users = mf.table('users', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }),
|
||||||
|
email: varchar({ length: 255 }),
|
||||||
|
mobile: varchar({ length: 255 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_email: unique('unique_email').on(t.email),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userDetails = mf.table('user_details', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id).unique(),
|
||||||
|
bio: varchar('bio', { length: 500 }),
|
||||||
|
dateOfBirth: date('date_of_birth'),
|
||||||
|
gender: varchar('gender', { length: 20 }),
|
||||||
|
occupation: varchar('occupation', { length: 100 }),
|
||||||
|
profileImage: varchar('profile_image', { length: 500 }),
|
||||||
|
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userCreds = mf.table('user_creds', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
userPassword: varchar('user_password', { length: 255 }).notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addresses = mf.table('addresses', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
phone: varchar('phone', { length: 15 }).notNull(),
|
||||||
|
addressLine1: varchar('address_line1', { length: 255 }).notNull(),
|
||||||
|
addressLine2: varchar('address_line2', { length: 255 }),
|
||||||
|
city: varchar('city', { length: 100 }).notNull(),
|
||||||
|
state: varchar('state', { length: 100 }).notNull(),
|
||||||
|
pincode: varchar('pincode', { length: 10 }).notNull(),
|
||||||
|
isDefault: boolean('is_default').notNull().default(false),
|
||||||
|
latitude: real('latitude'),
|
||||||
|
longitude: real('longitude'),
|
||||||
|
googleMapsUrl: varchar('google_maps_url', { length: 500 }),
|
||||||
|
adminLatitude: real('admin_latitude'),
|
||||||
|
adminLongitude: real('admin_longitude'),
|
||||||
|
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addressZones = mf.table('address_zones', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
zoneName: varchar('zone_name', { length: 255 }).notNull(),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addressAreas = mf.table('address_areas', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
placeName: varchar('place_name', { length: 255 }).notNull(),
|
||||||
|
zoneId: integer('zone_id').references(() => addressZones.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffUsers = mf.table('staff_users', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
password: varchar({ length: 255 }).notNull(),
|
||||||
|
staffRoleId: integer('staff_role_id').references(() => staffRoles.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const storeInfo = mf.table('store_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
description: varchar({ length: 500 }),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
owner: integer('owner').notNull().references(() => staffUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const units = mf.table('units', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
shortNotation: varchar('short_notation', { length: 50 }).notNull(),
|
||||||
|
fullName: varchar('full_name', { length: 100 }).notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_short_notation: unique('unique_short_notation').on(t.shortNotation),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productInfo = mf.table('product_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
shortDescription: varchar('short_description', { length: 500 }),
|
||||||
|
longDescription: varchar('long_description', { length: 1000 }),
|
||||||
|
unitId: integer('unit_id').notNull().references(() => units.id),
|
||||||
|
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||||
|
marketPrice: numeric('market_price', { precision: 10, scale: 2 }),
|
||||||
|
images: jsonb('images'),
|
||||||
|
isOutOfStock: boolean('is_out_of_stock').notNull().default(false),
|
||||||
|
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||||
|
isFlashAvailable: boolean('is_flash_available').notNull().default(false),
|
||||||
|
flashPrice: numeric('flash_price', { precision: 10, scale: 2 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
incrementStep: real('increment_step').notNull().default(1),
|
||||||
|
productQuantity: real('product_quantity').notNull().default(1),
|
||||||
|
storeId: integer('store_id').references(() => storeInfo.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productGroupInfo = mf.table('product_group_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
groupName: varchar('group_name', { length: 255 }).notNull(),
|
||||||
|
description: varchar({ length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productGroupMembership = mf.table('product_group_membership', {
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
groupId: integer('group_id').notNull().references(() => productGroupInfo.id),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
pk: unique('product_group_membership_pk').on(t.productId, t.groupId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const homeBanners = mf.table('home_banners', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }).notNull(),
|
||||||
|
description: varchar('description', { length: 500 }),
|
||||||
|
productIds: integer('product_ids').array(),
|
||||||
|
redirectUrl: varchar('redirect_url', { length: 500 }),
|
||||||
|
serialNum: integer('serial_num'),
|
||||||
|
isActive: boolean('is_active').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
lastUpdated: timestamp('last_updated').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productReviews = mf.table('product_reviews', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
reviewBody: text('review_body').notNull(),
|
||||||
|
imageUrls: jsonb('image_urls').$defaultFn(() => []),
|
||||||
|
reviewTime: timestamp('review_time').notNull().defaultNow(),
|
||||||
|
ratings: real('ratings').notNull(),
|
||||||
|
adminResponse: text('admin_response'),
|
||||||
|
adminResponseImages: jsonb('admin_response_images').$defaultFn(() => []),
|
||||||
|
}, (t) => ({
|
||||||
|
ratingCheck: check('rating_check', sql`${t.ratings} >= 1 AND ${t.ratings} <= 5`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const uploadStatusEnum = pgEnum('upload_status', ['pending', 'claimed']);
|
||||||
|
|
||||||
|
export const staffRoleEnum = pgEnum('staff_role', ['super_admin', 'admin', 'marketer', 'delivery_staff']);
|
||||||
|
|
||||||
|
export const staffPermissionEnum = pgEnum('staff_permission', ['crud_product', 'make_coupon', 'crud_staff_users']);
|
||||||
|
|
||||||
|
export const uploadUrlStatus = mf.table('upload_url_status', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
key: varchar('key', { length: 500 }).notNull(),
|
||||||
|
status: uploadStatusEnum('status').notNull().default('pending'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productTagInfo = mf.table('product_tag_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
tagName: varchar('tag_name', { length: 100 }).notNull().unique(),
|
||||||
|
tagDescription: varchar('tag_description', { length: 500 }),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }),
|
||||||
|
isDashboardTag: boolean('is_dashboard_tag').notNull().default(false),
|
||||||
|
relatedStores: jsonb('related_stores').$defaultFn(() => []),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productTags = mf.table('product_tags', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
tagId: integer('tag_id').notNull().references(() => productTagInfo.id),
|
||||||
|
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_product_tag: unique('unique_product_tag').on(t.productId, t.tagId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deliverySlotInfo = mf.table('delivery_slot_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
deliveryTime: timestamp('delivery_time').notNull(),
|
||||||
|
freezeTime: timestamp('freeze_time').notNull(),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
isFlash: boolean('is_flash').notNull().default(false),
|
||||||
|
isCapacityFull: boolean('is_capacity_full').notNull().default(false),
|
||||||
|
deliverySequence: jsonb('delivery_sequence').$defaultFn(() => {}),
|
||||||
|
groupIds: jsonb('group_ids').$defaultFn(() => []),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const vendorSnippets = mf.table('vendor_snippets', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
snippetCode: varchar('snippet_code', { length: 255 }).notNull().unique(),
|
||||||
|
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||||
|
isPermanent: boolean('is_permanent').notNull().default(false),
|
||||||
|
productIds: integer('product_ids').array().notNull(),
|
||||||
|
validTill: timestamp('valid_till'),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const vendorSnippetsRelations = relations(vendorSnippets, ({ one }) => ({
|
||||||
|
slot: one(deliverySlotInfo, { fields: [vendorSnippets.slotId], references: [deliverySlotInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productSlots = mf.table('product_slots', {
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
slotId: integer('slot_id').notNull().references(() => deliverySlotInfo.id),
|
||||||
|
}, (t) => ({
|
||||||
|
pk: unique('product_slot_pk').on(t.productId, t.slotId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const specialDeals = mf.table('special_deals', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||||
|
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||||
|
validTill: timestamp('valid_till').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const orders = mf.table('orders', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
addressId: integer('address_id').notNull().references(() => addresses.id),
|
||||||
|
slotId: integer('slot_id').references(() => deliverySlotInfo.id),
|
||||||
|
isCod: boolean('is_cod').notNull().default(false),
|
||||||
|
isOnlinePayment: boolean('is_online_payment').notNull().default(false),
|
||||||
|
paymentInfoId: integer('payment_info_id').references(() => paymentInfoTable.id),
|
||||||
|
totalAmount: numeric('total_amount', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
deliveryCharge: numeric('delivery_charge', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||||
|
readableId: integer('readable_id').notNull(),
|
||||||
|
adminNotes: text('admin_notes'),
|
||||||
|
userNotes: text('user_notes'),
|
||||||
|
orderGroupId: varchar('order_group_id', { length: 255 }),
|
||||||
|
orderGroupProportion: decimal('order_group_proportion', { precision: 10, scale: 4 }),
|
||||||
|
isFlashDelivery: boolean('is_flash_delivery').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const orderItems = mf.table('order_items', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
quantity: varchar('quantity', { length: 50 }).notNull(),
|
||||||
|
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||||
|
discountedPrice: numeric('discounted_price', { precision: 10, scale: 2 }),
|
||||||
|
is_packaged: boolean('is_packaged').notNull().default(false),
|
||||||
|
is_package_verified: boolean('is_package_verified').notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'success', 'cod', 'failed']);
|
||||||
|
|
||||||
|
export const orderStatus = mf.table('order_status', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
orderTime: timestamp('order_time').notNull().defaultNow(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||||
|
isPackaged: boolean('is_packaged').notNull().default(false),
|
||||||
|
isDelivered: boolean('is_delivered').notNull().default(false),
|
||||||
|
isCancelled: boolean('is_cancelled').notNull().default(false),
|
||||||
|
cancelReason: varchar('cancel_reason', { length: 255 }),
|
||||||
|
isCancelledByAdmin: boolean('is_cancelled_by_admin'),
|
||||||
|
paymentStatus: paymentStatusEnum('payment_state').notNull().default('pending'),
|
||||||
|
cancellationUserNotes: text('cancellation_user_notes'),
|
||||||
|
cancellationAdminNotes: text('cancellation_admin_notes'),
|
||||||
|
cancellationReviewed: boolean('cancellation_reviewed').notNull().default(false),
|
||||||
|
cancellationReviewedAt: timestamp('cancellation_reviewed_at'),
|
||||||
|
refundCouponId: integer('refund_coupon_id').references(() => coupons.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const paymentInfoTable = mf.table('payment_info', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
status: varchar({ length: 50 }).notNull(),
|
||||||
|
gateway: varchar({ length: 50 }).notNull(),
|
||||||
|
orderId: varchar('order_id', { length: 500 }),
|
||||||
|
token: varchar({ length: 500 }),
|
||||||
|
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||||
|
payload: jsonb('payload'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const payments = mf.table('payments', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
status: varchar({ length: 50 }).notNull(),
|
||||||
|
gateway: varchar({ length: 50 }).notNull(),
|
||||||
|
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||||
|
token: varchar({ length: 500 }),
|
||||||
|
merchantOrderId: varchar('merchant_order_id', { length: 255 }).notNull().unique(),
|
||||||
|
payload: jsonb('payload'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refunds = mf.table('refunds', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
orderId: integer('order_id').notNull().references(() => orders.id),
|
||||||
|
refundAmount: numeric('refund_amount', { precision: 10, scale: 2 }),
|
||||||
|
refundStatus: varchar('refund_status', { length: 50 }).default('none'),
|
||||||
|
merchantRefundId: varchar('merchant_refund_id', { length: 255 }),
|
||||||
|
refundProcessedAt: timestamp('refund_processed_at'),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const keyValStore = mf.table('key_val_store', {
|
||||||
|
key: varchar('key', { length: 255 }).primaryKey(),
|
||||||
|
value: jsonb('value'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notifications = mf.table('notifications', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
title: varchar({ length: 255 }).notNull(),
|
||||||
|
body: varchar({ length: 512 }).notNull(),
|
||||||
|
type: varchar({ length: 50 }),
|
||||||
|
isRead: boolean('is_read').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const productCategories = mf.table('product_categories', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
description: varchar({ length: 500 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cartItems = mf.table('cart_items', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
quantity: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_user_product: unique('unique_user_product').on(t.userId, t.productId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const complaints = mf.table('complaints', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
orderId: integer('order_id').references(() => orders.id),
|
||||||
|
complaintBody: varchar('complaint_body', { length: 1000 }).notNull(),
|
||||||
|
images: jsonb('images'),
|
||||||
|
response: varchar('response', { length: 1000 }),
|
||||||
|
isResolved: boolean('is_resolved').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const coupons = mf.table('coupons', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
couponCode: varchar('coupon_code', { length: 50 }).notNull().unique('unique_coupon_code'),
|
||||||
|
isUserBased: boolean('is_user_based').notNull().default(false),
|
||||||
|
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||||
|
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||||
|
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||||
|
productIds: jsonb('product_ids'),
|
||||||
|
createdBy: integer('created_by').references(() => staffUsers.id),
|
||||||
|
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||||
|
isApplyForAll: boolean('is_apply_for_all').notNull().default(false),
|
||||||
|
validTill: timestamp('valid_till'),
|
||||||
|
maxLimitForUser: integer('max_limit_for_user'),
|
||||||
|
isInvalidated: boolean('is_invalidated').notNull().default(false),
|
||||||
|
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const couponUsage = mf.table('coupon_usage', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||||
|
orderId: integer('order_id').references(() => orders.id),
|
||||||
|
orderItemId: integer('order_item_id').references(() => orderItems.id),
|
||||||
|
usedAt: timestamp('used_at').notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const couponApplicableUsers = mf.table('coupon_applicable_users', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_coupon_user: unique('unique_coupon_user').on(t.couponId, t.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const couponApplicableProducts = mf.table('coupon_applicable_products', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
couponId: integer('coupon_id').notNull().references(() => coupons.id),
|
||||||
|
productId: integer('product_id').notNull().references(() => productInfo.id),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_coupon_product: unique('unique_coupon_product').on(t.couponId, t.productId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userIncidents = mf.table('user_incidents', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
orderId: integer('order_id').references(() => orders.id),
|
||||||
|
dateAdded: timestamp('date_added').notNull().defaultNow(),
|
||||||
|
adminComment: text('admin_comment'),
|
||||||
|
addedBy: integer('added_by').references(() => staffUsers.id),
|
||||||
|
negativityScore: integer('negativity_score'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reservedCoupons = mf.table('reserved_coupons', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
secretCode: varchar('secret_code', { length: 50 }).notNull().unique(),
|
||||||
|
couponCode: varchar('coupon_code', { length: 50 }).notNull(),
|
||||||
|
discountPercent: numeric('discount_percent', { precision: 5, scale: 2 }),
|
||||||
|
flatDiscount: numeric('flat_discount', { precision: 10, scale: 2 }),
|
||||||
|
minOrder: numeric('min_order', { precision: 10, scale: 2 }),
|
||||||
|
productIds: jsonb('product_ids'),
|
||||||
|
maxValue: numeric('max_value', { precision: 10, scale: 2 }),
|
||||||
|
validTill: timestamp('valid_till'),
|
||||||
|
maxLimitForUser: integer('max_limit_for_user'),
|
||||||
|
exclusiveApply: boolean('exclusive_apply').notNull().default(false),
|
||||||
|
isRedeemed: boolean('is_redeemed').notNull().default(false),
|
||||||
|
redeemedBy: integer('redeemed_by').references(() => users.id),
|
||||||
|
redeemedAt: timestamp('redeemed_at'),
|
||||||
|
createdBy: integer('created_by').notNull().references(() => staffUsers.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_secret_code: unique('unique_secret_code').on(t.secretCode),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notifCreds = mf.table('notif_creds', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
token: varchar({ length: 500 }).notNull().unique(),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
userId: integer('user_id').notNull().references(() => users.id),
|
||||||
|
lastVerified: timestamp('last_verified'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unloggedUserTokens = mf.table('unlogged_user_tokens', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
token: varchar({ length: 500 }).notNull().unique(),
|
||||||
|
addedAt: timestamp('added_at').notNull().defaultNow(),
|
||||||
|
lastVerified: timestamp('last_verified'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userNotifications = mf.table('user_notifications', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
|
imageUrl: varchar('image_url', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
applicableUsers: jsonb('applicable_users'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const staffRoles = mf.table('staff_roles', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
roleName: staffRoleEnum('role_name').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_role_name: unique('unique_role_name').on(t.roleName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffPermissions = mf.table('staff_permissions', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
permissionName: staffPermissionEnum('permission_name').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_permission_name: unique('unique_permission_name').on(t.permissionName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffRolePermissions = mf.table('staff_role_permissions', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
staffRoleId: integer('staff_role_id').notNull().references(() => staffRoles.id),
|
||||||
|
staffPermissionId: integer('staff_permission_id').notNull().references(() => staffPermissions.id),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
}, (t) => ({
|
||||||
|
unq_role_permission: unique('unique_role_permission').on(t.staffRoleId, t.staffPermissionId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
export const usersRelations = relations(users, ({ many, one }) => ({
|
||||||
|
addresses: many(addresses),
|
||||||
|
orders: many(orders),
|
||||||
|
notifications: many(notifications),
|
||||||
|
cartItems: many(cartItems),
|
||||||
|
userCreds: one(userCreds),
|
||||||
|
coupons: many(coupons),
|
||||||
|
couponUsages: many(couponUsage),
|
||||||
|
applicableCoupons: many(couponApplicableUsers),
|
||||||
|
userDetails: one(userDetails),
|
||||||
|
notifCreds: many(notifCreds),
|
||||||
|
userIncidents: many(userIncidents),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userCredsRelations = relations(userCreds, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [userCreds.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffUsersRelations = relations(staffUsers, ({ one, many }) => ({
|
||||||
|
role: one(staffRoles, { fields: [staffUsers.staffRoleId], references: [staffRoles.id] }),
|
||||||
|
coupons: many(coupons),
|
||||||
|
stores: many(storeInfo),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const addressesRelations = relations(addresses, ({ one, many }) => ({
|
||||||
|
user: one(users, { fields: [addresses.userId], references: [users.id] }),
|
||||||
|
orders: many(orders),
|
||||||
|
zone: one(addressZones, { fields: [addresses.zoneId], references: [addressZones.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const unitsRelations = relations(units, ({ many }) => ({
|
||||||
|
products: many(productInfo),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productInfoRelations = relations(productInfo, ({ one, many }) => ({
|
||||||
|
unit: one(units, { fields: [productInfo.unitId], references: [units.id] }),
|
||||||
|
store: one(storeInfo, { fields: [productInfo.storeId], references: [storeInfo.id] }),
|
||||||
|
productSlots: many(productSlots),
|
||||||
|
specialDeals: many(specialDeals),
|
||||||
|
orderItems: many(orderItems),
|
||||||
|
cartItems: many(cartItems),
|
||||||
|
tags: many(productTags),
|
||||||
|
applicableCoupons: many(couponApplicableProducts),
|
||||||
|
reviews: many(productReviews),
|
||||||
|
groups: many(productGroupMembership),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({
|
||||||
|
products: many(productTags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productTagsRelations = relations(productTags, ({ one }) => ({
|
||||||
|
product: one(productInfo, { fields: [productTags.productId], references: [productInfo.id] }),
|
||||||
|
tag: one(productTagInfo, { fields: [productTags.tagId], references: [productTagInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deliverySlotInfoRelations = relations(deliverySlotInfo, ({ many }) => ({
|
||||||
|
productSlots: many(productSlots),
|
||||||
|
orders: many(orders),
|
||||||
|
vendorSnippets: many(vendorSnippets),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productSlotsRelations = relations(productSlots, ({ one }) => ({
|
||||||
|
product: one(productInfo, { fields: [productSlots.productId], references: [productInfo.id] }),
|
||||||
|
slot: one(deliverySlotInfo, { fields: [productSlots.slotId], references: [deliverySlotInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const specialDealsRelations = relations(specialDeals, ({ one }) => ({
|
||||||
|
product: one(productInfo, { fields: [specialDeals.productId], references: [productInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||||
|
user: one(users, { fields: [orders.userId], references: [users.id] }),
|
||||||
|
address: one(addresses, { fields: [orders.addressId], references: [addresses.id] }),
|
||||||
|
slot: one(deliverySlotInfo, { fields: [orders.slotId], references: [deliverySlotInfo.id] }),
|
||||||
|
orderItems: many(orderItems),
|
||||||
|
payment: one(payments),
|
||||||
|
paymentInfo: one(paymentInfoTable, { fields: [orders.paymentInfoId], references: [paymentInfoTable.id] }),
|
||||||
|
orderStatus: many(orderStatus),
|
||||||
|
refunds: many(refunds),
|
||||||
|
couponUsages: many(couponUsage),
|
||||||
|
userIncidents: many(userIncidents),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
|
||||||
|
product: one(productInfo, { fields: [orderItems.productId], references: [productInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const orderStatusRelations = relations(orderStatus, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [orderStatus.orderId], references: [orders.id] }),
|
||||||
|
user: one(users, { fields: [orderStatus.userId], references: [users.id] }),
|
||||||
|
refundCoupon: one(coupons, { fields: [orderStatus.refundCouponId], references: [coupons.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const paymentInfoRelations = relations(paymentInfoTable, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [paymentInfoTable.id], references: [orders.paymentInfoId] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [payments.orderId], references: [orders.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const refundsRelations = relations(refunds, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [refunds.orderId], references: [orders.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [notifications.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productCategoriesRelations = relations(productCategories, ({}) => ({}));
|
||||||
|
|
||||||
|
export const cartItemsRelations = relations(cartItems, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [cartItems.userId], references: [users.id] }),
|
||||||
|
product: one(productInfo, { fields: [cartItems.productId], references: [productInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const complaintsRelations = relations(complaints, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [complaints.userId], references: [users.id] }),
|
||||||
|
order: one(orders, { fields: [complaints.orderId], references: [orders.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const couponsRelations = relations(coupons, ({ one, many }) => ({
|
||||||
|
creator: one(staffUsers, { fields: [coupons.createdBy], references: [staffUsers.id] }),
|
||||||
|
usages: many(couponUsage),
|
||||||
|
applicableUsers: many(couponApplicableUsers),
|
||||||
|
applicableProducts: many(couponApplicableProducts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const couponUsageRelations = relations(couponUsage, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [couponUsage.userId], references: [users.id] }),
|
||||||
|
coupon: one(coupons, { fields: [couponUsage.couponId], references: [coupons.id] }),
|
||||||
|
order: one(orders, { fields: [couponUsage.orderId], references: [orders.id] }),
|
||||||
|
orderItem: one(orderItems, { fields: [couponUsage.orderItemId], references: [orderItems.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userDetailsRelations = relations(userDetails, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [userDetails.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const notifCredsRelations = relations(notifCreds, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [notifCreds.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userNotificationsRelations = relations(userNotifications, ({}) => ({
|
||||||
|
// No relations needed for now
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const storeInfoRelations = relations(storeInfo, ({ one, many }) => ({
|
||||||
|
owner: one(staffUsers, { fields: [storeInfo.owner], references: [staffUsers.id] }),
|
||||||
|
products: many(productInfo),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const couponApplicableUsersRelations = relations(couponApplicableUsers, ({ one }) => ({
|
||||||
|
coupon: one(coupons, { fields: [couponApplicableUsers.couponId], references: [coupons.id] }),
|
||||||
|
user: one(users, { fields: [couponApplicableUsers.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const couponApplicableProductsRelations = relations(couponApplicableProducts, ({ one }) => ({
|
||||||
|
coupon: one(coupons, { fields: [couponApplicableProducts.couponId], references: [coupons.id] }),
|
||||||
|
product: one(productInfo, { fields: [couponApplicableProducts.productId], references: [productInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const reservedCouponsRelations = relations(reservedCoupons, ({ one }) => ({
|
||||||
|
redeemedUser: one(users, { fields: [reservedCoupons.redeemedBy], references: [users.id] }),
|
||||||
|
creator: one(staffUsers, { fields: [reservedCoupons.createdBy], references: [staffUsers.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productReviewsRelations = relations(productReviews, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [productReviews.userId], references: [users.id] }),
|
||||||
|
product: one(productInfo, { fields: [productReviews.productId], references: [productInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const addressZonesRelations = relations(addressZones, ({ many }) => ({
|
||||||
|
addresses: many(addresses),
|
||||||
|
areas: many(addressAreas),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const addressAreasRelations = relations(addressAreas, ({ one }) => ({
|
||||||
|
zone: one(addressZones, { fields: [addressAreas.zoneId], references: [addressZones.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productGroupInfoRelations = relations(productGroupInfo, ({ many }) => ({
|
||||||
|
memberships: many(productGroupMembership),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const productGroupMembershipRelations = relations(productGroupMembership, ({ one }) => ({
|
||||||
|
product: one(productInfo, { fields: [productGroupMembership.productId], references: [productInfo.id] }),
|
||||||
|
group: one(productGroupInfo, { fields: [productGroupMembership.groupId], references: [productGroupInfo.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const homeBannersRelations = relations(homeBanners, ({}) => ({
|
||||||
|
// Relations for productIds array would be more complex, skipping for now
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffRolesRelations = relations(staffRoles, ({ many }) => ({
|
||||||
|
staffUsers: many(staffUsers),
|
||||||
|
rolePermissions: many(staffRolePermissions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffPermissionsRelations = relations(staffPermissions, ({ many }) => ({
|
||||||
|
rolePermissions: many(staffRolePermissions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const staffRolePermissionsRelations = relations(staffRolePermissions, ({ one }) => ({
|
||||||
|
role: one(staffRoles, { fields: [staffRolePermissions.staffRoleId], references: [staffRoles.id] }),
|
||||||
|
permission: one(staffPermissions, { fields: [staffRolePermissions.staffPermissionId], references: [staffPermissions.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userIncidentsRelations = relations(userIncidents, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [userIncidents.userId], references: [users.id] }),
|
||||||
|
order: one(orders, { fields: [userIncidents.orderId], references: [orders.id] }),
|
||||||
|
addedBy: one(staffUsers, { fields: [userIncidents.addedBy], references: [staffUsers.id] }),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,138 @@
|
||||||
import { seed as seedPostgres } from '@db-helper-postgres/db/seed'
|
import { db } from "@/src/db/db_index"
|
||||||
import { seed as seedSqlite } from '@db-helper-sqlite/db/seed'
|
import { units, productInfo, deliverySlotInfo, productSlots, keyValStore, staffRoles, staffPermissions, staffRolePermissions } from "@/src/db/schema"
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { minOrderValue, deliveryCharge } from '@/src/lib/env-exporter'
|
||||||
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
|
|
||||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
export async function seed() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
const seedImpl = dialect === 'sqlite' ? seedSqlite : seedPostgres
|
// Seed units individually
|
||||||
|
const unitsToSeed = [
|
||||||
|
{ shortNotation: "Kg", fullName: "Kilogram" },
|
||||||
|
{ shortNotation: "L", fullName: "Litre" },
|
||||||
|
{ shortNotation: "Dz", fullName: "Dozen" },
|
||||||
|
{ shortNotation: "Pc", fullName: "Unit Piece" },
|
||||||
|
];
|
||||||
|
|
||||||
export const seed = async () => seedImpl()
|
for (const unit of unitsToSeed) {
|
||||||
|
const existingUnit = await db.query.units.findFirst({
|
||||||
|
where: eq(units.shortNotation, unit.shortNotation),
|
||||||
|
});
|
||||||
|
if (!existingUnit) {
|
||||||
|
await db.insert(units).values(unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed staff roles individually
|
||||||
|
const rolesToSeed = ['super_admin', 'admin', 'marketer', 'delivery_staff'] as const;
|
||||||
|
|
||||||
|
for (const roleName of rolesToSeed) {
|
||||||
|
const existingRole = await db.query.staffRoles.findFirst({
|
||||||
|
where: eq(staffRoles.roleName, roleName),
|
||||||
|
});
|
||||||
|
if (!existingRole) {
|
||||||
|
await db.insert(staffRoles).values({ roleName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed staff permissions individually
|
||||||
|
const permissionsToSeed = ['crud_product', 'make_coupon', 'crud_staff_users'] as const;
|
||||||
|
|
||||||
|
for (const permissionName of permissionsToSeed) {
|
||||||
|
const existingPermission = await db.query.staffPermissions.findFirst({
|
||||||
|
where: eq(staffPermissions.permissionName, permissionName),
|
||||||
|
});
|
||||||
|
if (!existingPermission) {
|
||||||
|
await db.insert(staffPermissions).values({ permissionName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed role-permission assignments
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Get role IDs
|
||||||
|
const superAdminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'super_admin') });
|
||||||
|
const adminRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'admin') });
|
||||||
|
const marketerRole = await tx.query.staffRoles.findFirst({ where: eq(staffRoles.roleName, 'marketer') });
|
||||||
|
|
||||||
|
// Get permission IDs
|
||||||
|
const crudProductPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_product') });
|
||||||
|
const makeCouponPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'make_coupon') });
|
||||||
|
const crudStaffUsersPerm = await tx.query.staffPermissions.findFirst({ where: eq(staffPermissions.permissionName, 'crud_staff_users') });
|
||||||
|
|
||||||
|
// Assign all permissions to super_admin
|
||||||
|
[crudProductPerm, makeCouponPerm, crudStaffUsersPerm].forEach(async (perm) => {
|
||||||
|
if (superAdminRole && perm) {
|
||||||
|
const existingSuperAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||||
|
where: eq(staffRolePermissions.staffRoleId, superAdminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||||
|
});
|
||||||
|
if (!existingSuperAdminPerm) {
|
||||||
|
await tx.insert(staffRolePermissions).values({
|
||||||
|
staffRoleId: superAdminRole.id,
|
||||||
|
staffPermissionId: perm.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign all permissions to admin
|
||||||
|
[crudProductPerm, makeCouponPerm].forEach(async (perm) => {
|
||||||
|
if (adminRole && perm) {
|
||||||
|
const existingAdminPerm = await tx.query.staffRolePermissions.findFirst({
|
||||||
|
where: eq(staffRolePermissions.staffRoleId, adminRole.id) && eq(staffRolePermissions.staffPermissionId, perm.id),
|
||||||
|
});
|
||||||
|
if (!existingAdminPerm) {
|
||||||
|
await tx.insert(staffRolePermissions).values({
|
||||||
|
staffRoleId: adminRole.id,
|
||||||
|
staffPermissionId: perm.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign make_coupon to marketer
|
||||||
|
if (marketerRole && makeCouponPerm) {
|
||||||
|
const existingMarketerCoupon = await tx.query.staffRolePermissions.findFirst({
|
||||||
|
where: eq(staffRolePermissions.staffRoleId, marketerRole.id) && eq(staffRolePermissions.staffPermissionId, makeCouponPerm.id),
|
||||||
|
});
|
||||||
|
if (!existingMarketerCoupon) {
|
||||||
|
await tx.insert(staffRolePermissions).values({
|
||||||
|
staffRoleId: marketerRole.id,
|
||||||
|
staffPermissionId: makeCouponPerm.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed key-val store constants using CONST_KEYS
|
||||||
|
const constantsToSeed = [
|
||||||
|
{ key: CONST_KEYS.readableOrderId, value: 0 },
|
||||||
|
{ key: CONST_KEYS.minRegularOrderValue, value: minOrderValue },
|
||||||
|
{ key: CONST_KEYS.freeDeliveryThreshold, value: minOrderValue },
|
||||||
|
{ key: CONST_KEYS.deliveryCharge, value: deliveryCharge },
|
||||||
|
{ key: CONST_KEYS.flashFreeDeliveryThreshold, value: 500 },
|
||||||
|
{ key: CONST_KEYS.flashDeliveryCharge, value: 69 },
|
||||||
|
{ key: CONST_KEYS.popularItems, value: [] },
|
||||||
|
{ key: CONST_KEYS.allItemsOrder, value: [] },
|
||||||
|
{ key: CONST_KEYS.versionNum, value: '1.1.0' },
|
||||||
|
{ key: CONST_KEYS.playStoreUrl, value: 'https://play.google.com/store/apps/details?id=in.freshyo.app' },
|
||||||
|
{ key: CONST_KEYS.appStoreUrl, value: 'https://apps.apple.com/in/app/freshyo/id6756889077' },
|
||||||
|
{ key: CONST_KEYS.isFlashDeliveryEnabled, value: false },
|
||||||
|
{ key: CONST_KEYS.supportMobile, value: '8688182552' },
|
||||||
|
{ key: CONST_KEYS.supportEmail, value: 'qushammohd@gmail.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const constant of constantsToSeed) {
|
||||||
|
const existing = await db.query.keyValStore.findFirst({
|
||||||
|
where: eq(keyValStore.key, constant.key),
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(keyValStore).values({
|
||||||
|
key: constant.key,
|
||||||
|
value: constant.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seeding completed.");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from '@/db-helper-sqlite/db/sqlite-casts'
|
|
||||||
|
|
@ -1,58 +1,47 @@
|
||||||
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
User as PostgresUser,
|
users,
|
||||||
Address as PostgresAddress,
|
addresses,
|
||||||
Unit as PostgresUnit,
|
units,
|
||||||
ProductInfo as PostgresProductInfo,
|
productInfo,
|
||||||
DeliverySlotInfo as PostgresDeliverySlotInfo,
|
deliverySlotInfo,
|
||||||
ProductSlot as PostgresProductSlot,
|
productSlots,
|
||||||
SpecialDeal as PostgresSpecialDeal,
|
specialDeals,
|
||||||
Order as PostgresOrder,
|
orders,
|
||||||
OrderItem as PostgresOrderItem,
|
orderItems,
|
||||||
Payment as PostgresPayment,
|
payments,
|
||||||
Notification as PostgresNotification,
|
notifications,
|
||||||
ProductCategory as PostgresProductCategory,
|
productCategories,
|
||||||
CartItem as PostgresCartItem,
|
cartItems,
|
||||||
Coupon as PostgresCoupon,
|
coupons,
|
||||||
ProductWithUnit as PostgresProductWithUnit,
|
} from "@/src/db/schema";
|
||||||
OrderWithItems as PostgresOrderWithItems,
|
|
||||||
CartItemWithProduct as PostgresCartItemWithProduct,
|
|
||||||
} from '@db-helper-postgres/db/types'
|
|
||||||
import type {
|
|
||||||
User as SqliteUser,
|
|
||||||
Address as SqliteAddress,
|
|
||||||
Unit as SqliteUnit,
|
|
||||||
ProductInfo as SqliteProductInfo,
|
|
||||||
DeliverySlotInfo as SqliteDeliverySlotInfo,
|
|
||||||
ProductSlot as SqliteProductSlot,
|
|
||||||
SpecialDeal as SqliteSpecialDeal,
|
|
||||||
Order as SqliteOrder,
|
|
||||||
OrderItem as SqliteOrderItem,
|
|
||||||
Payment as SqlitePayment,
|
|
||||||
Notification as SqliteNotification,
|
|
||||||
ProductCategory as SqliteProductCategory,
|
|
||||||
CartItem as SqliteCartItem,
|
|
||||||
Coupon as SqliteCoupon,
|
|
||||||
ProductWithUnit as SqliteProductWithUnit,
|
|
||||||
OrderWithItems as SqliteOrderWithItems,
|
|
||||||
CartItemWithProduct as SqliteCartItemWithProduct,
|
|
||||||
} from '@db-helper-sqlite/db/types'
|
|
||||||
|
|
||||||
type UseSqlite = typeof DB_DIALECT_TYPE extends 'sqlite' ? true : false
|
export type User = InferSelectModel<typeof users>;
|
||||||
|
export type Address = InferSelectModel<typeof addresses>;
|
||||||
|
export type Unit = InferSelectModel<typeof units>;
|
||||||
|
export type ProductInfo = InferSelectModel<typeof productInfo>;
|
||||||
|
export type DeliverySlotInfo = InferSelectModel<typeof deliverySlotInfo>;
|
||||||
|
export type ProductSlot = InferSelectModel<typeof productSlots>;
|
||||||
|
export type SpecialDeal = InferSelectModel<typeof specialDeals>;
|
||||||
|
export type Order = InferSelectModel<typeof orders>;
|
||||||
|
export type OrderItem = InferSelectModel<typeof orderItems>;
|
||||||
|
export type Payment = InferSelectModel<typeof payments>;
|
||||||
|
export type Notification = InferSelectModel<typeof notifications>;
|
||||||
|
export type ProductCategory = InferSelectModel<typeof productCategories>;
|
||||||
|
export type CartItem = InferSelectModel<typeof cartItems>;
|
||||||
|
export type Coupon = InferSelectModel<typeof coupons>;
|
||||||
|
|
||||||
export type User = UseSqlite extends true ? SqliteUser : PostgresUser
|
// Combined types
|
||||||
export type Address = UseSqlite extends true ? SqliteAddress : PostgresAddress
|
export type ProductWithUnit = ProductInfo & {
|
||||||
export type Unit = UseSqlite extends true ? SqliteUnit : PostgresUnit
|
unit: Unit;
|
||||||
export type ProductInfo = UseSqlite extends true ? SqliteProductInfo : PostgresProductInfo
|
};
|
||||||
export type DeliverySlotInfo = UseSqlite extends true ? SqliteDeliverySlotInfo : PostgresDeliverySlotInfo
|
|
||||||
export type ProductSlot = UseSqlite extends true ? SqliteProductSlot : PostgresProductSlot
|
export type OrderWithItems = Order & {
|
||||||
export type SpecialDeal = UseSqlite extends true ? SqliteSpecialDeal : PostgresSpecialDeal
|
items: (OrderItem & { product: ProductInfo })[];
|
||||||
export type Order = UseSqlite extends true ? SqliteOrder : PostgresOrder
|
address: Address;
|
||||||
export type OrderItem = UseSqlite extends true ? SqliteOrderItem : PostgresOrderItem
|
slot: DeliverySlotInfo;
|
||||||
export type Payment = UseSqlite extends true ? SqlitePayment : PostgresPayment
|
};
|
||||||
export type Notification = UseSqlite extends true ? SqliteNotification : PostgresNotification
|
|
||||||
export type ProductCategory = UseSqlite extends true ? SqliteProductCategory : PostgresProductCategory
|
export type CartItemWithProduct = CartItem & {
|
||||||
export type CartItem = UseSqlite extends true ? SqliteCartItem : PostgresCartItem
|
product: ProductInfo;
|
||||||
export type Coupon = UseSqlite extends true ? SqliteCoupon : PostgresCoupon
|
};
|
||||||
export type ProductWithUnit = UseSqlite extends true ? SqliteProductWithUnit : PostgresProductWithUnit
|
|
||||||
export type OrderWithItems = UseSqlite extends true ? SqliteOrderWithItems : PostgresOrderWithItems
|
|
||||||
export type CartItemWithProduct = UseSqlite extends true ? SqliteCartItemWithProduct : PostgresCartItemWithProduct
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { claimUploadUrlStatus as claimUploadUrlStatusPostgres, createUploadUrlStatus as createUploadUrlStatusPostgres } from '@db-helper-postgres/lib/upload-url'
|
|
||||||
import { claimUploadUrlStatus as claimUploadUrlStatusSqlite, createUploadUrlStatus as createUploadUrlStatusSqlite } from '@db-helper-sqlite/lib/upload-url'
|
|
||||||
|
|
||||||
const dialect = process.env.DB_DIALECT || DB_DIALECT_TYPE
|
|
||||||
|
|
||||||
const createUploadUrlStatus = dialect === 'sqlite'
|
|
||||||
? createUploadUrlStatusSqlite
|
|
||||||
: createUploadUrlStatusPostgres
|
|
||||||
|
|
||||||
const claimUploadUrlStatus = dialect === 'sqlite'
|
|
||||||
? claimUploadUrlStatusSqlite
|
|
||||||
: claimUploadUrlStatusPostgres
|
|
||||||
|
|
||||||
export { claimUploadUrlStatus, createUploadUrlStatus }
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import * as cron from 'node-cron';
|
import * as cron from 'node-cron';
|
||||||
|
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
||||||
|
|
||||||
const runCombinedJob = async () => {
|
const runCombinedJob = async () => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
console.log('Starting combined job');
|
console.log('Starting combined job: payments and refunds check');
|
||||||
|
|
||||||
|
// Run payment check
|
||||||
|
// await checkPendingPayments();
|
||||||
|
|
||||||
|
// Run refund check
|
||||||
|
// await checkRefundStatuses();
|
||||||
|
|
||||||
console.log('Combined job completed successfully');
|
console.log('Combined job completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in combined job:', error);
|
console.error('Error in combined job:', error);
|
||||||
|
|
|
||||||
79
apps/backend/src/jobs/payment-status-checker.ts
Normal file
79
apps/backend/src/jobs/payment-status-checker.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as cron from 'node-cron';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
|
||||||
|
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
||||||
|
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
|
||||||
|
|
||||||
|
interface PendingPaymentRecord {
|
||||||
|
payment: typeof payments.$inferSelect;
|
||||||
|
order: typeof orders.$inferSelect;
|
||||||
|
slot: typeof deliverySlotInfo.$inferSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||||
|
// Construct message from record data
|
||||||
|
const message = `Payment pending for order ORD${record.order.id}. Please complete before orders close time.`;
|
||||||
|
|
||||||
|
// TODO: Implement notification sending logic using record.order.userId, record.order.id, message
|
||||||
|
console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkRefundStatuses = async () => {
|
||||||
|
try {
|
||||||
|
const initiatedRefunds = await db
|
||||||
|
.select()
|
||||||
|
.from(refunds)
|
||||||
|
.where(and(
|
||||||
|
eq(refunds.refundStatus, 'initiated'),
|
||||||
|
isNotNull(refunds.merchantRefundId)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Process refunds concurrently using Promise.allSettled
|
||||||
|
const promises = initiatedRefunds.map(async (refund) => {
|
||||||
|
if (!refund.merchantRefundId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
|
||||||
|
|
||||||
|
if (razorpayRefund.status === 'processed') {
|
||||||
|
await db
|
||||||
|
.update(refunds)
|
||||||
|
.set({ refundStatus: 'success', refundProcessedAt: new Date() })
|
||||||
|
.where(eq(refunds.id, refund.id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking refund ${refund.id}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all promises to complete
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in checkRefundStatuses:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkPendingPayments = async () => {
|
||||||
|
try {
|
||||||
|
const pendingPayments = await db
|
||||||
|
.select({
|
||||||
|
payment: payments,
|
||||||
|
order: orders,
|
||||||
|
slot: deliverySlotInfo,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(orders, eq(payments.orderId, orders.id))
|
||||||
|
.innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id))
|
||||||
|
.where(and(
|
||||||
|
eq(payments.status, 'pending'),
|
||||||
|
gt(deliverySlotInfo.freezeTime, new Date()) // Freeze time not passed
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const record of pendingPayments) {
|
||||||
|
createPaymentNotification(record);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking pending payments:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
85
apps/backend/src/lib/automatedJobs.ts
Normal file
85
apps/backend/src/lib/automatedJobs.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import * as cron from 'node-cron';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||||
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
|
import { computeConstants } from '@/src/lib/const-store'
|
||||||
|
|
||||||
|
|
||||||
|
const MUTTON_ITEMS = [
|
||||||
|
12, //Lamb mutton
|
||||||
|
14, // Mutton Boti
|
||||||
|
35, //Mutton Kheema
|
||||||
|
84, //Mutton Brain
|
||||||
|
4, //Mutton
|
||||||
|
86, //Mutton Chops
|
||||||
|
87, //Mutton Soup bones
|
||||||
|
85 //Mutton paya
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const startAutomatedJobs = () => {
|
||||||
|
// Job to disable flash delivery for mutton at 12 PM daily
|
||||||
|
cron.schedule('0 12 * * *', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Disabling flash delivery for products at 12 PM');
|
||||||
|
await db
|
||||||
|
.update(productInfo)
|
||||||
|
.set({ isFlashAvailable: false })
|
||||||
|
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||||
|
console.log('Flash delivery disabled successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disabling flash delivery:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job to enable flash delivery for mutton at 6 AM daily
|
||||||
|
cron.schedule('0 6 * * *', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Enabling flash delivery for products at 5 AM');
|
||||||
|
await db
|
||||||
|
.update(productInfo)
|
||||||
|
.set({ isFlashAvailable: true })
|
||||||
|
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
||||||
|
console.log('Flash delivery enabled successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error enabling flash delivery:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job to disable flash delivery feature at 9 PM daily
|
||||||
|
cron.schedule('0 21 * * *', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Disabling flash delivery feature at 9 PM');
|
||||||
|
await db
|
||||||
|
.update(keyValStore)
|
||||||
|
.set({ value: false })
|
||||||
|
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||||
|
await computeConstants(); // Refresh Redis cache
|
||||||
|
console.log('Flash delivery feature disabled successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disabling flash delivery feature:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job to enable flash delivery feature at 6 AM daily
|
||||||
|
cron.schedule('0 6 * * *', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Enabling flash delivery feature at 6 AM');
|
||||||
|
await db
|
||||||
|
.update(keyValStore)
|
||||||
|
.set({ value: true })
|
||||||
|
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
||||||
|
await computeConstants(); // Refresh Redis cache
|
||||||
|
console.log('Flash delivery feature enabled successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error enabling flash delivery feature:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Automated jobs scheduled');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional: Call on import if desired, or export and call in main app
|
||||||
|
// startAutomatedJobs();
|
||||||
6
apps/backend/src/lib/catch-async.ts
Executable file
6
apps/backend/src/lib/catch-async.ts
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import express from 'express';
|
||||||
|
const catchAsync =
|
||||||
|
(fn: express.RequestHandler) =>
|
||||||
|
(req: express.Request, res: express.Response, next: express.NextFunction) =>
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
export default catchAsync;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { keyValStore } from '../db/schema'
|
import { keyValStore } from '@/src/db/schema'
|
||||||
import redisClient from '@/src/lib/redis-client'
|
import redisClient from '@/src/lib/redis-client'
|
||||||
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
|
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
|
||||||
|
|
||||||
|
|
@ -1,54 +1,46 @@
|
||||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from '@/src/lib/s3-client'
|
import { eq } from "drizzle-orm";
|
||||||
import { assetsDomain, s3Url } from '@/src/lib/env-exporter'
|
import { db } from "@/src/db/db_index"
|
||||||
|
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||||
|
import { s3Url } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
function extractS3Key(url: string): string | null {
|
function extractS3Key(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
// Check if this is a signed URL first and get the original if it is
|
// Check if this is a signed URL first and get the original if it is
|
||||||
const originalUrl = getOriginalUrlFromSignedUrl(url) || url
|
const originalUrl = getOriginalUrlFromSignedUrl(url) || url;
|
||||||
|
|
||||||
// Find the index of '.com/' in the URL
|
// Find the index of '.com/' in the URL
|
||||||
// const comIndex = originalUrl.indexOf(".com/");
|
// const comIndex = originalUrl.indexOf(".com/");
|
||||||
const baseUrlIndex = originalUrl.indexOf(s3Url)
|
const baseUrlIndex = originalUrl.indexOf(s3Url);
|
||||||
|
|
||||||
// If '.com/' is found, return everything after it
|
// If '.com/' is found, return everything after it
|
||||||
if (baseUrlIndex !== -1) {
|
if (baseUrlIndex !== -1) {
|
||||||
return originalUrl.substring(baseUrlIndex + s3Url.length) // +5 to skip '.com/'
|
return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting key from URL:', error)
|
console.error("Error extracting key from URL:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return null if the pattern isn't found or there was an error
|
// Return null if the pattern isn't found or there was an error
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function deleteS3Image(imageUrl: string) {
|
export async function deleteS3Image(imageUrl: string) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
let key: string | null = ''
|
|
||||||
|
|
||||||
if (imageUrl.includes(assetsDomain)) {
|
|
||||||
key = imageUrl.replace(assetsDomain, '')
|
|
||||||
}
|
|
||||||
else if (imageUrl.startsWith('http')) {
|
|
||||||
// First check if this is a signed URL and get the original if it is
|
// First check if this is a signed URL and get the original if it is
|
||||||
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl
|
const originalUrl = getOriginalUrlFromSignedUrl(imageUrl) || imageUrl;
|
||||||
|
|
||||||
|
const key = extractS3Key(originalUrl || "");
|
||||||
|
|
||||||
key = extractS3Key(originalUrl || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
else {
|
|
||||||
key = imageUrl
|
|
||||||
}
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
throw new Error('Invalid image URL format')
|
throw new Error("Invalid image URL format");
|
||||||
}
|
}
|
||||||
const deleteS3 = await deleteImageUtil({ keys: [key] })
|
const deleteS3 = await deleteImageUtil({keys: [key] });
|
||||||
if (!deleteS3) {
|
if (!deleteS3) {
|
||||||
throw new Error('Failed to delete image from S3')
|
throw new Error("Failed to delete image from S3");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting image from S3:', error)
|
console.error("Error deleting image from S3:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema'
|
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,12 +17,6 @@ export const s3Region = process.env.S3_REGION as string
|
||||||
|
|
||||||
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||||
|
|
||||||
export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
|
||||||
|
|
||||||
export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
|
||||||
|
|
||||||
export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
|
||||||
|
|
||||||
export const s3Url = process.env.S3_URL as string
|
export const s3Url = process.env.S3_URL as string
|
||||||
|
|
||||||
export const redisUrl = process.env.REDIS_URL as string
|
export const redisUrl = process.env.REDIS_URL as string
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||||
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
||||||
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
||||||
import { deleteOrders } from '@/src/lib/delete-orders'
|
import { deleteOrders } from '@/src/lib/delete-orders'
|
||||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
|
||||||
import { verifyProductsAvailabilityBySchedule } from './manage-scheduled-availability'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all application services
|
* Initialize all application services
|
||||||
|
|
@ -20,7 +18,6 @@ export const initFunc = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
console.log('Starting application initialization...');
|
console.log('Starting application initialization...');
|
||||||
|
|
||||||
await verifyProductsAvailabilityBySchedule(false);
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
initializeAllStores(),
|
initializeAllStores(),
|
||||||
initializeUserNegativityStore(),
|
initializeUserNegativityStore(),
|
||||||
|
|
@ -28,10 +25,6 @@ export const initFunc = async (): Promise<void> => {
|
||||||
startCancellationHandler(),
|
startCancellationHandler(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create all cache files after stores are initialized
|
|
||||||
await createAllCacheFiles();
|
|
||||||
console.log('Cache files created successfully');
|
|
||||||
|
|
||||||
console.log('Application initialization completed successfully');
|
console.log('Application initialization completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Application initialization failed:', error);
|
console.error('Application initialization failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { SignJWT, jwtVerify, errors, JWTPayload } from 'jose';
|
|
||||||
import { jwtSecret } from './env-exporter';
|
|
||||||
|
|
||||||
// JWT Payload Types
|
|
||||||
export interface UserJWTPayload extends JWTPayload {
|
|
||||||
userId: number;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
mobile?: string;
|
|
||||||
roles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StaffJWTPayload extends JWTPayload {
|
|
||||||
staffId: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert string secret to Uint8Array
|
|
||||||
const getSecret = () => {
|
|
||||||
if (!jwtSecret) {
|
|
||||||
throw new Error('JWT secret not configured');
|
|
||||||
}
|
|
||||||
return new TextEncoder().encode(jwtSecret);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign a JWT token
|
|
||||||
* Compatible with tokens signed by jsonwebtoken library
|
|
||||||
*/
|
|
||||||
export const signToken = async (
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
expiresIn: string = '7d'
|
|
||||||
): Promise<string> => {
|
|
||||||
const secret = getSecret();
|
|
||||||
|
|
||||||
return new SignJWT(payload)
|
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(expiresIn)
|
|
||||||
.sign(secret);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a JWT token
|
|
||||||
* Compatible with tokens signed by jsonwebtoken library
|
|
||||||
*/
|
|
||||||
export const verifyToken = async (token: string): Promise<JWTPayload> => {
|
|
||||||
try {
|
|
||||||
const secret = getSecret();
|
|
||||||
const { payload } = await jwtVerify(token, secret);
|
|
||||||
return payload;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof errors.JWTExpired) {
|
|
||||||
throw new Error('Token expired');
|
|
||||||
}
|
|
||||||
if (error instanceof errors.JWTInvalid) {
|
|
||||||
throw new Error('Invalid token');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is a JWT-related error
|
|
||||||
*/
|
|
||||||
export const isJWTError = (error: unknown): boolean => {
|
|
||||||
return error instanceof errors.JOSEError ||
|
|
||||||
(error instanceof Error && (
|
|
||||||
error.message.includes('token') ||
|
|
||||||
error.message.includes('JWT')
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Queue, Worker } from 'bullmq';
|
import { Queue, Worker } from 'bullmq';
|
||||||
import { Expo } from 'expo-server-sdk';
|
import { Expo } from 'expo-server-sdk';
|
||||||
import { redisUrl } from '@/src/lib/env-exporter'
|
import { redisUrl } from '@/src/lib/env-exporter'
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import {
|
import {
|
||||||
NOTIFS_QUEUE,
|
NOTIFS_QUEUE,
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { sendPushNotificationsMany } from '@/src/lib/expo-service'
|
import { db } from "@/src/db/db_index"
|
||||||
|
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||||
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
// Core notification dispatch methods (renamed for clarity)
|
// Core notification dispatch methods (renamed for clarity)
|
||||||
export async function dispatchBulkNotification({
|
export async function dispatchBulkNotification({
|
||||||
|
|
|
||||||
59
apps/backend/src/lib/payments-utils.ts
Normal file
59
apps/backend/src/lib/payments-utils.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import Razorpay from "razorpay";
|
||||||
|
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
|
||||||
|
import { db } from "@/src/db/db_index"
|
||||||
|
import { payments } from "@/src/db/schema"
|
||||||
|
|
||||||
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||||
|
|
||||||
|
export class RazorpayPaymentService {
|
||||||
|
private static instance = new Razorpay({
|
||||||
|
key_id: razorpayId,
|
||||||
|
key_secret: razorpaySecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
static async createOrder(orderId: number, amount: string) {
|
||||||
|
// Create Razorpay order
|
||||||
|
const razorpayOrder = await this.instance.orders.create({
|
||||||
|
amount: parseFloat(amount) * 100, // Convert to paisa
|
||||||
|
currency: 'INR',
|
||||||
|
receipt: `order_${orderId}`,
|
||||||
|
notes: {
|
||||||
|
customerOrderId: orderId.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return razorpayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
|
||||||
|
// Use transaction if provided, otherwise use db
|
||||||
|
const dbInstance = tx || db;
|
||||||
|
|
||||||
|
// Insert payment record
|
||||||
|
const [payment] = await dbInstance
|
||||||
|
.insert(payments)
|
||||||
|
.values({
|
||||||
|
status: 'pending',
|
||||||
|
gateway: 'razorpay',
|
||||||
|
orderId,
|
||||||
|
token: orderId.toString(),
|
||||||
|
merchantOrderId: razorpayOrder.id,
|
||||||
|
payload: razorpayOrder,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async initiateRefund(paymentId: string, amount: number) {
|
||||||
|
const refund = await this.instance.payments.refund(paymentId, {
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
return refund;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fetchRefund(refundId: string) {
|
||||||
|
const refund = await this.instance.refunds.fetch(refundId);
|
||||||
|
return refund;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { orders, orderStatus } from '../db/schema'
|
import { orders, orderStatus } from '@/src/db/schema'
|
||||||
import redisClient from '@/src/lib/redis-client'
|
import redisClient from '@/src/lib/redis-client'
|
||||||
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
export async function retryWithExponentialBackoff<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
maxRetries: number = 3,
|
|
||||||
delayMs: number = 1000
|
|
||||||
): Promise<T> {
|
|
||||||
let lastError: Error | undefined
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error))
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
||||||
delayMs *= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { db } from "@/src/db/db_index"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for role names to avoid hardcoding and typos
|
* Constants for role names to avoid hardcoding and typos
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client,
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
import signedUrlCache from "@/src/lib/signed-url-cache"
|
import signedUrlCache from "@/src/lib/signed-url-cache"
|
||||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||||
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/db/upload-url'
|
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
||||||
|
import { uploadUrlStatus } from "@/src/db/schema"
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: s3Region,
|
region: s3Region,
|
||||||
|
|
@ -159,7 +161,10 @@ export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expi
|
||||||
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Insert record into upload_url_status
|
// Insert record into upload_url_status
|
||||||
await createUploadUrlStatus(key)
|
await db.insert(uploadUrlStatus).values({
|
||||||
|
key: key,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
// Generate signed upload URL
|
// Generate signed upload URL
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
|
|
@ -196,13 +201,19 @@ export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
|
|
||||||
export async function claimUploadUrl(url: string): Promise<void> {
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let semiKey:string = ''
|
const semiKey = extractKeyFromPresignedUrl(url);
|
||||||
|
const key = s3BucketName+'/'+ semiKey
|
||||||
|
|
||||||
if(url.startsWith('http'))
|
// Update status to 'claimed' if currently 'pending'
|
||||||
semiKey = extractKeyFromPresignedUrl(url);
|
const result = await db
|
||||||
else
|
.update(uploadUrlStatus)
|
||||||
semiKey = url
|
.set({ status: 'claimed' })
|
||||||
await claimUploadUrlStatus(semiKey)
|
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error('Upload URL not found or already claimed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error claiming upload URL:', error);
|
console.error('Error claiming upload URL:', error);
|
||||||
throw new Error('Failed to claim upload URL');
|
throw new Error('Failed to claim upload URL');
|
||||||
|
|
|
||||||
132
apps/backend/src/lib/signed-url-cache.ts
Normal file → Executable file
132
apps/backend/src/lib/signed-url-cache.ts
Normal file → Executable file
|
|
@ -1,3 +1,8 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||||
|
|
||||||
// Interface for cache entries with TTL
|
// Interface for cache entries with TTL
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -11,7 +16,18 @@ class SignedURLCache {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.originalToSignedCache = new Map();
|
this.originalToSignedCache = new Map();
|
||||||
this.signedToOriginalCache = new Map();
|
this.signedToOriginalCache = new Map();
|
||||||
console.log('SignedURLCache: Initialized (in-memory only)');
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||||
|
if (!fs.existsSync(cacheDir)) {
|
||||||
|
console.log('creating the directory')
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log('the directory is already present')
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,7 +110,7 @@ class SignedURLCache {
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.originalToSignedCache.clear();
|
this.originalToSignedCache.clear();
|
||||||
this.signedToOriginalCache.clear();
|
this.signedToOriginalCache.clear();
|
||||||
console.log('SignedURLCache: Cleared all entries');
|
this.saveToDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -129,27 +145,119 @@ class SignedURLCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache statistics
|
* Save the cache to disk
|
||||||
*/
|
*/
|
||||||
getStats(): { totalEntries: number } {
|
saveToDisk(): void {
|
||||||
return {
|
try {
|
||||||
totalEntries: this.originalToSignedCache.size
|
// Remove expired entries before saving
|
||||||
|
const removedCount = this.clearExpired();
|
||||||
|
|
||||||
|
// Convert Maps to serializable objects
|
||||||
|
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||||
|
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||||
|
|
||||||
|
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||||
|
serializedOriginalToSigned[originalUrl] = {
|
||||||
|
value: entry.value,
|
||||||
|
expiresAt: entry.expiresAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||||
* Stub methods for backward compatibility - do nothing in in-memory mode
|
serializedSignedToOriginal[signedUrl] = {
|
||||||
*/
|
value: entry.value,
|
||||||
saveToDisk(): void {
|
expiresAt: entry.expiresAt
|
||||||
// No-op: In-memory cache only
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
loadFromDisk(): void {
|
||||||
// No-op: In-memory cache only
|
try {
|
||||||
|
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||||
|
// Read from file
|
||||||
|
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||||
|
|
||||||
|
// Parse the data
|
||||||
|
const parsedData = JSON.parse(data) as {
|
||||||
|
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||||
|
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only load entries that haven't expired yet
|
||||||
|
const now = Date.now();
|
||||||
|
let loadedCount = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
|
|
||||||
|
// Load original to signed mappings
|
||||||
|
if (parsedData.originalToSigned) {
|
||||||
|
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||||
|
if (now <= entry.expiresAt) {
|
||||||
|
this.originalToSignedCache.set(originalUrl, entry);
|
||||||
|
loadedCount++;
|
||||||
|
} else {
|
||||||
|
expiredCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load signed to original mappings
|
||||||
|
if (parsedData.signedToOriginal) {
|
||||||
|
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||||
|
if (now <= entry.expiresAt) {
|
||||||
|
this.signedToOriginalCache.set(signedUrl, entry);
|
||||||
|
// Don't increment loadedCount as these are pairs of what we already counted
|
||||||
|
} else {
|
||||||
|
// Don't increment expiredCount as these are pairs of what we already counted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||||
|
} else {
|
||||||
|
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading SignedURLCache from disk:', error);
|
||||||
|
// Start with empty caches if loading fails
|
||||||
|
this.originalToSignedCache = new Map();
|
||||||
|
this.signedToOriginalCache = new Map();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a singleton instance to be used throughout the application
|
// Create a singleton instance to be used throughout the application
|
||||||
const signedUrlCache = new SignedURLCache();
|
const signedUrlCache = new SignedURLCache();
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||||
|
signedUrlCache.saveToDisk();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||||
|
signedUrlCache.saveToDisk();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
export default signedUrlCache;
|
export default signedUrlCache;
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
|
||||||
|
|
||||||
// Interface for cache entries with TTL
|
|
||||||
interface CacheEntry {
|
|
||||||
value: string;
|
|
||||||
expiresAt: number; // Timestamp when this entry expires
|
|
||||||
}
|
|
||||||
|
|
||||||
class SignedURLCache {
|
|
||||||
private originalToSignedCache: Map<string, CacheEntry>;
|
|
||||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.originalToSignedCache = new Map();
|
|
||||||
this.signedToOriginalCache = new Map();
|
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
|
||||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
|
||||||
if (!fs.existsSync(cacheDir)) {
|
|
||||||
console.log('creating the directory')
|
|
||||||
|
|
||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log('the directory is already present')
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a signed URL from the cache using an original URL as the key
|
|
||||||
*/
|
|
||||||
get(originalUrl: string): string | undefined {
|
|
||||||
const entry = this.originalToSignedCache.get(originalUrl);
|
|
||||||
|
|
||||||
// If no entry or entry has expired, return undefined
|
|
||||||
if (!entry || Date.now() > entry.expiresAt) {
|
|
||||||
if (entry) {
|
|
||||||
// Remove expired entry
|
|
||||||
this.originalToSignedCache.delete(originalUrl);
|
|
||||||
// Also remove from reverse mapping if it exists
|
|
||||||
this.signedToOriginalCache.delete(entry.value);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the original URL from the cache using a signed URL as the key
|
|
||||||
*/
|
|
||||||
getOriginalUrl(signedUrl: string): string | undefined {
|
|
||||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
|
||||||
|
|
||||||
// If no entry or entry has expired, return undefined
|
|
||||||
if (!entry || Date.now() > entry.expiresAt) {
|
|
||||||
if (entry) {
|
|
||||||
// Remove expired entry
|
|
||||||
this.signedToOriginalCache.delete(signedUrl);
|
|
||||||
// Also remove from primary mapping if it exists
|
|
||||||
this.originalToSignedCache.delete(entry.value);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in the cache with a TTL (Time To Live)
|
|
||||||
* @param originalUrl The original S3 URL
|
|
||||||
* @param signedUrl The signed URL
|
|
||||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
|
||||||
*/
|
|
||||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
|
||||||
const expiresAt = Date.now() + ttlMs;
|
|
||||||
|
|
||||||
const entry: CacheEntry = {
|
|
||||||
value: signedUrl,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
|
|
||||||
const reverseEntry: CacheEntry = {
|
|
||||||
value: originalUrl,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
|
|
||||||
this.originalToSignedCache.set(originalUrl, entry);
|
|
||||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
has(originalUrl: string): boolean {
|
|
||||||
const entry = this.originalToSignedCache.get(originalUrl);
|
|
||||||
|
|
||||||
// Entry exists and hasn't expired
|
|
||||||
return !!entry && Date.now() <= entry.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSignedUrl(signedUrl: string): boolean {
|
|
||||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
|
||||||
|
|
||||||
// Entry exists and hasn't expired
|
|
||||||
return !!entry && Date.now() <= entry.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.originalToSignedCache.clear();
|
|
||||||
this.signedToOriginalCache.clear();
|
|
||||||
this.saveToDisk();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all expired entries from the cache
|
|
||||||
* @returns The number of expired entries that were removed
|
|
||||||
*/
|
|
||||||
clearExpired(): number {
|
|
||||||
const now = Date.now();
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
// Clear expired entries from original to signed cache
|
|
||||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
|
||||||
if (now > entry.expiresAt) {
|
|
||||||
this.originalToSignedCache.delete(originalUrl);
|
|
||||||
removedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear expired entries from signed to original cache
|
|
||||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
|
||||||
if (now > entry.expiresAt) {
|
|
||||||
this.signedToOriginalCache.delete(signedUrl);
|
|
||||||
// No need to increment removedCount as we've already counted these in the first loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedCount > 0) {
|
|
||||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the cache to disk
|
|
||||||
*/
|
|
||||||
saveToDisk(): void {
|
|
||||||
try {
|
|
||||||
// Remove expired entries before saving
|
|
||||||
const removedCount = this.clearExpired();
|
|
||||||
|
|
||||||
// Convert Maps to serializable objects
|
|
||||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
|
||||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
|
||||||
|
|
||||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
|
||||||
serializedOriginalToSigned[originalUrl] = {
|
|
||||||
value: entry.value,
|
|
||||||
expiresAt: entry.expiresAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
|
||||||
serializedSignedToOriginal[signedUrl] = {
|
|
||||||
value: entry.value,
|
|
||||||
expiresAt: entry.expiresAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedCache = {
|
|
||||||
originalToSigned: serializedOriginalToSigned,
|
|
||||||
signedToOriginal: serializedSignedToOriginal
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write to file
|
|
||||||
fs.writeFileSync(
|
|
||||||
CACHE_FILE_PATH,
|
|
||||||
JSON.stringify(serializedCache),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving SignedURLCache to disk:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the cache from disk
|
|
||||||
*/
|
|
||||||
loadFromDisk(): void {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
|
||||||
// Read from file
|
|
||||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
|
||||||
|
|
||||||
// Parse the data
|
|
||||||
const parsedData = JSON.parse(data) as {
|
|
||||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
|
||||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only load entries that haven't expired yet
|
|
||||||
const now = Date.now();
|
|
||||||
let loadedCount = 0;
|
|
||||||
let expiredCount = 0;
|
|
||||||
|
|
||||||
// Load original to signed mappings
|
|
||||||
if (parsedData.originalToSigned) {
|
|
||||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
|
||||||
if (now <= entry.expiresAt) {
|
|
||||||
this.originalToSignedCache.set(originalUrl, entry);
|
|
||||||
loadedCount++;
|
|
||||||
} else {
|
|
||||||
expiredCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load signed to original mappings
|
|
||||||
if (parsedData.signedToOriginal) {
|
|
||||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
|
||||||
if (now <= entry.expiresAt) {
|
|
||||||
this.signedToOriginalCache.set(signedUrl, entry);
|
|
||||||
// Don't increment loadedCount as these are pairs of what we already counted
|
|
||||||
} else {
|
|
||||||
// Don't increment expiredCount as these are pairs of what we already counted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
|
||||||
} else {
|
|
||||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading SignedURLCache from disk:', error);
|
|
||||||
// Start with empty caches if loading fails
|
|
||||||
this.originalToSignedCache = new Map();
|
|
||||||
this.signedToOriginalCache = new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a singleton instance to be used throughout the application
|
|
||||||
const signedUrlCache = new SignedURLCache();
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
|
||||||
signedUrlCache.saveToDisk();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
|
||||||
signedUrlCache.saveToDisk();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default signedUrlCache;
|
|
||||||
8
apps/backend/src/lib/upload-handler.ts
Executable file
8
apps/backend/src/lib/upload-handler.ts
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import multerParent from 'multer';
|
||||||
|
const uploadHandler = multerParent({
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10 MB
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default uploadHandler
|
||||||
|
|
@ -1,34 +1,65 @@
|
||||||
import { Hono } from 'hono'
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
import { authenticateUser } from '@/src/middleware/auth.middleware'
|
import avRouter from "@/src/apis/admin-apis/apis/av-router"
|
||||||
import v1Router from '@/src/v1-router'
|
import { ApiError } from "@/src/lib/api-error"
|
||||||
|
import v1Router from "@/src/v1-router"
|
||||||
|
import testController from "@/src/test-controller"
|
||||||
|
import { authenticateUser } from "@/src/middleware/auth.middleware"
|
||||||
|
import { raiseComplaint } from "@/src/uv-apis/user-rest.controller"
|
||||||
|
import uploadHandler from "@/src/lib/upload-handler"
|
||||||
|
|
||||||
// Note: This router is kept for compatibility during migration
|
|
||||||
// Most routes have been moved to tRPC
|
const router = Router();
|
||||||
const router = new Hono()
|
|
||||||
|
|
||||||
// Health check endpoints (no auth required)
|
// Health check endpoints (no auth required)
|
||||||
// Note: These are also defined in index.ts, keeping for compatibility
|
router.get('/health', (req: Request, res: Response) => {
|
||||||
router.get('/health', (c) => {
|
res.status(200).json({
|
||||||
return c.json({
|
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
message: 'Hello world'
|
message: 'Hello world'
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
router.get('/seed', (req:Request, res: Response) => {
|
||||||
router.get('/seed', (c) => {
|
res.status(200).json({
|
||||||
return c.json({
|
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime: process.uptime()
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// Mount v1 routes (REST API)
|
|
||||||
router.route('/v1', v1Router)
|
|
||||||
|
|
||||||
// Apply authentication middleware to all subsequent routes
|
// Apply authentication middleware to all subsequent routes
|
||||||
router.use('*', authenticateUser)
|
router.use(authenticateUser);
|
||||||
|
|
||||||
export default router
|
router.use('/v1', v1Router);
|
||||||
|
// router.use('/av', avRouter);
|
||||||
|
router.use('/test', testController);
|
||||||
|
|
||||||
|
// User REST APIs
|
||||||
|
router.post('/uv/complaints/raise',
|
||||||
|
uploadHandler.array('images', 5),
|
||||||
|
raiseComplaint
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global error handling middleware
|
||||||
|
router.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
return res.status(err.statusCode).json({
|
||||||
|
error: err.message,
|
||||||
|
details: err.details,
|
||||||
|
statusCode: err.statusCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unknown errors
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||||
|
statusCode: 500
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainRouter = router;
|
||||||
|
|
||||||
|
export default mainRouter;
|
||||||
67
apps/backend/src/middleware/auth.middleware.ts
Normal file
67
apps/backend/src/middleware/auth.middleware.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { staffUsers, userDetails } from '@/src/db/schema'
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
userId: number;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
mobile?: string;
|
||||||
|
};
|
||||||
|
staffUser?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticateUser = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
throw new ApiError('Authorization token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
console.log(req.headers)
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
|
||||||
|
|
||||||
|
// Check if this is a staff token (has staffId)
|
||||||
|
if (decoded.staffId) {
|
||||||
|
// This is a staff token, verify staff exists
|
||||||
|
const staff = await db.query.staffUsers.findFirst({
|
||||||
|
where: eq(staffUsers.id, decoded.staffId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!staff) {
|
||||||
|
throw new ApiError('Invalid staff token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.staffUser = {
|
||||||
|
id: staff.id,
|
||||||
|
name: staff.name,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// This is a regular user token
|
||||||
|
req.user = decoded;
|
||||||
|
|
||||||
|
// Check if user is suspended
|
||||||
|
const details = await db.query.userDetails.findFirst({
|
||||||
|
where: eq(userDetails.userId, decoded.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (details?.isSuspended) {
|
||||||
|
throw new ApiError('Account suspended', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,67 +1,67 @@
|
||||||
import { createMiddleware } from 'hono/factory'
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import { ApiError } from '@/src/lib/api-error'
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { verifyToken, isJWTError, UserJWTPayload } from '@/src/lib/jwt-utils'
|
|
||||||
|
|
||||||
// Type for Hono context variables
|
// Extend the Request interface to include user property
|
||||||
type Variables = {
|
declare global {
|
||||||
user: UserJWTPayload
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const verifyToken = (req: Request, res: Response, next: NextFunction) => {
|
||||||
* Hono middleware to verify JWT token and attach user to context
|
|
||||||
*/
|
|
||||||
export const verifyTokenMiddleware = createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
|
||||||
try {
|
try {
|
||||||
// Get token from Authorization header
|
// Get token from Authorization header
|
||||||
const authHeader = c.req.header('authorization')
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
throw new ApiError('Access denied. No token provided', 401)
|
throw new ApiError('Access denied. No token provided', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1]
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new ApiError('Access denied. Invalid token format', 401)
|
throw new ApiError('Access denied. Invalid token format', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
const decoded = await verifyToken(token) as UserJWTPayload
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||||
|
|
||||||
// Add user info to context
|
|
||||||
c.set('user', decoded)
|
|
||||||
|
|
||||||
await next()
|
// Add user info to request
|
||||||
|
req.user = decoded;
|
||||||
|
|
||||||
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isJWTError(error)) {
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
throw new ApiError('Invalid Auth Credentials', 401)
|
next(new ApiError('Invalid Auth Credentials', 401));
|
||||||
|
} else {
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
|
|
||||||
// Keep old name for backward compatibility
|
|
||||||
export { verifyTokenMiddleware as verifyToken }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hono middleware to require specific roles
|
|
||||||
*/
|
|
||||||
export const requireRole = (roles: string[]) => {
|
export const requireRole = (roles: string[]) => {
|
||||||
return createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
const user = c.get('user')
|
try {
|
||||||
|
if (!req.user) {
|
||||||
if (!user) {
|
throw new ApiError('Authentication required', 401);
|
||||||
throw new ApiError('Authentication required', 401)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has any of the required roles
|
// Check if user has any of the required roles
|
||||||
const userRoles = user.roles || []
|
const userRoles = req.user.roles || [];
|
||||||
const hasPermission = roles.some(role => userRoles.includes(role))
|
const hasPermission = roles.some(role => userRoles.includes(role));
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
throw new ApiError('Access denied. Insufficient permissions', 403)
|
throw new ApiError('Access denied. Insufficient permissions', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next()
|
next();
|
||||||
})
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,40 @@
|
||||||
import { createMiddleware } from 'hono/factory'
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { db } from '../db/db_index'
|
import jwt from 'jsonwebtoken';
|
||||||
import { staffUsers } from '../db/schema'
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { staffUsers } from '@/src/db/schema'
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { ApiError } from '@/src/lib/api-error'
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { verifyToken, StaffJWTPayload } from '@/src/lib/jwt-utils'
|
|
||||||
|
|
||||||
// Type for Hono context variables
|
// Extend Request interface to include staffUser
|
||||||
type Variables = {
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
staffUser?: {
|
staffUser?: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify JWT token and extract payload
|
* Verify JWT token and extract payload
|
||||||
*/
|
*/
|
||||||
const verifyStaffToken = async (token: string): Promise<StaffJWTPayload> => {
|
const verifyStaffToken = (token: string) => {
|
||||||
try {
|
try {
|
||||||
const payload = await verifyToken(token);
|
return jwt.verify(token, process.env.JWT_SECRET || 'default-secret');
|
||||||
return payload as StaffJWTPayload;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new ApiError('Access denied. Invalid auth credentials', 401);
|
throw new ApiError('Access denied. Invalid auth credentials', 401);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hono middleware to authenticate staff users and attach staffUser to context
|
* Middleware to authenticate staff users and attach staffUser to request
|
||||||
*/
|
*/
|
||||||
export const authenticateStaff = createMiddleware<{ Variables: Variables }>(async (c, next) => {
|
export const authenticateStaff = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
// Extract token from Authorization header
|
// Extract token from Authorization header
|
||||||
const authHeader = c.req.header('authorization');
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
throw new ApiError('Staff authentication required', 401);
|
throw new ApiError('Staff authentication required', 401);
|
||||||
|
|
@ -44,7 +47,7 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token and extract payload
|
// Verify token and extract payload
|
||||||
const decoded = await verifyStaffToken(token);
|
const decoded = verifyStaffToken(token) as any;
|
||||||
|
|
||||||
// Verify staffId exists in token
|
// Verify staffId exists in token
|
||||||
if (!decoded.staffId) {
|
if (!decoded.staffId) {
|
||||||
|
|
@ -60,14 +63,14 @@ export const authenticateStaff = createMiddleware<{ Variables: Variables }>(asyn
|
||||||
throw new ApiError('Staff user not found', 401);
|
throw new ApiError('Staff user not found', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach staff user to context
|
// Attach staff user to request
|
||||||
c.set('staffUser', {
|
req.staffUser = {
|
||||||
id: staff.id,
|
id: staff.id,
|
||||||
name: staff.name,
|
name: staff.name,
|
||||||
});
|
};
|
||||||
|
|
||||||
await next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
405
apps/backend/src/services/user/order-service.ts
Normal file
405
apps/backend/src/services/user/order-service.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import {
|
||||||
|
orders,
|
||||||
|
orderItems,
|
||||||
|
orderStatus,
|
||||||
|
addresses,
|
||||||
|
productInfo,
|
||||||
|
paymentInfoTable,
|
||||||
|
coupons,
|
||||||
|
couponUsage,
|
||||||
|
payments,
|
||||||
|
cartItems,
|
||||||
|
refunds,
|
||||||
|
units,
|
||||||
|
userDetails,
|
||||||
|
} from '@/src/db/schema'
|
||||||
|
import { eq, and, inArray, desc, gte } from 'drizzle-orm'
|
||||||
|
|
||||||
|
// ============ User/Auth Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user details by user ID
|
||||||
|
*/
|
||||||
|
export async function getUserDetails(userId: number) {
|
||||||
|
return db.query.userDetails.findFirst({
|
||||||
|
where: eq(userDetails.userId, userId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Address Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user address by ID
|
||||||
|
*/
|
||||||
|
export async function getUserAddress(userId: number, addressId: number) {
|
||||||
|
return db.query.addresses.findFirst({
|
||||||
|
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Product Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by ID
|
||||||
|
*/
|
||||||
|
export async function getProductById(productId: number) {
|
||||||
|
return db.query.productInfo.findFirst({
|
||||||
|
where: eq(productInfo.id, productId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple products by IDs with unit info
|
||||||
|
*/
|
||||||
|
export async function getProductsByIdsWithUnits(productIds: number[]) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: productInfo.id,
|
||||||
|
name: productInfo.name,
|
||||||
|
shortDescription: productInfo.shortDescription,
|
||||||
|
price: productInfo.price,
|
||||||
|
images: productInfo.images,
|
||||||
|
isOutOfStock: productInfo.isOutOfStock,
|
||||||
|
unitShortNotation: units.shortNotation,
|
||||||
|
incrementStep: productInfo.incrementStep,
|
||||||
|
})
|
||||||
|
.from(productInfo)
|
||||||
|
.innerJoin(units, eq(productInfo.unitId, units.id))
|
||||||
|
.where(and(inArray(productInfo.id, productIds), eq(productInfo.isSuspended, false)))
|
||||||
|
.orderBy(desc(productInfo.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Coupon Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get coupon with usages for user
|
||||||
|
*/
|
||||||
|
export async function getCouponWithUsages(couponId: number, userId: number) {
|
||||||
|
return db.query.coupons.findFirst({
|
||||||
|
where: eq(coupons.id, couponId),
|
||||||
|
with: {
|
||||||
|
usages: { where: eq(couponUsage.userId, userId) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert coupon usage
|
||||||
|
*/
|
||||||
|
export async function insertCouponUsage(data: {
|
||||||
|
userId: number
|
||||||
|
couponId: number
|
||||||
|
orderId: number
|
||||||
|
orderItemId: number | null
|
||||||
|
usedAt: Date
|
||||||
|
}) {
|
||||||
|
return db.insert(couponUsage).values(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get coupon usages for order
|
||||||
|
*/
|
||||||
|
export async function getCouponUsagesForOrder(orderId: number) {
|
||||||
|
return db.query.couponUsage.findMany({
|
||||||
|
where: eq(couponUsage.orderId, orderId),
|
||||||
|
with: {
|
||||||
|
coupon: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Cart Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cart items for user by product IDs
|
||||||
|
*/
|
||||||
|
export async function deleteCartItems(userId: number, productIds: number[]) {
|
||||||
|
return db.delete(cartItems).where(
|
||||||
|
and(
|
||||||
|
eq(cartItems.userId, userId),
|
||||||
|
inArray(cartItems.productId, productIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Payment Info Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create payment info
|
||||||
|
*/
|
||||||
|
export async function createPaymentInfo(data: {
|
||||||
|
status: string
|
||||||
|
gateway: string
|
||||||
|
merchantOrderId: string
|
||||||
|
}) {
|
||||||
|
return db.insert(paymentInfoTable).values(data).returning()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============ Order Queries ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert multiple orders
|
||||||
|
*/
|
||||||
|
export async function insertOrders(ordersData: any[]) {
|
||||||
|
return db.insert(orders).values(ordersData).returning()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert multiple order items
|
||||||
|
*/
|
||||||
|
export async function insertOrderItems(itemsData: any[]) {
|
||||||
|
return db.insert(orderItems).values(itemsData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert multiple order statuses
|
||||||
|
*/
|
||||||
|
export async function insertOrderStatuses(statusesData: any[]) {
|
||||||
|
return db.insert(orderStatus).values(statusesData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user orders with all relations
|
||||||
|
*/
|
||||||
|
export async function getUserOrdersWithRelations(userId: number, limit: number, offset: number) {
|
||||||
|
return db.query.orders.findMany({
|
||||||
|
where: eq(orders.userId, userId),
|
||||||
|
with: {
|
||||||
|
orderItems: {
|
||||||
|
with: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slot: true,
|
||||||
|
paymentInfo: true,
|
||||||
|
orderStatus: true,
|
||||||
|
refunds: true,
|
||||||
|
},
|
||||||
|
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count user orders
|
||||||
|
*/
|
||||||
|
export async function countUserOrders(userId: number) {
|
||||||
|
return db.$count(orders, eq(orders.userId, userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID with all relations
|
||||||
|
*/
|
||||||
|
export async function getOrderByIdWithRelations(orderId: number) {
|
||||||
|
return db.query.orders.findFirst({
|
||||||
|
where: eq(orders.id, orderId),
|
||||||
|
with: {
|
||||||
|
orderItems: {
|
||||||
|
with: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slot: true,
|
||||||
|
paymentInfo: true,
|
||||||
|
orderStatus: {
|
||||||
|
with: {
|
||||||
|
refundCoupon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
refunds: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order by ID with order status
|
||||||
|
*/
|
||||||
|
export async function getOrderWithStatus(orderId: number) {
|
||||||
|
return db.query.orders.findFirst({
|
||||||
|
where: eq(orders.id, orderId),
|
||||||
|
with: {
|
||||||
|
orderStatus: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update order status to cancelled
|
||||||
|
*/
|
||||||
|
export async function updateOrderStatusToCancelled(
|
||||||
|
statusId: number,
|
||||||
|
data: {
|
||||||
|
isCancelled: boolean
|
||||||
|
cancelReason: string
|
||||||
|
cancellationUserNotes: string
|
||||||
|
cancellationReviewed: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return db
|
||||||
|
.update(orderStatus)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(orderStatus.id, statusId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert refund record
|
||||||
|
*/
|
||||||
|
export async function insertRefund(data: { orderId: number; refundStatus: string }) {
|
||||||
|
return db.insert(refunds).values(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update order notes
|
||||||
|
*/
|
||||||
|
export async function updateOrderNotes(orderId: number, userNotes: string | null) {
|
||||||
|
return db
|
||||||
|
.update(orders)
|
||||||
|
.set({ userNotes })
|
||||||
|
.where(eq(orders.id, orderId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent delivered orders for user
|
||||||
|
*/
|
||||||
|
export async function getRecentDeliveredOrders(
|
||||||
|
userId: number,
|
||||||
|
since: Date,
|
||||||
|
limit: number
|
||||||
|
) {
|
||||||
|
return db
|
||||||
|
.select({ id: orders.id })
|
||||||
|
.from(orders)
|
||||||
|
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orders.userId, userId),
|
||||||
|
eq(orderStatus.isDelivered, true),
|
||||||
|
gte(orders.createdAt, since)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(orders.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order items by order IDs
|
||||||
|
*/
|
||||||
|
export async function getOrderItemsByOrderIds(orderIds: number[]) {
|
||||||
|
return db
|
||||||
|
.select({ productId: orderItems.productId })
|
||||||
|
.from(orderItems)
|
||||||
|
.where(inArray(orderItems.orderId, orderIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Transaction Helper ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute function within a database transaction
|
||||||
|
*/
|
||||||
|
export async function withTransaction<T>(fn: (tx: any) => Promise<T>): Promise<T> {
|
||||||
|
return db.transaction(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel order with refund record in a transaction
|
||||||
|
*/
|
||||||
|
export async function cancelOrderWithRefund(
|
||||||
|
statusId: number,
|
||||||
|
orderId: number,
|
||||||
|
isCod: boolean,
|
||||||
|
reason: string
|
||||||
|
): Promise<{ orderId: number }> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
// Update order status
|
||||||
|
await tx
|
||||||
|
.update(orderStatus)
|
||||||
|
.set({
|
||||||
|
isCancelled: true,
|
||||||
|
cancelReason: reason,
|
||||||
|
cancellationUserNotes: reason,
|
||||||
|
cancellationReviewed: false,
|
||||||
|
})
|
||||||
|
.where(eq(orderStatus.id, statusId))
|
||||||
|
|
||||||
|
// Insert refund record
|
||||||
|
const refundStatus = isCod ? "na" : "pending"
|
||||||
|
await tx.insert(refunds).values({
|
||||||
|
orderId,
|
||||||
|
refundStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { orderId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create orders with payment info in a transaction
|
||||||
|
*/
|
||||||
|
export async function createOrdersWithPayment(
|
||||||
|
ordersData: any[],
|
||||||
|
paymentMethod: "online" | "cod",
|
||||||
|
totalWithDelivery: number,
|
||||||
|
razorpayOrderCreator?: (paymentInfoId: number, amount: string) => Promise<any>,
|
||||||
|
paymentRecordInserter?: (paymentInfoId: number, razorpayOrder: any, tx: Tx) => Promise<any>
|
||||||
|
): Promise<typeof orders.$inferSelect[]> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
let sharedPaymentInfoId: number | null = null
|
||||||
|
if (paymentMethod === "online") {
|
||||||
|
const [paymentInfo] = await tx
|
||||||
|
.insert(paymentInfoTable)
|
||||||
|
.values({
|
||||||
|
status: "pending",
|
||||||
|
gateway: "razorpay",
|
||||||
|
merchantOrderId: `multi_order_${Date.now()}`,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
sharedPaymentInfoId = paymentInfo.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordersToInsert: Omit<typeof orders.$inferInsert, "id">[] = ordersData.map(
|
||||||
|
(od) => ({
|
||||||
|
...od.order,
|
||||||
|
paymentInfoId: sharedPaymentInfoId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning()
|
||||||
|
|
||||||
|
const allOrderItems: Omit<typeof orderItems.$inferInsert, "id">[] = []
|
||||||
|
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, "id">[] = []
|
||||||
|
|
||||||
|
insertedOrders.forEach((order: typeof orders.$inferSelect, index: number) => {
|
||||||
|
const od = ordersData[index]
|
||||||
|
od.orderItems.forEach((item: any) => {
|
||||||
|
allOrderItems.push({ ...item, orderId: order.id as number })
|
||||||
|
})
|
||||||
|
allOrderStatuses.push({
|
||||||
|
...od.orderStatus,
|
||||||
|
orderId: order.id as number,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.insert(orderItems).values(allOrderItems)
|
||||||
|
await tx.insert(orderStatus).values(allOrderStatuses)
|
||||||
|
|
||||||
|
if (paymentMethod === "online" && sharedPaymentInfoId && razorpayOrderCreator && paymentRecordInserter) {
|
||||||
|
const razorpayOrder = await razorpayOrderCreator(
|
||||||
|
sharedPaymentInfoId,
|
||||||
|
totalWithDelivery.toString()
|
||||||
|
)
|
||||||
|
await paymentRecordInserter(
|
||||||
|
sharedPaymentInfoId,
|
||||||
|
razorpayOrder,
|
||||||
|
tx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertedOrders
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { db } from '../../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '../../db/schema'
|
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productReviews, users } from '@/src/db/schema'
|
||||||
import { eq, and, gt, sql, desc } from 'drizzle-orm'
|
import { eq, and, gt, sql, desc } from 'drizzle-orm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,8 +55,8 @@ export async function getProductDeliverySlots(productId: number) {
|
||||||
and(
|
and(
|
||||||
eq(productSlots.productId, productId),
|
eq(productSlots.productId, productId),
|
||||||
eq(deliverySlotInfo.isActive, true),
|
eq(deliverySlotInfo.isActive, true),
|
||||||
gt(deliverySlotInfo.deliveryTime, new Date()),
|
gt(deliverySlotInfo.deliveryTime, sql`NOW()`),
|
||||||
gt(deliverySlotInfo.freezeTime, new Date())
|
gt(deliverySlotInfo.freezeTime, sql`NOW()`)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(deliverySlotInfo.deliveryTime)
|
.orderBy(deliverySlotInfo.deliveryTime)
|
||||||
|
|
@ -76,7 +76,7 @@ export async function getProductSpecialDeals(productId: number) {
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(specialDeals.productId, productId),
|
eq(specialDeals.productId, productId),
|
||||||
gt(specialDeals.validTill, new Date())
|
gt(specialDeals.validTill, sql`NOW()`)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(specialDeals.quantity)
|
.orderBy(specialDeals.quantity)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// import redisClient from '@/src/stores/redis-client';
|
// import redisClient from '@/src/stores/redis-client';
|
||||||
import redisClient from '@/src/lib/redis-client';
|
import redisClient from '@/src/lib/redis-client';
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { homeBanners } from '../db/schema'
|
import { homeBanners } from '@/src/db/schema'
|
||||||
import { isNotNull, asc } from 'drizzle-orm';
|
import { isNotNull, asc } from 'drizzle-orm';
|
||||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client';
|
import { scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// import redisClient from '@/src/stores/redis-client';
|
// import redisClient from '@/src/stores/redis-client';
|
||||||
import redisClient from '@/src/lib/redis-client';
|
import redisClient from '@/src/lib/redis-client';
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '../db/schema'
|
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTags, productTagInfo } from '@/src/db/schema'
|
||||||
import { eq, and, gt, sql } from 'drizzle-orm';
|
import { eq, and, gt, sql } from 'drizzle-orm';
|
||||||
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ export async function initializeProducts(): Promise<void> {
|
||||||
and(
|
and(
|
||||||
eq(deliverySlotInfo.isActive, true),
|
eq(deliverySlotInfo.isActive, true),
|
||||||
eq(deliverySlotInfo.isCapacityFull, false),
|
eq(deliverySlotInfo.isCapacityFull, false),
|
||||||
gt(deliverySlotInfo.deliveryTime, new Date())
|
gt(deliverySlotInfo.deliveryTime, sql`NOW()`)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const deliverySlotsMap = new Map<number, typeof allDeliverySlots>();
|
const deliverySlotsMap = new Map<number, typeof allDeliverySlots>();
|
||||||
|
|
@ -90,7 +90,7 @@ export async function initializeProducts(): Promise<void> {
|
||||||
validTill: specialDeals.validTill,
|
validTill: specialDeals.validTill,
|
||||||
})
|
})
|
||||||
.from(specialDeals)
|
.from(specialDeals)
|
||||||
.where(gt(specialDeals.validTill, new Date()));
|
.where(gt(specialDeals.validTill, sql`NOW()`));
|
||||||
const specialDealsMap = new Map<number, typeof allSpecialDeals>();
|
const specialDealsMap = new Map<number, typeof allSpecialDeals>();
|
||||||
for (const deal of allSpecialDeals) {
|
for (const deal of allSpecialDeals) {
|
||||||
if (!specialDealsMap.has(deal.productId)) specialDealsMap.set(deal.productId, []);
|
if (!specialDealsMap.has(deal.productId)) specialDealsMap.set(deal.productId, []);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// import redisClient from '@/src/stores/redis-client';
|
// import redisClient from '@/src/stores/redis-client';
|
||||||
import redisClient from '@/src/lib/redis-client';
|
import redisClient from '@/src/lib/redis-client';
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { productTagInfo, productTags } from '../db/schema'
|
import { productTagInfo, productTags } from '@/src/db/schema'
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';
|
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client';
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import redisClient from '@/src/lib/redis-client';
|
import redisClient from '@/src/lib/redis-client';
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { deliverySlotInfo, productSlots, productInfo, units } from '../db/schema'
|
import { deliverySlotInfo, productSlots, productInfo, units } from '@/src/db/schema'
|
||||||
import { eq, and, gt, asc } from 'drizzle-orm';
|
import { eq, and, gt, asc } from 'drizzle-orm';
|
||||||
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
@ -4,11 +4,6 @@ import { initializeProducts } from '@/src/stores/product-store'
|
||||||
import { initializeProductTagStore } from '@/src/stores/product-tag-store'
|
import { initializeProductTagStore } from '@/src/stores/product-tag-store'
|
||||||
import { initializeSlotStore } from '@/src/stores/slot-store'
|
import { initializeSlotStore } from '@/src/stores/slot-store'
|
||||||
import { initializeBannerStore } from '@/src/stores/banner-store'
|
import { initializeBannerStore } from '@/src/stores/banner-store'
|
||||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
|
||||||
|
|
||||||
// const STORE_INIT_DELAY_MS = 3 * 60 * 1000
|
|
||||||
const STORE_INIT_DELAY_MS = 0.5 * 60 * 1000
|
|
||||||
let storeInitializationTimeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all application stores
|
* Initialize all application stores
|
||||||
|
|
@ -34,27 +29,8 @@ export const initializeAllStores = async (): Promise<void> => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('All application stores initialized successfully');
|
console.log('All application stores initialized successfully');
|
||||||
|
|
||||||
// Regenerate all cache files (fire-and-forget)
|
|
||||||
createAllCacheFiles().catch(error => {
|
|
||||||
console.error('Failed to regenerate cache files during store initialization:', error)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Application stores initialization failed:', error);
|
console.error('Application stores initialization failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scheduleStoreInitialization = (): void => {
|
|
||||||
if (storeInitializationTimeout) {
|
|
||||||
clearTimeout(storeInitializationTimeout)
|
|
||||||
storeInitializationTimeout = null
|
|
||||||
}
|
|
||||||
|
|
||||||
storeInitializationTimeout = setTimeout(() => {
|
|
||||||
storeInitializationTimeout = null
|
|
||||||
initializeAllStores().catch(error => {
|
|
||||||
console.error('Scheduled store initialization failed:', error)
|
|
||||||
})
|
|
||||||
}, STORE_INIT_DELAY_MS)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import redisClient from '@/src/lib/redis-client';
|
import redisClient from '@/src/lib/redis-client';
|
||||||
import { db } from '../db/db_index'
|
import { db } from '@/src/db/db_index'
|
||||||
import { userIncidents } from '../db/schema'
|
import { userIncidents } from '@/src/db/schema'
|
||||||
import { eq, sum } from 'drizzle-orm';
|
import { eq, sum } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function initializeUserNegativityStore(): Promise<void> {
|
export async function initializeUserNegativityStore(): Promise<void> {
|
||||||
13
apps/backend/src/test-controller.ts
Normal file
13
apps/backend/src/test-controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', (req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Health check passed',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
32
apps/backend/src/trpc/apis/admin-apis/apis/address.ts
Normal file
32
apps/backend/src/trpc/apis/admin-apis/apis/address.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { addressZones, addressAreas } from '@/src/db/schema'
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { router,protectedProcedure } from '@/src/trpc/trpc-index'
|
||||||
|
|
||||||
|
const addressRouter = router({
|
||||||
|
getZones: protectedProcedure.query(async () => {
|
||||||
|
const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt));
|
||||||
|
return zones
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAreas: protectedProcedure.query(async () => {
|
||||||
|
const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt));
|
||||||
|
return areas
|
||||||
|
}),
|
||||||
|
|
||||||
|
createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => {
|
||||||
|
|
||||||
|
const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning();
|
||||||
|
return {zone: zone};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => {
|
||||||
|
const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning();
|
||||||
|
return {area};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// TODO: Add update and delete mutations if needed
|
||||||
|
});
|
||||||
|
|
||||||
|
export default addressRouter;
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { router } from '@/src/trpc/trpc-index'
|
import { router } from '@/src/trpc/trpc-index'
|
||||||
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
|
import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint'
|
||||||
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
|
import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon'
|
||||||
|
import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders'
|
||||||
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
|
import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order'
|
||||||
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
|
import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets'
|
||||||
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
|
import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots'
|
||||||
|
|
@ -9,15 +10,15 @@ import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product'
|
||||||
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
|
import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user'
|
||||||
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
|
import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store'
|
||||||
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
|
import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments'
|
||||||
|
import addressRouter from '@/src/trpc/apis/admin-apis/apis/address'
|
||||||
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner'
|
||||||
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user'
|
||||||
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const'
|
||||||
import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules'
|
|
||||||
import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag'
|
|
||||||
|
|
||||||
export const adminRouter = router({
|
export const adminRouter = router({
|
||||||
complaint: complaintRouter,
|
complaint: complaintRouter,
|
||||||
coupon: couponRouter,
|
coupon: couponRouter,
|
||||||
|
cancelledOrders: cancelledOrdersRouter,
|
||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
vendorSnippets: vendorSnippetsRouter,
|
vendorSnippets: vendorSnippetsRouter,
|
||||||
slots: slotsRouter,
|
slots: slotsRouter,
|
||||||
|
|
@ -25,11 +26,10 @@ export const adminRouter = router({
|
||||||
staffUser: staffUserRouter,
|
staffUser: staffUserRouter,
|
||||||
store: storeRouter,
|
store: storeRouter,
|
||||||
payments: adminPaymentsRouter,
|
payments: adminPaymentsRouter,
|
||||||
|
address: addressRouter,
|
||||||
banner: bannerRouter,
|
banner: bannerRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
const: constRouter,
|
const: constRouter,
|
||||||
productAvailabilitySchedules: productAvailabilitySchedulesRouter,
|
|
||||||
tag: tagRouter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdminRouter = typeof adminRouter;
|
export type AdminRouter = typeof adminRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { homeBanners } from '@/src/db/schema'
|
||||||
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
|
import { protectedProcedure, router } from '@/src/trpc/trpc-index'
|
||||||
import { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client'
|
import { extractKeyFromPresignedUrl, generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||||
import { ApiError } from '@/src/lib/api-error';
|
import { ApiError } from '@/src/lib/api-error';
|
||||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||||
import { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
|
||||||
|
|
||||||
export const bannerRouter = router({
|
export const bannerRouter = router({
|
||||||
// Get all banners
|
// Get all banners
|
||||||
getBanners: protectedProcedure
|
getBanners: protectedProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const banners = await bannerDbService.getAllBanners()
|
|
||||||
|
const banners = await db.query.homeBanners.findMany({
|
||||||
|
orderBy: desc(homeBanners.createdAt), // Order by creation date instead
|
||||||
|
// Removed product relationship since we now use productIds array
|
||||||
|
});
|
||||||
|
|
||||||
// Convert S3 keys to signed URLs for client
|
// Convert S3 keys to signed URLs for client
|
||||||
const bannersWithSignedUrls = await Promise.all(
|
const bannersWithSignedUrls = await Promise.all(
|
||||||
|
|
@ -18,14 +24,16 @@ export const bannerRouter = router({
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
...banner,
|
...banner,
|
||||||
imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl,
|
imageUrl: banner.imageUrl ? await generateSignedUrlFromS3Url(banner.imageUrl) : banner.imageUrl,
|
||||||
|
// Ensure productIds is always an array
|
||||||
productIds: banner.productIds || [],
|
productIds: banner.productIds || [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||||
return {
|
return {
|
||||||
...banner,
|
...banner,
|
||||||
imageUrl: banner.imageUrl,
|
imageUrl: banner.imageUrl, // Keep original on error
|
||||||
|
// Ensure productIds is always an array
|
||||||
productIds: banner.productIds || [],
|
productIds: banner.productIds || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +43,10 @@ export const bannerRouter = router({
|
||||||
return {
|
return {
|
||||||
banners: bannersWithSignedUrls,
|
banners: bannersWithSignedUrls,
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
}
|
||||||
|
catch(e:any) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
|
||||||
throw new ApiError(e.message);
|
throw new ApiError(e.message);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -45,17 +55,23 @@ export const bannerRouter = router({
|
||||||
getBanner: protectedProcedure
|
getBanner: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const banner = await bannerDbService.getBannerById(input.id)
|
const banner = await db.query.homeBanners.findFirst({
|
||||||
|
where: eq(homeBanners.id, input.id),
|
||||||
|
// Removed product relationship since we now use productIds array
|
||||||
|
});
|
||||||
|
|
||||||
if (banner) {
|
if (banner) {
|
||||||
try {
|
try {
|
||||||
|
// Convert S3 key to signed URL for client
|
||||||
if (banner.imageUrl) {
|
if (banner.imageUrl) {
|
||||||
banner.imageUrl = scaffoldAssetUrl(banner.imageUrl);
|
banner.imageUrl = await generateSignedUrlFromS3Url(banner.imageUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
console.error(`Failed to generate signed URL for banner ${banner.id}:`, error);
|
||||||
|
// Keep original imageUrl on error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure productIds is always an array (handle migration compatibility)
|
||||||
if (!banner.productIds) {
|
if (!banner.productIds) {
|
||||||
banner.productIds = [];
|
banner.productIds = [];
|
||||||
}
|
}
|
||||||
|
|
@ -68,31 +84,32 @@ export const bannerRouter = router({
|
||||||
createBanner: protectedProcedure
|
createBanner: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
imageUrl: z.string(),
|
imageUrl: z.string().url(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
productIds: z.array(z.number()).optional(),
|
productIds: z.array(z.number()).optional(),
|
||||||
redirectUrl: z.string().url().optional(),
|
redirectUrl: z.string().url().optional(),
|
||||||
|
// serialNum removed completely
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
const imageUrl = extractKeyFromPresignedUrl(input.imageUrl)
|
||||||
|
const [banner] = await db.insert(homeBanners).values({
|
||||||
const banner = await bannerDbService.createBanner({
|
|
||||||
name: input.name,
|
name: input.name,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
productIds: input.productIds || [],
|
productIds: input.productIds || [],
|
||||||
redirectUrl: input.redirectUrl,
|
redirectUrl: input.redirectUrl,
|
||||||
serialNum: 999,
|
serialNum: 999, // Default value, not used
|
||||||
isActive: false,
|
isActive: false, // Default to inactive
|
||||||
})
|
}).returning();
|
||||||
|
|
||||||
scheduleStoreInitialization()
|
// Reinitialize stores to reflect changes
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
return banner;
|
return banner;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating banner:', error);
|
console.error('Error creating banner:', error);
|
||||||
throw error;
|
throw error; // Re-throw to maintain tRPC error handling
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -110,21 +127,31 @@ export const bannerRouter = router({
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { id, ...updateData } = input;
|
const { id, ...updateData } = input;
|
||||||
|
const incomingProductIds = input.productIds;
|
||||||
|
// Extract S3 key from presigned URL if imageUrl is provided
|
||||||
|
const processedData = {
|
||||||
|
...updateData,
|
||||||
|
...(updateData.imageUrl && {
|
||||||
|
imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
const processedData: any = { ...updateData }
|
// Handle serialNum null case
|
||||||
|
const finalData: any = { ...processedData };
|
||||||
if (updateData.imageUrl) {
|
if ('serialNum' in finalData && finalData.serialNum === null) {
|
||||||
processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl)
|
// Set to null explicitly
|
||||||
|
finalData.serialNum = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('serialNum' in processedData && processedData.serialNum === null) {
|
const [banner] = await db.update(homeBanners)
|
||||||
processedData.serialNum = null;
|
.set({ ...finalData, lastUpdated: new Date(), })
|
||||||
}
|
.where(eq(homeBanners.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
const banner = await bannerDbService.updateBannerById(id, processedData)
|
// Reinitialize stores to reflect changes
|
||||||
|
await initializeAllStores();
|
||||||
scheduleStoreInitialization()
|
|
||||||
|
|
||||||
return banner;
|
return banner;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -137,9 +164,10 @@ export const bannerRouter = router({
|
||||||
deleteBanner: protectedProcedure
|
deleteBanner: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await bannerDbService.deleteBannerById(input.id)
|
await db.delete(homeBanners).where(eq(homeBanners.id, input.id));
|
||||||
|
|
||||||
scheduleStoreInitialization()
|
// Reinitialize stores to reflect changes
|
||||||
|
await initializeAllStores();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
179
apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts
Normal file
179
apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema'
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const updateCancellationReviewSchema = z.object({
|
||||||
|
orderId: z.number(),
|
||||||
|
cancellationReviewed: z.boolean(),
|
||||||
|
adminNotes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRefundSchema = z.object({
|
||||||
|
orderId: z.number(),
|
||||||
|
isRefundDone: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelledOrdersRouter = router({
|
||||||
|
getAll: protectedProcedure
|
||||||
|
.query(async () => {
|
||||||
|
// First get cancelled order statuses with order details
|
||||||
|
const cancelledOrderStatuses = await db.query.orderStatus.findMany({
|
||||||
|
where: eq(orderStatus.isCancelled, true),
|
||||||
|
with: {
|
||||||
|
order: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
address: true,
|
||||||
|
orderItems: {
|
||||||
|
with: {
|
||||||
|
product: {
|
||||||
|
with: {
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
refunds: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [desc(orderStatus.orderTime)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredStatuses = cancelledOrderStatuses.filter(status => {
|
||||||
|
return status.order.isCod || status.paymentStatus === 'success';
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredStatuses.map(status => {
|
||||||
|
const refund = status.order.refunds[0];
|
||||||
|
return {
|
||||||
|
id: status.order.id,
|
||||||
|
readableId: status.order.id,
|
||||||
|
customerName: `${status.order.user.name}`,
|
||||||
|
address: `${status.order.address.addressLine1}, ${status.order.address.city}`,
|
||||||
|
totalAmount: status.order.totalAmount,
|
||||||
|
cancellationReviewed: status.cancellationReviewed || false,
|
||||||
|
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||||
|
adminNotes: status.order.adminNotes,
|
||||||
|
cancelReason: status.cancelReason,
|
||||||
|
paymentMode: status.order.isCod ? 'COD' : 'Online',
|
||||||
|
paymentStatus: status.paymentStatus || 'pending',
|
||||||
|
items: status.order.orderItems.map(item => ({
|
||||||
|
name: item.product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price,
|
||||||
|
unit: item.product.unit?.shortNotation,
|
||||||
|
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'),
|
||||||
|
})),
|
||||||
|
createdAt: status.order.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateReview: protectedProcedure
|
||||||
|
.input(updateCancellationReviewSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { orderId, cancellationReviewed, adminNotes } = input;
|
||||||
|
|
||||||
|
const result = await db.update(orderStatus)
|
||||||
|
.set({
|
||||||
|
cancellationReviewed,
|
||||||
|
cancellationAdminNotes: adminNotes || null,
|
||||||
|
cancellationReviewedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(orderStatus.orderId, orderId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error("Cancellation record not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
// Get cancelled order with full details
|
||||||
|
const cancelledOrderStatus = await db.query.orderStatus.findFirst({
|
||||||
|
where: eq(orderStatus.id, id),
|
||||||
|
with: {
|
||||||
|
order: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
address: true,
|
||||||
|
orderItems: {
|
||||||
|
with: {
|
||||||
|
product: {
|
||||||
|
with: {
|
||||||
|
unit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) {
|
||||||
|
throw new Error("Cancelled order not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refund details separately
|
||||||
|
const refund = await db.query.refunds.findFirst({
|
||||||
|
where: eq(refunds.orderId, cancelledOrderStatus.orderId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = cancelledOrderStatus.order;
|
||||||
|
|
||||||
|
// Format the response similar to the getAll method
|
||||||
|
const formattedOrder = {
|
||||||
|
id: order.id,
|
||||||
|
readableId: order.id,
|
||||||
|
customerName: order.user.name,
|
||||||
|
address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false,
|
||||||
|
isRefundDone: refund?.refundStatus === 'processed' || false,
|
||||||
|
adminNotes: cancelledOrderStatus.cancellationAdminNotes || null,
|
||||||
|
cancelReason: cancelledOrderStatus.cancelReason || null,
|
||||||
|
items: order.orderItems.map((item: any) => ({
|
||||||
|
name: item.product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: parseFloat(item.price.toString()),
|
||||||
|
unit: item.product.unit?.shortNotation || 'unit',
|
||||||
|
amount: parseFloat(item.price.toString()) * parseFloat(item.quantity),
|
||||||
|
image: item.product.images?.[0] || null,
|
||||||
|
})),
|
||||||
|
createdAt: order.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { order: formattedOrder };
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateRefund: protectedProcedure
|
||||||
|
.input(updateRefundSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { orderId, isRefundDone } = input;
|
||||||
|
|
||||||
|
const refundStatus = isRefundDone ? 'processed' : 'none';
|
||||||
|
const result = await db.update(refunds)
|
||||||
|
.set({
|
||||||
|
refundStatus,
|
||||||
|
refundProcessedAt: isRefundDone ? new Date() : null,
|
||||||
|
})
|
||||||
|
.where(eq(refunds.orderId, orderId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error("Cancellation record not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
import { db } from '@/src/db/db_index'
|
||||||
import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
import { complaints, users } from '@/src/db/schema'
|
||||||
|
import { eq, desc, lt, and } from 'drizzle-orm';
|
||||||
|
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
|
||||||
|
|
||||||
export const complaintRouter = router({
|
export const complaintRouter = router({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
|
|
@ -12,7 +14,27 @@ export const complaintRouter = router({
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { cursor, limit } = input;
|
const { cursor, limit } = input;
|
||||||
|
|
||||||
const complaintsData = await complaintDbService.getComplaints(cursor, limit);
|
let whereCondition = cursor
|
||||||
|
? lt(complaints.id, cursor)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const complaintsData = await db
|
||||||
|
.select({
|
||||||
|
id: complaints.id,
|
||||||
|
complaintBody: complaints.complaintBody,
|
||||||
|
userId: complaints.userId,
|
||||||
|
orderId: complaints.orderId,
|
||||||
|
isResolved: complaints.isResolved,
|
||||||
|
createdAt: complaints.createdAt,
|
||||||
|
userName: users.name,
|
||||||
|
userMobile: users.mobile,
|
||||||
|
images: complaints.images,
|
||||||
|
})
|
||||||
|
.from(complaints)
|
||||||
|
.leftJoin(users, eq(complaints.userId, users.id))
|
||||||
|
.where(whereCondition)
|
||||||
|
.orderBy(desc(complaints.id))
|
||||||
|
.limit(limit + 1);
|
||||||
|
|
||||||
const hasMore = complaintsData.length > limit;
|
const hasMore = complaintsData.length > limit;
|
||||||
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
|
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
|
||||||
|
|
@ -20,7 +42,7 @@ export const complaintRouter = router({
|
||||||
const complaintsWithSignedImages = await Promise.all(
|
const complaintsWithSignedImages = await Promise.all(
|
||||||
complaintsToReturn.map(async (c) => {
|
complaintsToReturn.map(async (c) => {
|
||||||
const signedImages = c.images
|
const signedImages = c.images
|
||||||
? scaffoldAssetUrl(c.images as string[])
|
? await generateSignedUrlsFromS3Urls(c.images as string[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -48,7 +70,10 @@ export const complaintRouter = router({
|
||||||
resolve: protectedProcedure
|
resolve: protectedProcedure
|
||||||
.input(z.object({ id: z.string(), response: z.string().optional() }))
|
.input(z.object({ id: z.string(), response: z.string().optional() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await complaintDbService.resolveComplaint(parseInt(input.id), input.response);
|
await db
|
||||||
|
.update(complaints)
|
||||||
|
.set({ isResolved: true, response: input.response })
|
||||||
|
.where(eq(complaints.id, parseInt(input.id)));
|
||||||
|
|
||||||
return { message: 'Complaint resolved successfully' };
|
return { message: 'Complaint resolved successfully' };
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { keyValStore } from '@/src/db/schema'
|
||||||
import { computeConstants } from '@/src/lib/const-store'
|
import { computeConstants } from '@/src/lib/const-store'
|
||||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
import { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
|
||||||
|
|
||||||
export const constRouter = router({
|
export const constRouter = router({
|
||||||
getConstants: protectedProcedure
|
getConstants: protectedProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
const constants = await constantDbService.getAllConstants();
|
|
||||||
|
const constants = await db.select().from(keyValStore);
|
||||||
|
|
||||||
const resp = constants.map(c => ({
|
const resp = constants.map(c => ({
|
||||||
key: c.key,
|
key: c.key,
|
||||||
|
|
@ -36,14 +38,23 @@ export const constRouter = router({
|
||||||
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
|
throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCount = await constantDbService.upsertConstants(constants);
|
await db.transaction(async (tx) => {
|
||||||
|
for (const { key, value } of constants) {
|
||||||
|
await tx.insert(keyValStore)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: keyValStore.key,
|
||||||
|
set: { value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh all constants in Redis after database update
|
// Refresh all constants in Redis after database update
|
||||||
await computeConstants();
|
await computeConstants();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
updatedCount,
|
updatedCount: constants.length,
|
||||||
keys: constants.map(c => c.key),
|
keys: constants.map(c => c.key),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema'
|
||||||
|
import { eq, and, like, or, inArray, lt } from 'drizzle-orm';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'
|
|
||||||
|
|
||||||
const createCouponBodySchema = z.object({
|
const createCouponBodySchema = z.object({
|
||||||
couponCode: z.string().optional(),
|
couponCode: z.string().optional(),
|
||||||
|
|
@ -49,7 +51,10 @@ export const couponRouter = router({
|
||||||
|
|
||||||
// If applicableUsers is provided, verify users exist
|
// If applicableUsers is provided, verify users exist
|
||||||
if (applicableUsers && applicableUsers.length > 0) {
|
if (applicableUsers && applicableUsers.length > 0) {
|
||||||
const existingUsers = await couponDbService.getUsersByIds(applicableUsers);
|
const existingUsers = await db.query.users.findMany({
|
||||||
|
where: inArray(users.id, applicableUsers),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
if (existingUsers.length !== applicableUsers.length) {
|
if (existingUsers.length !== applicableUsers.length) {
|
||||||
throw new Error("Some applicable users not found");
|
throw new Error("Some applicable users not found");
|
||||||
}
|
}
|
||||||
|
|
@ -64,40 +69,56 @@ export const couponRouter = router({
|
||||||
// Generate coupon code if not provided
|
// Generate coupon code if not provided
|
||||||
let finalCouponCode = couponCode;
|
let finalCouponCode = couponCode;
|
||||||
if (!finalCouponCode) {
|
if (!finalCouponCode) {
|
||||||
|
// Generate a unique coupon code
|
||||||
const timestamp = Date.now().toString().slice(-6);
|
const timestamp = Date.now().toString().slice(-6);
|
||||||
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
finalCouponCode = `MF${timestamp}${random}`;
|
finalCouponCode = `MF${timestamp}${random}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if coupon code already exists
|
// Check if coupon code already exists
|
||||||
const existingCoupon = await couponDbService.getCouponByCode(finalCouponCode);
|
const existingCoupon = await db.query.coupons.findFirst({
|
||||||
|
where: eq(coupons.couponCode, finalCouponCode),
|
||||||
|
});
|
||||||
|
|
||||||
if (existingCoupon) {
|
if (existingCoupon) {
|
||||||
throw new Error("Coupon code already exists");
|
throw new Error("Coupon code already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const coupon = await couponDbService.createCoupon({
|
const result = await db.insert(coupons).values({
|
||||||
couponCode: finalCouponCode,
|
couponCode: finalCouponCode,
|
||||||
isUserBased: isUserBased || false,
|
isUserBased: isUserBased || false,
|
||||||
discountPercent: discountPercent?.toString() || null,
|
discountPercent: discountPercent?.toString(),
|
||||||
flatDiscount: flatDiscount?.toString() || null,
|
flatDiscount: flatDiscount?.toString(),
|
||||||
minOrder: minOrder?.toString() || null,
|
minOrder: minOrder?.toString(),
|
||||||
productIds: productIds || null,
|
productIds: productIds || null,
|
||||||
createdBy: staffUserId,
|
createdBy: staffUserId,
|
||||||
maxValue: maxValue?.toString() || null,
|
maxValue: maxValue?.toString(),
|
||||||
isApplyForAll: isApplyForAll || false,
|
isApplyForAll: isApplyForAll || false,
|
||||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||||
maxLimitForUser: maxLimitForUser || null,
|
maxLimitForUser: maxLimitForUser,
|
||||||
exclusiveApply: exclusiveApply || false,
|
exclusiveApply: exclusiveApply || false,
|
||||||
});
|
}).returning();
|
||||||
|
|
||||||
|
const coupon = result[0];
|
||||||
|
|
||||||
// Insert applicable users
|
// Insert applicable users
|
||||||
if (applicableUsers && applicableUsers.length > 0) {
|
if (applicableUsers && applicableUsers.length > 0) {
|
||||||
await couponDbService.addApplicableUsers(coupon.id, applicableUsers);
|
await db.insert(couponApplicableUsers).values(
|
||||||
|
applicableUsers.map(userId => ({
|
||||||
|
couponId: coupon.id,
|
||||||
|
userId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert applicable products
|
// Insert applicable products
|
||||||
if (applicableProducts && applicableProducts.length > 0) {
|
if (applicableProducts && applicableProducts.length > 0) {
|
||||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
await db.insert(couponApplicableProducts).values(
|
||||||
|
applicableProducts.map(productId => ({
|
||||||
|
couponId: coupon.id,
|
||||||
|
productId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return coupon;
|
return coupon;
|
||||||
|
|
@ -112,7 +133,39 @@ export const couponRouter = router({
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { cursor, limit, search } = input;
|
const { cursor, limit, search } = input;
|
||||||
|
|
||||||
const result = await couponDbService.getAllCoupons({ cursor, limit, search });
|
let whereCondition = undefined;
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
conditions.push(lt(coupons.id, cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
conditions.push(like(coupons.couponCode, `%${search}%`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
whereCondition = and(...conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.coupons.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
with: {
|
||||||
|
creator: true,
|
||||||
|
applicableUsers: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
applicableProducts: {
|
||||||
|
with: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (coupons, { desc }) => [desc(coupons.createdAt)],
|
||||||
|
limit: limit + 1,
|
||||||
|
});
|
||||||
|
|
||||||
const hasMore = result.length > limit;
|
const hasMore = result.length > limit;
|
||||||
const couponsList = hasMore ? result.slice(0, limit) : result;
|
const couponsList = hasMore ? result.slice(0, limit) : result;
|
||||||
|
|
@ -124,7 +177,24 @@ export const couponRouter = router({
|
||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const result = await couponDbService.getCouponById(input.id);
|
const couponId = input.id;
|
||||||
|
|
||||||
|
const result = await db.query.coupons.findFirst({
|
||||||
|
where: eq(coupons.id, couponId),
|
||||||
|
with: {
|
||||||
|
creator: true,
|
||||||
|
applicableUsers: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
applicableProducts: {
|
||||||
|
with: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("Coupon not found");
|
throw new Error("Coupon not found");
|
||||||
|
|
@ -157,7 +227,7 @@ export const couponRouter = router({
|
||||||
|
|
||||||
// If updating to user-based, applicableUsers is required
|
// If updating to user-based, applicableUsers is required
|
||||||
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
|
if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) {
|
||||||
const existingCount = await couponDbService.countApplicableUsers(id);
|
const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id));
|
||||||
if (existingCount === 0) {
|
if (existingCount === 0) {
|
||||||
throw new Error("applicableUsers is required for user-based coupons");
|
throw new Error("applicableUsers is required for user-based coupons");
|
||||||
}
|
}
|
||||||
|
|
@ -165,14 +235,17 @@ export const couponRouter = router({
|
||||||
|
|
||||||
// If applicableUsers is provided, verify users exist
|
// If applicableUsers is provided, verify users exist
|
||||||
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
|
if (updates.applicableUsers && updates.applicableUsers.length > 0) {
|
||||||
const existingUsers = await couponDbService.getUsersByIds(updates.applicableUsers);
|
const existingUsers = await db.query.users.findMany({
|
||||||
|
where: inArray(users.id, updates.applicableUsers),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
if (existingUsers.length !== updates.applicableUsers.length) {
|
if (existingUsers.length !== updates.applicableUsers.length) {
|
||||||
throw new Error("Some applicable users not found");
|
throw new Error("Some applicable users not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = { ...updates };
|
const updateData: any = { ...updates };
|
||||||
delete updateData.applicableUsers;
|
delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table
|
||||||
if (updates.discountPercent !== undefined) {
|
if (updates.discountPercent !== undefined) {
|
||||||
updateData.discountPercent = updates.discountPercent?.toString();
|
updateData.discountPercent = updates.discountPercent?.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -189,31 +262,60 @@ export const couponRouter = router({
|
||||||
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
|
updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await couponDbService.updateCoupon(id, updateData);
|
const result = await db.update(coupons)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(coupons.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error("Coupon not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('updated coupon successfully')
|
||||||
|
|
||||||
// Update applicable users: delete existing and insert new
|
// Update applicable users: delete existing and insert new
|
||||||
if (updates.applicableUsers !== undefined) {
|
if (updates.applicableUsers !== undefined) {
|
||||||
await couponDbService.removeAllApplicableUsers(id);
|
await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id));
|
||||||
if (updates.applicableUsers.length > 0) {
|
if (updates.applicableUsers.length > 0) {
|
||||||
await couponDbService.addApplicableUsers(id, updates.applicableUsers);
|
await db.insert(couponApplicableUsers).values(
|
||||||
|
updates.applicableUsers.map(userId => ({
|
||||||
|
couponId: id,
|
||||||
|
userId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update applicable products: delete existing and insert new
|
// Update applicable products: delete existing and insert new
|
||||||
if (updates.applicableProducts !== undefined) {
|
if (updates.applicableProducts !== undefined) {
|
||||||
await couponDbService.removeAllApplicableProducts(id);
|
await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id));
|
||||||
if (updates.applicableProducts.length > 0) {
|
if (updates.applicableProducts.length > 0) {
|
||||||
await couponDbService.addApplicableProducts(id, updates.applicableProducts);
|
await db.insert(couponApplicableProducts).values(
|
||||||
|
updates.applicableProducts.map(productId => ({
|
||||||
|
couponId: id,
|
||||||
|
productId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result[0];
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await couponDbService.invalidateCoupon(input.id);
|
const { id } = input;
|
||||||
|
|
||||||
|
const result = await db.update(coupons)
|
||||||
|
.set({ isInvalidated: true })
|
||||||
|
.where(eq(coupons.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error("Coupon not found");
|
||||||
|
}
|
||||||
|
|
||||||
return { message: "Coupon invalidated successfully" };
|
return { message: "Coupon invalidated successfully" };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -226,9 +328,14 @@ export const couponRouter = router({
|
||||||
return { valid: false, message: "Invalid coupon code" };
|
return { valid: false, message: "Invalid coupon code" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const coupon = await couponDbService.getCouponByCode(code.toUpperCase());
|
const coupon = await db.query.coupons.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(coupons.couponCode, code.toUpperCase()),
|
||||||
|
eq(coupons.isInvalidated, false)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
if (!coupon || coupon.isInvalidated) {
|
if (!coupon) {
|
||||||
return { valid: false, message: "Coupon not found or invalidated" };
|
return { valid: false, message: "Coupon not found or invalidated" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,39 +383,73 @@ export const couponRouter = router({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
generateCancellationCoupon: protectedProcedure
|
generateCancellationCoupon: protectedProcedure
|
||||||
.input(z.object({ orderId: z.number() }))
|
.input(
|
||||||
|
z.object({
|
||||||
|
orderId: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { orderId } = input;
|
const { orderId } = input;
|
||||||
|
|
||||||
|
// Get staff user ID from auth middleware
|
||||||
const staffUserId = ctx.staffUser?.id;
|
const staffUserId = ctx.staffUser?.id;
|
||||||
if (!staffUserId) {
|
if (!staffUserId) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = await couponDbService.getOrderByIdWithUserAndStatus(orderId);
|
// Find the order with user and order status information
|
||||||
|
const order = await db.query.orders.findFirst({
|
||||||
|
where: eq(orders.id, orderId),
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
orderStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error("Order not found");
|
throw new Error("Order not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if order is cancelled (check if any status entry has isCancelled: true)
|
||||||
|
// const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false;
|
||||||
|
// if (!isOrderCancelled) {
|
||||||
|
// throw new Error("Order is not cancelled");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Check if payment method is COD
|
||||||
|
// if (order.isCod) {
|
||||||
|
// throw new Error("Can't generate refund coupon for CoD Order");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
if (!order.user) {
|
if (!order.user) {
|
||||||
throw new Error("User not found for this order");
|
throw new Error("User not found for this order");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate coupon code: first 3 letters of user name or mobile + orderId
|
||||||
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
|
const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase();
|
||||||
const couponCode = `${userNamePrefix}${orderId}`;
|
const couponCode = `${userNamePrefix}${orderId}`;
|
||||||
|
|
||||||
const existingCoupon = await couponDbService.getCouponByCode(couponCode);
|
// Check if coupon code already exists
|
||||||
|
const existingCoupon = await db.query.coupons.findFirst({
|
||||||
|
where: eq(coupons.couponCode, couponCode),
|
||||||
|
});
|
||||||
|
|
||||||
if (existingCoupon) {
|
if (existingCoupon) {
|
||||||
throw new Error("Coupon code already exists");
|
throw new Error("Coupon code already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get order total amount
|
||||||
const orderAmount = parseFloat(order.totalAmount);
|
const orderAmount = parseFloat(order.totalAmount);
|
||||||
|
|
||||||
|
// Calculate expiry date (30 days from now)
|
||||||
const expiryDate = new Date();
|
const expiryDate = new Date();
|
||||||
expiryDate.setDate(expiryDate.getDate() + 30);
|
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||||
|
|
||||||
const coupon = await couponDbService.withTransaction(async (tx) => {
|
// Create the coupon and update order status in a transaction
|
||||||
const newCoupon = await couponDbService.createCoupon({
|
const coupon = await db.transaction(async (tx) => {
|
||||||
|
// Create the coupon
|
||||||
|
const result = await tx.insert(coupons).values({
|
||||||
couponCode,
|
couponCode,
|
||||||
isUserBased: true,
|
isUserBased: true,
|
||||||
flatDiscount: orderAmount.toString(),
|
flatDiscount: orderAmount.toString(),
|
||||||
|
|
@ -318,12 +459,22 @@ export const couponRouter = router({
|
||||||
maxLimitForUser: 1,
|
maxLimitForUser: 1,
|
||||||
createdBy: staffUserId,
|
createdBy: staffUserId,
|
||||||
isApplyForAll: false,
|
isApplyForAll: false,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
const coupon = result[0];
|
||||||
|
|
||||||
|
// Insert applicable users
|
||||||
|
await tx.insert(couponApplicableUsers).values({
|
||||||
|
couponId: coupon.id,
|
||||||
|
userId: order.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await couponDbService.addApplicableUsers(newCoupon.id, [order.userId]);
|
// Update order_status with refund coupon ID
|
||||||
await couponDbService.updateOrderStatusRefundCoupon(orderId, newCoupon.id);
|
await tx.update(orderStatus)
|
||||||
|
.set({ refundCouponId: coupon.id })
|
||||||
|
.where(eq(orderStatus.orderId, orderId));
|
||||||
|
|
||||||
return newCoupon;
|
return coupon;
|
||||||
});
|
});
|
||||||
|
|
||||||
return coupon;
|
return coupon;
|
||||||
|
|
@ -336,52 +487,100 @@ export const couponRouter = router({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const result = await couponDbService.getReservedCoupons(input);
|
const { cursor, limit, search } = input;
|
||||||
|
|
||||||
const hasMore = result.length > input.limit;
|
let whereCondition = undefined;
|
||||||
const coupons = hasMore ? result.slice(0, input.limit) : result;
|
const conditions = [];
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
conditions.push(lt(reservedCoupons.id, cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search && search.trim()) {
|
||||||
|
conditions.push(or(
|
||||||
|
like(reservedCoupons.secretCode, `%${search}%`),
|
||||||
|
like(reservedCoupons.couponCode, `%${search}%`)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
whereCondition = and(...conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.reservedCoupons.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
with: {
|
||||||
|
redeemedUser: true,
|
||||||
|
creator: true,
|
||||||
|
},
|
||||||
|
orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)],
|
||||||
|
limit: limit + 1, // Fetch one extra to check if there's more
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = result.length > limit;
|
||||||
|
const coupons = hasMore ? result.slice(0, limit) : result;
|
||||||
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
|
const nextCursor = hasMore ? result[result.length - 1].id : undefined;
|
||||||
|
|
||||||
return { coupons, nextCursor };
|
return {
|
||||||
|
coupons,
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createReservedCoupon: protectedProcedure
|
createReservedCoupon: protectedProcedure
|
||||||
.input(createCouponBodySchema)
|
.input(createCouponBodySchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { couponCode, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input;
|
const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input;
|
||||||
|
|
||||||
|
// Validation: ensure at least one discount type is provided
|
||||||
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
|
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
|
||||||
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
|
throw new Error("Either discountPercent or flatDiscount must be provided (but not both)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For reserved coupons, applicableUsers is not used, as it's redeemed by one user
|
||||||
|
|
||||||
|
// Get staff user ID from auth middleware
|
||||||
const staffUserId = ctx.staffUser?.id;
|
const staffUserId = ctx.staffUser?.id;
|
||||||
if (!staffUserId) {
|
if (!staffUserId) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate secret code if not provided (use couponCode as base)
|
||||||
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
||||||
|
|
||||||
const existing = await couponDbService.getCouponByCode(secretCode);
|
// Check if secret code already exists
|
||||||
|
const existing = await db.query.reservedCoupons.findFirst({
|
||||||
|
where: eq(reservedCoupons.secretCode, secretCode),
|
||||||
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error("Secret code already exists");
|
throw new Error("Secret code already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const coupon = await couponDbService.createReservedCoupon({
|
const result = await db.insert(reservedCoupons).values({
|
||||||
secretCode,
|
secretCode,
|
||||||
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
|
couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`,
|
||||||
discountPercent: discountPercent?.toString() || null,
|
discountPercent: discountPercent?.toString(),
|
||||||
flatDiscount: flatDiscount?.toString() || null,
|
flatDiscount: flatDiscount?.toString(),
|
||||||
minOrder: minOrder?.toString() || null,
|
minOrder: minOrder?.toString(),
|
||||||
productIds: productIds || null,
|
productIds,
|
||||||
maxValue: maxValue?.toString() || null,
|
maxValue: maxValue?.toString(),
|
||||||
validTill: validTill ? dayjs(validTill).toDate() : null,
|
validTill: validTill ? dayjs(validTill).toDate() : undefined,
|
||||||
maxLimitForUser: maxLimitForUser || null,
|
maxLimitForUser,
|
||||||
exclusiveApply: exclusiveApply || false,
|
exclusiveApply: exclusiveApply || false,
|
||||||
createdBy: staffUserId,
|
createdBy: staffUserId,
|
||||||
});
|
}).returning();
|
||||||
|
|
||||||
|
const coupon = result[0];
|
||||||
|
|
||||||
|
// Insert applicable products if provided
|
||||||
if (applicableProducts && applicableProducts.length > 0) {
|
if (applicableProducts && applicableProducts.length > 0) {
|
||||||
await couponDbService.addApplicableProducts(coupon.id, applicableProducts);
|
await db.insert(couponApplicableProducts).values(
|
||||||
|
applicableProducts.map(productId => ({
|
||||||
|
couponId: coupon.id,
|
||||||
|
productId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return coupon;
|
return coupon;
|
||||||
|
|
@ -394,11 +593,27 @@ export const couponRouter = router({
|
||||||
offset: z.number().min(0).default(0),
|
offset: z.number().min(0).default(0),
|
||||||
}))
|
}))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { search, limit, offset } = input;
|
const { search, limit } = input;
|
||||||
|
|
||||||
const userList = search
|
let whereCondition = undefined;
|
||||||
? await couponDbService.getUsersBySearch(search, limit, offset)
|
if (search && search.trim()) {
|
||||||
: await couponDbService.getUsersByIds([]);
|
whereCondition = or(
|
||||||
|
like(users.name, `%${search}%`),
|
||||||
|
like(users.mobile, `%${search}%`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userList = await db.query.users.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
mobile: true,
|
||||||
|
},
|
||||||
|
limit: limit,
|
||||||
|
offset: input.offset,
|
||||||
|
orderBy: (users, { asc }) => [asc(users.name)],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: userList.map(user => ({
|
users: userList.map(user => ({
|
||||||
|
|
@ -410,54 +625,74 @@ export const couponRouter = router({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createCoupon: protectedProcedure
|
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 }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { mobile } = input;
|
const { mobile } = input;
|
||||||
|
|
||||||
|
// Get staff user ID from auth middleware
|
||||||
const staffUserId = ctx.staffUser?.id;
|
const staffUserId = ctx.staffUser?.id;
|
||||||
if (!staffUserId) {
|
if (!staffUserId) {
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean mobile number (remove non-digits)
|
||||||
const cleanMobile = mobile.replace(/\D/g, '');
|
const cleanMobile = mobile.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Validate: exactly 10 digits
|
||||||
if (cleanMobile.length !== 10) {
|
if (cleanMobile.length !== 10) {
|
||||||
throw new Error("Mobile number must be exactly 10 digits");
|
throw new Error("Mobile number must be exactly 10 digits");
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = await couponDbService.getUserByMobile(cleanMobile);
|
// Check if user exists, create if not
|
||||||
|
let user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.mobile, cleanMobile),
|
||||||
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await couponDbService.createUser({
|
// Create new user
|
||||||
|
const [newUser] = await db.insert(users).values({
|
||||||
name: null,
|
name: null,
|
||||||
email: null,
|
email: null,
|
||||||
mobile: cleanMobile,
|
mobile: cleanMobile,
|
||||||
});
|
}).returning();
|
||||||
|
user = newUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate unique coupon code
|
||||||
const timestamp = Date.now().toString().slice(-6);
|
const timestamp = Date.now().toString().slice(-6);
|
||||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||||
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
|
const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`;
|
||||||
|
|
||||||
const existingCode = await couponDbService.getCouponByCode(couponCode);
|
// Check if coupon code already exists (very unlikely but safe)
|
||||||
|
const existingCode = await db.query.coupons.findFirst({
|
||||||
|
where: eq(coupons.couponCode, couponCode),
|
||||||
|
});
|
||||||
|
|
||||||
if (existingCode) {
|
if (existingCode) {
|
||||||
throw new Error("Generated coupon code already exists - please try again");
|
throw new Error("Generated coupon code already exists - please try again");
|
||||||
}
|
}
|
||||||
|
|
||||||
const coupon = await couponDbService.createCoupon({
|
// Create the coupon
|
||||||
|
const [coupon] = await db.insert(coupons).values({
|
||||||
couponCode,
|
couponCode,
|
||||||
isUserBased: true,
|
isUserBased: true,
|
||||||
discountPercent: "20",
|
discountPercent: "20", // 20% discount
|
||||||
minOrder: "1000",
|
minOrder: "1000", // ₹1000 minimum order
|
||||||
maxValue: "500",
|
maxValue: "500", // ₹500 maximum discount
|
||||||
maxLimitForUser: 1,
|
maxLimitForUser: 1, // One-time use
|
||||||
isApplyForAll: false,
|
isApplyForAll: false,
|
||||||
exclusiveApply: false,
|
exclusiveApply: false,
|
||||||
createdBy: staffUserId,
|
createdBy: staffUserId,
|
||||||
validTill: dayjs().add(90, 'days').toDate(),
|
validTill: dayjs().add(90, 'days').toDate(), // 90 days from now
|
||||||
});
|
}).returning();
|
||||||
|
|
||||||
await couponDbService.addApplicableUsers(coupon.id, [user.id]);
|
// Associate coupon with user
|
||||||
|
await db.insert(couponApplicableUsers).values({
|
||||||
|
couponId: coupon.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue