Compare commits
47 commits
main
...
backend_ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4199ff7d9b | ||
|
|
8ea26f5705 | ||
|
|
dc21636b3f | ||
|
|
55bfd1aafa | ||
|
|
b27e05aab0 | ||
|
|
6b4f512d90 | ||
|
|
1f42cfbc5e | ||
|
|
982d3027f8 | ||
|
|
15991f46db | ||
|
|
b86fa8a2e0 | ||
|
|
7432f8dfd5 | ||
|
|
18f36107d8 | ||
|
|
1b042819af | ||
|
|
639428caba | ||
|
|
68103010c6 | ||
|
|
128e3b6a58 | ||
|
|
ca7d8df1c8 | ||
|
|
5e9bc3e38e | ||
|
|
89de986764 | ||
|
|
9137b5e1e6 | ||
|
|
fe05769343 | ||
|
|
4414f9f64b | ||
|
|
3c836e274d | ||
|
|
306244e8df | ||
|
|
038733c14a | ||
|
|
d9652405ca | ||
|
|
97812fa4c5 | ||
|
|
44e53d2978 | ||
|
|
a5bde12f19 | ||
|
|
31029cc3a7 | ||
|
|
a4758ea9cd | ||
|
|
0c84808637 | ||
|
|
f2763b0597 | ||
|
|
8f48ec39c2 | ||
|
|
5d598b0752 | ||
|
|
4aab508286 | ||
|
|
ad2447d14e | ||
|
|
b4caa383b5 | ||
|
|
a7350914e0 | ||
| 76c43d869d | |||
|
|
2d37726c62 | ||
|
|
5df040de9a | ||
|
|
ca9eb8a7d2 | ||
|
|
aa900db3e1 | ||
|
|
f7c55ea492 | ||
|
|
c14e32522a | ||
|
|
a4218ee1ad |
531 changed files with 545654 additions and 37390 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
apps/users-ui/app
|
||||||
|
apps/users-ui/src
|
||||||
|
apps/admin-ui/app
|
||||||
|
apps/users-ui/src
|
||||||
|
**/package-lock.json
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
**/.wrangler/*
|
||||||
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
# Agent Instructions for Meat Farmer Monorepo
|
# Agent Instructions for Meat Farmer Monorepo
|
||||||
|
|
||||||
## Important instructions
|
## Important instructions
|
||||||
- Don't try to build the code or run or compile it. Just make changes and leave the rest for the user.
|
|
||||||
- Don't run any drizzle migrations. User will handle it.
|
- Don't run any drizzle migrations. User will handle it.
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
@ -48,6 +47,4 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
|
||||||
- Database: Drizzle ORM with PostgreSQL
|
- Database: Drizzle ORM with PostgreSQL
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
|
|
||||||
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
|
|
||||||
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
||||||
|
|
|
||||||
4
APIS_TO_REMOVE.md
Normal file
4
APIS_TO_REMOVE.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
||||||
|
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
||||||
|
- remove slots from products cache
|
||||||
|
- remove redundant product details like name, description etc from the slots api
|
||||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,32 +1,36 @@
|
||||||
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||||
|
|
||||||
# 1. ---- Base Node image
|
# 1. ---- Base Bun image
|
||||||
FROM node:20-slim AS base
|
FROM oven/bun:1.3.10 AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 2. ---- Pruner ----
|
# 2. ---- Pruner ----
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Copy config files first for better caching
|
# Copy config files first for better caching
|
||||||
COPY package.json package-lock.json turbo.json ./
|
COPY package.json turbo.json ./
|
||||||
COPY apps/backend/package.json ./apps/backend/
|
COPY apps/backend/package.json ./apps/backend/
|
||||||
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||||
|
COPY packages/shared/ ./packages/shared
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
RUN npm install -g turbo
|
RUN bun install -g turbo
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
|
||||||
|
# RUN find . -path "./node_modules" -prune -o -print
|
||||||
|
|
||||||
# 3. ---- Builder ----
|
# 3. ---- Builder ----
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Copy package files first to cache npm install
|
# Copy package files first to cache bun install
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||||
|
#RUN cat ./bun.lock
|
||||||
COPY --from=pruner /app/turbo.json .
|
COPY --from=pruner /app/turbo.json .
|
||||||
RUN npm ci
|
RUN bun install
|
||||||
# Copy source code after dependencies are installed
|
# Copy source code after dependencies are installed
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
|
||||||
|
RUN find . -path "./node_modules" -prune -o -print
|
||||||
|
|
||||||
# 4. ---- Runner ----
|
# 4. ---- Runner ----
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
@ -34,12 +38,15 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Copy package files and install production deps
|
# Copy package files and install production deps
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||||
RUN npm ci --production --omit=dev
|
RUN bun install --production
|
||||||
# Copy built applications
|
# Copy built applications
|
||||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||||
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||||
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
|
|
||||||
|
# RUN ls -R
|
||||||
|
RUN find . -path "./node_modules" -prune -o -print
|
||||||
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
RUN npm i -g bun
|
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
|
||||||
CMD ["bun", "apps/backend/dist/index.js"]
|
|
||||||
# CMD ["node", "apps/backend/dist/index.js"]
|
|
||||||
|
|
|
||||||
83
apps/admin-ui/.detoxrc.js
Normal file
83
apps/admin-ui/.detoxrc.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/** @type {Detox.DetoxConfig} */
|
||||||
|
module.exports = {
|
||||||
|
testRunner: {
|
||||||
|
args: {
|
||||||
|
'$0': 'jest',
|
||||||
|
config: 'e2e/jest.config.js'
|
||||||
|
},
|
||||||
|
jest: {
|
||||||
|
setupTimeout: 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
'ios.debug': {
|
||||||
|
type: 'ios.app',
|
||||||
|
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YOUR_APP.app',
|
||||||
|
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
|
||||||
|
},
|
||||||
|
'ios.release': {
|
||||||
|
type: 'ios.app',
|
||||||
|
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YOUR_APP.app',
|
||||||
|
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
|
||||||
|
},
|
||||||
|
'android.debug': {
|
||||||
|
type: 'android.apk',
|
||||||
|
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||||
|
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
|
||||||
|
reversePorts: [
|
||||||
|
8081
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'android.release': {
|
||||||
|
type: 'android.apk',
|
||||||
|
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
|
||||||
|
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
simulator: {
|
||||||
|
type: 'ios.simulator',
|
||||||
|
device: {
|
||||||
|
type: 'iPhone 15'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attached: {
|
||||||
|
type: 'android.attached',
|
||||||
|
device: {
|
||||||
|
adbName: '.*'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emulator: {
|
||||||
|
type: 'android.emulator',
|
||||||
|
device: {
|
||||||
|
avdName: 'Pixel_3a_API_30_x86'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurations: {
|
||||||
|
'ios.sim.debug': {
|
||||||
|
device: 'simulator',
|
||||||
|
app: 'ios.debug'
|
||||||
|
},
|
||||||
|
'ios.sim.release': {
|
||||||
|
device: 'simulator',
|
||||||
|
app: 'ios.release'
|
||||||
|
},
|
||||||
|
'android.att.debug': {
|
||||||
|
device: 'attached',
|
||||||
|
app: 'android.debug'
|
||||||
|
},
|
||||||
|
'android.att.release': {
|
||||||
|
device: 'attached',
|
||||||
|
app: 'android.release'
|
||||||
|
},
|
||||||
|
'android.emu.debug': {
|
||||||
|
device: 'emulator',
|
||||||
|
app: 'android.debug'
|
||||||
|
},
|
||||||
|
'android.emu.release': {
|
||||||
|
device: 'emulator',
|
||||||
|
app: 'android.release'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -63,7 +63,21 @@
|
||||||
"backgroundColor": "#fff0f6"
|
"backgroundColor": "#fff0f6"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"package": "in.freshyo.adminui"
|
"package": "in.freshyo.adminui",
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"autoVerify": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"scheme": "https",
|
||||||
|
"host": "ui.freshyo.in",
|
||||||
|
"pathPrefix": "/manage-orders/order-details"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": ["BROWSABLE", "DEFAULT"]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
|
|
|
||||||
|
|
@ -226,9 +226,8 @@ export default function Layout() {
|
||||||
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||||
<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" }} />
|
||||||
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
|
||||||
import { BottomDialog , tw } from 'common-ui'
|
|
||||||
import { trpc } from '@/src/trpc-client'
|
|
||||||
import AddressZoneForm from '@/components/AddressZoneForm'
|
|
||||||
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
|
||||||
|
|
||||||
const AddressManagement: React.FC = () => {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
|
||||||
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
|
||||||
|
|
||||||
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
|
||||||
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
|
||||||
|
|
||||||
const createZone = trpc.admin.address.createZone.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
refetchZones()
|
|
||||||
setDialogOpen(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const createArea = trpc.admin.address.createArea.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
refetchAreas()
|
|
||||||
setDialogOpen(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleAddZone = () => {
|
|
||||||
setDialogType('zone')
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddPlace = () => {
|
|
||||||
setDialogType('place')
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleZone = (zoneId: number) => {
|
|
||||||
setExpandedZones(prev => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
if (newSet.has(zoneId)) {
|
|
||||||
newSet.delete(zoneId)
|
|
||||||
} else {
|
|
||||||
newSet.add(zoneId)
|
|
||||||
}
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedAreas = areas?.reduce((acc, area) => {
|
|
||||||
if (area.zoneId) {
|
|
||||||
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
|
||||||
acc[area.zoneId].push(area)
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {} as Record<number, typeof areas[0][]>) || {}
|
|
||||||
|
|
||||||
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={tw`flex-1 bg-white`}>
|
|
||||||
<View style={tw`flex-row justify-between p-4`}>
|
|
||||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
|
||||||
<Text style={tw`text-white`}>Add Zone</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
|
||||||
<Text style={tw`text-white`}>Add Place</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView style={tw`flex-1 p-4`}>
|
|
||||||
{zones?.map(zone => (
|
|
||||||
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
|
||||||
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
|
||||||
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
|
||||||
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
{expandedZones.has(zone.id) && (
|
|
||||||
<View style={tw`p-3`}>
|
|
||||||
{groupedAreas[zone.id]?.map(area => (
|
|
||||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
|
||||||
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View style={tw`mt-6`}>
|
|
||||||
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
|
||||||
{unzonedAreas.map(area => (
|
|
||||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
|
||||||
))}
|
|
||||||
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
|
||||||
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
|
||||||
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
|
||||||
</BottomDialog>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddressManagement
|
|
||||||
|
|
@ -9,6 +9,20 @@ export default function CreateCoupon() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createCoupon = trpc.admin.coupon.create.useMutation();
|
const createCoupon = trpc.admin.coupon.create.useMutation();
|
||||||
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
|
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
|
||||||
|
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: '' },
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: '' },
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const handleCreateCoupon = (values: any) => {
|
const handleCreateCoupon = (values: any) => {
|
||||||
console.log('Form values:', values); // Debug log
|
console.log('Form values:', values); // Debug log
|
||||||
|
|
@ -27,7 +41,9 @@ export default function CreateCoupon() {
|
||||||
if (isLoading) return; // Prevent double submission
|
if (isLoading) return; // Prevent double submission
|
||||||
|
|
||||||
mutation.mutate(payload, {
|
mutation.mutate(payload, {
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await refetchCoupons()
|
||||||
|
await refetchReservedCoupons()
|
||||||
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
|
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
|
||||||
{ text: 'OK', onPress: () => router.back() }
|
{ text: 'OK', onPress: () => router.back() }
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,21 @@ export default function EditCoupon() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const couponId = parseInt(id as string);
|
const couponId = parseInt(id as string);
|
||||||
|
|
||||||
const { data: coupon, isLoading } = trpc.admin.coupon.getById.useQuery({ id: couponId });
|
const { data: coupon, isLoading, refetch } = trpc.admin.coupon.getById.useQuery({ id: couponId });
|
||||||
|
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: '' },
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||||
|
{ limit: 20, search: '' },
|
||||||
|
{
|
||||||
|
enabled: false,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
}
|
||||||
|
)
|
||||||
const updateCoupon = trpc.admin.coupon.update.useMutation();
|
const updateCoupon = trpc.admin.coupon.update.useMutation();
|
||||||
|
|
||||||
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
|
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
|
||||||
|
|
@ -24,7 +38,10 @@ export default function EditCoupon() {
|
||||||
delete updates.targetUsers;
|
delete updates.targetUsers;
|
||||||
|
|
||||||
updateCoupon.mutate({ id: couponId, updates }, {
|
updateCoupon.mutate({ id: couponId, updates }, {
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await refetch()
|
||||||
|
await refetchCoupons()
|
||||||
|
await refetchReservedCoupons()
|
||||||
Alert.alert('Success', 'Coupon updated successfully', [
|
Alert.alert('Success', 'Coupon updated successfully', [
|
||||||
{ text: 'OK', onPress: () => router.back() }
|
{ text: 'OK', onPress: () => router.back() }
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,7 @@ import { trpc } from '../../../src/trpc-client';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
|
||||||
interface ConstantFormData {
|
type ConstantFormData = Record<string, any>
|
||||||
constants: ConstantItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConstantItem {
|
|
||||||
key: string;
|
|
||||||
value: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONST_LABELS: Record<string, string> = {
|
const CONST_LABELS: Record<string, string> = {
|
||||||
minRegularOrderValue: 'Minimum Regular Order Value',
|
minRegularOrderValue: 'Minimum Regular Order Value',
|
||||||
|
|
@ -37,23 +30,45 @@ const CONST_LABELS: Record<string, string> = {
|
||||||
supportEmail: 'Support Email',
|
supportEmail: 'Support Email',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONST_VISIBILITY: Record<string, boolean> = {
|
||||||
|
minRegularOrderValue: true,
|
||||||
|
freeDeliveryThreshold: true,
|
||||||
|
deliveryCharge: true,
|
||||||
|
flashFreeDeliveryThreshold: true,
|
||||||
|
flashDeliveryCharge: true,
|
||||||
|
platformFeePercent: true,
|
||||||
|
taxRate: false,
|
||||||
|
minOrderAmountForCoupon: true,
|
||||||
|
maxCouponDiscount: false,
|
||||||
|
flashDeliverySlotId: true,
|
||||||
|
readableOrderId: false,
|
||||||
|
versionNum: true,
|
||||||
|
playStoreUrl: true,
|
||||||
|
appStoreUrl: true,
|
||||||
|
popularItems: true,
|
||||||
|
allItemsOrder: true,
|
||||||
|
isFlashDeliveryEnabled: true,
|
||||||
|
supportMobile: true,
|
||||||
|
supportEmail: true,
|
||||||
|
tester: false,
|
||||||
|
};
|
||||||
|
|
||||||
interface ConstantInputProps {
|
interface ConstantInputProps {
|
||||||
constant: ConstantItem;
|
constantKey: string;
|
||||||
|
value: any;
|
||||||
setFieldValue: (field: string, value: any) => void;
|
setFieldValue: (field: string, value: any) => void;
|
||||||
index: number;
|
|
||||||
router: any;
|
router: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue, index, router }) => {
|
const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
|
||||||
const fieldName = `constants.${index}.value`;
|
const fieldName = constantKey
|
||||||
|
|
||||||
// Special handling for popularItems - show navigation button instead of input
|
// Special handling for popularItems - show navigation button instead of input
|
||||||
if (constant.key === 'popularItems') {
|
if (constantKey === 'popularItems') {
|
||||||
console.log('key is allItemsOrder')
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
{CONST_LABELS[constant.key] || constant.key}
|
{CONST_LABELS[constantKey] || constantKey}
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => router.push('/(drawer)/customize-app/popular-items')}
|
onPress={() => router.push('/(drawer)/customize-app/popular-items')}
|
||||||
|
|
@ -61,7 +76,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
>
|
>
|
||||||
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
|
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
|
||||||
<MyText style={tw`text-blue-700 font-medium`}>
|
<MyText style={tw`text-blue-700 font-medium`}>
|
||||||
Manage Popular Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
|
Manage Popular Items ({Array.isArray(value) ? value.length : 0} items)
|
||||||
</MyText>
|
</MyText>
|
||||||
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
|
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
|
|
@ -70,12 +85,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for allItemsOrder - show navigation button instead of input
|
// Special handling for allItemsOrder - show navigation button instead of input
|
||||||
if (constant.key === 'allItemsOrder') {
|
if (constantKey === 'allItemsOrder') {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
{CONST_LABELS[constant.key] || constant.key}
|
{CONST_LABELS[constantKey] || constantKey}
|
||||||
</MyText>
|
</MyText>
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
|
onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
|
||||||
|
|
@ -83,7 +98,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
>
|
>
|
||||||
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
|
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
|
||||||
<MyText style={tw`text-green-700 font-medium`}>
|
<MyText style={tw`text-green-700 font-medium`}>
|
||||||
Manage All Visible Items ({Array.isArray(constant.value) ? constant.value.length : 0} items)
|
Manage All Visible Items ({Array.isArray(value) ? value.length : 0} items)
|
||||||
</MyText>
|
</MyText>
|
||||||
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
|
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
|
||||||
</MyTouchableOpacity>
|
</MyTouchableOpacity>
|
||||||
|
|
@ -92,20 +107,20 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle boolean values - show checkbox
|
// Handle boolean values - show checkbox
|
||||||
if (typeof constant.value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||||
{CONST_LABELS[constant.key] || constant.key}
|
{CONST_LABELS[constantKey] || constantKey}
|
||||||
</MyText>
|
</MyText>
|
||||||
<View style={tw`flex-row items-center`}>
|
<View style={tw`flex-row items-center`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={constant.value}
|
checked={value}
|
||||||
onPress={() => setFieldValue(fieldName, !constant.value)}
|
onPress={() => setFieldValue(fieldName, !value)}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
<MyText style={tw`ml-3 text-gray-700`}>
|
<MyText style={tw`ml-3 text-gray-700`}>
|
||||||
{constant.value ? 'Enabled' : 'Disabled'}
|
{value ? 'Enabled' : 'Disabled'}
|
||||||
</MyText>
|
</MyText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -113,11 +128,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different value types
|
// Handle different value types
|
||||||
if (typeof constant.value === 'number') {
|
if (typeof value === 'number') {
|
||||||
return (
|
return (
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||||
value={constant.value.toString()}
|
value={value.toString()}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value) => {
|
||||||
const numValue = parseFloat(value);
|
const numValue = parseFloat(value);
|
||||||
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue);
|
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue);
|
||||||
|
|
@ -128,11 +143,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(constant.value)) {
|
if (Array.isArray(value)) {
|
||||||
return (
|
return (
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||||
value={constant.value.join(', ')}
|
value={value.join(', ')}
|
||||||
onChangeText={(value) => {
|
onChangeText={(value) => {
|
||||||
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
setFieldValue(fieldName, arrayValue);
|
setFieldValue(fieldName, arrayValue);
|
||||||
|
|
@ -145,9 +160,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
||||||
// Default to string
|
// Default to string
|
||||||
return (
|
return (
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||||
value={String(constant.value)}
|
// value={value === null || value === undefined ? '' : String(value)}
|
||||||
onChangeText={(value) => setFieldValue(fieldName, value)}
|
value={value}
|
||||||
|
onChangeText={(value) => {
|
||||||
|
setFieldValue(fieldName, value)
|
||||||
|
}}
|
||||||
placeholder="Enter value"
|
placeholder="Enter value"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -161,10 +179,13 @@ export default function CustomizeApp() {
|
||||||
|
|
||||||
const handleSubmit = (values: ConstantFormData) => {
|
const handleSubmit = (values: ConstantFormData) => {
|
||||||
// Filter out constants that haven't changed
|
// Filter out constants that haven't changed
|
||||||
const changedConstants = values.constants.filter((constant, index) => {
|
const changedConstants = (constants || []).filter((constant) => {
|
||||||
const original = constants?.[index];
|
const nextValue = values[constant.key]
|
||||||
return original && JSON.stringify(constant.value) !== JSON.stringify(original.value);
|
return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
|
||||||
});
|
}).map((constant) => ({
|
||||||
|
key: constant.key,
|
||||||
|
value: values[constant.key],
|
||||||
|
}))
|
||||||
|
|
||||||
if (changedConstants.length === 0) {
|
if (changedConstants.length === 0) {
|
||||||
Alert.alert('No Changes', 'No constants were modified.');
|
Alert.alert('No Changes', 'No constants were modified.');
|
||||||
|
|
@ -202,9 +223,10 @@ export default function CustomizeApp() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialValues: ConstantFormData = {
|
const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
|
||||||
constants: constants.map(c => ({ key: c.key, value: c.value ?? '' } as ConstantItem)),
|
acc[constant.key] = constant.value ?? ''
|
||||||
};
|
return acc
|
||||||
|
}, {} as ConstantFormData)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -219,11 +241,22 @@ export default function CustomizeApp() {
|
||||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||||
{({ handleSubmit, values, setFieldValue }) => (
|
{({ handleSubmit, values, setFieldValue }) => (
|
||||||
<View>
|
<View>
|
||||||
{values.constants.map((constant, index) => (
|
{constants.map((constant) => {
|
||||||
<View key={constant.key} style={tw`mb-4`}>
|
if (!CONST_VISIBILITY[constant.key]) {
|
||||||
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} />
|
return null
|
||||||
</View>
|
}
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<View key={constant.key} style={tw`mb-4`}>
|
||||||
|
<ConstantInput
|
||||||
|
constantKey={constant.key}
|
||||||
|
value={values[constant.key]}
|
||||||
|
setFieldValue={setFieldValue}
|
||||||
|
router={router}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
onPress={() => handleSubmit()}
|
onPress={() => handleSubmit()}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ export default function CreateBanner() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBannerMutation = trpc.admin.banner.createBanner.useMutation();
|
const createBannerMutation = trpc.admin.banner.createBanner.useMutation();
|
||||||
|
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
|
|
@ -39,6 +42,7 @@ export default function CreateBanner() {
|
||||||
redirectUrl: values.redirectUrl || undefined,
|
redirectUrl: values.redirectUrl || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await refetchBanners()
|
||||||
Alert.alert('Success', 'Banner created successfully', [
|
Alert.alert('Success', 'Banner created successfully', [
|
||||||
{
|
{
|
||||||
text: 'OK',
|
text: 'OK',
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export default function EditBanner() {
|
||||||
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
|
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
|
||||||
id: parseInt(bannerId)
|
id: parseInt(bannerId)
|
||||||
});
|
});
|
||||||
|
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
const [banner, setBanner] = useState<typeof bannerData>(undefined);
|
const [banner, setBanner] = useState<typeof bannerData>(undefined);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,6 +103,7 @@ export default function EditBanner() {
|
||||||
redirectUrl: values.redirectUrl || undefined,
|
redirectUrl: values.redirectUrl || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await refetchBanners()
|
||||||
Alert.alert('Success', 'Banner updated successfully', [
|
Alert.alert('Success', 'Banner updated successfully', [
|
||||||
{
|
{
|
||||||
text: 'OK',
|
text: 'OK',
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export default function Dashboard() {
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Manage Orders',
|
title: 'Manage Orderss',
|
||||||
icon: 'shopping-bag',
|
icon: 'shopping-bag',
|
||||||
description: 'View and manage customer orders',
|
description: 'View and manage customer orders',
|
||||||
route: '/(drawer)/manage-orders',
|
route: '/(drawer)/manage-orders',
|
||||||
|
|
@ -176,15 +176,6 @@ export default function Dashboard() {
|
||||||
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',
|
||||||
icon: 'settings-applications',
|
icon: 'settings-applications',
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ export default function OrderDetails() {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Success",
|
"Success",
|
||||||
`Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
|
`Refund initiated successfully!\n\nAmount: `
|
||||||
|
// `Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
|
||||||
);
|
);
|
||||||
setInitiateRefundDialogOpen(false);
|
setInitiateRefundDialogOpen(false);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Alert } from 'react-native';
|
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, type ImageUploaderNeoItem } 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';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
interface TagFormData {
|
interface TagFormData {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
|
|
@ -15,50 +15,51 @@ interface TagFormData {
|
||||||
|
|
||||||
export default function AddTag() {
|
export default function AddTag() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutate: createTag, isPending: isCreating } = useCreateTag();
|
const createTag = trpc.admin.product.createProductTag.useMutation();
|
||||||
|
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||||
|
const { upload, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
|
||||||
const formData = new FormData();
|
try {
|
||||||
|
let imageUrl: string | null | undefined;
|
||||||
|
let uploadUrls: string[] = []
|
||||||
|
|
||||||
// Add text fields
|
const newImage = images.find((image) => image.mimeType !== null)
|
||||||
formData.append('tagName', values.tagName);
|
if (newImage) {
|
||||||
if (values.tagDescription) {
|
const response = await fetch(newImage.imgUrl)
|
||||||
formData.append('tagDescription', values.tagDescription);
|
const blob = await response.blob()
|
||||||
|
const result = await upload({
|
||||||
|
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||||
|
contextString: 'tags',
|
||||||
|
})
|
||||||
|
imageUrl = result.keys[0]
|
||||||
|
uploadUrls = result.presignedUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
await createTag.mutateAsync({
|
||||||
|
tagName: values.tagName,
|
||||||
|
tagDescription: values.tagDescription || undefined,
|
||||||
|
imageUrl,
|
||||||
|
isDashboardTag: values.isDashboardTag,
|
||||||
|
relatedStores: values.relatedStores,
|
||||||
|
uploadUrls,
|
||||||
|
})
|
||||||
|
|
||||||
|
await refetchTags()
|
||||||
|
Alert.alert('Success', 'Tag created successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.message || 'Failed to create tag'
|
||||||
|
Alert.alert('Error', errorMessage)
|
||||||
}
|
}
|
||||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
}
|
||||||
|
|
||||||
// Add related stores
|
|
||||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
|
||||||
|
|
||||||
// Add image if uploaded
|
|
||||||
if (image?.uri) {
|
|
||||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
|
||||||
const match = /\.(\w+)$/.exec(filename);
|
|
||||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
|
||||||
|
|
||||||
formData.append('image', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: filename,
|
|
||||||
type,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
createTag(formData, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', 'Tag created successfully', [
|
|
||||||
{
|
|
||||||
text: 'OK',
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
const errorMessage = error.message || 'Failed to create tag';
|
|
||||||
Alert.alert('Error', errorMessage);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialValues: TagFormData = {
|
const initialValues: TagFormData = {
|
||||||
tagName: '',
|
tagName: '',
|
||||||
|
|
@ -76,8 +77,8 @@ export default function AddTag() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isCreating}
|
isLoading={createTag.isPending || isUploading}
|
||||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Alert } from 'react-native';
|
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, type ImageUploaderNeoItem } 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';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
interface TagFormData {
|
interface TagFormData {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
|
|
@ -19,53 +19,60 @@ export default function EditTag() {
|
||||||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||||
|
|
||||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
|
||||||
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
{ id: tagIdNum || 0 },
|
||||||
|
{ enabled: !!tagIdNum }
|
||||||
|
)
|
||||||
|
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const updateTag = trpc.admin.product.updateProductTag.useMutation();
|
||||||
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
const { data: storesData } = trpc.admin.store.getStores.useQuery();
|
||||||
|
const { upload, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
|
||||||
if (!tagIdNum) return;
|
if (!tagIdNum) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
try {
|
||||||
|
let imageUrl: string | null | undefined
|
||||||
|
let uploadUrls: string[] = []
|
||||||
|
|
||||||
// Add text fields
|
const newImage = images.find((image) => image.mimeType !== null)
|
||||||
formData.append('tagName', values.tagName);
|
if (newImage) {
|
||||||
if (values.tagDescription) {
|
const response = await fetch(newImage.imgUrl)
|
||||||
formData.append('tagDescription', values.tagDescription);
|
const blob = await response.blob()
|
||||||
|
const result = await upload({
|
||||||
|
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||||
|
contextString: 'tags',
|
||||||
|
})
|
||||||
|
imageUrl = result.keys[0]
|
||||||
|
uploadUrls = result.presignedUrls
|
||||||
|
} else if (removedExisting) {
|
||||||
|
imageUrl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTag.mutateAsync({
|
||||||
|
id: tagIdNum,
|
||||||
|
tagName: values.tagName,
|
||||||
|
tagDescription: values.tagDescription || undefined,
|
||||||
|
imageUrl,
|
||||||
|
isDashboardTag: values.isDashboardTag,
|
||||||
|
relatedStores: values.relatedStores,
|
||||||
|
uploadUrls,
|
||||||
|
})
|
||||||
|
|
||||||
|
await refetchTags()
|
||||||
|
Alert.alert('Success', 'Tag updated successfully', [
|
||||||
|
{
|
||||||
|
text: 'OK',
|
||||||
|
onPress: () => router.back(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.message || 'Failed to update tag'
|
||||||
|
Alert.alert('Error', errorMessage)
|
||||||
}
|
}
|
||||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
}
|
||||||
|
|
||||||
// Add related stores
|
|
||||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
|
||||||
|
|
||||||
// Add image if uploaded
|
|
||||||
if (image?.uri) {
|
|
||||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
|
||||||
const match = /\.(\w+)$/.exec(filename);
|
|
||||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
|
||||||
|
|
||||||
formData.append('image', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: filename,
|
|
||||||
type,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTag({ id: tagIdNum, formData }, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', 'Tag updated successfully', [
|
|
||||||
{
|
|
||||||
text: 'OK',
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
const errorMessage = error.message || 'Failed to update tag';
|
|
||||||
Alert.alert('Error', errorMessage);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingTag) {
|
if (isLoadingTag) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -92,7 +99,7 @@ export default function EditTag() {
|
||||||
tagName: tag.tagName,
|
tagName: tag.tagName,
|
||||||
tagDescription: tag.tagDescription || '',
|
tagDescription: tag.tagDescription || '',
|
||||||
isDashboardTag: tag.isDashboardTag,
|
isDashboardTag: tag.isDashboardTag,
|
||||||
relatedStores: tag.relatedStores || [],
|
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||||
existingImageUrl: tag.imageUrl || undefined,
|
existingImageUrl: tag.imageUrl || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,8 +113,8 @@ export default function EditTag() {
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
existingImageUrl={tag.imageUrl || undefined}
|
existingImageUrl={tag.imageUrl || undefined}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isUpdating}
|
isLoading={updateTag.isPending || isUploading}
|
||||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,20 @@ import { useRouter } from 'expo-router';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||||
import { TagMenu } from '@/src/components/TagMenu';
|
import { TagMenu } from '@/src/components/TagMenu';
|
||||||
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
|
interface TagItemData {
|
||||||
|
id: number;
|
||||||
|
tagName: string;
|
||||||
|
tagDescription: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isDashboardTag: boolean;
|
||||||
|
relatedStores?: unknown;
|
||||||
|
createdAt: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface TagItemProps {
|
interface TagItemProps {
|
||||||
item: Tag;
|
item: TagItemData;
|
||||||
onDeleteSuccess: () => void;
|
onDeleteSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
||||||
|
|
||||||
export default function ProductTags() {
|
export default function ProductTags() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const tags = tagsData?.tags || [];
|
const tags = tagsData?.tags || [];
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,56 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import { AppContainer } from 'common-ui';
|
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
|
||||||
import ProductForm from '@/src/components/ProductForm';
|
import ProductForm from '@/src/components/ProductForm';
|
||||||
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
|
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||||
|
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
const { upload, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
|
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
|
||||||
const payload: CreateProductPayload = {
|
try {
|
||||||
name: values.name,
|
let uploadUrls: string[] = [];
|
||||||
shortDescription: values.shortDescription,
|
|
||||||
longDescription: values.longDescription,
|
|
||||||
unitId: parseInt(values.unitId),
|
|
||||||
storeId: parseInt(values.storeId),
|
|
||||||
price: parseFloat(values.price),
|
|
||||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
|
||||||
incrementStep: 1,
|
|
||||||
productQuantity: values.productQuantity || 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const formData = new FormData();
|
if (images.length > 0) {
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
const blobs = await Promise.all(
|
||||||
if (value !== undefined && value !== null) {
|
images.map(async (img) => {
|
||||||
formData.append(key, value as string);
|
const response = await fetch(img.url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await upload({ images: blobs, contextString: 'product_info' });
|
||||||
|
uploadUrls = result.presignedUrls;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Append tag IDs
|
await createProduct.mutateAsync({
|
||||||
if (values.tagIds && values.tagIds.length > 0) {
|
name: values.name,
|
||||||
values.tagIds.forEach((tagId: number) => {
|
shortDescription: values.shortDescription,
|
||||||
formData.append('tagIds', tagId.toString());
|
longDescription: values.longDescription,
|
||||||
|
unitId: parseInt(values.unitId),
|
||||||
|
storeId: parseInt(values.storeId),
|
||||||
|
price: parseFloat(values.price),
|
||||||
|
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||||
|
incrementStep: 1,
|
||||||
|
productQuantity: values.productQuantity || 1,
|
||||||
|
isSuspended: values.isSuspended || false,
|
||||||
|
isFlashAvailable: values.isFlashAvailable || false,
|
||||||
|
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||||
|
uploadUrls,
|
||||||
|
tagIds: values.tagIds || [],
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Append images
|
await refetchProducts();
|
||||||
if (images) {
|
Alert.alert('Success', 'Product created successfully!');
|
||||||
images.forEach((image, index) => {
|
} catch (error: any) {
|
||||||
if (image.uri) {
|
Alert.alert('Error', error.message || 'Failed to create product');
|
||||||
formData.append('images', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: `image-${index}.jpg`,
|
|
||||||
// type: 'image/jpeg',
|
|
||||||
type: image.mimeType as any,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createProduct(formData, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', 'Product created successfully!');
|
|
||||||
// Reset form or navigate
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to create product');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
|
|
@ -81,8 +75,7 @@ export default function AddProduct() {
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isCreating}
|
isLoading={createProduct.isPending || isUploading}
|
||||||
existingImages={[]}
|
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
||||||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
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';
|
||||||
|
|
@ -23,10 +24,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
const [adminResponse, setAdminResponse] = useState('');
|
const [adminResponse, setAdminResponse] = useState('');
|
||||||
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 [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
const { upload } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -62,37 +62,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
|
|
||||||
const handleSubmit = async (adminResponse: string) => {
|
const handleSubmit = async (adminResponse: string) => {
|
||||||
try {
|
try {
|
||||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
const { keys, presignedUrls } = await upload({
|
||||||
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
images: selectedImages,
|
||||||
contextString: 'review',
|
contextString: 'review',
|
||||||
mimeTypes,
|
|
||||||
});
|
});
|
||||||
const keys = generatedUrls.map(url => {
|
|
||||||
const u = new URL(url);
|
|
||||||
const rawKey = u.pathname.replace(/^\/+/, "");
|
|
||||||
const decodedKey = decodeURIComponent(rawKey);
|
|
||||||
const parts = decodedKey.split('/');
|
|
||||||
parts.shift();
|
|
||||||
return parts.join('/');
|
|
||||||
});
|
|
||||||
setUploadUrls(generatedUrls);
|
|
||||||
|
|
||||||
for (let i = 0; i < generatedUrls.length; i++) {
|
|
||||||
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({
|
||||||
reviewId,
|
reviewId,
|
||||||
adminResponse,
|
adminResponse,
|
||||||
adminResponseImages: keys,
|
adminResponseImages: keys,
|
||||||
uploadUrls: generatedUrls,
|
uploadUrls: presignedUrls,
|
||||||
});
|
});
|
||||||
|
|
||||||
Alert.alert('Success', 'Response submitted');
|
Alert.alert('Success', 'Response submitted');
|
||||||
|
|
@ -100,9 +79,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
||||||
setAdminResponse('');
|
setAdminResponse('');
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setDisplayImages([]);
|
setDisplayImages([]);
|
||||||
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.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,74 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { View, Text, Alert } from 'react-native';
|
import { View, 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, ImageUploaderNeoItem, ImageUploaderNeoPayload } 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';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
export default function EditProduct() {
|
export default function EditProduct() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
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 { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||||
|
const { upload, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
useManualRefresh(() => refetch());
|
useManualRefresh(() => refetch());
|
||||||
|
|
||||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
|
||||||
const payload = {
|
try {
|
||||||
|
// New images have mimeType !== null, existing images have mimeType === null
|
||||||
|
const newImages = images.filter(img => img.mimeType !== null);
|
||||||
|
let uploadUrls: string[] = [];
|
||||||
|
|
||||||
|
if (newImages.length > 0) {
|
||||||
|
const blobs = await Promise.all(
|
||||||
|
newImages.map(async (img) => {
|
||||||
|
const response = await fetch(img.url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await upload({ images: blobs, contextString: 'product_info' });
|
||||||
|
uploadUrls = result.presignedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProduct.mutateAsync({
|
||||||
|
id: productId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
shortDescription: values.shortDescription,
|
shortDescription: values.shortDescription,
|
||||||
longDescription: values.longDescription,
|
longDescription: values.longDescription,
|
||||||
unitId: parseInt(values.unitId),
|
unitId: parseInt(values.unitId),
|
||||||
storeId: parseInt(values.storeId),
|
storeId: parseInt(values.storeId),
|
||||||
price: parseFloat(values.price),
|
price: parseFloat(values.price),
|
||||||
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,
|
||||||
deals: values.deals?.filter((deal: any) =>
|
isSuspended: values.isSuspended || false,
|
||||||
deal.quantity && deal.price && deal.validTill
|
isFlashAvailable: values.isFlashAvailable || false,
|
||||||
).map((deal: any) => ({
|
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
|
||||||
quantity: parseInt(deal.quantity),
|
uploadUrls,
|
||||||
price: parseFloat(deal.price),
|
imagesToDelete,
|
||||||
validTill: deal.validTill instanceof Date
|
tagIds: values.tagIds || [],
|
||||||
? deal.validTill.toISOString().split('T')[0]
|
|
||||||
: deal.validTill, // Convert Date to YYYY-MM-DD string
|
|
||||||
})),
|
|
||||||
tagIds: values.tagIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
|
||||||
if (key === 'deals' && Array.isArray(value)) {
|
|
||||||
formData.append(key, JSON.stringify(value));
|
|
||||||
} else if (key === 'tagIds' && Array.isArray(value)) {
|
|
||||||
value.forEach(tagId => {
|
|
||||||
formData.append('tagIds', tagId.toString());
|
|
||||||
});
|
|
||||||
} else if (value !== undefined && value !== null) {
|
|
||||||
formData.append(key, value as string);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add new images
|
|
||||||
if (newImages && newImages.length > 0) {
|
|
||||||
newImages.forEach((image, index) => {
|
|
||||||
if (image.uri) {
|
|
||||||
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
|
||||||
formData.append('images', {
|
|
||||||
uri: image.uri,
|
|
||||||
name: fileName,
|
|
||||||
type: 'image/jpeg',
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add images to delete
|
await refetch();
|
||||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
await refetchProducts();
|
||||||
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
Alert.alert('Success', 'Product updated successfully!');
|
||||||
|
productFormRef.current?.clearImages();
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('Error', error.message || 'Failed to update product');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProduct(
|
|
||||||
{ id: productId, formData },
|
|
||||||
{
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Alert.alert('Success', 'Product updated successfully!');
|
|
||||||
// Clear newly added images after successful update
|
|
||||||
productFormRef.current?.clearImages();
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
Alert.alert('Error', error.message || 'Failed to update product');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|
@ -112,7 +91,13 @@ export default function EditProduct() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const productData = product.product; // The API returns { product: Product }
|
const productData = product.product;
|
||||||
|
|
||||||
|
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
|
||||||
|
imgUrl: url,
|
||||||
|
mimeType: null,
|
||||||
|
}));
|
||||||
|
const existingImageKeys = productData.imageKeys || [];
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: productData.name,
|
name: productData.name,
|
||||||
|
|
@ -125,7 +110,7 @@ export default function EditProduct() {
|
||||||
deals: productData.deals?.map(deal => ({
|
deals: productData.deals?.map(deal => ({
|
||||||
quantity: deal.quantity,
|
quantity: deal.quantity,
|
||||||
price: deal.price,
|
price: deal.price,
|
||||||
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||||
})) || [{ quantity: '', price: '', validTill: null }],
|
})) || [{ quantity: '', price: '', validTill: null }],
|
||||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||||
isSuspended: productData.isSuspended || false,
|
isSuspended: productData.isSuspended || false,
|
||||||
|
|
@ -141,8 +126,9 @@ export default function EditProduct() {
|
||||||
mode="edit"
|
mode="edit"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={isUpdating}
|
isLoading={updateProduct.isPending || isUploading}
|
||||||
existingImages={productData.images || []}
|
existingImages={existingImages}
|
||||||
|
existingImageKeys={existingImageKeys}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
|
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
|
||||||
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { Product } from '@/src/api-hooks/product.api';
|
import type { AdminProduct } from '@packages/shared';
|
||||||
|
|
||||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default function Products() {
|
||||||
|
|
||||||
|
|
||||||
// const handleToggleStock = (product: any) => {
|
// const handleToggleStock = (product: any) => {
|
||||||
const handleToggleStock = (product: Pick<Product, 'id' | 'name' | 'isOutOfStock'>) => {
|
const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
|
||||||
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
|
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Update Stock Status',
|
'Update Stock Status',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from 'common-ui';
|
} from 'common-ui';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||||
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -26,12 +27,6 @@ interface User {
|
||||||
isEligibleForNotif: boolean;
|
isEligibleForNotif: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractKeyFromUrl = (url: string): string => {
|
|
||||||
const u = new URL(url);
|
|
||||||
const rawKey = u.pathname.replace(/^\/+/, '');
|
|
||||||
return decodeURIComponent(rawKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SendNotifications() {
|
export default function SendNotifications() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
|
|
@ -46,8 +41,7 @@ export default function SendNotifications() {
|
||||||
search: searchQuery,
|
search: searchQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate upload URLs mutation
|
const { uploadSingle } = useUploadToObjectStorage();
|
||||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
|
||||||
|
|
||||||
// Send notification mutation
|
// Send notification mutation
|
||||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||||
|
|
@ -127,28 +121,8 @@ export default function SendNotifications() {
|
||||||
|
|
||||||
// Upload image if selected
|
// Upload image if selected
|
||||||
if (selectedImage) {
|
if (selectedImage) {
|
||||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
||||||
contextString: 'notification',
|
imageUrl = key;
|
||||||
mimeTypes: [selectedImage.mimeType],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadUrls.length > 0) {
|
|
||||||
const uploadUrl = uploadUrls[0];
|
|
||||||
imageUrl = extractKeyFromUrl(uploadUrl);
|
|
||||||
|
|
||||||
// Upload image
|
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: selectedImage.blob,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': selectedImage.mimeType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,14 @@ export default function AddStore() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const createStoreMutation = trpc.admin.store.createStore.useMutation();
|
const createStoreMutation = trpc.admin.store.createStore.useMutation();
|
||||||
|
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: StoreFormData) => {
|
const handleSubmit = (values: StoreFormData) => {
|
||||||
createStoreMutation.mutate(values, {
|
createStoreMutation.mutate(values, {
|
||||||
onSuccess: (data) => {
|
onSuccess: async (data) => {
|
||||||
|
await refetchStores();
|
||||||
Alert.alert('Success', data.message);
|
Alert.alert('Success', data.message);
|
||||||
router.push('/stores' as any); // Navigate back to stores list
|
router.push('/stores' as any); // Navigate back to stores list
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
|
||||||
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/useUploadToObjectStore';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
|
||||||
export interface BannerFormData {
|
export interface BannerFormData {
|
||||||
|
|
@ -52,10 +53,10 @@ export default function BannerForm({
|
||||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||||
|
|
||||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
const { uploadSingle } = useUploadToObjectStorage();
|
||||||
|
|
||||||
// Fetch products for dropdown
|
// Fetch products for dropdown
|
||||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
|
||||||
const products = productsData?.products || [];
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,33 +98,11 @@ 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 { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
imageUrl = presignedUrl;
|
||||||
method: 'PUT',
|
|
||||||
body: blob,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
imageUrl = uploadUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call onSubmit with form values and imageUrl
|
|
||||||
await onSubmit(values, imageUrl);
|
await onSubmit(values, imageUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { View, ScrollView, Dimensions } from 'react-native';
|
|
||||||
import { Image } from 'expo-image';
|
|
||||||
import { MyText, tw } from 'common-ui';
|
|
||||||
import { trpc } from '../src/trpc-client';
|
|
||||||
|
|
||||||
interface FullOrderViewProps {
|
|
||||||
orderId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FullOrderView: React.FC<FullOrderViewProps> = ({ orderId }) => {
|
|
||||||
const { data: order, isLoading, error } = trpc.admin.order.getFullOrder.useQuery({ orderId });
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<MyText style={tw`text-center text-gray-600`}>Loading order details...</MyText>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !order) {
|
|
||||||
return (
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<MyText style={tw`text-center text-red-600`}>Failed to load order details</MyText>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalAmount = order.items.reduce((sum, item) => sum + item.amount, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={[tw`flex-1`, { maxHeight: Dimensions.get('window').height * 0.8 }]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<View style={tw`p-6`}>
|
|
||||||
<MyText style={tw`text-2xl font-bold text-gray-800 mb-6`}>Order #{order.readableId}</MyText>
|
|
||||||
|
|
||||||
{/* Customer Information */}
|
|
||||||
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Customer Details</MyText>
|
|
||||||
<View style={tw`space-y-2`}>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Name:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.customerName}</MyText>
|
|
||||||
</View>
|
|
||||||
{order.customerEmail && (
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Email:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.customerEmail}</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Mobile:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.customerMobile}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Delivery Address */}
|
|
||||||
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Delivery Address</MyText>
|
|
||||||
<View style={tw`space-y-1`}>
|
|
||||||
<MyText style={tw`text-gray-800`}>{order.address.line1}</MyText>
|
|
||||||
{order.address.line2 && <MyText style={tw`text-gray-800`}>{order.address.line2}</MyText>}
|
|
||||||
<MyText style={tw`text-gray-800`}>
|
|
||||||
{order.address.city}, {order.address.state} - {order.address.pincode}
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-800`}>Phone: {order.address.phone}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Order Details */}
|
|
||||||
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Order Details</MyText>
|
|
||||||
<View style={tw`space-y-2`}>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Order Date:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>
|
|
||||||
{new Date(order.createdAt).toLocaleDateString('en-IN', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Payment Method:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>
|
|
||||||
{order.isCod ? 'Cash on Delivery' : 'Online Payment'}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
{order.slotInfo && (
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Delivery Slot:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>
|
|
||||||
{new Date(order.slotInfo.time).toLocaleDateString('en-IN', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Items ({order.items.length})</MyText>
|
|
||||||
{order.items.map((item, index) => (
|
|
||||||
<View key={item.id} style={tw`flex-row items-center py-3 ${index !== order.items.length - 1 ? 'border-b border-gray-100' : ''}`}>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<MyText style={tw`font-medium text-gray-800`} numberOfLines={2}>
|
|
||||||
{item.productName}
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-600`}>
|
|
||||||
Qty: {item.quantity} {item.unit} × ₹{parseFloat(item.price.toString()).toFixed(2)}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
<MyText style={tw`font-semibold text-gray-800`}>₹{item.amount.toFixed(2)}</MyText>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Payment Information */}
|
|
||||||
{(order.payment || order.paymentInfo) && (
|
|
||||||
<View style={tw`bg-white rounded-xl p-4 mb-4 shadow-sm`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-3`}>Payment Information</MyText>
|
|
||||||
{order.payment && (
|
|
||||||
<View style={tw`space-y-2 mb-3`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700`}>Payment Details:</MyText>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Status:</MyText>
|
|
||||||
<MyText style={tw`font-medium capitalize`}>{order.payment.status}</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Gateway:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.payment.gateway}</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Order ID:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.payment.merchantOrderId}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{order.paymentInfo && (
|
|
||||||
<View style={tw`space-y-2`}>
|
|
||||||
<MyText style={tw`text-sm font-medium text-gray-700`}>Payment Info:</MyText>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Status:</MyText>
|
|
||||||
<MyText style={tw`font-medium capitalize`}>{order.paymentInfo.status}</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Gateway:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.paymentInfo.gateway}</MyText>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row justify-between`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Order ID:</MyText>
|
|
||||||
<MyText style={tw`font-medium`}>{order.paymentInfo.merchantOrderId}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Notes */}
|
|
||||||
{order.userNotes && (
|
|
||||||
<View style={tw`bg-blue-50 rounded-xl p-4 mb-4`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-2`}>Customer Notes</MyText>
|
|
||||||
<MyText style={tw`text-gray-700`}>{order.userNotes}</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Admin Notes */}
|
|
||||||
{order.adminNotes && (
|
|
||||||
<View style={tw`bg-yellow-50 rounded-xl p-4 mb-4`}>
|
|
||||||
<MyText style={tw`text-lg font-semibold text-gray-800 mb-2`}>Admin Notes</MyText>
|
|
||||||
<MyText style={tw`text-gray-700`}>{order.adminNotes}</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Total */}
|
|
||||||
<View style={tw`bg-blue-50 rounded-xl p-4`}>
|
|
||||||
<View style={tw`flex-row justify-between items-center`}>
|
|
||||||
<MyText style={tw`text-xl font-bold text-gray-800`}>Total Amount</MyText>
|
|
||||||
<MyText style={tw`text-2xl font-bold text-blue-600`}>₹{parseFloat(order.totalAmount.toString()).toFixed(2)}</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-u
|
||||||
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/useUploadToObjectStore';
|
||||||
|
|
||||||
export interface StoreFormData {
|
export interface StoreFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -59,14 +60,19 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
});
|
});
|
||||||
}, [initialValues, initialSelectedProducts]);
|
}, [initialValues, initialSelectedProducts]);
|
||||||
|
|
||||||
const staffOptions = staffData?.staff.map(staff => ({
|
const existingImageUrls = useMemo(
|
||||||
|
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
|
||||||
|
[formInitialValues.imageUrl]
|
||||||
|
)
|
||||||
|
|
||||||
|
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
|
||||||
label: staff.name,
|
label: staff.name,
|
||||||
value: staff.id,
|
value: staff.id,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||||
|
|
||||||
const handleImagePick = usePickImage({
|
const handleImagePick = usePickImage({
|
||||||
setFile: async (assets: any) => {
|
setFile: async (assets: any) => {
|
||||||
|
|
@ -113,39 +119,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
|
|
||||||
if (selectedImages.length > 0) {
|
if (selectedImages.length > 0) {
|
||||||
// Generate upload URLs
|
const { blob, mimeType } = selectedImages[0];
|
||||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
imageUrl = presignedUrl;
|
||||||
contextString: 'store',
|
|
||||||
mimeTypes,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload images
|
|
||||||
for (let i = 0; i < uploadUrls.length; i++) {
|
|
||||||
const uploadUrl = uploadUrls[i];
|
|
||||||
const { blob, mimeType } = selectedImages[i];
|
|
||||||
|
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: blob,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract key from first upload URL
|
|
||||||
// const u = new URL(uploadUrls[0]);
|
|
||||||
// const rawKey = u.pathname.replace(/^\/+/, "");
|
|
||||||
// imageUrl = decodeURIComponent(rawKey);
|
|
||||||
imageUrl = uploadUrls[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit form with imageUrl
|
|
||||||
onSubmit({ ...values, imageUrl });
|
onSubmit({ ...values, imageUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
|
@ -195,20 +173,25 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||||
<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
|
<ImageUploader
|
||||||
images={displayImages}
|
images={displayImages}
|
||||||
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
existingImageUrls={existingImageUrls}
|
||||||
onAddImage={handleImagePick}
|
onAddImage={handleImagePick}
|
||||||
onRemoveImage={handleRemoveImage}
|
onRemoveImage={handleRemoveImage}
|
||||||
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
onRemoveExistingImage={() =>
|
||||||
|
setFormInitialValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
imageUrl: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
allowMultiple={false}
|
allowMultiple={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading || generateUploadUrls.isPending}
|
disabled={isLoading || isUploading}
|
||||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||||
>
|
>
|
||||||
<MyText style={tw`text-white text-lg font-bold`}>
|
<MyText style={tw`text-white text-lg font-bold`}>
|
||||||
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||||
</MyText>
|
</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
12
apps/admin-ui/e2e/jest.config.js
Normal file
12
apps/admin-ui/e2e/jest.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||||
|
module.exports = {
|
||||||
|
rootDir: '..',
|
||||||
|
testMatch: ['<rootDir>/e2e/**/*.test.js'],
|
||||||
|
testTimeout: 120000,
|
||||||
|
maxWorkers: 1,
|
||||||
|
globalSetup: 'detox/runners/jest/globalSetup',
|
||||||
|
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||||
|
reporters: ['detox/runners/jest/reporter'],
|
||||||
|
testEnvironment: 'detox/runners/jest/testEnvironment',
|
||||||
|
verbose: true,
|
||||||
|
};
|
||||||
23
apps/admin-ui/e2e/starter.test.js
Normal file
23
apps/admin-ui/e2e/starter.test.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
describe('Example', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await device.reloadReactNative();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have welcome screen', async () => {
|
||||||
|
await expect(element(by.id('welcome'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show hello screen after tap', async () => {
|
||||||
|
await element(by.id('hello_button')).tap();
|
||||||
|
await expect(element(by.text('Hello!!!'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show world screen after tap', async () => {
|
||||||
|
await element(by.id('world_button')).tap();
|
||||||
|
await expect(element(by.text('World!!!'))).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"developmentClient": true,
|
"distribution": "internal",
|
||||||
"distribution": "internal"
|
"channel": "development"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
|
|
|
||||||
118
apps/admin-ui/hooks/useUploadToObjectStore.ts
Normal file
118
apps/admin-ui/hooks/useUploadToObjectStore.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { trpc } from '../src/trpc-client';
|
||||||
|
|
||||||
|
type ContextString = 'review' | 'product_info' | 'notification' | 'store' | 'complaint' | 'profile' | 'tags';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
BIN
apps/admin-ui/mfa_20_mar_dev.apk
Normal file
BIN
apps/admin-ui/mfa_20_mar_dev.apk
Normal file
Binary file not shown.
|
|
@ -1,111 +0,0 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import axios from '../../services/axios-admin-ui';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface CreateProductPayload {
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
incrementStep?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
deals?: {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProductPayload {
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
incrementStep?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
deals?: {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Product {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
shortDescription?: string | null;
|
|
||||||
longDescription?: string;
|
|
||||||
unitId: number;
|
|
||||||
storeId: number;
|
|
||||||
price: number;
|
|
||||||
marketPrice?: number;
|
|
||||||
productQuantity?: number;
|
|
||||||
isOutOfStock?: boolean;
|
|
||||||
images?: string[];
|
|
||||||
createdAt: string;
|
|
||||||
unit?: {
|
|
||||||
id: number;
|
|
||||||
shortNotation: string;
|
|
||||||
fullName: string;
|
|
||||||
};
|
|
||||||
deals?: {
|
|
||||||
id: number;
|
|
||||||
quantity: string;
|
|
||||||
price: string;
|
|
||||||
validTill: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProductResponse {
|
|
||||||
product: Product;
|
|
||||||
deals?: any[];
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API functions
|
|
||||||
const createProductApi = async (formData: FormData): Promise<CreateProductResponse> => {
|
|
||||||
const response = await axios.post('/av/products', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProductApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateProductResponse> => {
|
|
||||||
const response = await axios.put(`/av/products/${id}`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export const useCreateProduct = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createProductApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateProduct = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateProductApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import axios from '../../services/axios-admin-ui';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface CreateTagPayload {
|
|
||||||
tagName: string;
|
|
||||||
tagDescription?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateTagPayload {
|
|
||||||
tagName: string;
|
|
||||||
tagDescription?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tag {
|
|
||||||
id: number;
|
|
||||||
tagName: string;
|
|
||||||
tagDescription: string | null;
|
|
||||||
imageUrl: string | null;
|
|
||||||
isDashboardTag: boolean;
|
|
||||||
relatedStores: number[];
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTagResponse {
|
|
||||||
tag: Tag;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetTagsResponse {
|
|
||||||
tags: Tag[];
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API functions
|
|
||||||
const createTagApi = async (formData: FormData): Promise<CreateTagResponse> => {
|
|
||||||
const response = await axios.post('/av/product-tags', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTagApi = async ({ id, formData }: { id: number; formData: FormData }): Promise<CreateTagResponse> => {
|
|
||||||
const response = await axios.put(`/av/product-tags/${id}`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTagApi = async (id: number): Promise<{ message: string }> => {
|
|
||||||
const response = await axios.delete(`/av/product-tags/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTagsApi = async (): Promise<GetTagsResponse> => {
|
|
||||||
const response = await axios.get('/av/product-tags');
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTagApi = async (id: number): Promise<{ tag: Tag }> => {
|
|
||||||
const response = await axios.get(`/av/product-tags/${id}`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export const useCreateTag = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createTagApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateTag = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateTagApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteTag = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteTagApi,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGetTags = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['tags'],
|
|
||||||
queryFn: getTagsApi,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGetTag = (id: number) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['tags', id],
|
|
||||||
queryFn: () => getTagApi(id),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
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';
|
||||||
import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui';
|
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { trpc } from '../trpc-client';
|
import { trpc } from '../trpc-client';
|
||||||
import { useGetTags } from '../api-hooks/tag.api';
|
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -38,9 +35,10 @@ export interface ProductFormRef {
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: ProductFormData;
|
initialValues: ProductFormData;
|
||||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
existingImages?: string[];
|
existingImages?: ImageUploaderNeoItem[];
|
||||||
|
existingImageKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitOptions = [
|
const unitOptions = [
|
||||||
|
|
@ -50,18 +48,22 @@ const unitOptions = [
|
||||||
{ label: 'Unit Piece', value: 4 },
|
{ label: 'Unit Piece', value: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
mode,
|
mode,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
existingImages = []
|
existingImages:existingImagesRaw,
|
||||||
|
existingImageKeys = [],
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
|
||||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
|
||||||
|
const existingImages = existingImagesRaw || []
|
||||||
|
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
|
||||||
|
useEffect(() => {
|
||||||
|
setImages(existingImages);
|
||||||
|
}, [existingImagesRaw]);
|
||||||
|
|
||||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
|
||||||
const storeOptions = storesData?.stores.map(store => ({
|
const storeOptions = storesData?.stores.map(store => ({
|
||||||
|
|
@ -69,44 +71,50 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
value: store.id,
|
value: store.id,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const { data: tagsData } = useGetTags();
|
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
|
||||||
const tagOptions = tagsData?.tags.map(tag => ({
|
const tagOptions = tagsData?.tags.map(tag => ({
|
||||||
label: tag.tagName,
|
label: tag.tagName,
|
||||||
value: tag.id.toString(),
|
value: tag.id.toString(),
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Initialize existing images state when existingImages prop changes
|
// Build signed URL -> S3 key mapping for existing images
|
||||||
useEffect(() => {
|
const signedUrlToKey = useMemo(() => {
|
||||||
console.log('changing existing imaes statte')
|
const map: Record<string, string> = {};
|
||||||
|
existingImages.forEach((img, i) => {
|
||||||
setExistingImagesState(existingImages);
|
if (existingImageKeys[i]) {
|
||||||
}, [existingImages]);
|
map[img.imgUrl] = existingImageKeys[i];
|
||||||
|
}
|
||||||
const pickImage = usePickImage({
|
});
|
||||||
setFile: (files) => setImages(prev => [...prev, ...files]),
|
return map;
|
||||||
multiple: true,
|
}, [existingImages, existingImageKeys]);
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate which existing images were deleted
|
|
||||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={(values) => onSubmit(values, images, deletedImages)}
|
onSubmit={(values) => {
|
||||||
|
// New images have mimeType set, existing images have mimeType === null
|
||||||
|
const newImages = images.filter(img => img.mimeType !== null);
|
||||||
|
const deletedImageKeys = existingImages
|
||||||
|
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
|
||||||
|
.map(deleted => signedUrlToKey[deleted.imgUrl])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
onSubmit(
|
||||||
|
values,
|
||||||
|
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
|
||||||
|
deletedImageKeys,
|
||||||
|
);
|
||||||
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||||
// Clear form when screen comes into focus
|
|
||||||
const clearForm = useCallback(() => {
|
const clearForm = useCallback(() => {
|
||||||
setImages([]);
|
setImages([]);
|
||||||
setExistingImagesState([]);
|
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
||||||
useFocusCallback(clearForm);
|
useFocusCallback(clearForm);
|
||||||
|
|
||||||
// Update ref with current clearForm function
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
clearImages: clearForm,
|
clearImages: clearForm,
|
||||||
}), [clearForm]);
|
}), [clearForm]);
|
||||||
|
|
@ -141,44 +149,18 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mode === 'create' && (
|
<ImageUploaderNeo
|
||||||
<ImageUploader
|
images={images}
|
||||||
images={images}
|
onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
|
||||||
onAddImage={pickImage}
|
onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
|
||||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
allowMultiple={true}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'edit' && existingImagesState.length > 0 && (
|
|
||||||
<View style={{ marginBottom: 16 }}>
|
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Current Images</MyText>
|
|
||||||
<ImageGalleryWithDelete
|
|
||||||
imageUrls={existingImagesState}
|
|
||||||
setImageUrls={setExistingImagesState}
|
|
||||||
imageHeight={100}
|
|
||||||
imageWidth={100}
|
|
||||||
columns={3}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'edit' && (
|
|
||||||
<View style={{ marginBottom: 16 }}>
|
|
||||||
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
|
|
||||||
<ImageUploader
|
|
||||||
images={images}
|
|
||||||
onAddImage={pickImage}
|
|
||||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
topLabel='Unit'
|
topLabel='Unit'
|
||||||
label="Unit"
|
label="Unit"
|
||||||
value={values.unitId}
|
value={values.unitId}
|
||||||
options={unitOptions}
|
options={unitOptions}
|
||||||
// onValueChange={(value) => handleChange('unitId')(value+'')}
|
|
||||||
onValueChange={(value) => setFieldValue('unitId', value)}
|
onValueChange={(value) => setFieldValue('unitId', value)}
|
||||||
placeholder="Select unit"
|
placeholder="Select unit"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
|
@ -188,18 +170,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
placeholder="Enter product quantity"
|
placeholder="Enter product quantity"
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={values.productQuantity.toString()}
|
value={values.productQuantity.toString()}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => setFieldValue('productQuantity', text)}
|
||||||
// if(text)
|
|
||||||
// setFieldValue('productQuantity', text);
|
|
||||||
// else
|
|
||||||
setFieldValue('productQuantity', text);
|
|
||||||
// if (text === '' || text === null || text === undefined) {
|
|
||||||
// setFieldValue('productQuantity', 1);
|
|
||||||
// } else {
|
|
||||||
// const num = parseFloat(text);
|
|
||||||
// setFieldValue('productQuantity', isNaN(num) ? 1 : num);
|
|
||||||
// }
|
|
||||||
}}
|
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
<BottomDropdown
|
<BottomDropdown
|
||||||
|
|
@ -238,8 +209,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
<View style={tw`flex-row items-center mb-4`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={values.isSuspended}
|
checked={values.isSuspended}
|
||||||
|
|
@ -254,7 +223,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
checked={values.isFlashAvailable}
|
checked={values.isFlashAvailable}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
|
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
|
||||||
if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled
|
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
|
||||||
}}
|
}}
|
||||||
style={tw`mr-3`}
|
style={tw`mr-3`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -272,87 +241,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <FieldArray name="deals">
|
|
||||||
{({ push, remove, form }) => (
|
|
||||||
<View style={{ marginBottom: 16 }}>
|
|
||||||
<View style={tw`flex-row items-center mb-4`}>
|
|
||||||
<MaterialIcons name="local-offer" size={20} color="#3B82F6" />
|
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 ml-2`}>
|
|
||||||
Special Package Deals
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-sm text-gray-500 ml-1`}>(Optional)</MyText>
|
|
||||||
</View>
|
|
||||||
{(form.values.deals || []).map((deal: any, index: number) => (
|
|
||||||
<View key={index} style={tw`bg-white p-4 rounded-2xl shadow-lg mb-4 border border-gray-100`}>
|
|
||||||
<View style={tw`mb-3`}>
|
|
||||||
<View style={tw`flex-row items-end gap-3 mb-3`}>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Quantity"
|
|
||||||
placeholder="Enter quantity"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={deal.quantity || ''}
|
|
||||||
onChangeText={form.handleChange(`deals.${index}.quantity`)}
|
|
||||||
fullWidth={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<MyTextInput
|
|
||||||
topLabel="Price"
|
|
||||||
placeholder="Enter price"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={deal.price || ''}
|
|
||||||
onChangeText={form.handleChange(`deals.${index}.price`)}
|
|
||||||
fullWidth={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-row items-end gap-3`}>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<DatePicker
|
|
||||||
value={deal.validTill}
|
|
||||||
setValue={(date) => form.setFieldValue(`deals.${index}.validTill`, date)}
|
|
||||||
showLabel={true}
|
|
||||||
placeholder="Valid Till"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => remove(index)}
|
|
||||||
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-row items-center justify-center`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="delete" size={16} color="white" />
|
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>Remove</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(form.values.deals || []).length === 0 && (
|
|
||||||
<View style={tw`bg-gray-50 p-6 rounded-2xl border-2 border-dashed border-gray-300 items-center mb-4`}>
|
|
||||||
<MaterialIcons name="local-offer" size={32} color="#9CA3AF" />
|
|
||||||
<MyText style={tw`text-gray-500 text-center mt-2`}>
|
|
||||||
No package deals added yet
|
|
||||||
</MyText>
|
|
||||||
<MyText style={tw`text-gray-400 text-sm text-center mt-1`}>
|
|
||||||
Add special pricing for bulk purchases
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => push({ quantity: '', price: '', validTill: null })}
|
|
||||||
style={tw`bg-green-500 px-4 py-2 rounded-lg shadow-lg flex-row items-center justify-center mt-4`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="add" size={20} color="white" />
|
|
||||||
<MyText style={tw`text-white font-bold text-lg ml-2`}>Add Package Deal</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</FieldArray> */}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||||
import { View, TouchableOpacity } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
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, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
|
||||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
|
||||||
interface StoreOption {
|
interface StoreOption {
|
||||||
|
|
@ -23,7 +21,7 @@ interface TagFormProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
initialValues: TagFormData;
|
initialValues: TagFormData;
|
||||||
existingImageUrl?: string;
|
existingImageUrl?: string;
|
||||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
stores?: StoreOption[];
|
stores?: StoreOption[];
|
||||||
}
|
}
|
||||||
|
|
@ -31,27 +29,28 @@ interface TagFormProps {
|
||||||
const TagForm = forwardRef<any, TagFormProps>(({
|
const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
mode,
|
mode,
|
||||||
initialValues,
|
initialValues,
|
||||||
existingImageUrl = '',
|
existingImageUrl: existingImageUrlRaw,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
stores = [],
|
stores: storesRaw,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [image, setImage] = useState<{ uri?: string } | null>(null);
|
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
|
||||||
|
const [removedExisting, setRemovedExisting] = useState(false)
|
||||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
|
||||||
|
|
||||||
|
const existingImageUrl = existingImageUrlRaw || ''
|
||||||
|
const stores = storesRaw || []
|
||||||
|
|
||||||
// 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})
|
if (existingImageUrl) {
|
||||||
}, [initialValues.isDashboardTag]);
|
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
|
||||||
|
} else {
|
||||||
const pickImage = usePickImage({
|
setImages([])
|
||||||
setFile: (files) => {
|
}
|
||||||
|
setRemovedExisting(false)
|
||||||
setImage(files || null)
|
}, [existingImageUrlRaw, initialValues.isDashboardTag]);
|
||||||
},
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const validationSchema = Yup.object().shape({
|
const validationSchema = Yup.object().shape({
|
||||||
|
|
@ -67,17 +66,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
onSubmit={(values) => onSubmit(values, images, removedExisting)}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, 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(() => {
|
||||||
setImage(null);
|
setImages([])
|
||||||
|
setRemovedExisting(false)
|
||||||
setIsDashboardTagChecked(false);
|
setIsDashboardTagChecked(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [resetForm]);
|
}, [resetForm]);
|
||||||
|
|
||||||
useFocusCallback(clearForm);
|
useFocusCallback(clearForm);
|
||||||
|
|
||||||
|
|
@ -108,10 +107,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
||||||
</MyText>
|
</MyText>
|
||||||
|
|
||||||
|
|
||||||
<ImageUploader
|
<ImageUploaderNeo
|
||||||
images={image ? [image] : []}
|
images={images}
|
||||||
onAddImage={pickImage}
|
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
|
||||||
onRemoveImage={() => setImage(null)}
|
setImages((prev) => [...prev, ...payload.map((img) => ({
|
||||||
|
imgUrl: img.url,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
}))])
|
||||||
|
}}
|
||||||
|
onImageRemove={(payload) => {
|
||||||
|
if (payload.mimeType === null) {
|
||||||
|
setRemovedExisting(true)
|
||||||
|
}
|
||||||
|
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
|
||||||
|
}}
|
||||||
|
allowMultiple={false}
|
||||||
/>
|
/>
|
||||||
</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 { useDeleteTag } from '../api-hooks/tag.api';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
|
||||||
export interface TagMenuProps {
|
export interface TagMenuProps {
|
||||||
tagId: number;
|
tagId: number;
|
||||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
|
||||||
|
|
||||||
const handleOpenMenu = () => {
|
const handleOpenMenu = () => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const performDelete = () => {
|
const performDelete = () => {
|
||||||
deleteTag(tagId, {
|
deleteTag.mutate({ id: tagId }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Alert.alert('Success', 'Tag deleted successfully');
|
Alert.alert('Success', 'Tag deleted successfully');
|
||||||
onDeleteSuccess?.();
|
onDeleteSuccess?.();
|
||||||
|
|
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
||||||
const errorMessage = error.message || 'Failed to delete tag';
|
const errorMessage = error.message || 'Failed to delete tag';
|
||||||
Alert.alert('Error', errorMessage);
|
Alert.alert('Error', errorMessage);
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
|
|
|
||||||
42
apps/backend/.envz
Executable file
42
apps/backend/.envz
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
ENV_MODE=PROD
|
||||||
|
DATABASE_URL=postgresql://postgres:meatfarmer_master_password@57.128.212.174:7447/meatfarmer #technocracy
|
||||||
|
# DATABASE_URL=postgres://postgres:meatfarmer_master_password@5.223.55.14:7447/meatfarmer #hetzner
|
||||||
|
PHONE_PE_BASE_URL=https://api-preprod.phonepe.com/
|
||||||
|
PHONE_PE_CLIENT_ID=TEST-M23F2IGP34ZAR_25090
|
||||||
|
PHONE_PE_CLIENT_VERSION=1
|
||||||
|
PHONE_PE_CLIENT_SECRET=MTU1MmIzOTgtM2Q0Mi00N2M5LTkyMWUtNzBiMjdmYzVmZWUy
|
||||||
|
PHONE_PE_MERCHANT_ID=M23F2IGP34ZAR
|
||||||
|
|
||||||
|
# S3_REGION=ap-hyderabad-1
|
||||||
|
# S3_REGION=sgp
|
||||||
|
# S3_ACCESS_KEY_ID=52932a33abce40b38b559dadccab640f
|
||||||
|
# S3_SECRET_ACCESS_KEY=d287998b696d4a1c912e727f6394e53b
|
||||||
|
# S3_URL=https://s3.sgp.io.cloud.ovh.net/
|
||||||
|
# S3_BUCKET_NAME=theobjectstore
|
||||||
|
S3_REGION=apac
|
||||||
|
S3_ACCESS_KEY_ID=8fab47503efb9547b50e4fb317e35cc7
|
||||||
|
S3_SECRET_ACCESS_KEY=47c2eb5636843cf568dda7ad0959a3e42071303f26dbdff94bd45a3c33dcd950
|
||||||
|
S3_URL=https://da9b1aa7c1951c23e2c0c3246ba68a58.r2.cloudflarestorage.com
|
||||||
|
S3_BUCKET_NAME=meatfarmer-dev
|
||||||
|
EXPO_ACCESS_TOKEN=Asvpy8cByRh6T4ksnWScO6PLcio2n35-BwES5zK-
|
||||||
|
JWT_SECRET=my_meatfarmer_jwt_secret_key
|
||||||
|
ASSETS_DOMAIN=https://assets2.freshyo.in/
|
||||||
|
API_CACHE_KEY=api-cache-dev
|
||||||
|
# CLOUDFLARE_API_TOKEN=I8Vp4E9TX58E8qEDeH0nTFDS2d2zXNYiXvbs4Ckj
|
||||||
|
CLOUDFLARE_API_TOKEN=N7jAg5X-RUj_fVfMW6zbfJ8qIYc81TSIKKlbZ6oh
|
||||||
|
CLOUDFLARE_ZONE_ID=edefbf750bfc3ff26ccd11e8e28dc8d7
|
||||||
|
# REDIS_URL=redis://default:redis_shafi_password@5.223.55.14:6379
|
||||||
|
REDIS_URL=redis://default:redis_shafi_password@57.128.212.174:6379
|
||||||
|
APP_URL=http://localhost:4000
|
||||||
|
RAZORPAY_KEY=rzp_test_RdCBBUJ56NLaJK
|
||||||
|
RAZORPAY_SECRET=namEwKBE1ypWxH0QDVg6fWOe
|
||||||
|
OTP_SENDER_AUTH_TOKEN=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJDLTM5OENEMkJDRTM0MjQ4OCIsImlhdCI6MTc0Nzg0MTEwMywiZXhwIjoxOTA1NTIxMTAzfQ.IV64ofVKjcwveIanxu_P2XlACtPeA9sJQ74uM53osDeyUXsFv0rwkCl6NNBIX93s_wnh4MKITLbcF_ClwmFQ0A
|
||||||
|
|
||||||
|
MIN_ORDER_VALUE=300
|
||||||
|
DELIVERY_CHARGE=20
|
||||||
|
|
||||||
|
# Telegram Configuration
|
||||||
|
TELEGRAM_BOT_TOKEN=8410461852:AAGXQCwRPFbndqwTgLJh8kYxST4Z0vgh72U
|
||||||
|
TELEGRAM_CHAT_IDS=5147760058
|
||||||
|
# TELEGRAM_BOT_TOKEN=8410461852:AAGXQCwRPFbndqwTgLJh8kYxST4Z0vgh72U
|
||||||
|
# TELEGRAM_CHAT_IDS=-5075171894
|
||||||
Binary file not shown.
95506
apps/backend/.wrangler/tmp/dev-4RTD5h/worker.js
Normal file
95506
apps/backend/.wrangler/tmp/dev-4RTD5h/worker.js
Normal file
File diff suppressed because it is too large
Load diff
8
apps/backend/.wrangler/tmp/dev-4RTD5h/worker.js.map
Normal file
8
apps/backend/.wrangler/tmp/dev-4RTD5h/worker.js.map
Normal file
File diff suppressed because one or more lines are too long
77826
apps/backend/.wrangler/tmp/dev-RJsRQO/worker.js
Normal file
77826
apps/backend/.wrangler/tmp/dev-RJsRQO/worker.js
Normal file
File diff suppressed because one or more lines are too long
8
apps/backend/.wrangler/tmp/dev-RJsRQO/worker.js.map
Normal file
8
apps/backend/.wrangler/tmp/dev-RJsRQO/worker.js.map
Normal file
File diff suppressed because one or more lines are too long
78404
apps/backend/.wrangler/tmp/dev-UMGKJQ/worker.js
Normal file
78404
apps/backend/.wrangler/tmp/dev-UMGKJQ/worker.js
Normal file
File diff suppressed because one or more lines are too long
8
apps/backend/.wrangler/tmp/dev-UMGKJQ/worker.js.map
Normal file
8
apps/backend/.wrangler/tmp/dev-UMGKJQ/worker.js.map
Normal file
File diff suppressed because one or more lines are too long
77854
apps/backend/.wrangler/tmp/dev-knF4eP/worker.js
Normal file
77854
apps/backend/.wrangler/tmp/dev-knF4eP/worker.js
Normal file
File diff suppressed because one or more lines are too long
8
apps/backend/.wrangler/tmp/dev-knF4eP/worker.js.map
Normal file
8
apps/backend/.wrangler/tmp/dev-knF4eP/worker.js.map
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46531
apps/backend/dumps/090426_2111_freshyo-dev.sql
Normal file
46531
apps/backend/dumps/090426_2111_freshyo-dev.sql
Normal file
File diff suppressed because it is too large
Load diff
56362
apps/backend/dumps/latest.sql
Normal file
56362
apps/backend/dumps/latest.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,9 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express, { NextFunction, Request, Response } from "express";
|
import { serve } from '@hono/node-server';
|
||||||
import cors from "cors";
|
|
||||||
// import bodyParser from "body-parser";
|
|
||||||
import multer from "multer";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
import { db } from '@/src/db/db_index';
|
|
||||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import mainRouter from '@/src/main-router';
|
|
||||||
import initFunc from '@/src/lib/init';
|
import initFunc from '@/src/lib/init';
|
||||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
import { createApp } from '@/src/app'
|
||||||
import { appRouter } from '@/src/trpc/router';
|
// import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { seed } from '@/src/lib/seed';
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
|
||||||
import { seed } from '@/src/db/seed';
|
|
||||||
import '@/src/jobs/jobs-index';
|
import '@/src/jobs/jobs-index';
|
||||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||||
|
|
||||||
|
|
@ -23,163 +11,13 @@ seed()
|
||||||
initFunc()
|
initFunc()
|
||||||
startAutomatedJobs()
|
startAutomatedJobs()
|
||||||
|
|
||||||
const app = express();
|
// signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility
|
||||||
|
|
||||||
app.use(cors({
|
const app = createApp()
|
||||||
origin: 'http://localhost:5174'
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
serve({
|
||||||
signedUrlCache.loadFromDisk();
|
fetch: app.fetch,
|
||||||
|
port: 4000,
|
||||||
app.use(express.json());
|
}, (info) => {
|
||||||
app.use(express.urlencoded({ extended: true }));
|
console.log(`Server is running on http://localhost:${info.port}/api/mobile/`);
|
||||||
|
|
||||||
// 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 = 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) {
|
|
||||||
user=staffUser
|
|
||||||
staffUser = {
|
|
||||||
id: staff.id,
|
|
||||||
name: staff.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
|
|
||||||
// This is a regular user token
|
|
||||||
user = decoded;
|
|
||||||
|
|
||||||
// Check if user is suspended
|
|
||||||
const details = await db.query.userDetails.findFirst({
|
|
||||||
where: eq(userDetails.userId, user.userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
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/");
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
54550
apps/backend/migrated.sql
Normal file
54550
apps/backend/migrated.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,14 +4,17 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"migrate": "drizzle-kit generate:pg",
|
|
||||||
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
|
||||||
"build2": "rimraf ./dist && tsc",
|
"build2": "rimraf ./dist && tsc",
|
||||||
"db:push": "drizzle-kit push:pg",
|
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
"dev2": "tsx watch index.ts",
|
"dev2": "tsx watch index.ts",
|
||||||
"dev_node": "tsx watch index.ts",
|
"dev_node": "tsx watch index.ts",
|
||||||
"dev": "bun --watch index.ts",
|
"dev_prev": "bun --watch index.ts",
|
||||||
|
"dev": "wrangler dev --config wrangler.dev.toml --ip 0.0.0.0",
|
||||||
|
"deploy": "wrangler deploy --config wrangler.prod.toml",
|
||||||
|
"wrangler:dev": "wrangler dev worker.ts --config wrangler.toml",
|
||||||
|
"wrangler:deploy": "wrangler deploy worker.ts --config wrangler.toml",
|
||||||
|
"pull_db": "wrangler d1 export freshyo-dev --config wrangler.prod.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -22,40 +25,29 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.888.0",
|
"@aws-sdk/s3-request-presigner": "^3.888.0",
|
||||||
|
"@hono/node-server": "^1.19.11",
|
||||||
|
"@hono/trpc-server": "^0.4.2",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
"@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/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",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"drizzle-orm": "^0.44.5",
|
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
"express": "^5.1.0",
|
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"hono": "^4.12.9",
|
||||||
"multer": "^2.0.2",
|
"jose": "^6.2.2",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pg-sdk-node": "https://phonepe.mycloudrepo.io/public/repositories/phonepe-pg-sdk-node/releases/v2/phonepe-pg-sdk-node.tgz",
|
|
||||||
"razorpay": "^2.9.6",
|
|
||||||
"redis": "^5.9.0",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.3",
|
"@cloudflare/workers-types": "^4.20260401.1",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/pg": "^8.15.5",
|
|
||||||
"drizzle-kit": "^0.31.4",
|
|
||||||
"rimraf": "^6.1.2",
|
"rimraf": "^6.1.2",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"tsx": "^4.20.5",
|
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"typescript": "^5.9.2"
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"wrangler": "^3.114.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
apps/backend/reset-remote-db.sh
Executable file
28
apps/backend/reset-remote-db.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_NAME="freshyo-dev"
|
||||||
|
DUMP_DIR="./dumps"
|
||||||
|
MIGRATION_FILE="./migrated.sql"
|
||||||
|
|
||||||
|
if ! command -v wrangler >/dev/null 2>&1; then
|
||||||
|
echo "wrangler not found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$MIGRATION_FILE" ]; then
|
||||||
|
echo "Migration file not found: $MIGRATION_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +"%d%m%y_%H%M")"
|
||||||
|
DUMP_FILE="${DUMP_DIR}/${TIMESTAMP}_${DB_NAME}.sql"
|
||||||
|
|
||||||
|
wrangler d1 export "$DB_NAME" --remote --output "$DUMP_FILE"
|
||||||
|
wrangler d1 delete "$DB_NAME"
|
||||||
|
wrangler d1 create "$DB_NAME"
|
||||||
|
wrangler d1 execute "$DB_NAME" --remote --file="$MIGRATION_FILE"
|
||||||
|
|
||||||
|
echo "Done. Dump saved at: $DUMP_FILE"
|
||||||
21
apps/backend/scripts/populate_localdb.sh
Executable file
21
apps/backend/scripts/populate_localdb.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DUMP_FILE="$ROOT_DIR/dumps/latest.sql"
|
||||||
|
WRANGLER_CONFIG="$ROOT_DIR/wrangler.dev.toml"
|
||||||
|
DB_NAME="freshyo-dev"
|
||||||
|
|
||||||
|
if [ ! -f "$DUMP_FILE" ]; then
|
||||||
|
echo "Dump file not found: $DUMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$WRANGLER_CONFIG" ]; then
|
||||||
|
echo "Wrangler config not found: $WRANGLER_CONFIG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
wrangler d1 execute "$DB_NAME" --local --file "$DUMP_FILE" --config "$WRANGLER_CONFIG"
|
||||||
|
|
||||||
|
echo "Local D1 database populated from $DUMP_FILE"
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
import { Router } from "express";
|
import { Hono } from 'hono';
|
||||||
import { authenticateStaff } from "@/src/middleware/staff-auth";
|
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();
|
const router = new Hono();
|
||||||
|
|
||||||
// Apply staff authentication to all admin routes
|
// Apply staff authentication to all admin routes
|
||||||
router.use(authenticateStaff);
|
router.use('*', authenticateStaff);
|
||||||
|
|
||||||
// Product routes
|
|
||||||
router.use("/products", productRouter);
|
|
||||||
|
|
||||||
// Tag routes
|
|
||||||
router.use("/product-tags", tagRouter);
|
|
||||||
|
|
||||||
const avRouter = router;
|
const avRouter = router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { db } from "@/src/db/db_index";
|
|
||||||
import { productTagInfo } from "@/src/db/schema";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { ApiError } from "@/src/lib/api-error";
|
|
||||||
import { imageUploadS3, generateSignedUrlFromS3Url } from "@/src/lib/s3-client";
|
|
||||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
|
||||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new product tag
|
|
||||||
*/
|
|
||||||
export const createTag = async (req: Request, res: Response) => {
|
|
||||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
|
||||||
|
|
||||||
if (!tagName) {
|
|
||||||
throw new ApiError("Tag name is required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate tag name
|
|
||||||
const existingTag = await db.query.productTagInfo.findFirst({
|
|
||||||
where: eq(productTagInfo.tagName, tagName.trim()),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingTag) {
|
|
||||||
throw new ApiError("A tag with this name already exists", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
let imageUrl: string | null = null;
|
|
||||||
|
|
||||||
// Handle image upload if file is provided
|
|
||||||
if (req.file) {
|
|
||||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
|
||||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse relatedStores if it's a string (from FormData)
|
|
||||||
let parsedRelatedStores: number[] = [];
|
|
||||||
if (relatedStores) {
|
|
||||||
try {
|
|
||||||
parsedRelatedStores = typeof relatedStores === 'string'
|
|
||||||
? JSON.parse(relatedStores)
|
|
||||||
: relatedStores;
|
|
||||||
} catch (e) {
|
|
||||||
parsedRelatedStores = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newTag] = await db
|
|
||||||
.insert(productTagInfo)
|
|
||||||
.values({
|
|
||||||
tagName: tagName.trim(),
|
|
||||||
tagDescription,
|
|
||||||
imageUrl,
|
|
||||||
isDashboardTag: isDashboardTag || false,
|
|
||||||
relatedStores: parsedRelatedStores,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes in cache
|
|
||||||
await initializeAllStores();
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
tag: newTag,
|
|
||||||
message: "Tag created successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all product tags
|
|
||||||
*/
|
|
||||||
export const getAllTags = async (req: Request, res: Response) => {
|
|
||||||
const tags = await db
|
|
||||||
.select()
|
|
||||||
.from(productTagInfo)
|
|
||||||
.orderBy(productTagInfo.tagName);
|
|
||||||
|
|
||||||
// Generate signed URLs for tag images
|
|
||||||
const tagsWithSignedUrls = await Promise.all(
|
|
||||||
tags.map(async (tag) => ({
|
|
||||||
...tag,
|
|
||||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
tags: tagsWithSignedUrls,
|
|
||||||
message: "Tags retrieved successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single product tag by ID
|
|
||||||
*/
|
|
||||||
export const getTagById = async (req: Request, res: Response) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const tag = await db.query.productTagInfo.findFirst({
|
|
||||||
where: eq(productTagInfo.id, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tag) {
|
|
||||||
throw new ApiError("Tag not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate signed URL for tag image
|
|
||||||
const tagWithSignedUrl = {
|
|
||||||
...tag,
|
|
||||||
imageUrl: tag.imageUrl ? await generateSignedUrlFromS3Url(tag.imageUrl) : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
tag: tagWithSignedUrl,
|
|
||||||
message: "Tag retrieved successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a product tag
|
|
||||||
*/
|
|
||||||
export const updateTag = async (req: Request, res: Response) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { tagName, tagDescription, isDashboardTag, relatedStores } = req.body;
|
|
||||||
|
|
||||||
// Get the current tag to check for existing image
|
|
||||||
const currentTag = await db.query.productTagInfo.findFirst({
|
|
||||||
where: eq(productTagInfo.id, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentTag) {
|
|
||||||
throw new ApiError("Tag not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let imageUrl = currentTag.imageUrl;
|
|
||||||
|
|
||||||
// Handle image upload if new file is provided
|
|
||||||
if (req.file) {
|
|
||||||
// Delete old image if it exists
|
|
||||||
if (currentTag.imageUrl) {
|
|
||||||
try {
|
|
||||||
await deleteS3Image(currentTag.imageUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete old image:", error);
|
|
||||||
// Continue with update even if delete fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Upload new image
|
|
||||||
const key = `tags/${Date.now()}-${req.file.originalname}`;
|
|
||||||
console.log('file', key)
|
|
||||||
imageUrl = await imageUploadS3(req.file.buffer, req.file.mimetype, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse relatedStores if it's a string (from FormData)
|
|
||||||
let parsedRelatedStores: number[] | undefined;
|
|
||||||
if (relatedStores !== undefined) {
|
|
||||||
try {
|
|
||||||
parsedRelatedStores = typeof relatedStores === 'string'
|
|
||||||
? JSON.parse(relatedStores)
|
|
||||||
: relatedStores;
|
|
||||||
} catch (e) {
|
|
||||||
parsedRelatedStores = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedTag] = await db
|
|
||||||
.update(productTagInfo)
|
|
||||||
.set({
|
|
||||||
tagName: tagName?.trim(),
|
|
||||||
tagDescription,
|
|
||||||
imageUrl,
|
|
||||||
isDashboardTag,
|
|
||||||
relatedStores: parsedRelatedStores,
|
|
||||||
})
|
|
||||||
.where(eq(productTagInfo.id, parseInt(id)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes in cache
|
|
||||||
await initializeAllStores();
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
tag: updatedTag,
|
|
||||||
message: "Tag updated successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product tag
|
|
||||||
*/
|
|
||||||
export const deleteTag = async (req: Request, res: Response) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
// Check if tag exists
|
|
||||||
const tag = await db.query.productTagInfo.findFirst({
|
|
||||||
where: eq(productTagInfo.id, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tag) {
|
|
||||||
throw new ApiError("Tag not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete image from S3 if it exists
|
|
||||||
if (tag.imageUrl) {
|
|
||||||
try {
|
|
||||||
await deleteS3Image(tag.imageUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete image from S3:", error);
|
|
||||||
// Continue with deletion even if image delete fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: This will fail if tag is still assigned to products due to foreign key constraint
|
|
||||||
await db.delete(productTagInfo).where(eq(productTagInfo.id, parseInt(id)));
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes in cache
|
|
||||||
await initializeAllStores();
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Tag deleted successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { db } from "@/src/db/db_index";
|
|
||||||
import { productInfo, units, specialDeals, productTags } from "@/src/db/schema";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import { ApiError } from "@/src/lib/api-error";
|
|
||||||
import { imageUploadS3, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client";
|
|
||||||
import { deleteS3Image } from "@/src/lib/delete-image";
|
|
||||||
import type { SpecialDeal } from "@/src/db/types";
|
|
||||||
import { initializeAllStores } from '@/src/stores/store-initializer';
|
|
||||||
|
|
||||||
type CreateDeal = {
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
validTill: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new product
|
|
||||||
*/
|
|
||||||
export const createProduct = async (req: Request, res: Response) => {
|
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals, tagIds } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!name || !unitId || !storeId || !price) {
|
|
||||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate name
|
|
||||||
const existingProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.name, name.trim()),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingProduct) {
|
|
||||||
throw new ApiError("A product with this name already exists", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if unit exists
|
|
||||||
const unit = await db.query.units.findFirst({
|
|
||||||
where: eq(units.id, unitId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!unit) {
|
|
||||||
throw new ApiError("Invalid unit ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract images from req.files
|
|
||||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
|
||||||
let uploadedImageUrls: string[] = [];
|
|
||||||
|
|
||||||
if (images && Array.isArray(images)) {
|
|
||||||
const imageUploadPromises = images.map((file, index) => {
|
|
||||||
const key = `product-images/${Date.now()}-${index}`;
|
|
||||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create product
|
|
||||||
const productData: any = {
|
|
||||||
name,
|
|
||||||
shortDescription,
|
|
||||||
longDescription,
|
|
||||||
unitId,
|
|
||||||
storeId,
|
|
||||||
price,
|
|
||||||
marketPrice,
|
|
||||||
incrementStep: incrementStep || 1,
|
|
||||||
productQuantity: productQuantity || 1,
|
|
||||||
isSuspended: isSuspended || false,
|
|
||||||
isFlashAvailable: isFlashAvailable || false,
|
|
||||||
images: uploadedImageUrls,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (flashPrice) {
|
|
||||||
productData.flashPrice = parseFloat(flashPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newProduct] = await db
|
|
||||||
.insert(productInfo)
|
|
||||||
.values(productData)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// Handle deals if provided
|
|
||||||
let createdDeals: SpecialDeal[] = [];
|
|
||||||
if (deals && Array.isArray(deals)) {
|
|
||||||
const dealInserts = deals.map((deal: CreateDeal) => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
|
|
||||||
createdDeals = await db
|
|
||||||
.insert(specialDeals)
|
|
||||||
.values(dealInserts)
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tag assignments if provided
|
|
||||||
if (tagIds && Array.isArray(tagIds)) {
|
|
||||||
const tagAssociations = tagIds.map((tagId: number) => ({
|
|
||||||
productId: newProduct.id,
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes
|
|
||||||
await initializeAllStores();
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
product: newProduct,
|
|
||||||
deals: createdDeals,
|
|
||||||
message: "Product created successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a product
|
|
||||||
*/
|
|
||||||
export const updateProduct = async (req: Request, res: Response) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { name, shortDescription, longDescription, unitId, storeId, price, marketPrice, incrementStep, productQuantity, isSuspended, isFlashAvailable, flashPrice, deals:dealsRaw, imagesToDelete:imagesToDeleteRaw, tagIds } = req.body;
|
|
||||||
|
|
||||||
|
|
||||||
const deals = dealsRaw ? JSON.parse(dealsRaw) : null;
|
|
||||||
const imagesToDelete = imagesToDeleteRaw ? JSON.parse(imagesToDeleteRaw) : [];
|
|
||||||
|
|
||||||
if (!name || !unitId || !storeId || !price) {
|
|
||||||
throw new ApiError("Name, unitId, storeId, and price are required", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if unit exists
|
|
||||||
const unit = await db.query.units.findFirst({
|
|
||||||
where: eq(units.id, unitId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!unit) {
|
|
||||||
throw new ApiError("Invalid unit ID", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current product to handle image updates
|
|
||||||
const currentProduct = await db.query.productInfo.findFirst({
|
|
||||||
where: eq(productInfo.id, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentProduct) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle image deletions
|
|
||||||
let currentImages = (currentProduct.images as string[]) || [];
|
|
||||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
|
||||||
// Convert signed URLs to original S3 URLs for comparison
|
|
||||||
const originalUrlsToDelete = imagesToDelete
|
|
||||||
.map((signedUrl: string) => getOriginalUrlFromSignedUrl(signedUrl))
|
|
||||||
.filter(Boolean); // Remove nulls
|
|
||||||
|
|
||||||
// Find which stored images match the ones to delete
|
|
||||||
const imagesToRemoveFromDb = currentImages.filter(storedUrl =>
|
|
||||||
originalUrlsToDelete.includes(storedUrl)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete the matching images from S3
|
|
||||||
const deletePromises = imagesToRemoveFromDb.map(imageUrl => deleteS3Image(imageUrl));
|
|
||||||
await Promise.all(deletePromises);
|
|
||||||
|
|
||||||
// Remove deleted images from current images array
|
|
||||||
currentImages = currentImages.filter(img => !imagesToRemoveFromDb.includes(img));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract new images from req.files
|
|
||||||
const images = (req.files as Express.Multer.File[])?.filter(item => item.fieldname === 'images');
|
|
||||||
let uploadedImageUrls: string[] = [];
|
|
||||||
|
|
||||||
if (images && Array.isArray(images)) {
|
|
||||||
const imageUploadPromises = images.map((file, index) => {
|
|
||||||
const key = `product-images/${Date.now()}-${index}`;
|
|
||||||
return imageUploadS3(file.buffer, file.mimetype, key);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedImageUrls = await Promise.all(imageUploadPromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine remaining current images with new uploaded images
|
|
||||||
const finalImages = [...currentImages, ...uploadedImageUrls];
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
name,
|
|
||||||
shortDescription,
|
|
||||||
longDescription,
|
|
||||||
unitId,
|
|
||||||
storeId,
|
|
||||||
price,
|
|
||||||
marketPrice,
|
|
||||||
incrementStep: incrementStep || 1,
|
|
||||||
productQuantity: productQuantity || 1,
|
|
||||||
isSuspended: isSuspended || false,
|
|
||||||
images: finalImages.length > 0 ? finalImages : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isFlashAvailable !== undefined) {
|
|
||||||
updateData.isFlashAvailable = isFlashAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flashPrice !== undefined) {
|
|
||||||
updateData.flashPrice = flashPrice ? parseFloat(flashPrice) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedProduct] = await db
|
|
||||||
.update(productInfo)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(productInfo.id, parseInt(id)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updatedProduct) {
|
|
||||||
throw new ApiError("Product not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle deals if provided
|
|
||||||
if (deals && Array.isArray(deals)) {
|
|
||||||
// Get existing deals
|
|
||||||
const existingDeals = await db.query.specialDeals.findMany({
|
|
||||||
where: eq(specialDeals.productId, parseInt(id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create maps for comparison
|
|
||||||
const existingDealsMap = new Map(existingDeals.map(deal => [`${deal.quantity}-${deal.price}`, deal]));
|
|
||||||
const newDealsMap = new Map(deals.map((deal: CreateDeal) => [`${deal.quantity}-${deal.price}`, deal]));
|
|
||||||
|
|
||||||
// Find deals to add, update, and remove
|
|
||||||
const dealsToAdd = deals.filter((deal: CreateDeal) => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
return !existingDealsMap.has(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dealsToRemove = existingDeals.filter(deal => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
return !newDealsMap.has(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dealsToUpdate = deals.filter((deal: CreateDeal) => {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
const existing = existingDealsMap.get(key);
|
|
||||||
return existing && existing.validTill.toISOString().split('T')[0] !== deal.validTill;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove old deals
|
|
||||||
if (dealsToRemove.length > 0) {
|
|
||||||
await db.delete(specialDeals).where(
|
|
||||||
inArray(specialDeals.id, dealsToRemove.map(deal => deal.id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new deals
|
|
||||||
if (dealsToAdd.length > 0) {
|
|
||||||
const dealInserts = dealsToAdd.map((deal: CreateDeal) => ({
|
|
||||||
productId: parseInt(id),
|
|
||||||
quantity: deal.quantity.toString(),
|
|
||||||
price: deal.price.toString(),
|
|
||||||
validTill: new Date(deal.validTill),
|
|
||||||
}));
|
|
||||||
await db.insert(specialDeals).values(dealInserts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing deals
|
|
||||||
for (const deal of dealsToUpdate) {
|
|
||||||
const key = `${deal.quantity}-${deal.price}`;
|
|
||||||
const existingDeal = existingDealsMap.get(key);
|
|
||||||
if (existingDeal) {
|
|
||||||
await db.update(specialDeals)
|
|
||||||
.set({ validTill: new Date(deal.validTill) })
|
|
||||||
.where(eq(specialDeals.id, existingDeal.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tag assignments if provided
|
|
||||||
// if (tagIds && Array.isArray(tagIds)) {
|
|
||||||
if (tagIds && Boolean(tagIds)) {
|
|
||||||
// Remove existing tags
|
|
||||||
await db.delete(productTags).where(eq(productTags.productId, parseInt(id)));
|
|
||||||
|
|
||||||
const tagIdsArray = Array.isArray(tagIds) ? tagIds : [+tagIds]
|
|
||||||
// Add new tags
|
|
||||||
const tagAssociations = tagIdsArray.map((tagId: number) => ({
|
|
||||||
productId: parseInt(id),
|
|
||||||
tagId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.insert(productTags).values(tagAssociations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinitialize stores to reflect changes
|
|
||||||
await initializeAllStores();
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
product: updatedProduct,
|
|
||||||
message: "Product updated successfully",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { createProduct, updateProduct } from "@/src/apis/admin-apis/apis/product.controller"
|
|
||||||
import uploadHandler from '@/src/lib/upload-handler';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Product routes
|
|
||||||
router.post("/", uploadHandler.array('images'), createProduct);
|
|
||||||
router.put("/:id", uploadHandler.array('images'), updateProduct);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { Router } from "express";
|
|
||||||
import { createTag, getAllTags, getTagById, updateTag, deleteTag } from "@/src/apis/admin-apis/apis/product-tags.controller"
|
|
||||||
import uploadHandler from '@/src/lib/upload-handler';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Tag routes
|
|
||||||
router.post("/", uploadHandler.single('image'), createTag);
|
|
||||||
router.get("/", getAllTags);
|
|
||||||
router.get("/:id", getTagById);
|
|
||||||
router.put("/:id", uploadHandler.single('image'), updateTag);
|
|
||||||
router.delete("/:id", deleteTag);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,12 +1,67 @@
|
||||||
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
import { Context } from 'hono';
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { db } from "@/src/db/db_index"
|
|
||||||
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
|
||||||
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
import { scaffoldAssetUrl } from "@/src/lib/s3-client"
|
||||||
|
import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"
|
||||||
|
import {
|
||||||
|
getAllProductsWithUnits,
|
||||||
|
type ProductSummaryData,
|
||||||
|
} from "@/src/dbService"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get next delivery date for a product
|
* Get all products summary for dropdown
|
||||||
*/
|
*/
|
||||||
|
export const getAllProductsSummary = async (c: Context) => {
|
||||||
|
try {
|
||||||
|
const tagId = c.req.query('tagId');
|
||||||
|
const tagIdNum = tagId ? parseInt(tagId) : undefined;
|
||||||
|
|
||||||
|
// If tagId is provided but no products found, return empty array
|
||||||
|
if (tagIdNum) {
|
||||||
|
const products = await getAllProductsWithUnits(tagIdNum);
|
||||||
|
if (products.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
products: [],
|
||||||
|
count: 0,
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productsWithUnits = await getAllProductsWithUnits(tagIdNum);
|
||||||
|
|
||||||
|
// Generate signed URLs for product images
|
||||||
|
const formattedProducts = await Promise.all(
|
||||||
|
productsWithUnits.map(async (product: ProductSummaryData) => {
|
||||||
|
const nextDeliveryDate = await getNextDeliveryDate(product.id);
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
shortDescription: product.shortDescription,
|
||||||
|
price: product.price,
|
||||||
|
marketPrice: product.marketPrice,
|
||||||
|
unit: product.unitShortNotation,
|
||||||
|
productQuantity: product.productQuantity,
|
||||||
|
isOutOfStock: product.isOutOfStock,
|
||||||
|
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
|
||||||
|
images: scaffoldAssetUrl((product.images as string[]) || []),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
products: formattedProducts,
|
||||||
|
count: formattedProducts.length,
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get products summary error:", error);
|
||||||
|
return c.json({ error: "Failed to fetch products summary" }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Old implementation - direct DB queries:
|
||||||
|
import { eq, gt, and, sql, inArray } from "drizzle-orm";
|
||||||
|
import { db } from "@/src/db/db_index"
|
||||||
|
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||||
|
|
||||||
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
||||||
|
|
@ -22,13 +77,9 @@ const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
||||||
.orderBy(deliverySlotInfo.deliveryTime)
|
.orderBy(deliverySlotInfo.deliveryTime)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
|
||||||
return result[0]?.deliveryTime || null;
|
return result[0]?.deliveryTime || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all products summary for dropdown
|
|
||||||
*/
|
|
||||||
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { tagId } = req.query;
|
const { tagId } = req.query;
|
||||||
|
|
@ -103,3 +154,4 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||||
return res.status(500).json({ error: "Failed to fetch products summary" });
|
return res.status(500).json({ error: "Failed to fetch products summary" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Hono } from 'hono';
|
||||||
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||||
|
|
||||||
const router = Router();
|
const router = new Hono();
|
||||||
|
|
||||||
router.get("/summary", getAllProductsSummary);
|
router.get("/summary", getAllProductsSummary);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Router } from "express";
|
import { Hono } from 'hono';
|
||||||
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
|
||||||
|
|
||||||
const router = Router();
|
const router = new Hono();
|
||||||
|
|
||||||
router.use('/products', commonProductsRouter)
|
router.route('/products', commonProductsRouter)
|
||||||
|
|
||||||
const commonRouter = router;
|
const commonRouter = router;
|
||||||
|
|
||||||
|
|
|
||||||
126
apps/backend/src/app.ts
Normal file
126
apps/backend/src/app.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { cors } from 'hono/cors'
|
||||||
|
import { logger } from 'hono/logger'
|
||||||
|
import { trpcServer } from '@hono/trpc-server'
|
||||||
|
import { getStaffUserById, isUserSuspended } from '@/src/dbService'
|
||||||
|
import mainRouter from '@/src/main-router'
|
||||||
|
import { appRouter } from '@/src/trpc/router'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
import { getEncodedJwtSecret } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
|
export const createApp = () => {
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
// CORS middleware
|
||||||
|
app.use(cors({
|
||||||
|
origin: ['http://localhost:5174', 'https://ui.freshyo.in'],
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Logger middleware
|
||||||
|
app.use(logger())
|
||||||
|
|
||||||
|
// tRPC middleware
|
||||||
|
app.use('/api/trpc/*', trpcServer({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: async ({ req }) => {
|
||||||
|
let user = null
|
||||||
|
let staffUser = null
|
||||||
|
const authHeader = req.headers.get('authorization')
|
||||||
|
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7)
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, getEncodedJwtSecret())
|
||||||
|
const decoded = payload 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 getStaffUserById(decoded.staffId)
|
||||||
|
|
||||||
|
if (staff) {
|
||||||
|
user = staffUser
|
||||||
|
staffUser = {
|
||||||
|
id: staff.id,
|
||||||
|
name: staff.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This is a regular user token
|
||||||
|
user = decoded
|
||||||
|
|
||||||
|
// Check if user is suspended
|
||||||
|
const suspended = await isUserSuspended(user.userId)
|
||||||
|
|
||||||
|
if (suspended) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Account suspended',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Invalid token, both user and staffUser remain null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { req, 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mount main router
|
||||||
|
app.route('/api', mainRouter)
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(err)
|
||||||
|
// Handle different error types
|
||||||
|
let status = 500
|
||||||
|
let message = 'Internal Server Error'
|
||||||
|
|
||||||
|
if (err instanceof TRPCError) {
|
||||||
|
// Map TRPC error codes to HTTP status codes
|
||||||
|
const trpcStatusMap: Record<string, number> = {
|
||||||
|
BAD_REQUEST: 400,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
TIMEOUT: 408,
|
||||||
|
CONFLICT: 409,
|
||||||
|
PRECONDITION_FAILED: 412,
|
||||||
|
PAYLOAD_TOO_LARGE: 413,
|
||||||
|
METHOD_NOT_SUPPORTED: 405,
|
||||||
|
UNPROCESSABLE_CONTENT: 422,
|
||||||
|
TOO_MANY_REQUESTS: 429,
|
||||||
|
INTERNAL_SERVER_ERROR: 500,
|
||||||
|
}
|
||||||
|
status = trpcStatusMap[err.code] || 500
|
||||||
|
message = err.message
|
||||||
|
} else if ((err as any).statusCode) {
|
||||||
|
status = (err as any).statusCode
|
||||||
|
message = err.message
|
||||||
|
} else if ((err as any).status) {
|
||||||
|
status = (err as any).status
|
||||||
|
message = err.message
|
||||||
|
} else if (err.message) {
|
||||||
|
message = err.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message }, status as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
178
apps/backend/src/dbService.ts
Normal file
178
apps/backend/src/dbService.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// Database Service - Central export for all database-related imports
|
||||||
|
// This file re-exports everything from postgresImporter to provide a clean abstraction layer
|
||||||
|
|
||||||
|
import type { AdminOrderDetails } from '@packages/shared'
|
||||||
|
// import { getOrderDetails } from '@/src/postgresImporter'
|
||||||
|
import { getOrderDetails, initDb } from '@/src/sqliteImporter'
|
||||||
|
|
||||||
|
// Re-export everything from postgresImporter
|
||||||
|
// export * from '@/src/postgresImporter'
|
||||||
|
|
||||||
|
export * from '@/src/sqliteImporter'
|
||||||
|
|
||||||
|
export { initDb }
|
||||||
|
|
||||||
|
// Re-export getOrderDetails with the correct signature
|
||||||
|
export async function getOrderDetailsWrapper(orderId: number): Promise<AdminOrderDetails | null> {
|
||||||
|
return getOrderDetails(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export all types from shared package
|
||||||
|
export type {
|
||||||
|
// Admin types
|
||||||
|
Banner,
|
||||||
|
Complaint,
|
||||||
|
ComplaintWithUser,
|
||||||
|
Constant,
|
||||||
|
ConstantUpdateResult,
|
||||||
|
Coupon,
|
||||||
|
CouponValidationResult,
|
||||||
|
UserMiniInfo,
|
||||||
|
Store,
|
||||||
|
StaffUser,
|
||||||
|
StaffRole,
|
||||||
|
AdminOrderRow,
|
||||||
|
AdminOrderDetails,
|
||||||
|
AdminOrderUpdateResult,
|
||||||
|
AdminOrderItemPackagingResult,
|
||||||
|
AdminOrderMessageResult,
|
||||||
|
AdminOrderBasicResult,
|
||||||
|
AdminGetSlotOrdersResult,
|
||||||
|
AdminGetAllOrdersResult,
|
||||||
|
AdminGetAllOrdersResultWithUserId,
|
||||||
|
AdminRebalanceSlotsResult,
|
||||||
|
AdminCancelOrderResult,
|
||||||
|
AdminUnit,
|
||||||
|
AdminProduct,
|
||||||
|
AdminProductWithRelations,
|
||||||
|
AdminProductWithDetails,
|
||||||
|
AdminProductTagInfo,
|
||||||
|
AdminProductTagWithProducts,
|
||||||
|
AdminProductListResponse,
|
||||||
|
AdminProductResponse,
|
||||||
|
AdminDeleteProductResult,
|
||||||
|
AdminToggleOutOfStockResult,
|
||||||
|
AdminUpdateSlotProductsResult,
|
||||||
|
AdminSlotProductIdsResult,
|
||||||
|
AdminSlotsProductIdsResult,
|
||||||
|
AdminProductReview,
|
||||||
|
AdminProductReviewWithSignedUrls,
|
||||||
|
AdminProductReviewsResult,
|
||||||
|
AdminProductReviewResponse,
|
||||||
|
AdminProductGroup,
|
||||||
|
AdminProductGroupsResult,
|
||||||
|
AdminProductGroupResponse,
|
||||||
|
AdminProductGroupInfo,
|
||||||
|
AdminUpdateProductPricesResult,
|
||||||
|
AdminDeliverySlot,
|
||||||
|
AdminSlotProductSummary,
|
||||||
|
AdminSlotWithProducts,
|
||||||
|
AdminSlotWithProductsAndSnippets,
|
||||||
|
AdminSlotWithProductsAndSnippetsBase,
|
||||||
|
AdminSlotsResult,
|
||||||
|
AdminSlotsListResult,
|
||||||
|
AdminSlotResult,
|
||||||
|
AdminSlotCreateResult,
|
||||||
|
AdminSlotUpdateResult,
|
||||||
|
AdminSlotDeleteResult,
|
||||||
|
AdminDeliverySequence,
|
||||||
|
AdminDeliverySequenceResult,
|
||||||
|
AdminUpdateDeliverySequenceResult,
|
||||||
|
AdminUpdateSlotCapacityResult,
|
||||||
|
AdminVendorSnippet,
|
||||||
|
AdminVendorSnippetWithAccess,
|
||||||
|
AdminVendorSnippetWithSlot,
|
||||||
|
AdminVendorSnippetProduct,
|
||||||
|
AdminVendorSnippetWithProducts,
|
||||||
|
AdminVendorSnippetCreateInput,
|
||||||
|
AdminVendorSnippetUpdateInput,
|
||||||
|
AdminVendorSnippetDeleteResult,
|
||||||
|
AdminVendorSnippetOrderProduct,
|
||||||
|
AdminVendorSnippetOrderSummary,
|
||||||
|
AdminVendorSnippetOrdersResult,
|
||||||
|
AdminVendorSnippetOrdersWithSlotResult,
|
||||||
|
AdminVendorOrderSummary,
|
||||||
|
AdminUpcomingSlotsResult,
|
||||||
|
AdminVendorUpdatePackagingResult,
|
||||||
|
UserAddress,
|
||||||
|
UserAddressResponse,
|
||||||
|
UserAddressesResponse,
|
||||||
|
UserAddressDeleteResponse,
|
||||||
|
UserBanner,
|
||||||
|
UserBannersResponse,
|
||||||
|
UserCartProduct,
|
||||||
|
UserCartItem,
|
||||||
|
UserCartResponse,
|
||||||
|
UserComplaint,
|
||||||
|
UserComplaintsResponse,
|
||||||
|
UserRaiseComplaintResponse,
|
||||||
|
UserStoreSummary,
|
||||||
|
UserStoreSummaryData,
|
||||||
|
UserStoresResponse,
|
||||||
|
UserStoreSampleProduct,
|
||||||
|
UserStoreSampleProductData,
|
||||||
|
UserStoreDetail,
|
||||||
|
UserStoreDetailData,
|
||||||
|
UserStoreProduct,
|
||||||
|
UserStoreProductData,
|
||||||
|
UserTagSummary,
|
||||||
|
UserProductDetail,
|
||||||
|
UserProductDetailData,
|
||||||
|
UserProductReview,
|
||||||
|
UserProductReviewWithSignedUrls,
|
||||||
|
UserProductReviewsResponse,
|
||||||
|
UserCreateReviewResponse,
|
||||||
|
UserSlotProduct,
|
||||||
|
UserSlotWithProducts,
|
||||||
|
UserSlotData,
|
||||||
|
UserSlotAvailability,
|
||||||
|
UserDeliverySlot,
|
||||||
|
UserSlotsResponse,
|
||||||
|
UserSlotsWithProductsResponse,
|
||||||
|
UserSlotsListResponse,
|
||||||
|
UserPaymentOrderResponse,
|
||||||
|
UserPaymentVerifyResponse,
|
||||||
|
UserPaymentFailResponse,
|
||||||
|
UserAuthProfile,
|
||||||
|
UserAuthResponse,
|
||||||
|
UserAuthResult,
|
||||||
|
UserOtpVerifyResponse,
|
||||||
|
UserPasswordUpdateResponse,
|
||||||
|
UserProfileResponse,
|
||||||
|
UserDeleteAccountResponse,
|
||||||
|
UserCouponUsage,
|
||||||
|
UserCouponApplicableUser,
|
||||||
|
UserCouponApplicableProduct,
|
||||||
|
UserCoupon,
|
||||||
|
UserCouponWithRelations,
|
||||||
|
UserEligibleCouponsResponse,
|
||||||
|
UserCouponDisplay,
|
||||||
|
UserMyCouponsResponse,
|
||||||
|
UserRedeemCouponResponse,
|
||||||
|
UserSelfDataResponse,
|
||||||
|
UserProfileCompleteResponse,
|
||||||
|
UserSavePushTokenResponse,
|
||||||
|
UserOrderItemSummary,
|
||||||
|
UserOrderSummary,
|
||||||
|
UserOrdersResponse,
|
||||||
|
UserOrderDetail,
|
||||||
|
UserCancelOrderResponse,
|
||||||
|
UserUpdateNotesResponse,
|
||||||
|
UserRecentProduct,
|
||||||
|
UserRecentProductsResponse,
|
||||||
|
// Store types
|
||||||
|
StoreSummary,
|
||||||
|
StoresSummaryResponse,
|
||||||
|
} from '@packages/shared';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
// User types
|
||||||
|
User,
|
||||||
|
UserDetails,
|
||||||
|
Address,
|
||||||
|
Product,
|
||||||
|
CartItem,
|
||||||
|
Order,
|
||||||
|
OrderItem,
|
||||||
|
Payment,
|
||||||
|
} from '@packages/shared';
|
||||||
69
apps/backend/src/jobs/cache-creator.ts
Normal file
69
apps/backend/src/jobs/cache-creator.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { initializeAllStores } from '@/src/stores/store-initializer'
|
||||||
|
import { ensureWorkerInit } from '@/src/lib/worker-init'
|
||||||
|
|
||||||
|
const LAST_TRIGGER_KEY = 'lastTrigger'
|
||||||
|
const ALARM_DELAY_MINUTES = 0.5
|
||||||
|
// const ALARM_DELAY_MINUTES = 0.1
|
||||||
|
|
||||||
|
export class CacheCreator {
|
||||||
|
private state: any
|
||||||
|
private env: any
|
||||||
|
|
||||||
|
constructor(state: any, env: any) {
|
||||||
|
this.state = state
|
||||||
|
this.env = env
|
||||||
|
ensureWorkerInit(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
async schedule(): Promise<void> {
|
||||||
|
console.log( 'from the fetch method of durable object')
|
||||||
|
// if (request.method === 'POST' && url.pathname === '/schedule') {
|
||||||
|
const now = Date.now()
|
||||||
|
await this.state.storage.put(LAST_TRIGGER_KEY, now)
|
||||||
|
const alarmAt = dayjs(now).add(ALARM_DELAY_MINUTES, 'minute').valueOf()
|
||||||
|
await this.state.storage.setAlarm(alarmAt)
|
||||||
|
// return new Response('OK')
|
||||||
|
// }
|
||||||
|
// if (request.method === 'POST' && url.pathname === '/clear') {
|
||||||
|
await this.state.storage.deleteAll()
|
||||||
|
// return new Response('OK')
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return new Response('CacheCreator ready', { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (request.method === 'POST' && url.pathname === '/schedule') {
|
||||||
|
const now = Date.now()
|
||||||
|
await this.state.storage.put(LAST_TRIGGER_KEY, now)
|
||||||
|
const alarmAt = dayjs(now).add(ALARM_DELAY_MINUTES, 'minute').valueOf()
|
||||||
|
await this.state.storage.setAlarm(alarmAt)
|
||||||
|
return new Response('OK')
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && url.pathname === '/clear') {
|
||||||
|
await this.state.storage.deleteAll()
|
||||||
|
return new Response('OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('CacheCreator ready', { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async alarm(): Promise<void> {
|
||||||
|
ensureWorkerInit(this.env)
|
||||||
|
console.log('from the shceduler')
|
||||||
|
const lastTrigger = await this.state.storage.get(LAST_TRIGGER_KEY)
|
||||||
|
if (!lastTrigger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = dayjs().subtract(ALARM_DELAY_MINUTES, 'minute')
|
||||||
|
const isQualify = dayjs(lastTrigger).isBefore(threshold);
|
||||||
|
console.log({isQualify, threshold, curr: dayjs(lastTrigger)})
|
||||||
|
if (isQualify) {
|
||||||
|
await initializeAllStores()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import * as cron from 'node-cron';
|
|
||||||
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
|
||||||
|
|
||||||
const runCombinedJob = async () => {
|
const runCombinedJob = async () => {
|
||||||
|
|
@ -25,4 +24,4 @@ const runCombinedJob = async () => {
|
||||||
runCombinedJob();
|
runCombinedJob();
|
||||||
|
|
||||||
// Schedule combined cron job every 10 minutes
|
// Schedule combined cron job every 10 minutes
|
||||||
cron.schedule('*/10 * * * *', runCombinedJob);
|
// cron.schedule('*/10 * * * *', runCombinedJob);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
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 {
|
interface PendingPaymentRecord {
|
||||||
payment: typeof payments.$inferSelect;
|
payment: any;
|
||||||
order: typeof orders.$inferSelect;
|
order: any;
|
||||||
slot: typeof deliverySlotInfo.$inferSelect;
|
slot: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||||
|
|
@ -20,20 +15,46 @@ export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||||
|
|
||||||
export const checkRefundStatuses = async () => {
|
export const checkRefundStatuses = async () => {
|
||||||
try {
|
try {
|
||||||
const initiatedRefunds = await db
|
// TODO: Reimplement with helpers from @/src/dbService
|
||||||
.select()
|
// This function checks Razorpay refund status and updates database
|
||||||
.from(refunds)
|
// Requires: getPendingRefunds(), updateRefundStatus()
|
||||||
.where(and(
|
} catch (error) {
|
||||||
eq(refunds.refundStatus, 'initiated'),
|
console.error('Error in checkRefundStatuses:', error);
|
||||||
isNotNull(refunds.merchantRefundId)
|
}
|
||||||
));
|
};
|
||||||
|
|
||||||
// Process refunds concurrently using Promise.allSettled
|
export const checkPendingPayments = async () => {
|
||||||
const promises = initiatedRefunds.map(async (refund) => {
|
try {
|
||||||
if (!refund.merchantRefundId) return;
|
// TODO: Reimplement with helpers from @/src/dbService
|
||||||
|
// This function finds pending payments and sends notifications
|
||||||
|
// Requires: getPendingPaymentsWithOrders()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking pending payments:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
/*
|
||||||
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
|
// Old implementation - direct DB queries:
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
|
||||||
|
import { eq, and, gt, isNotNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
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') {
|
if (razorpayRefund.status === 'processed') {
|
||||||
await db
|
await db
|
||||||
|
|
@ -76,4 +97,4 @@ export const checkPendingPayments = async () => {
|
||||||
console.error('Error checking pending payments:', error);
|
console.error('Error checking pending payments:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ export class ApiError extends Error {
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
Error.captureStackTrace?.(this, ApiError);
|
// Error.captureStackTrace?.(this, ApiError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as cron from 'node-cron';
|
// import * as cron from 'node-cron';
|
||||||
import { db } from '@/src/db/db_index'
|
const cron:any = {}
|
||||||
import { productInfo, keyValStore } from '@/src/db/schema'
|
import { toggleFlashDeliveryForItems, toggleKeyVal } from '@/src/dbService';
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
|
||||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
import { computeConstants } from '@/src/lib/const-store'
|
import { computeConstants } from '@/src/lib/const-store'
|
||||||
|
|
||||||
|
|
@ -24,10 +23,7 @@ export const startAutomatedJobs = () => {
|
||||||
cron.schedule('0 12 * * *', async () => {
|
cron.schedule('0 12 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Disabling flash delivery for products at 12 PM');
|
console.log('Disabling flash delivery for products at 12 PM');
|
||||||
await db
|
await toggleFlashDeliveryForItems(false, MUTTON_ITEMS);
|
||||||
.update(productInfo)
|
|
||||||
.set({ isFlashAvailable: false })
|
|
||||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
|
||||||
console.log('Flash delivery disabled successfully');
|
console.log('Flash delivery disabled successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error disabling flash delivery:', error);
|
console.error('Error disabling flash delivery:', error);
|
||||||
|
|
@ -38,10 +34,7 @@ export const startAutomatedJobs = () => {
|
||||||
cron.schedule('0 6 * * *', async () => {
|
cron.schedule('0 6 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Enabling flash delivery for products at 5 AM');
|
console.log('Enabling flash delivery for products at 5 AM');
|
||||||
await db
|
await toggleFlashDeliveryForItems(true, MUTTON_ITEMS);
|
||||||
.update(productInfo)
|
|
||||||
.set({ isFlashAvailable: true })
|
|
||||||
.where(inArray(productInfo.id, MUTTON_ITEMS));
|
|
||||||
console.log('Flash delivery enabled successfully');
|
console.log('Flash delivery enabled successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error enabling flash delivery:', error);
|
console.error('Error enabling flash delivery:', error);
|
||||||
|
|
@ -52,10 +45,7 @@ export const startAutomatedJobs = () => {
|
||||||
cron.schedule('0 21 * * *', async () => {
|
cron.schedule('0 21 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Disabling flash delivery feature at 9 PM');
|
console.log('Disabling flash delivery feature at 9 PM');
|
||||||
await db
|
await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, false);
|
||||||
.update(keyValStore)
|
|
||||||
.set({ value: false })
|
|
||||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
|
||||||
await computeConstants(); // Refresh Redis cache
|
await computeConstants(); // Refresh Redis cache
|
||||||
console.log('Flash delivery feature disabled successfully');
|
console.log('Flash delivery feature disabled successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -67,10 +57,7 @@ export const startAutomatedJobs = () => {
|
||||||
cron.schedule('0 6 * * *', async () => {
|
cron.schedule('0 6 * * *', async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Enabling flash delivery feature at 6 AM');
|
console.log('Enabling flash delivery feature at 6 AM');
|
||||||
await db
|
await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, true);
|
||||||
.update(keyValStore)
|
|
||||||
.set({ value: true })
|
|
||||||
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
|
|
||||||
await computeConstants(); // Refresh Redis cache
|
await computeConstants(); // Refresh Redis cache
|
||||||
console.log('Flash delivery feature enabled successfully');
|
console.log('Flash delivery feature enabled successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -81,5 +68,70 @@ export const startAutomatedJobs = () => {
|
||||||
console.log('Automated jobs scheduled');
|
console.log('Automated jobs scheduled');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Old implementation - direct DB queries:
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||||
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
// Optional: Call on import if desired, or export and call in main app
|
// Optional: Call on import if desired, or export and call in main app
|
||||||
// startAutomatedJobs();
|
// startAutomatedJobs();
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import axiosParent from "axios";
|
import axiosParent from "axios";
|
||||||
import { phonePeBaseUrl } from "@/src/lib/env-exporter"
|
import { getPhonePeBaseUrl } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
export const phonepeAxios = axiosParent.create({
|
export const phonepeAxios = axiosParent.create({
|
||||||
baseURL: phonePeBaseUrl,
|
baseURL: getPhonePeBaseUrl(),
|
||||||
timeout: 40000,
|
timeout: 40000,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import express from 'express';
|
// catchAsync is no longer needed with Hono
|
||||||
const catchAsync =
|
// Hono handles async errors automatically
|
||||||
(fn: express.RequestHandler) =>
|
// This file is kept for backward compatibility but should be removed in the future
|
||||||
(req: express.Request, res: express.Response, next: express.NextFunction) =>
|
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
import { Context } from 'hono';
|
||||||
|
|
||||||
|
const catchAsync = (fn: (c: Context) => Promise<Response>) => {
|
||||||
|
return fn;
|
||||||
|
};
|
||||||
|
|
||||||
export default catchAsync;
|
export default catchAsync;
|
||||||
247
apps/backend/src/lib/cloud_cache.ts
Normal file
247
apps/backend/src/lib/cloud_cache.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common'
|
||||||
|
import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index'
|
||||||
|
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
|
||||||
|
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
|
||||||
|
import { scaffoldBanners } from '@/src/trpc/apis/user-apis/apis/banners'
|
||||||
|
import { scaffoldStoreWithProducts } from '@/src/trpc/apis/user-apis/apis/stores'
|
||||||
|
import { getStoresSummary, incrementCacheVersion } from '@/src/dbService'
|
||||||
|
import { imageUploadS3 } from '@/src/lib/s3-client'
|
||||||
|
import { getApiCacheKey, getCloudflareApiToken, getCloudflareZoneId, getAssetsDomain } from '@/src/lib/env-exporter'
|
||||||
|
import { CACHE_FILENAMES } from '@packages/shared'
|
||||||
|
import { retryWithExponentialBackoff } from '@/src/lib/retry'
|
||||||
|
|
||||||
|
const buildCachePath = (path: string, version: number) => `v-${version}/${path}`
|
||||||
|
|
||||||
|
function constructCacheUrl(path: string, version: number): string {
|
||||||
|
return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAllCacheFilesResult {
|
||||||
|
cacheVersion: number
|
||||||
|
products: string
|
||||||
|
essentialConsts: string
|
||||||
|
stores: string
|
||||||
|
slots: string
|
||||||
|
banners: string
|
||||||
|
individualStores: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult> {
|
||||||
|
console.log('Starting creation of all cache files...')
|
||||||
|
|
||||||
|
const cacheVersion = await incrementCacheVersion()
|
||||||
|
|
||||||
|
// Create all global cache files in parallel
|
||||||
|
const [
|
||||||
|
productsKey,
|
||||||
|
essentialConstsKey,
|
||||||
|
storesKey,
|
||||||
|
slotsKey,
|
||||||
|
bannersKey,
|
||||||
|
individualStoreKeys,
|
||||||
|
] = await Promise.all([
|
||||||
|
createProductsFileInternal(cacheVersion),
|
||||||
|
createEssentialConstsFileInternal(cacheVersion),
|
||||||
|
createStoresFileInternal(cacheVersion),
|
||||||
|
createSlotsFileInternal(cacheVersion),
|
||||||
|
createBannersFileInternal(cacheVersion),
|
||||||
|
createAllStoresFilesInternal(cacheVersion),
|
||||||
|
])
|
||||||
|
|
||||||
|
const stores = await getStoresSummary()
|
||||||
|
|
||||||
|
// Collect all URLs for batch cache purge
|
||||||
|
const urls = [
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.products, cacheVersion),
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion),
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion),
|
||||||
|
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
|
||||||
|
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Purge all caches in one batch with retry
|
||||||
|
try {
|
||||||
|
await retryWithExponentialBackoff(() => clearUrlCache(urls))
|
||||||
|
console.log(`Cache purged for all ${urls.length} files`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to purge cache for all files after 3 retries`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All cache files created successfully')
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheVersion,
|
||||||
|
products: productsKey,
|
||||||
|
essentialConsts: essentialConstsKey,
|
||||||
|
stores: storesKey,
|
||||||
|
slots: slotsKey,
|
||||||
|
banners: bannersKey,
|
||||||
|
individualStores: individualStoreKeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal versions that skip cache purging (for batch operations)
|
||||||
|
async function createProductsFileInternal(version: number): Promise<string> {
|
||||||
|
const productsData = await scaffoldProducts()
|
||||||
|
const jsonContent = JSON.stringify(productsData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
const filePath = `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.products, version)}`
|
||||||
|
|
||||||
|
console.log(filePath)
|
||||||
|
return await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
filePath
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEssentialConstsFileInternal(version: number): Promise<string> {
|
||||||
|
const essentialConstsData = await scaffoldEssentialConsts()
|
||||||
|
const jsonContent = JSON.stringify(essentialConstsData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
return await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.essentialConsts, version)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStoresFileInternal(version: number): Promise<string> {
|
||||||
|
const storesData = await scaffoldStores()
|
||||||
|
const jsonContent = JSON.stringify(storesData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
return await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.stores, version)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSlotsFileInternal(version: number): Promise<string> {
|
||||||
|
const slotsData = await scaffoldSlotsWithProducts()
|
||||||
|
const jsonContent = JSON.stringify(slotsData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
return await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBannersFileInternal(version: number): Promise<string> {
|
||||||
|
const bannersData = await scaffoldBanners()
|
||||||
|
const jsonContent = JSON.stringify(bannersData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
return await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.banners, version)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAllStoresFilesInternal(version: number): Promise<string[]> {
|
||||||
|
/*
|
||||||
|
// Old implementation - direct DB queries:
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { storeInfo } from '@/src/db/schema'
|
||||||
|
|
||||||
|
const stores = await db.select({ id: storeInfo.id }).from(storeInfo)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stores = await getStoresSummary()
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
const storeData = await scaffoldStoreWithProducts(store.id)
|
||||||
|
const jsonContent = JSON.stringify(storeData, null, 2)
|
||||||
|
const buffer = Buffer.from(jsonContent, 'utf-8')
|
||||||
|
const s3Key = await imageUploadS3(
|
||||||
|
buffer,
|
||||||
|
'application/json',
|
||||||
|
`${getApiCacheKey()}/${buildCachePath(`stores/${store.id}.json`, version)}`
|
||||||
|
)
|
||||||
|
results.push(s3Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created ${results.length} store cache files`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearUrlCache(urls: string[]): Promise<{ success: boolean; errors?: string[] }> {
|
||||||
|
const cloudflareApiToken = getCloudflareApiToken()
|
||||||
|
const cloudflareZoneId = getCloudflareZoneId()
|
||||||
|
if (!cloudflareApiToken || !cloudflareZoneId) {
|
||||||
|
console.warn('Cloudflare credentials not configured, skipping cache clear')
|
||||||
|
return { success: false, errors: ['Cloudflare credentials not configured'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/purge_cache`,
|
||||||
|
{ files: urls },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${cloudflareApiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data as { success: boolean; errors?: { message: string }[] }
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errorMessages = result.errors?.map(e => e.message) || ['Unknown error']
|
||||||
|
console.error(`Cloudflare cache purge failed for URLs: ${urls.join(', ')}`, errorMessages)
|
||||||
|
return { success: false, errors: errorMessages }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully purged ${urls.length} URLs from Cloudflare cache: ${urls.join(', ')}`)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error(`Error clearing Cloudflare cache for URLs: ${urls.join(', ')}`, errorMessage)
|
||||||
|
return { success: false, errors: [errorMessage] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllCache(): Promise<{ success: boolean; errors?: string[] }> {
|
||||||
|
const cloudflareApiToken = getCloudflareApiToken()
|
||||||
|
const cloudflareZoneId = getCloudflareZoneId()
|
||||||
|
if (!cloudflareApiToken || !cloudflareZoneId) {
|
||||||
|
console.warn('Cloudflare credentials not configured, skipping cache clear')
|
||||||
|
return { success: false, errors: ['Cloudflare credentials not configured'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/purge_cache`,
|
||||||
|
{ purge_everything: true },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${cloudflareApiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data as { success: boolean; errors?: { message: string }[] }
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errorMessages = result.errors?.map(e => e.message) || ['Unknown error']
|
||||||
|
console.error('Cloudflare cache purge failed:', errorMessages)
|
||||||
|
return { success: false, errors: errorMessages }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Successfully purged all cache from Cloudflare')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('Error clearing Cloudflare cache:', errorMessage)
|
||||||
|
return { success: false, errors: [errorMessage] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export const CONST_KEYS = {
|
||||||
flashDeliverySlotId: 'flashDeliverySlotId',
|
flashDeliverySlotId: 'flashDeliverySlotId',
|
||||||
readableOrderId: 'readableOrderId',
|
readableOrderId: 'readableOrderId',
|
||||||
versionNum: 'versionNum',
|
versionNum: 'versionNum',
|
||||||
|
cacheVersion: 'cache_version',
|
||||||
playStoreUrl: 'playStoreUrl',
|
playStoreUrl: 'playStoreUrl',
|
||||||
appStoreUrl: 'appStoreUrl',
|
appStoreUrl: 'appStoreUrl',
|
||||||
popularItems: 'popularItems',
|
popularItems: 'popularItems',
|
||||||
|
|
@ -35,6 +36,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
||||||
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
||||||
readableOrderId: 'Readable Order ID',
|
readableOrderId: 'Readable Order ID',
|
||||||
versionNum: 'Version Number',
|
versionNum: 'Version Number',
|
||||||
|
'cache_version': 'Cache Version',
|
||||||
playStoreUrl: 'Play Store URL',
|
playStoreUrl: 'Play Store URL',
|
||||||
appStoreUrl: 'App Store URL',
|
appStoreUrl: 'App Store URL',
|
||||||
popularItems: 'Popular Items',
|
popularItems: 'Popular Items',
|
||||||
|
|
@ -47,3 +49,53 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
||||||
export type ConstKey = (typeof CONST_KEYS)[keyof typeof CONST_KEYS];
|
export type ConstKey = (typeof CONST_KEYS)[keyof typeof CONST_KEYS];
|
||||||
|
|
||||||
export const CONST_KEYS_ARRAY = Object.values(CONST_KEYS) as ConstKey[];
|
export const CONST_KEYS_ARRAY = Object.values(CONST_KEYS) as ConstKey[];
|
||||||
|
|
||||||
|
export type ConstValueType = 'string' | 'boolean' | 'number'
|
||||||
|
|
||||||
|
export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
|
||||||
|
minRegularOrderValue: 'number',
|
||||||
|
freeDeliveryThreshold: 'number',
|
||||||
|
deliveryCharge: 'number',
|
||||||
|
flashFreeDeliveryThreshold: 'number',
|
||||||
|
flashDeliveryCharge: 'number',
|
||||||
|
platformFeePercent: 'number',
|
||||||
|
taxRate: 'number',
|
||||||
|
tester: 'string',
|
||||||
|
minOrderAmountForCoupon: 'number',
|
||||||
|
maxCouponDiscount: 'number',
|
||||||
|
flashDeliverySlotId: 'number',
|
||||||
|
readableOrderId: 'number',
|
||||||
|
versionNum: 'string',
|
||||||
|
'cache_version': 'number',
|
||||||
|
playStoreUrl: 'string',
|
||||||
|
appStoreUrl: 'string',
|
||||||
|
popularItems: 'string',
|
||||||
|
allItemsOrder: 'string',
|
||||||
|
isFlashDeliveryEnabled: 'boolean',
|
||||||
|
supportMobile: 'string',
|
||||||
|
supportEmail: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
|
||||||
|
minRegularOrderValue: true,
|
||||||
|
freeDeliveryThreshold: true,
|
||||||
|
deliveryCharge: true,
|
||||||
|
flashFreeDeliveryThreshold: true,
|
||||||
|
flashDeliveryCharge: true,
|
||||||
|
platformFeePercent: true,
|
||||||
|
taxRate: false,
|
||||||
|
tester: false,
|
||||||
|
minOrderAmountForCoupon: true,
|
||||||
|
maxCouponDiscount: false,
|
||||||
|
flashDeliverySlotId: true,
|
||||||
|
readableOrderId: false,
|
||||||
|
versionNum: true,
|
||||||
|
'cache_version': false,
|
||||||
|
playStoreUrl: true,
|
||||||
|
appStoreUrl: true,
|
||||||
|
popularItems: true,
|
||||||
|
allItemsOrder: true,
|
||||||
|
isFlashDeliveryEnabled: true,
|
||||||
|
supportMobile: true,
|
||||||
|
supportEmail: true,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,30 @@
|
||||||
import { db } from '@/src/db/db_index'
|
import { getAllKeyValStore } from '@/src/dbService'
|
||||||
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'
|
||||||
|
|
||||||
const CONST_REDIS_PREFIX = 'const:';
|
// const CONST_REDIS_PREFIX = 'const:';
|
||||||
|
|
||||||
export const computeConstants = async (): Promise<void> => {
|
export const computeConstants = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
console.log('Computing constants from database...');
|
console.log('Computing constants from database...');
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Old implementation - direct DB queries:
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { keyValStore } from '@/src/db/schema'
|
||||||
|
|
||||||
const constants = await db.select().from(keyValStore);
|
const constants = await db.select().from(keyValStore);
|
||||||
|
*/
|
||||||
|
|
||||||
for (const constant of constants) {
|
const constants = await getAllKeyValStore();
|
||||||
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
|
|
||||||
const value = JSON.stringify(constant.value);
|
|
||||||
// console.log({redisKey, value})
|
|
||||||
|
|
||||||
await redisClient.set(redisKey, value);
|
// for (const constant of constants) {
|
||||||
}
|
// const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
|
||||||
|
// const value = JSON.stringify(constant.value);
|
||||||
|
// await redisClient.set(redisKey, value);
|
||||||
|
// }
|
||||||
|
|
||||||
console.log(`Computed and stored ${constants.length} constants in Redis`);
|
console.log(`Computed ${constants.length} constants from DB`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to compute constants:', error);
|
console.error('Failed to compute constants:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -27,46 +32,76 @@ export const computeConstants = async (): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getConstant = async <T = any>(key: string): Promise<T | null> => {
|
export const getConstant = async <T = any>(key: string): Promise<T | null> => {
|
||||||
const redisKey = `${CONST_REDIS_PREFIX}${key}`;
|
// const redisKey = `${CONST_REDIS_PREFIX}${key}`;
|
||||||
const value = await redisClient.get(redisKey);
|
// const value = await redisClient.get(redisKey);
|
||||||
|
//
|
||||||
|
// if (!value) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// try {
|
||||||
|
// return JSON.parse(value) as T;
|
||||||
|
// } catch {
|
||||||
|
// return value as unknown as T;
|
||||||
|
// }
|
||||||
|
|
||||||
if (!value) {
|
const constants = await getAllKeyValStore();
|
||||||
|
const entry = constants.find(c => c.key === key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return entry.value as T;
|
||||||
return JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
return value as unknown as T;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getConstants = async <T = any>(keys: string[]): Promise<Record<string, T | null>> => {
|
export const getConstants = async <T = any>(keys: string[]): Promise<Record<string, T | null>> => {
|
||||||
const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`);
|
// const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`);
|
||||||
const values = await redisClient.MGET(redisKeys);
|
// const values = await redisClient.MGET(redisKeys);
|
||||||
|
//
|
||||||
|
// const result: Record<string, T | null> = {};
|
||||||
|
// keys.forEach((key, index) => {
|
||||||
|
// const value = values[index];
|
||||||
|
// if (!value) {
|
||||||
|
// result[key] = null;
|
||||||
|
// } else {
|
||||||
|
// try {
|
||||||
|
// result[key] = JSON.parse(value) as T;
|
||||||
|
// } catch {
|
||||||
|
// result[key] = value as unknown as T;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// return result;
|
||||||
|
|
||||||
|
const constants = await getAllKeyValStore();
|
||||||
|
const constantsMap = new Map(constants.map(c => [c.key, c.value]));
|
||||||
|
|
||||||
const result: Record<string, T | null> = {};
|
const result: Record<string, T | null> = {};
|
||||||
keys.forEach((key, index) => {
|
for (const key of keys) {
|
||||||
const value = values[index];
|
const value = constantsMap.get(key);
|
||||||
if (!value) {
|
result[key] = (value !== undefined ? value : null) as T | null;
|
||||||
result[key] = null;
|
}
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
result[key] = JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
result[key] = value as unknown as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllConstValues = async (): Promise<Record<ConstKey, any>> => {
|
export const getAllConstValues = async (): Promise<Record<ConstKey, any>> => {
|
||||||
const result: Record<string, any> = {};
|
// const result: Record<string, any> = {};
|
||||||
|
//
|
||||||
|
// for (const key of CONST_KEYS_ARRAY) {
|
||||||
|
// result[key] = await getConstant(key);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return result as Record<ConstKey, any>;
|
||||||
|
|
||||||
|
const constants = await getAllKeyValStore();
|
||||||
|
const constantsMap = new Map(constants.map(c => [c.key, c.value]));
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
for (const key of CONST_KEYS_ARRAY) {
|
for (const key of CONST_KEYS_ARRAY) {
|
||||||
result[key] = await getConstant(key);
|
result[key] = constantsMap.get(key) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result as Record<ConstKey, any>;
|
return result as Record<ConstKey, any>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "@/src/db/db_index"
|
|
||||||
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
|
||||||
import { s3Url } from "@/src/lib/env-exporter"
|
import { getS3Url } from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
function extractS3Key(url: string): string | null {
|
function extractS3Key(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -10,11 +8,11 @@ function extractS3Key(url: string): string | null {
|
||||||
|
|
||||||
// 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(getS3Url());
|
||||||
|
|
||||||
// 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 + getS3Url().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);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { db } from '@/src/db/db_index'
|
import { deleteOrdersWithRelations } from '@/src/dbService'
|
||||||
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
|
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete orders and all their related records
|
* Delete orders and all their related records
|
||||||
|
|
@ -8,6 +6,26 @@ import { eq, inArray } from 'drizzle-orm';
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
* @throws Error if deletion fails
|
* @throws Error if deletion fails
|
||||||
*/
|
*/
|
||||||
|
export const deleteOrders = async (orderIds: number[]): Promise<void> => {
|
||||||
|
if (orderIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteOrdersWithRelations(orderIds);
|
||||||
|
console.log(`Successfully deleted ${orderIds.length} orders and all related records`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete orders ${orderIds.join(', ')}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Old implementation - direct DB queries:
|
||||||
|
import { db } from '@/src/db/db_index'
|
||||||
|
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
|
||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
export const deleteOrders = async (orderIds: number[]): Promise<void> => {
|
export const deleteOrders = async (orderIds: number[]): Promise<void> => {
|
||||||
if (orderIds.length === 0) {
|
if (orderIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -43,3 +61,4 @@ export const deleteOrders = async (orderIds: number[]): Promise<void> => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,79 @@
|
||||||
import fs from "fs";
|
// import fs from "fs";
|
||||||
import path from "path";
|
// import path from "path";
|
||||||
|
//
|
||||||
export class DiskPersistedSet {
|
// export class DiskPersistedSet {
|
||||||
private set: Set<string>;
|
// private set: Set<string>;
|
||||||
private readonly filePath: string;
|
// private readonly filePath: string;
|
||||||
private dirty = false;
|
// private dirty = false;
|
||||||
|
//
|
||||||
constructor(filePath: string = "./persister") {
|
// constructor(filePath: string = "./persister") {
|
||||||
this.filePath = path.resolve(filePath);
|
// this.filePath = path.resolve(filePath);
|
||||||
|
//
|
||||||
// ✅ Ensure file exists
|
// // ✅ Ensure file exists
|
||||||
if (!fs.existsSync(this.filePath)) {
|
// if (!fs.existsSync(this.filePath)) {
|
||||||
fs.writeFileSync(this.filePath, "", "utf8");
|
// fs.writeFileSync(this.filePath, "", "utf8");
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// ✅ Load existing values from file
|
// // ✅ Load existing values from file
|
||||||
const contents = fs.readFileSync(this.filePath, "utf8");
|
// const contents = fs.readFileSync(this.filePath, "utf8");
|
||||||
this.set = new Set(
|
// this.set = new Set(
|
||||||
contents.split("\n").map(x => x.trim()).filter(x => x.length > 0)
|
// contents.split("\n").map(x => x.trim()).filter(x => x.length > 0)
|
||||||
);
|
// );
|
||||||
|
//
|
||||||
this.registerExitHandlers();
|
// this.registerExitHandlers();
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private persist() {
|
// private persist() {
|
||||||
if (!this.dirty) return;
|
// if (!this.dirty) return;
|
||||||
fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8");
|
// fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8");
|
||||||
this.dirty = false;
|
// this.dirty = false;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private markDirty() {
|
// private markDirty() {
|
||||||
this.dirty = true;
|
// this.dirty = true;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
add(value: string): void {
|
// add(value: string): void {
|
||||||
if (!this.set.has(value)) {
|
// if (!this.set.has(value)) {
|
||||||
this.set.add(value);
|
// this.set.add(value);
|
||||||
this.markDirty();
|
// this.markDirty();
|
||||||
this.persist();
|
// this.persist();
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
delete(value: string): void {
|
// delete(value: string): void {
|
||||||
if (this.set.delete(value)) {
|
// if (this.set.delete(value)) {
|
||||||
this.markDirty();
|
// this.markDirty();
|
||||||
this.persist();
|
// this.persist();
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
has(value: string): boolean {
|
// has(value: string): boolean {
|
||||||
return this.set.has(value);
|
// return this.set.has(value);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
values(): string[] {
|
// values(): string[] {
|
||||||
return Array.from(this.set);
|
// return Array.from(this.set);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
clear(): void {
|
// clear(): void {
|
||||||
if (this.set.size > 0) {
|
// if (this.set.size > 0) {
|
||||||
this.set.clear();
|
// this.set.clear();
|
||||||
this.markDirty();
|
// this.markDirty();
|
||||||
this.persist();
|
// this.persist();
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private registerExitHandlers() {
|
// private registerExitHandlers() {
|
||||||
const flush = () => this.persist();
|
// const flush = () => this.persist();
|
||||||
|
//
|
||||||
process.on("exit", flush);
|
// process.on("exit", flush);
|
||||||
process.on("SIGINT", () => { flush(); process.exit(); });
|
// process.on("SIGINT", () => { flush(); process.exit(); });
|
||||||
process.on("SIGTERM", () => { flush(); process.exit(); });
|
// process.on("SIGTERM", () => { flush(); process.exit(); });
|
||||||
process.on("uncaughtException", (err) => {
|
// process.on("uncaughtException", (err) => {
|
||||||
console.error("Uncaught exception. Flushing DiskPersistedSet:", err);
|
// console.error("Uncaught exception. Flushing DiskPersistedSet:", err);
|
||||||
flush();
|
// flush();
|
||||||
process.exit(1);
|
// process.exit(1);
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,120 @@
|
||||||
|
|
||||||
export const appUrl = process.env.APP_URL as string;
|
// Old env loading (Node only)
|
||||||
|
// export const appUrl = process.env.APP_URL as string;
|
||||||
|
//
|
||||||
|
// export const jwtSecret: string = process.env.JWT_SECRET as string
|
||||||
|
//
|
||||||
|
// export const defaultRoleName = 'gen_user';
|
||||||
|
//
|
||||||
|
// export const encodedJwtSecret = new TextEncoder().encode(jwtSecret)
|
||||||
|
//
|
||||||
|
// export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string
|
||||||
|
//
|
||||||
|
// export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string
|
||||||
|
//
|
||||||
|
// export const s3BucketName = process.env.S3_BUCKET_NAME as string
|
||||||
|
//
|
||||||
|
// export const s3Region = process.env.S3_REGION as string
|
||||||
|
//
|
||||||
|
// export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
||||||
|
//
|
||||||
|
// export const apiCacheKey = process.env.API_CACHE_KEY as string;
|
||||||
|
//
|
||||||
|
// export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
|
||||||
|
//
|
||||||
|
// export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
|
||||||
|
//
|
||||||
|
// export const s3Url = process.env.S3_URL as string
|
||||||
|
//
|
||||||
|
// export const redisUrl = process.env.REDIS_URL as string
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string;
|
||||||
|
//
|
||||||
|
// export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string;
|
||||||
|
//
|
||||||
|
// export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string;
|
||||||
|
//
|
||||||
|
// export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string);
|
||||||
|
//
|
||||||
|
// export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string;
|
||||||
|
//
|
||||||
|
// export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string;
|
||||||
|
//
|
||||||
|
// export const razorpayId = process.env.RAZORPAY_KEY as string;
|
||||||
|
//
|
||||||
|
// export const razorpaySecret = process.env.RAZORPAY_SECRET as string;
|
||||||
|
//
|
||||||
|
// export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string;
|
||||||
|
//
|
||||||
|
// export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string);
|
||||||
|
//
|
||||||
|
// export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string);
|
||||||
|
//
|
||||||
|
// export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
|
||||||
|
//
|
||||||
|
// export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
|
||||||
|
//
|
||||||
|
// export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
|
||||||
|
|
||||||
export const jwtSecret: string = process.env.JWT_SECRET as string
|
const getRuntimeEnv = () => (globalThis as any).ENV || (globalThis as any).process?.env || {}
|
||||||
|
|
||||||
|
export const getAppUrl = () => getRuntimeEnv().APP_URL as string
|
||||||
|
|
||||||
|
export const getJwtSecret = () => getRuntimeEnv().JWT_SECRET as string
|
||||||
|
|
||||||
export const defaultRoleName = 'gen_user';
|
export const defaultRoleName = 'gen_user';
|
||||||
|
|
||||||
export const encodedJwtSecret = new TextEncoder().encode(jwtSecret)
|
export const getEncodedJwtSecret = () => {
|
||||||
|
const env = getRuntimeEnv()
|
||||||
|
const secret = (env.JWT_SECRET as string) || ''
|
||||||
|
return new TextEncoder().encode(secret)
|
||||||
|
}
|
||||||
|
|
||||||
export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string
|
export const getS3AccessKeyId = () => getRuntimeEnv().S3_ACCESS_KEY_ID as string
|
||||||
|
|
||||||
export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string
|
export const getS3SecretAccessKey = () => getRuntimeEnv().S3_SECRET_ACCESS_KEY as string
|
||||||
|
|
||||||
export const s3BucketName = process.env.S3_BUCKET_NAME as string
|
export const getS3BucketName = () => getRuntimeEnv().S3_BUCKET_NAME as string
|
||||||
|
|
||||||
export const s3Region = process.env.S3_REGION as string
|
export const getS3Region = () => getRuntimeEnv().S3_REGION as string
|
||||||
|
|
||||||
export const assetsDomain = process.env.ASSETS_DOMAIN as string;
|
export const getAssetsDomain = () => getRuntimeEnv().ASSETS_DOMAIN as string
|
||||||
|
|
||||||
export const s3Url = process.env.S3_URL as string
|
export const getApiCacheKey = () => getRuntimeEnv().API_CACHE_KEY as string
|
||||||
|
|
||||||
export const redisUrl = process.env.REDIS_URL as string
|
export const getCloudflareApiToken = () => getRuntimeEnv().CLOUDFLARE_API_TOKEN as string
|
||||||
|
|
||||||
|
export const getCloudflareZoneId = () => getRuntimeEnv().CLOUDFLARE_ZONE_ID as string
|
||||||
|
|
||||||
export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string;
|
export const getS3Url = () => getRuntimeEnv().S3_URL as string
|
||||||
|
|
||||||
export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string;
|
export const getRedisUrl = () => getRuntimeEnv().REDIS_URL as string
|
||||||
|
|
||||||
export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string;
|
export const getExpoAccessToken = () => getRuntimeEnv().EXPO_ACCESS_TOKEN as string
|
||||||
|
|
||||||
export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string);
|
export const getPhonePeBaseUrl = () => getRuntimeEnv().PHONE_PE_BASE_URL as string
|
||||||
|
|
||||||
export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string;
|
export const getPhonePeClientId = () => getRuntimeEnv().PHONE_PE_CLIENT_ID as string
|
||||||
|
|
||||||
export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string;
|
export const getPhonePeClientVersion = () => Number(getRuntimeEnv().PHONE_PE_CLIENT_VERSION as string)
|
||||||
|
|
||||||
export const razorpayId = process.env.RAZORPAY_KEY as string;
|
export const getPhonePeClientSecret = () => getRuntimeEnv().PHONE_PE_CLIENT_SECRET as string
|
||||||
|
|
||||||
export const razorpaySecret = process.env.RAZORPAY_SECRET as string;
|
export const getPhonePeMerchantId = () => getRuntimeEnv().PHONE_PE_MERCHANT_ID as string
|
||||||
|
|
||||||
export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string;
|
export const getRazorpayId = () => getRuntimeEnv().RAZORPAY_KEY as string
|
||||||
|
|
||||||
export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string);
|
export const getRazorpaySecret = () => getRuntimeEnv().RAZORPAY_SECRET as string
|
||||||
|
|
||||||
export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string);
|
export const getOtpSenderAuthToken = () => getRuntimeEnv().OTP_SENDER_AUTH_TOKEN as string
|
||||||
|
|
||||||
export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
|
export const getMinOrderValue = () => Number(getRuntimeEnv().MIN_ORDER_VALUE as string)
|
||||||
|
|
||||||
export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
|
export const getDeliveryCharge = () => Number(getRuntimeEnv().DELIVERY_CHARGE as string)
|
||||||
|
|
||||||
export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
|
export const getTelegramBotToken = () => getRuntimeEnv().TELEGRAM_BOT_TOKEN as string
|
||||||
|
|
||||||
|
export const getTelegramChatIds = () => (getRuntimeEnv().TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || []
|
||||||
|
|
||||||
|
export const getIsDevMode = () => (getRuntimeEnv().ENV_MODE as string) === 'dev'
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import redisClient from '@/src/lib/redis-client'
|
// import redisClient from '@/src/lib/redis-client'
|
||||||
|
|
||||||
export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
|
export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.stringify(eventData);
|
const jsonData = JSON.stringify(eventData);
|
||||||
const result = await redisClient.lPush(queueName, jsonData);
|
// const result = await redisClient.lPush(queueName, jsonData);
|
||||||
return result > 0;
|
// return result > 0;
|
||||||
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Event enqueue error:', error);
|
console.error('Event enqueue error:', error);
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Expo } from "expo-server-sdk";
|
import { Expo } from "expo-server-sdk";
|
||||||
import { title } from "process";
|
import { getExpoAccessToken } from "@/src/lib/env-exporter"
|
||||||
import { expoAccessToken } from "@/src/lib/env-exporter"
|
|
||||||
|
|
||||||
const expo = new Expo({
|
const expo = new Expo({
|
||||||
accessToken: expoAccessToken,
|
accessToken: getExpoAccessToken(),
|
||||||
useFcmV1: true,
|
useFcmV1: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all application services
|
* Initialize all application services
|
||||||
|
|
@ -25,6 +26,10 @@ export const initFunc = async (): Promise<void> => {
|
||||||
startCancellationHandler(),
|
startCancellationHandler(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create all cache files after stores are initialized
|
||||||
|
await createAllCacheFiles();
|
||||||
|
console.log('Cache files created successfully');
|
||||||
|
|
||||||
console.log('Application initialization completed successfully');
|
console.log('Application initialization completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Application initialization failed:', error);
|
console.error('Application initialization failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
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 { db } from '@/src/db/db_index'
|
||||||
import { db } from '@/src/db/db_index'
|
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||||
import {
|
import {
|
||||||
NOTIFS_QUEUE,
|
NOTIFS_QUEUE,
|
||||||
ORDER_PLACED_MESSAGE,
|
ORDER_PLACED_MESSAGE,
|
||||||
|
|
@ -14,33 +14,37 @@ import {
|
||||||
REFUND_INITIATED_MESSAGE
|
REFUND_INITIATED_MESSAGE
|
||||||
} from '@/src/lib/const-strings';
|
} from '@/src/lib/const-strings';
|
||||||
|
|
||||||
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
|
|
||||||
connection: { url: redisUrl },
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: 10,
|
|
||||||
attempts: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
|
export const notificationQueue:any = {};
|
||||||
if (!job) return;
|
|
||||||
|
|
||||||
const { name, data } = job;
|
// export const notificationQueue = new Queue(NOTIFS_QUEUE, {
|
||||||
console.log(`Processing notification job ${job.id} - ${name}`);
|
// connection: { url: redisUrl },
|
||||||
|
// defaultJobOptions: {
|
||||||
|
// removeOnComplete: true,
|
||||||
|
// removeOnFail: 10,
|
||||||
|
// attempts: 3,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
if (name === 'send-admin-notification') {
|
export const notificationWorker:any = {};
|
||||||
await sendAdminNotification(data);
|
// export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
|
||||||
} else if (name === 'send-notification') {
|
// if (!job) return;
|
||||||
// Handle legacy notification type
|
//
|
||||||
console.log('Legacy notification job - not implemented yet');
|
// const { name, data } = job;
|
||||||
}
|
// console.log(`Processing notification job ${job.id} - ${name}`);
|
||||||
}, {
|
//
|
||||||
connection: { url: redisUrl },
|
// if (name === 'send-admin-notification') {
|
||||||
concurrency: 5,
|
// await sendAdminNotification(data);
|
||||||
});
|
// } else if (name === 'send-notification') {
|
||||||
|
// // Handle legacy notification type
|
||||||
|
// console.log('Legacy notification job - not implemented yet');
|
||||||
|
// }
|
||||||
|
// }, {
|
||||||
|
// connection: { url: redisUrl },
|
||||||
|
// concurrency: 5,
|
||||||
|
// });
|
||||||
|
|
||||||
async function sendAdminNotification(data: {
|
export async function sendAdminNotification(data: {
|
||||||
token: string;
|
token: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -55,7 +59,7 @@ async function sendAdminNotification(data: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed URL for image if provided
|
// Generate signed URL for image if provided
|
||||||
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
|
const signedImageUrl = imageUrl ? scaffoldAssetUrl(imageUrl) : null;
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
const expo = new Expo();
|
const expo = new Expo();
|
||||||
|
|
@ -84,16 +88,16 @@ async function sendAdminNotification(data: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationWorker.on('completed', (job) => {
|
// notificationWorker.on('completed', (job) => {
|
||||||
if (job) console.log(`Notification job ${job.id} completed`);
|
// if (job) console.log(`Notification job ${job.id} completed`);
|
||||||
});
|
// });
|
||||||
notificationWorker.on('failed', (job, err) => {
|
// notificationWorker.on('failed', (job, err) => {
|
||||||
if (job) console.error(`Notification job ${job.id} failed:`, err);
|
// if (job) console.error(`Notification job ${job.id} failed:`, err);
|
||||||
});
|
// });
|
||||||
|
|
||||||
export async function scheduleNotification(userId: number, payload: any, options?: { delay?: number; priority?: number }) {
|
export async function scheduleNotification(userId: number, payload: any, options?: { delay?: number; priority?: number }) {
|
||||||
const jobData = { userId, ...payload };
|
const jobData = { userId, ...payload };
|
||||||
await notificationQueue.add('send-notification', jobData, options);
|
await queueDataPusher.pushNotifQueue({ name: 'send-notification', jobData, options })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility methods for specific notification events
|
// Utility methods for specific notification events
|
||||||
|
|
@ -159,8 +163,8 @@ export async function sendRefundInitiatedNotification(userId: number, orderId?:
|
||||||
orderId
|
orderId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
//
|
||||||
process.on('SIGTERM', async () => {
|
// process.on('SIGTERM', async () => {
|
||||||
await notificationQueue.close();
|
// await notificationQueue.close();
|
||||||
await notificationWorker.close();
|
// await notificationWorker.close();
|
||||||
});
|
// });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { db } from "@/src/db/db_index"
|
|
||||||
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||||
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
|
|
||||||
// Core notification dispatch methods (renamed for clarity)
|
// Core notification dispatch methods (renamed for clarity)
|
||||||
export async function dispatchBulkNotification({
|
export async function dispatchBulkNotification({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ApiError } from '@/src/lib/api-error'
|
import { ApiError } from '@/src/lib/api-error'
|
||||||
import { otpSenderAuthToken } from '@/src/lib/env-exporter'
|
import { getOtpSenderAuthToken } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
const otpStore = new Map<string, string>();
|
const otpStore = new Map<string, string>();
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const sendOtp = async (phone: string) => {
|
||||||
const reqUrl = `https://cpaas.messagecentral.com/verification/v3/send?countryCode=91&flowType=SMS&mobileNumber=${phone}&timeout=300`;
|
const reqUrl = `https://cpaas.messagecentral.com/verification/v3/send?countryCode=91&flowType=SMS&mobileNumber=${phone}&timeout=300`;
|
||||||
const resp = await fetch(reqUrl, {
|
const resp = await fetch(reqUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
authToken: otpSenderAuthToken,
|
authToken: getOtpSenderAuthToken(),
|
||||||
},
|
},
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
@ -42,7 +42,7 @@ export async function verifyOtpUtil(mobile: string, otp: string, verifId: string
|
||||||
const resp = await fetch(reqUrl, {
|
const resp = await fetch(reqUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
authToken: otpSenderAuthToken,
|
authToken: getOtpSenderAuthToken(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,54 @@
|
||||||
import Razorpay from "razorpay";
|
// 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 {
|
export class RazorpayPaymentService {
|
||||||
private static instance = new Razorpay({
|
// private static instance = new Razorpay({
|
||||||
key_id: razorpayId,
|
// key_id: razorpayId,
|
||||||
key_secret: razorpaySecret,
|
// key_secret: razorpaySecret,
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
static async createOrder(orderId: number, amount: string) {
|
static async createOrder(orderId: number, amount: string) {
|
||||||
// Create Razorpay order
|
// Create Razorpay order
|
||||||
const razorpayOrder = await this.instance.orders.create({
|
// const razorpayOrder = await this.instance.orders.create({
|
||||||
amount: parseFloat(amount) * 100, // Convert to paisa
|
// amount: parseFloat(amount) * 100, // Convert to paisa
|
||||||
currency: 'INR',
|
// currency: 'INR',
|
||||||
receipt: `order_${orderId}`,
|
// receipt: `order_${orderId}`,
|
||||||
notes: {
|
// notes: {
|
||||||
customerOrderId: orderId.toString(),
|
// customerOrderId: orderId.toString(),
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
return razorpayOrder;
|
// return razorpayOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
|
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: unknown) {
|
||||||
// Use transaction if provided, otherwise use db
|
// Use transaction if provided, otherwise use db
|
||||||
const dbInstance = tx || db;
|
// const dbInstance = tx || db;
|
||||||
|
//
|
||||||
// Insert payment record
|
// // Insert payment record
|
||||||
const [payment] = await dbInstance
|
// const [payment] = await dbInstance
|
||||||
.insert(payments)
|
// .insert(payments)
|
||||||
.values({
|
// .values({
|
||||||
status: 'pending',
|
// status: 'pending',
|
||||||
gateway: 'razorpay',
|
// gateway: 'razorpay',
|
||||||
orderId,
|
// orderId,
|
||||||
token: orderId.toString(),
|
// token: orderId.toString(),
|
||||||
merchantOrderId: razorpayOrder.id,
|
// merchantOrderId: razorpayOrder.id,
|
||||||
payload: razorpayOrder,
|
// payload: razorpayOrder,
|
||||||
})
|
// })
|
||||||
.returning();
|
// .returning();
|
||||||
|
//
|
||||||
return payment;
|
// return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async initiateRefund(paymentId: string, amount: number) {
|
static async initiateRefund(paymentId: string, amount: number) {
|
||||||
const refund = await this.instance.payments.refund(paymentId, {
|
// const refund = await this.instance.payments.refund(paymentId, {
|
||||||
amount,
|
// amount,
|
||||||
});
|
// });
|
||||||
return refund;
|
// return refund;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fetchRefund(refundId: string) {
|
static async fetchRefund(refundId: string) {
|
||||||
const refund = await this.instance.refunds.fetch(refundId);
|
// const refund = await this.instance.refunds.fetch(refundId);
|
||||||
return refund;
|
// return refund;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { db } from '@/src/db/db_index'
|
import {
|
||||||
import { orders, orderStatus } from '@/src/db/schema'
|
getOrdersByIdsWithFullData,
|
||||||
import redisClient from '@/src/lib/redis-client'
|
getOrderByIdWithFullData,
|
||||||
|
} from '@/src/dbService'
|
||||||
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||||
|
import { ensureWorkerInit } from './worker-init';
|
||||||
const ORDER_CHANNEL = 'orders:placed';
|
import { getAppUrl } from '@/src/lib/env-exporter'
|
||||||
const CANCELLED_CHANNEL = 'orders:cancelled';
|
|
||||||
|
|
||||||
interface OrderIdMessage {
|
interface OrderIdMessage {
|
||||||
orderIds: number[];
|
orderIds: number[];
|
||||||
|
|
@ -27,7 +27,20 @@ const formatDateTime = (dateStr: string | null | undefined): string => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildTelegramLinks = (orderId: number, userId?: number | null): string => {
|
||||||
|
const baseUrl = getAppUrl() || 'https://ui.freshyo.in'
|
||||||
|
const orderUrl = `${baseUrl}/manage-orders/order-details/${orderId}`
|
||||||
|
const orderLink = `↪ <a href="${orderUrl}">Order</a>`
|
||||||
|
if (!userId) {
|
||||||
|
return orderLink
|
||||||
|
}
|
||||||
|
const userUrl = `${baseUrl}/user-management/${userId}`
|
||||||
|
const userLink = `↪ <a href="${userUrl}">User</a>`
|
||||||
|
return `${orderLink} | ${userLink}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
||||||
|
console.log('formatting the msg')
|
||||||
let message = '🛒 <b>New Order Placed</b>\n\n';
|
let message = '🛒 <b>New Order Placed</b>\n\n';
|
||||||
|
|
||||||
ordersData.forEach((order, index) => {
|
ordersData.forEach((order, index) => {
|
||||||
|
|
@ -35,7 +48,7 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
||||||
|
|
||||||
message += '📦 <b>Items:</b>\n';
|
message += '📦 <b>Items:</b>\n';
|
||||||
order.orderItems?.forEach((item: any) => {
|
order.orderItems?.forEach((item: any) => {
|
||||||
message += ` • ${item.product?.name || 'Unknown'} x${item.quantity}\n`;
|
message += ` • ${item.product?.name || 'Unknown'} • ${item.product.productQuantity}${item.product.unit?.shortNotation}x${item.quantity}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
|
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
|
||||||
|
|
@ -55,6 +68,8 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
||||||
message += ` 📞 ${order.address.phone}\n`;
|
message += ` 📞 ${order.address.phone}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message += `\n${buildTelegramLinks(order.id, order.userId)}\n`
|
||||||
|
|
||||||
if (index < ordersData.length - 1) {
|
if (index < ordersData.length - 1) {
|
||||||
message += '\n---\n\n';
|
message += '\n---\n\n';
|
||||||
}
|
}
|
||||||
|
|
@ -79,115 +94,63 @@ ${orderData.orderItems?.map((item: any) => ` • ${item.product?.name || 'Unkno
|
||||||
|
|
||||||
❓ <b>Reason:</b> ${cancellationData.reason}
|
❓ <b>Reason:</b> ${cancellationData.reason}
|
||||||
👤 <b>Cancelled by:</b> ${cancellationData.cancelledBy === 'admin' ? 'Admin' : 'User'}
|
👤 <b>Cancelled by:</b> ${cancellationData.cancelledBy === 'admin' ? 'Admin' : 'User'}
|
||||||
⏰ <b>Time:</b> ${formatDateTime(cancellationData.cancelledAt)}
|
⏰ <b>Time:</b> ${formatDateTime(cancellationData.cancelledAt)}
|
||||||
|
|
||||||
|
${buildTelegramLinks(orderData.id, orderData.userId)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleOrderPlaced = async (orderIds: number[], rawMessage?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const ordersData = await getOrdersByIdsWithFullData(orderIds)
|
||||||
|
const telegramMessage = formatOrderMessageWithFullData(ordersData)
|
||||||
|
await sendTelegramMessage(telegramMessage)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process order message:', error)
|
||||||
|
const fallback = rawMessage ? `⚠️ Error parsing order: ${rawMessage}` : '⚠️ Error parsing order'
|
||||||
|
await sendTelegramMessage(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleOrderCancelled = async (
|
||||||
|
cancellationData: CancellationMessage,
|
||||||
|
rawMessage?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
console.log('Order cancellation received, sending to Telegram...')
|
||||||
|
const orderData = await getOrderByIdWithFullData(cancellationData.orderId)
|
||||||
|
|
||||||
|
if (!orderData) {
|
||||||
|
console.error('Order not found for cancellation:', cancellationData.orderId)
|
||||||
|
await sendTelegramMessage(`⚠️ Order ${cancellationData.orderId} was cancelled but could not be found in database`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundStatus = orderData.refunds?.[0]?.refundStatus || 'pending'
|
||||||
|
|
||||||
|
|
||||||
|
const telegramMessage = formatCancellationMessage({ ...orderData, refundStatus }, cancellationData)
|
||||||
|
await sendTelegramMessage(telegramMessage)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process cancellation message:', error)
|
||||||
|
const fallback = rawMessage ? `⚠️ Error processing cancellation: ${rawMessage}` : '⚠️ Error processing cancellation'
|
||||||
|
await sendTelegramMessage(fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the post order handler
|
* Start the post order handler
|
||||||
* Subscribes to the orders:placed channel and sends to Telegram
|
* Subscribes to the orders:placed channel and sends to Telegram
|
||||||
*/
|
*/
|
||||||
export const startOrderHandler = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
console.log('Starting post order handler...');
|
|
||||||
|
|
||||||
await redisClient.subscribe(ORDER_CHANNEL, async (message: string) => {
|
|
||||||
try {
|
|
||||||
const { orderIds }: OrderIdMessage = JSON.parse(message);
|
|
||||||
console.log('New order received, sending to Telegram...');
|
|
||||||
|
|
||||||
const ordersData = await db.query.orders.findMany({
|
|
||||||
where: inArray(orders.id, orderIds),
|
|
||||||
with: {
|
|
||||||
address: true,
|
|
||||||
orderItems: { with: { product: true } },
|
|
||||||
slot: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const telegramMessage = formatOrderMessageWithFullData(ordersData);
|
|
||||||
await sendTelegramMessage(telegramMessage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to process order message:', error);
|
|
||||||
await sendTelegramMessage(`⚠️ Error parsing order: ${message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Post order handler started successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start post order handler:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the post order handler
|
|
||||||
*/
|
|
||||||
export const stopOrderHandler = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await redisClient.unsubscribe(ORDER_CHANNEL);
|
|
||||||
console.log('Post order handler stopped');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping post order handler:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startCancellationHandler = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
console.log('Starting cancellation handler...');
|
|
||||||
|
|
||||||
await redisClient.subscribe(CANCELLED_CHANNEL, async (message: string) => {
|
|
||||||
try {
|
|
||||||
const cancellationData: CancellationMessage = JSON.parse(message);
|
|
||||||
console.log('Order cancellation received, sending to Telegram...');
|
|
||||||
|
|
||||||
const orderData = await db.query.orders.findFirst({
|
|
||||||
where: eq(orders.id, cancellationData.orderId),
|
|
||||||
with: {
|
|
||||||
address: true,
|
|
||||||
orderItems: { with: { product: true } },
|
|
||||||
refunds: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!orderData) {
|
|
||||||
console.error('Order not found for cancellation:', cancellationData.orderId);
|
|
||||||
await sendTelegramMessage(`⚠️ Order ${cancellationData.orderId} was cancelled but could not be found in database`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refundStatus = orderData.refunds?.[0]?.refundStatus || 'pending';
|
|
||||||
|
|
||||||
const telegramMessage = formatCancellationMessage({ ...orderData, refundStatus }, cancellationData);
|
|
||||||
await sendTelegramMessage(telegramMessage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to process cancellation message:', error);
|
|
||||||
await sendTelegramMessage(`⚠️ Error processing cancellation: ${message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Cancellation handler started successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start cancellation handler:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopCancellationHandler = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await redisClient.unsubscribe(CANCELLED_CHANNEL);
|
|
||||||
console.log('Cancellation handler stopped');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping cancellation handler:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const publishOrder = async (orderDetails: OrderIdMessage): Promise<boolean> => {
|
export const publishOrder = async (orderDetails: OrderIdMessage): Promise<boolean> => {
|
||||||
|
console.log('publishing order')
|
||||||
try {
|
try {
|
||||||
const message = JSON.stringify(orderDetails);
|
await queueDataPusher.pushOrderPlacedQueue({
|
||||||
await redisClient.publish(ORDER_CHANNEL, message);
|
name: 'order-placed',
|
||||||
|
orderIds: orderDetails.orderIds,
|
||||||
|
})
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to publish order:', error);
|
console.error('Failed to publish order:', error);
|
||||||
|
|
@ -220,8 +183,11 @@ export const publishCancellation = async (
|
||||||
reason,
|
reason,
|
||||||
cancelledAt: new Date().toISOString(),
|
cancelledAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await redisClient.publish(CANCELLED_CHANNEL, JSON.stringify(message));
|
await queueDataPusher.pushOrderCancelledQueue({
|
||||||
console.log('Cancellation published to Redis:', orderId);
|
name: 'order-cancelled',
|
||||||
|
...message,
|
||||||
|
})
|
||||||
|
console.log('Cancellation published to queue:', orderId);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to publish cancellation:', error);
|
console.error('Failed to publish cancellation:', error);
|
||||||
|
|
|
||||||
54
apps/backend/src/lib/queue-consumer.ts
Normal file
54
apps/backend/src/lib/queue-consumer.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { sendAdminNotification } from '@/src/lib/notif-job'
|
||||||
|
import { handleOrderCancelled, handleOrderPlaced } from '@/src/lib/post-order-handler'
|
||||||
|
|
||||||
|
export const handleNotifQueue = (batch: any) => {
|
||||||
|
batch.messages.forEach((message: any) => {
|
||||||
|
const body = message?.body
|
||||||
|
if (!body) {
|
||||||
|
console.log('notif_queue message received with empty body')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name === 'send-admin-notification' && body.jobData?.token) {
|
||||||
|
void sendAdminNotification({
|
||||||
|
token: body.jobData.token,
|
||||||
|
title: body.jobData.title,
|
||||||
|
body: body.jobData.body,
|
||||||
|
imageUrl: body.jobData.imageUrl ?? null,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('notif_queue message received', body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleOrderPlacedQueue = async (batch: any) => {
|
||||||
|
console.log('from the order placed queue handler')
|
||||||
|
for (const message of batch.messages || []) {
|
||||||
|
const body = message?.body
|
||||||
|
if (!body || !Array.isArray(body.orderIds)) {
|
||||||
|
console.log('order_placed_queue message received', body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleOrderPlaced(body.orderIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleOrderCancelledQueue = async (batch: any) => {
|
||||||
|
for (const message of batch.messages || []) {
|
||||||
|
const body = message?.body
|
||||||
|
if (!body || !body.orderId) {
|
||||||
|
console.log('order_cancelled_queue message received', body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleOrderCancelled({
|
||||||
|
orderId: body.orderId,
|
||||||
|
cancelledBy: body.cancelledBy,
|
||||||
|
reason: body.reason,
|
||||||
|
cancelledAt: body.cancelledAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/backend/src/lib/queue-data-pusher.ts
Normal file
51
apps/backend/src/lib/queue-data-pusher.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
type QueueSender = { send: (message: unknown) => Promise<void> }
|
||||||
|
|
||||||
|
export class QueueDataPusher {
|
||||||
|
private getEnv() {
|
||||||
|
return (globalThis as {
|
||||||
|
ENV?: {
|
||||||
|
NOTIF_QUEUE?: QueueSender
|
||||||
|
ORDER_PLACED_QUEUE?: QueueSender
|
||||||
|
ORDER_CANCELLED_QUEUE?: QueueSender
|
||||||
|
}
|
||||||
|
}).ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushNotifQueue(message: unknown): Promise<boolean> {
|
||||||
|
const env = this.getEnv()
|
||||||
|
if (!env?.NOTIF_QUEUE) {
|
||||||
|
console.warn('NOTIF_QUEUE binding not available, skipping enqueue')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await env.NOTIF_QUEUE.send(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushOrderPlacedQueue(message: { name: 'order-placed'; orderIds: number[] }): Promise<boolean> {
|
||||||
|
const env = this.getEnv()
|
||||||
|
if (!env?.ORDER_PLACED_QUEUE) {
|
||||||
|
console.warn('ORDER_PLACED_QUEUE binding not available, skipping publish')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await env.ORDER_PLACED_QUEUE.send(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushOrderCancelledQueue(message: {
|
||||||
|
name: 'order-cancelled'
|
||||||
|
orderId: number
|
||||||
|
cancelledBy: 'user' | 'admin'
|
||||||
|
reason: string
|
||||||
|
cancelledAt: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const env = this.getEnv()
|
||||||
|
if (!env?.ORDER_CANCELLED_QUEUE) {
|
||||||
|
console.warn('ORDER_CANCELLED_QUEUE binding not available, skipping publish')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await env.ORDER_CANCELLED_QUEUE.send(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queueDataPusher = new QueueDataPusher()
|
||||||
|
|
@ -1,42 +1,48 @@
|
||||||
import { createClient, RedisClientType } from 'redis';
|
// import { createClient, RedisClientType } from 'redis';
|
||||||
import { redisUrl } from '@/src/lib/env-exporter'
|
import { getRedisUrl } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
|
const createClient = (args:any) => {}
|
||||||
class RedisClient {
|
class RedisClient {
|
||||||
private client: RedisClientType;
|
// private client: RedisClientType;
|
||||||
private subscriberClient: RedisClientType | null = null;
|
// private subscriberClient: RedisClientType | null = null;
|
||||||
private isConnected: boolean = false;
|
// private isConnected: boolean = false;
|
||||||
|
//
|
||||||
|
private client: any;
|
||||||
|
private subscriberrlient: any;
|
||||||
|
private isConnected: any = false;
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = createClient({
|
this.client = createClient({
|
||||||
url: redisUrl,
|
url: getRedisUrl(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
// this.client.on('error', (err) => {
|
||||||
console.error('Redis Client Error:', err);
|
// console.error('Redis Client Error:', err);
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.client.on('connect', () => {
|
// this.client.on('connect', () => {
|
||||||
console.log('Redis Client Connected');
|
// console.log('Redis Client Connected');
|
||||||
this.isConnected = true;
|
// this.isConnected = true;
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.client.on('disconnect', () => {
|
// this.client.on('disconnect', () => {
|
||||||
console.log('Redis Client Disconnected');
|
// console.log('Redis Client Disconnected');
|
||||||
this.isConnected = false;
|
// this.isConnected = false;
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.client.on('ready', () => {
|
// this.client.on('ready', () => {
|
||||||
console.log('Redis Client Ready');
|
// console.log('Redis Client Ready');
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.client.on('reconnecting', () => {
|
// this.client.on('reconnecting', () => {
|
||||||
console.log('Redis Client Reconnecting');
|
// console.log('Redis Client Reconnecting');
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Connect immediately (fire and forget)
|
// Connect immediately (fire and forget)
|
||||||
this.client.connect().catch((err) => {
|
// this.client.connect().catch((err) => {
|
||||||
console.error('Failed to connect Redis:', err);
|
// console.error('Failed to connect Redis:', err);
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
|
async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
|
||||||
|
|
@ -79,41 +85,41 @@ class RedisClient {
|
||||||
|
|
||||||
// Subscribe to a channel with callback
|
// Subscribe to a channel with callback
|
||||||
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
||||||
if (!this.subscriberClient) {
|
// if (!this.subscriberClient) {
|
||||||
this.subscriberClient = createClient({
|
// this.subscriberClient = createClient({
|
||||||
url: redisUrl,
|
// url: redisUrl,
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.subscriberClient.on('error', (err) => {
|
// this.subscriberClient.on('error', (err) => {
|
||||||
console.error('Redis Subscriber Error:', err);
|
// console.error('Redis Subscriber Error:', err);
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
this.subscriberClient.on('connect', () => {
|
// this.subscriberClient.on('connect', () => {
|
||||||
console.log('Redis Subscriber Connected');
|
// console.log('Redis Subscriber Connected');
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
await this.subscriberClient.connect();
|
// await this.subscriberClient.connect();
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
await this.subscriberClient.subscribe(channel, callback);
|
// await this.subscriberClient.subscribe(channel, callback);
|
||||||
console.log(`Subscribed to channel: ${channel}`);
|
console.log(`Subscribed to channel: ${channel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe from a channel
|
// Unsubscribe from a channel
|
||||||
async unsubscribe(channel: string): Promise<void> {
|
async unsubscribe(channel: string): Promise<void> {
|
||||||
if (this.subscriberClient) {
|
// if (this.subscriberClient) {
|
||||||
await this.subscriberClient.unsubscribe(channel);
|
// await this.subscriberClient.unsubscribe(channel);
|
||||||
console.log(`Unsubscribed from channel: ${channel}`);
|
// console.log(`Unsubscribed from channel: ${channel}`);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.isConnected) {
|
// if (this.isConnected) {
|
||||||
this.client.disconnect();
|
// this.client.disconnect();
|
||||||
}
|
// }
|
||||||
if (this.subscriberClient) {
|
// if (this.subscriberClient) {
|
||||||
this.subscriberClient.disconnect();
|
// this.subscriberClient.disconnect();
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
get isClientConnected(): boolean {
|
get isClientConnected(): boolean {
|
||||||
|
|
|
||||||
23
apps/backend/src/lib/retry.ts
Normal file
23
apps/backend/src/lib/retry.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export async function retryWithExponentialBackoff<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
delayMs: number = 1000
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error | undefined
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||||
|
delayMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { db } from "@/src/db/db_index"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for role names to avoid hardcoding and typos
|
* Constants for role names to avoid hardcoding and typos
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
244
apps/backend/src/lib/s3-client.ts
Executable file → Normal file
244
apps/backend/src/lib/s3-client.ts
Executable file → Normal file
|
|
@ -1,82 +1,108 @@
|
||||||
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter"
|
import type { Buffer } from 'buffer'
|
||||||
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
import { AwsClient } from 'aws4fetch'
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService'
|
||||||
import signedUrlCache from "@/src/lib/signed-url-cache"
|
import {
|
||||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
getS3AccessKeyId,
|
||||||
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
getS3Region,
|
||||||
import { uploadUrlStatus } from "@/src/db/schema"
|
getS3Url,
|
||||||
import { and, eq } from 'drizzle-orm';
|
getS3SecretAccessKey,
|
||||||
|
getS3BucketName,
|
||||||
|
getAssetsDomain,
|
||||||
|
} from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
let awsClient: AwsClient | null = null
|
||||||
region: s3Region,
|
let awsClientKey = ''
|
||||||
endpoint: s3Url,
|
|
||||||
forcePathStyle: true,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: s3AccessKeyId,
|
|
||||||
secretAccessKey: s3SecretAccessKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default s3Client;
|
|
||||||
|
|
||||||
export const imageUploadS3 = async(body: Buffer<ArrayBufferLike>, type: string, key:string) => {
|
const getAwsClient = () => {
|
||||||
// const key = `${category}/${Date.now()}`
|
const region = getS3Region()
|
||||||
const command = new PutObjectCommand({
|
const endpoint = getS3Url()
|
||||||
Bucket: s3BucketName,
|
const accessKeyId = getS3AccessKeyId()
|
||||||
Key: key,
|
const secretAccessKey = getS3SecretAccessKey()
|
||||||
Body: body,
|
const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}`
|
||||||
ContentType: type,
|
|
||||||
})
|
|
||||||
const resp = await s3Client.send(command)
|
|
||||||
|
|
||||||
const imageUrl = `${key}`
|
if (!awsClient || nextKey !== awsClientKey) {
|
||||||
return imageUrl;
|
awsClientKey = nextKey
|
||||||
|
awsClient = new AwsClient({
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
region,
|
||||||
|
service: 's3',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return awsClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildObjectUrl = (bucket: string, key: string) => {
|
||||||
|
const endpoint = getS3Url()
|
||||||
|
const normalizedEndpoint = endpoint.endsWith('/')
|
||||||
|
? endpoint.slice(0, -1)
|
||||||
|
: endpoint
|
||||||
|
const normalizedKey = key.replace(/^\/+/, '')
|
||||||
|
return `${normalizedEndpoint}/${bucket}/${normalizedKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageUploadS3 = async(body: Buffer, type: string, key: string) => {
|
||||||
|
const client = getAwsClient()
|
||||||
|
const url = buildObjectUrl(getS3BucketName(), key)
|
||||||
|
const resp = await client.fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': type,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const responseBody = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`Failed to upload image: ${resp.status} ${responseBody}`)
|
||||||
|
}
|
||||||
|
const imageUrl = `${key}`
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
// export async function deleteImageUtil(...keys:string[]):Promise<boolean>;
|
// export async function deleteImageUtil(...keys:string[]):Promise<boolean>;
|
||||||
|
|
||||||
export async function deleteImageUtil({bucket = s3BucketName, keys}:{bucket?:string, keys: string[]}) {
|
export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?: string, keys: string[]}) {
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const deleteParams = {
|
const client = getAwsClient()
|
||||||
Bucket: bucket,
|
await Promise.all(
|
||||||
Delete: {
|
keys.map(async (key) => {
|
||||||
Objects: keys.map((key) => ({ Key: key })),
|
const url = buildObjectUrl(bucket, key)
|
||||||
Quiet: false,
|
const resp = await client.fetch(url, { method: 'DELETE' })
|
||||||
}
|
if (!resp.ok && resp.status !== 404) {
|
||||||
}
|
const body = await resp.text().catch(() => '')
|
||||||
|
throw new Error(`Failed to delete image: ${resp.status} ${body}`)
|
||||||
const deleteCommand = new DeleteObjectsCommand(deleteParams)
|
}
|
||||||
await s3Client.send(deleteCommand)
|
})
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Error deleting image:", error)
|
console.error('Error deleting image:', error)
|
||||||
throw new Error("Failed to delete image")
|
throw new Error('Failed to delete image')
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scaffoldAssetUrl(input: string | null): string
|
export function scaffoldAssetUrl(input: string | null): string
|
||||||
export function scaffoldAssetUrl(input: (string | null)[]): string[]
|
export function scaffoldAssetUrl(input: (string | null)[]): string[]
|
||||||
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
|
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
|
||||||
|
const assetsDomain = getAssetsDomain()
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
return input.map(key => scaffoldAssetUrl(key) as string);
|
return input.map(key => scaffoldAssetUrl(key) as string)
|
||||||
}
|
}
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return '';
|
return ''
|
||||||
}
|
}
|
||||||
const normalizedKey = input.replace(/^\/+/, '');
|
const normalizedKey = input.replace(/^\/+/, '')
|
||||||
const domain = assetsDomain.endsWith('/')
|
const domain = assetsDomain.endsWith('/')
|
||||||
? assetsDomain.slice(0, -1)
|
? assetsDomain.slice(0, -1)
|
||||||
: assetsDomain;
|
: assetsDomain
|
||||||
return `${domain}/${normalizedKey}`;
|
return `${domain}/${normalizedKey}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a signed URL from an S3 URL
|
* Generate a signed URL from an S3 URL
|
||||||
* @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object)
|
* @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object)
|
||||||
|
|
@ -85,35 +111,23 @@ export function scaffoldAssetUrl(input: string | null | (string | null)[]): stri
|
||||||
*/
|
*/
|
||||||
export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise<string> {
|
export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise<string> {
|
||||||
if (!s3UrlRaw) {
|
if (!s3UrlRaw) {
|
||||||
return '';
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3Url = s3UrlRaw
|
const s3Url = s3UrlRaw
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if we have a cached signed URL
|
const client = getAwsClient()
|
||||||
const cachedUrl = signedUrlCache.get(s3Url);
|
const url = buildObjectUrl(getS3BucketName(), s3Url)
|
||||||
if (cachedUrl) {
|
const signedRequest = await client.sign(url, {
|
||||||
// Found in cache, return it
|
method: 'GET',
|
||||||
return cachedUrl;
|
signQuery: true,
|
||||||
}
|
expires: expiresIn,
|
||||||
|
})
|
||||||
// Create the command to get the object
|
return signedRequest.url
|
||||||
const command = new GetObjectCommand({
|
|
||||||
Bucket: s3BucketName,
|
|
||||||
Key: s3Url,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate the signed URL
|
|
||||||
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
|
|
||||||
|
|
||||||
// Cache the signed URL with TTL matching the expiration time (convert seconds to milliseconds)
|
|
||||||
signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000); // Subtract 1 minute to ensure it doesn't expire before use
|
|
||||||
|
|
||||||
return signedUrl;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating signed URL:", error);
|
console.error('Error generating signed URL:', error)
|
||||||
throw new Error("Failed to generate signed URL");
|
throw new Error('Failed to generate signed URL')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,14 +137,9 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
||||||
* @returns The original S3 URL if found in cache, otherwise null
|
* @returns The original S3 URL if found in cache, otherwise null
|
||||||
*/
|
*/
|
||||||
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
|
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
|
||||||
if (!signedUrl) {
|
// Cache disabled for Workers compatibility - cannot retrieve original URL without cache
|
||||||
return null;
|
// To re-enable, migrate signed-url-cache to object storage (R2/S3)
|
||||||
}
|
return null
|
||||||
|
|
||||||
// Try to find the original URL in our cache
|
|
||||||
const originalUrl = signedUrlCache.getOriginalUrl(signedUrl);
|
|
||||||
|
|
||||||
return originalUrl || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,47 +150,43 @@ export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null
|
||||||
*/
|
*/
|
||||||
export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> {
|
export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> {
|
||||||
if (!s3Urls || !s3Urls.length) {
|
if (!s3Urls || !s3Urls.length) {
|
||||||
return [];
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process URLs in parallel for better performance
|
|
||||||
const signedUrls = await Promise.all(
|
const signedUrls = await Promise.all(
|
||||||
s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => ''))
|
s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => ''))
|
||||||
);
|
)
|
||||||
|
|
||||||
return signedUrls;
|
return signedUrls
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating multiple signed URLs:", error);
|
console.error('Error generating multiple signed URLs:', error)
|
||||||
// Return an array of empty strings with the same length as input
|
return s3Urls.map(() => '')
|
||||||
return s3Urls.map(() => '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
await createUploadUrlStatus(key)
|
||||||
await db.insert(uploadUrlStatus).values({
|
|
||||||
key: key,
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate signed upload URL
|
const client = getAwsClient()
|
||||||
const command = new PutObjectCommand({
|
const url = buildObjectUrl(getS3BucketName(), key)
|
||||||
Bucket: s3BucketName,
|
const signedRequest = await client.sign(url, {
|
||||||
Key: key,
|
method: 'PUT',
|
||||||
ContentType: mimeType,
|
signQuery: true,
|
||||||
});
|
expires: expiresIn,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
|
return signedRequest.url
|
||||||
return signedUrl;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating upload URL:', error);
|
console.error('Error generating upload URL:', error)
|
||||||
throw new Error('Failed to generate upload URL');
|
throw new Error('Failed to generate upload URL')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// export function extractKeyFromPresignedUrl(url:string) {
|
// export function extractKeyFromPresignedUrl(url:string) {
|
||||||
// const u = new URL(url);
|
// const u = new URL(url);
|
||||||
// const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
|
// const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
|
||||||
|
|
@ -190,32 +195,27 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
||||||
|
|
||||||
// New function (excludes bucket name)
|
// New function (excludes bucket name)
|
||||||
export function extractKeyFromPresignedUrl(url: string): string {
|
export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
const u = new URL(url);
|
const u = new URL(url)
|
||||||
const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
|
const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash
|
||||||
const decodedKey = decodeURIComponent(rawKey);
|
const decodedKey = decodeURIComponent(rawKey)
|
||||||
// Remove bucket prefix
|
// Remove bucket prefix
|
||||||
const parts = decodedKey.split('/');
|
const parts = decodedKey.split('/')
|
||||||
parts.shift(); // Remove bucket name
|
parts.shift() // Remove bucket name
|
||||||
return parts.join('/');
|
return parts.join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function claimUploadUrl(url: string): Promise<void> {
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const semiKey = extractKeyFromPresignedUrl(url);
|
const semiKey = extractKeyFromPresignedUrl(url)
|
||||||
const key = s3BucketName+'/'+ semiKey
|
|
||||||
|
|
||||||
// Update status to 'claimed' if currently 'pending'
|
// Update status to 'claimed' if currently 'pending'
|
||||||
const result = await db
|
const updated = await claimUploadUrlStatus(semiKey)
|
||||||
.update(uploadUrlStatus)
|
|
||||||
.set({ status: 'claimed' })
|
|
||||||
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (!updated) {
|
||||||
throw new Error('Upload URL not found or already claimed');
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
244
apps/backend/src/lib/s3-client.ts.txt
Normal file
244
apps/backend/src/lib/s3-client.ts.txt
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter"
|
||||||
|
import type { Buffer } from 'buffer'
|
||||||
|
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
|
// import signedUrlCache from "@/src/lib/signed-url-cache" // Disabled for Workers compatibility
|
||||||
|
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService'
|
||||||
|
import {
|
||||||
|
getS3AccessKeyId,
|
||||||
|
getS3Region,
|
||||||
|
getS3Url,
|
||||||
|
getS3SecretAccessKey,
|
||||||
|
getS3BucketName,
|
||||||
|
getAssetsDomain,
|
||||||
|
} from "@/src/lib/env-exporter"
|
||||||
|
|
||||||
|
let s3Client: S3Client | null = null
|
||||||
|
let s3ClientKey = ''
|
||||||
|
|
||||||
|
const getS3Client = () => {
|
||||||
|
const region = getS3Region()
|
||||||
|
const endpoint = getS3Url()
|
||||||
|
const accessKeyId = getS3AccessKeyId()
|
||||||
|
const secretAccessKey = getS3SecretAccessKey()
|
||||||
|
const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}`
|
||||||
|
|
||||||
|
if (!s3Client || nextKey !== s3ClientKey) {
|
||||||
|
s3ClientKey = nextKey
|
||||||
|
s3Client = new S3Client({
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
forcePathStyle: true,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// export const imageUploadS3 = async(body: Buffer, type: string, key:string) => {
|
||||||
|
// // const key = `${category}/${Date.now()}`
|
||||||
|
// const s3BucketName = getS3BucketName()
|
||||||
|
// const s3Client = getS3Client()
|
||||||
|
// const command = new PutObjectCommand({
|
||||||
|
// Bucket: s3BucketName,
|
||||||
|
// Key: key,
|
||||||
|
// Body: body,
|
||||||
|
// ContentType: type,
|
||||||
|
// })
|
||||||
|
// const resp = await s3Client.send(command)
|
||||||
|
//
|
||||||
|
// const imageUrl = `${key}`
|
||||||
|
// return imageUrl;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const imageUploadS3 = async(body: Buffer, type: string, key:string) => {
|
||||||
|
const env = (globalThis as any).ENV || {}
|
||||||
|
if (!env.MY_BUCKET) {
|
||||||
|
throw new Error('MY_BUCKET binding not found in runtime env')
|
||||||
|
}
|
||||||
|
await env.MY_BUCKET.put(key, body, {
|
||||||
|
httpMetadata: {
|
||||||
|
contentType: type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const imageUrl = `${key}`
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// export async function deleteImageUtil(...keys:string[]):Promise<boolean>;
|
||||||
|
|
||||||
|
export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?:string, keys: string[]}) {
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const s3Client = getS3Client()
|
||||||
|
await Promise.all(
|
||||||
|
keys.map((key) => {
|
||||||
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
return s3Client.send(deleteCommand)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error deleting image:", error)
|
||||||
|
throw new Error("Failed to delete image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaffoldAssetUrl(input: string | null): string
|
||||||
|
export function scaffoldAssetUrl(input: (string | null)[]): string[]
|
||||||
|
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
|
||||||
|
const assetsDomain = getAssetsDomain()
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input.map(key => scaffoldAssetUrl(key) as string);
|
||||||
|
}
|
||||||
|
if (!input) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const normalizedKey = input.replace(/^\/+/, '');
|
||||||
|
const domain = assetsDomain.endsWith('/')
|
||||||
|
? assetsDomain.slice(0, -1)
|
||||||
|
: assetsDomain;
|
||||||
|
return `${domain}/${normalizedKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signed URL from an S3 URL
|
||||||
|
* @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object)
|
||||||
|
* @param expiresIn Expiration time in seconds (default: 259200 seconds = 3 days)
|
||||||
|
* @returns A pre-signed URL that provides temporary access to the object
|
||||||
|
*/
|
||||||
|
export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise<string> {
|
||||||
|
if (!s3UrlRaw) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Url = s3UrlRaw
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cache disabled for Workers compatibility
|
||||||
|
// const cachedUrl = signedUrlCache.get(s3Url);
|
||||||
|
// if (cachedUrl) {
|
||||||
|
// return cachedUrl;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Create the command to get the object
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: getS3BucketName(),
|
||||||
|
Key: s3Url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate the signed URL
|
||||||
|
const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn });
|
||||||
|
|
||||||
|
// Cache disabled for Workers compatibility
|
||||||
|
// signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000);
|
||||||
|
|
||||||
|
return signedUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating signed URL:", error);
|
||||||
|
throw new Error("Failed to generate signed URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original S3 URL from a signed URL
|
||||||
|
* @param signedUrl The signed URL
|
||||||
|
* @returns The original S3 URL if found in cache, otherwise null
|
||||||
|
*/
|
||||||
|
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
|
||||||
|
// Cache disabled for Workers compatibility - cannot retrieve original URL without cache
|
||||||
|
// To re-enable, migrate signed-url-cache to object storage (R2/S3)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate signed URLs for multiple S3 URLs
|
||||||
|
* @param s3Urls Array of S3 URLs or null values
|
||||||
|
* @param expiresIn Expiration time in seconds (default: 259200 seconds = 3 days)
|
||||||
|
* @returns Array of signed URLs (empty strings for null/invalid inputs)
|
||||||
|
*/
|
||||||
|
export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> {
|
||||||
|
if (!s3Urls || !s3Urls.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process URLs in parallel for better performance
|
||||||
|
const signedUrls = await Promise.all(
|
||||||
|
s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
return signedUrls;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating multiple signed URLs:", error);
|
||||||
|
// Return an array of empty strings with the same length as input
|
||||||
|
return s3Urls.map(() => '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Insert record into upload_url_status
|
||||||
|
await createUploadUrlStatus(key)
|
||||||
|
|
||||||
|
// Generate signed upload URL
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: getS3BucketName(),
|
||||||
|
Key: key,
|
||||||
|
ContentType: mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedUrl = await getSignedUrl(getS3Client(), command, { expiresIn });
|
||||||
|
return signedUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating upload URL:', error);
|
||||||
|
throw new Error('Failed to generate upload URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// export function extractKeyFromPresignedUrl(url:string) {
|
||||||
|
// const u = new URL(url);
|
||||||
|
// const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
|
||||||
|
// return decodeURIComponent(rawKey);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// New function (excludes bucket name)
|
||||||
|
export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
|
||||||
|
const decodedKey = decodeURIComponent(rawKey);
|
||||||
|
// Remove bucket prefix
|
||||||
|
const parts = decodedKey.split('/');
|
||||||
|
parts.shift(); // Remove bucket name
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const semiKey = extractKeyFromPresignedUrl(url);
|
||||||
|
|
||||||
|
// Update status to 'claimed' if currently 'pending'
|
||||||
|
const updated = await claimUploadUrlStatus(semiKey)
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Upload URL not found or already claimed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error claiming upload URL:', error);
|
||||||
|
throw new Error('Failed to claim upload URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/backend/src/lib/seed.ts
Normal file
70
apps/backend/src/lib/seed.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
seedUnits,
|
||||||
|
seedStaffRoles,
|
||||||
|
seedStaffPermissions,
|
||||||
|
seedRolePermissions,
|
||||||
|
seedKeyValStore,
|
||||||
|
type UnitSeedData,
|
||||||
|
type RolePermissionAssignment,
|
||||||
|
type KeyValSeedData,
|
||||||
|
type StaffRoleName,
|
||||||
|
type StaffPermissionName,
|
||||||
|
} from '@/src/dbService'
|
||||||
|
import { getMinOrderValue, getDeliveryCharge } from '@/src/lib/env-exporter'
|
||||||
|
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||||
|
|
||||||
|
export async function seed() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
// Seed units
|
||||||
|
const unitsToSeed: UnitSeedData[] = [
|
||||||
|
{ shortNotation: "Kg", fullName: "Kilogram" },
|
||||||
|
{ shortNotation: "L", fullName: "Litre" },
|
||||||
|
{ shortNotation: "Dz", fullName: "Dozen" },
|
||||||
|
{ shortNotation: "Pc", fullName: "Unit Piece" },
|
||||||
|
];
|
||||||
|
await seedUnits(unitsToSeed);
|
||||||
|
|
||||||
|
// Seed staff roles
|
||||||
|
const rolesToSeed: StaffRoleName[] = ['super_admin', 'admin', 'marketer', 'delivery_staff'];
|
||||||
|
await seedStaffRoles(rolesToSeed);
|
||||||
|
|
||||||
|
// Seed staff permissions
|
||||||
|
const permissionsToSeed: StaffPermissionName[] = ['crud_product', 'make_coupon', 'crud_staff_users'];
|
||||||
|
await seedStaffPermissions(permissionsToSeed);
|
||||||
|
|
||||||
|
// Seed role-permission assignments
|
||||||
|
const rolePermissionAssignments: RolePermissionAssignment[] = [
|
||||||
|
// super_admin gets all permissions
|
||||||
|
{ roleName: 'super_admin', permissionName: 'crud_product' },
|
||||||
|
{ roleName: 'super_admin', permissionName: 'make_coupon' },
|
||||||
|
{ roleName: 'super_admin', permissionName: 'crud_staff_users' },
|
||||||
|
// admin gets product and coupon permissions
|
||||||
|
{ roleName: 'admin', permissionName: 'crud_product' },
|
||||||
|
{ roleName: 'admin', permissionName: 'make_coupon' },
|
||||||
|
// marketer gets coupon permission
|
||||||
|
{ roleName: 'marketer', permissionName: 'make_coupon' },
|
||||||
|
];
|
||||||
|
await seedRolePermissions(rolePermissionAssignments);
|
||||||
|
|
||||||
|
// Seed key-val store constants
|
||||||
|
const constantsToSeed: KeyValSeedData[] = [
|
||||||
|
{ key: CONST_KEYS.readableOrderId, value: 0 },
|
||||||
|
{ key: CONST_KEYS.minRegularOrderValue, value: getMinOrderValue() },
|
||||||
|
{ key: CONST_KEYS.freeDeliveryThreshold, value: getMinOrderValue() },
|
||||||
|
{ key: CONST_KEYS.deliveryCharge, value: getDeliveryCharge() },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
await seedKeyValStore(constantsToSeed);
|
||||||
|
|
||||||
|
console.log("Seeding completed.");
|
||||||
|
}
|
||||||
263
apps/backend/src/lib/signed-url-cache-old.ts
Executable file
263
apps/backend/src/lib/signed-url-cache-old.ts
Executable file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
||||||
|
|
||||||
|
// Interface for cache entries with TTL
|
||||||
|
interface CacheEntry {
|
||||||
|
value: string;
|
||||||
|
expiresAt: number; // Timestamp when this entry expires
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignedURLCache {
|
||||||
|
private originalToSignedCache: Map<string, CacheEntry>;
|
||||||
|
private signedToOriginalCache: Map<string, CacheEntry>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.originalToSignedCache = new Map();
|
||||||
|
this.signedToOriginalCache = new Map();
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
||||||
|
if (!fs.existsSync(cacheDir)) {
|
||||||
|
console.log('creating the directory')
|
||||||
|
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log('the directory is already present')
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL from the cache using an original URL as the key
|
||||||
|
*/
|
||||||
|
get(originalUrl: string): string | undefined {
|
||||||
|
const entry = this.originalToSignedCache.get(originalUrl);
|
||||||
|
|
||||||
|
// If no entry or entry has expired, return undefined
|
||||||
|
if (!entry || Date.now() > entry.expiresAt) {
|
||||||
|
if (entry) {
|
||||||
|
// Remove expired entry
|
||||||
|
this.originalToSignedCache.delete(originalUrl);
|
||||||
|
// Also remove from reverse mapping if it exists
|
||||||
|
this.signedToOriginalCache.delete(entry.value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original URL from the cache using a signed URL as the key
|
||||||
|
*/
|
||||||
|
getOriginalUrl(signedUrl: string): string | undefined {
|
||||||
|
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||||
|
|
||||||
|
// If no entry or entry has expired, return undefined
|
||||||
|
if (!entry || Date.now() > entry.expiresAt) {
|
||||||
|
if (entry) {
|
||||||
|
// Remove expired entry
|
||||||
|
this.signedToOriginalCache.delete(signedUrl);
|
||||||
|
// Also remove from primary mapping if it exists
|
||||||
|
this.originalToSignedCache.delete(entry.value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache with a TTL (Time To Live)
|
||||||
|
* @param originalUrl The original S3 URL
|
||||||
|
* @param signedUrl The signed URL
|
||||||
|
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
||||||
|
*/
|
||||||
|
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
||||||
|
const expiresAt = Date.now() + ttlMs;
|
||||||
|
|
||||||
|
const entry: CacheEntry = {
|
||||||
|
value: signedUrl,
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const reverseEntry: CacheEntry = {
|
||||||
|
value: originalUrl,
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
this.originalToSignedCache.set(originalUrl, entry);
|
||||||
|
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(originalUrl: string): boolean {
|
||||||
|
const entry = this.originalToSignedCache.get(originalUrl);
|
||||||
|
|
||||||
|
// Entry exists and hasn't expired
|
||||||
|
return !!entry && Date.now() <= entry.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSignedUrl(signedUrl: string): boolean {
|
||||||
|
const entry = this.signedToOriginalCache.get(signedUrl);
|
||||||
|
|
||||||
|
// Entry exists and hasn't expired
|
||||||
|
return !!entry && Date.now() <= entry.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.originalToSignedCache.clear();
|
||||||
|
this.signedToOriginalCache.clear();
|
||||||
|
this.saveToDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all expired entries from the cache
|
||||||
|
* @returns The number of expired entries that were removed
|
||||||
|
*/
|
||||||
|
clearExpired(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
// Clear expired entries from original to signed cache
|
||||||
|
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
this.originalToSignedCache.delete(originalUrl);
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear expired entries from signed to original cache
|
||||||
|
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
this.signedToOriginalCache.delete(signedUrl);
|
||||||
|
// No need to increment removedCount as we've already counted these in the first loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedCount > 0) {
|
||||||
|
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the cache to disk
|
||||||
|
*/
|
||||||
|
saveToDisk(): void {
|
||||||
|
try {
|
||||||
|
// Remove expired entries before saving
|
||||||
|
const removedCount = this.clearExpired();
|
||||||
|
|
||||||
|
// Convert Maps to serializable objects
|
||||||
|
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
||||||
|
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
||||||
|
|
||||||
|
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
||||||
|
serializedOriginalToSigned[originalUrl] = {
|
||||||
|
value: entry.value,
|
||||||
|
expiresAt: entry.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
||||||
|
serializedSignedToOriginal[signedUrl] = {
|
||||||
|
value: entry.value,
|
||||||
|
expiresAt: entry.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedCache = {
|
||||||
|
originalToSigned: serializedOriginalToSigned,
|
||||||
|
signedToOriginal: serializedSignedToOriginal
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
fs.writeFileSync(
|
||||||
|
CACHE_FILE_PATH,
|
||||||
|
JSON.stringify(serializedCache),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving SignedURLCache to disk:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the cache from disk
|
||||||
|
*/
|
||||||
|
loadFromDisk(): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CACHE_FILE_PATH)) {
|
||||||
|
// Read from file
|
||||||
|
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
||||||
|
|
||||||
|
// Parse the data
|
||||||
|
const parsedData = JSON.parse(data) as {
|
||||||
|
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
||||||
|
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only load entries that haven't expired yet
|
||||||
|
const now = Date.now();
|
||||||
|
let loadedCount = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
|
|
||||||
|
// Load original to signed mappings
|
||||||
|
if (parsedData.originalToSigned) {
|
||||||
|
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
||||||
|
if (now <= entry.expiresAt) {
|
||||||
|
this.originalToSignedCache.set(originalUrl, entry);
|
||||||
|
loadedCount++;
|
||||||
|
} else {
|
||||||
|
expiredCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load signed to original mappings
|
||||||
|
if (parsedData.signedToOriginal) {
|
||||||
|
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
||||||
|
if (now <= entry.expiresAt) {
|
||||||
|
this.signedToOriginalCache.set(signedUrl, entry);
|
||||||
|
// Don't increment loadedCount as these are pairs of what we already counted
|
||||||
|
} else {
|
||||||
|
// Don't increment expiredCount as these are pairs of what we already counted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
||||||
|
} else {
|
||||||
|
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading SignedURLCache from disk:', error);
|
||||||
|
// Start with empty caches if loading fails
|
||||||
|
this.originalToSignedCache = new Map();
|
||||||
|
this.signedToOriginalCache = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a singleton instance to be used throughout the application
|
||||||
|
const signedUrlCache = new SignedURLCache();
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||||
|
signedUrlCache.saveToDisk();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SignedURLCache: Saving cache before shutdown...');
|
||||||
|
signedUrlCache.saveToDisk();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default signedUrlCache;
|
||||||
285
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
285
apps/backend/src/lib/signed-url-cache.ts
Executable file → Normal file
|
|
@ -1,263 +1,24 @@
|
||||||
import fs from 'fs';
|
// SIGNED URL CACHE - DISABLED
|
||||||
import path from 'path';
|
// This file has been disabled to make the backend compatible with Cloudflare Workers.
|
||||||
|
// File system operations are not available in the Workers environment.
|
||||||
|
//
|
||||||
|
// To re-enable caching, migrate to Cloudflare R2 or another object storage solution.
|
||||||
|
// Original file saved as: signed-url-cache-old.ts
|
||||||
|
//
|
||||||
|
// Impact of disabling:
|
||||||
|
// - S3 signed URLs are generated fresh on every request
|
||||||
|
// - Increased AWS API calls (higher costs)
|
||||||
|
// - Slightly slower image loading
|
||||||
|
// - No file system dependencies (Workers-compatible)
|
||||||
|
|
||||||
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json');
|
export default {
|
||||||
|
get: () => undefined,
|
||||||
// Interface for cache entries with TTL
|
set: () => {},
|
||||||
interface CacheEntry {
|
getOriginalUrl: () => undefined,
|
||||||
value: string;
|
has: () => false,
|
||||||
expiresAt: number; // Timestamp when this entry expires
|
hasSignedUrl: () => false,
|
||||||
}
|
clear: () => {},
|
||||||
|
clearExpired: () => 0,
|
||||||
class SignedURLCache {
|
saveToDisk: () => {},
|
||||||
private originalToSignedCache: Map<string, CacheEntry>;
|
loadFromDisk: () => {},
|
||||||
private signedToOriginalCache: Map<string, CacheEntry>;
|
};
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.originalToSignedCache = new Map();
|
|
||||||
this.signedToOriginalCache = new Map();
|
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
|
||||||
const cacheDir = path.dirname(CACHE_FILE_PATH);
|
|
||||||
if (!fs.existsSync(cacheDir)) {
|
|
||||||
console.log('creating the directory')
|
|
||||||
|
|
||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log('the directory is already present')
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a signed URL from the cache using an original URL as the key
|
|
||||||
*/
|
|
||||||
get(originalUrl: string): string | undefined {
|
|
||||||
const entry = this.originalToSignedCache.get(originalUrl);
|
|
||||||
|
|
||||||
// If no entry or entry has expired, return undefined
|
|
||||||
if (!entry || Date.now() > entry.expiresAt) {
|
|
||||||
if (entry) {
|
|
||||||
// Remove expired entry
|
|
||||||
this.originalToSignedCache.delete(originalUrl);
|
|
||||||
// Also remove from reverse mapping if it exists
|
|
||||||
this.signedToOriginalCache.delete(entry.value);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the original URL from the cache using a signed URL as the key
|
|
||||||
*/
|
|
||||||
getOriginalUrl(signedUrl: string): string | undefined {
|
|
||||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
|
||||||
|
|
||||||
// If no entry or entry has expired, return undefined
|
|
||||||
if (!entry || Date.now() > entry.expiresAt) {
|
|
||||||
if (entry) {
|
|
||||||
// Remove expired entry
|
|
||||||
this.signedToOriginalCache.delete(signedUrl);
|
|
||||||
// Also remove from primary mapping if it exists
|
|
||||||
this.originalToSignedCache.delete(entry.value);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value in the cache with a TTL (Time To Live)
|
|
||||||
* @param originalUrl The original S3 URL
|
|
||||||
* @param signedUrl The signed URL
|
|
||||||
* @param ttlMs Time to live in milliseconds (default: 3 days)
|
|
||||||
*/
|
|
||||||
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
|
|
||||||
const expiresAt = Date.now() + ttlMs;
|
|
||||||
|
|
||||||
const entry: CacheEntry = {
|
|
||||||
value: signedUrl,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
|
|
||||||
const reverseEntry: CacheEntry = {
|
|
||||||
value: originalUrl,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
|
|
||||||
this.originalToSignedCache.set(originalUrl, entry);
|
|
||||||
this.signedToOriginalCache.set(signedUrl, reverseEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
has(originalUrl: string): boolean {
|
|
||||||
const entry = this.originalToSignedCache.get(originalUrl);
|
|
||||||
|
|
||||||
// Entry exists and hasn't expired
|
|
||||||
return !!entry && Date.now() <= entry.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSignedUrl(signedUrl: string): boolean {
|
|
||||||
const entry = this.signedToOriginalCache.get(signedUrl);
|
|
||||||
|
|
||||||
// Entry exists and hasn't expired
|
|
||||||
return !!entry && Date.now() <= entry.expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.originalToSignedCache.clear();
|
|
||||||
this.signedToOriginalCache.clear();
|
|
||||||
this.saveToDisk();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all expired entries from the cache
|
|
||||||
* @returns The number of expired entries that were removed
|
|
||||||
*/
|
|
||||||
clearExpired(): number {
|
|
||||||
const now = Date.now();
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
// Clear expired entries from original to signed cache
|
|
||||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
|
||||||
if (now > entry.expiresAt) {
|
|
||||||
this.originalToSignedCache.delete(originalUrl);
|
|
||||||
removedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear expired entries from signed to original cache
|
|
||||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
|
||||||
if (now > entry.expiresAt) {
|
|
||||||
this.signedToOriginalCache.delete(signedUrl);
|
|
||||||
// No need to increment removedCount as we've already counted these in the first loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedCount > 0) {
|
|
||||||
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the cache to disk
|
|
||||||
*/
|
|
||||||
saveToDisk(): void {
|
|
||||||
try {
|
|
||||||
// Remove expired entries before saving
|
|
||||||
const removedCount = this.clearExpired();
|
|
||||||
|
|
||||||
// Convert Maps to serializable objects
|
|
||||||
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
|
|
||||||
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
|
|
||||||
|
|
||||||
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
|
|
||||||
serializedOriginalToSigned[originalUrl] = {
|
|
||||||
value: entry.value,
|
|
||||||
expiresAt: entry.expiresAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
|
|
||||||
serializedSignedToOriginal[signedUrl] = {
|
|
||||||
value: entry.value,
|
|
||||||
expiresAt: entry.expiresAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedCache = {
|
|
||||||
originalToSigned: serializedOriginalToSigned,
|
|
||||||
signedToOriginal: serializedSignedToOriginal
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write to file
|
|
||||||
fs.writeFileSync(
|
|
||||||
CACHE_FILE_PATH,
|
|
||||||
JSON.stringify(serializedCache),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving SignedURLCache to disk:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the cache from disk
|
|
||||||
*/
|
|
||||||
loadFromDisk(): void {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(CACHE_FILE_PATH)) {
|
|
||||||
// Read from file
|
|
||||||
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
|
|
||||||
|
|
||||||
// Parse the data
|
|
||||||
const parsedData = JSON.parse(data) as {
|
|
||||||
originalToSigned: Record<string, { value: string; expiresAt: number }>,
|
|
||||||
signedToOriginal: Record<string, { value: string; expiresAt: number }>
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only load entries that haven't expired yet
|
|
||||||
const now = Date.now();
|
|
||||||
let loadedCount = 0;
|
|
||||||
let expiredCount = 0;
|
|
||||||
|
|
||||||
// Load original to signed mappings
|
|
||||||
if (parsedData.originalToSigned) {
|
|
||||||
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
|
|
||||||
if (now <= entry.expiresAt) {
|
|
||||||
this.originalToSignedCache.set(originalUrl, entry);
|
|
||||||
loadedCount++;
|
|
||||||
} else {
|
|
||||||
expiredCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load signed to original mappings
|
|
||||||
if (parsedData.signedToOriginal) {
|
|
||||||
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
|
|
||||||
if (now <= entry.expiresAt) {
|
|
||||||
this.signedToOriginalCache.set(signedUrl, entry);
|
|
||||||
// Don't increment loadedCount as these are pairs of what we already counted
|
|
||||||
} else {
|
|
||||||
// Don't increment expiredCount as these are pairs of what we already counted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
|
|
||||||
} else {
|
|
||||||
console.log('SignedURLCache: No cache file found, starting with empty cache');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading SignedURLCache from disk:', error);
|
|
||||||
// Start with empty caches if loading fails
|
|
||||||
this.originalToSignedCache = new Map();
|
|
||||||
this.signedToOriginalCache = new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a singleton instance to be used throughout the application
|
|
||||||
const signedUrlCache = new SignedURLCache();
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
|
||||||
signedUrlCache.saveToDisk();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
console.log('SignedURLCache: Saving cache before shutdown...');
|
|
||||||
signedUrlCache.saveToDisk();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default signedUrlCache;
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { isDevMode, telegramBotToken, telegramChatIds } from '@/src/lib/env-exporter'
|
import { getIsDevMode, getTelegramBotToken, getTelegramChatIds } from '@/src/lib/env-exporter'
|
||||||
|
|
||||||
const BOT_TOKEN = telegramBotToken;
|
|
||||||
const CHAT_IDS = telegramChatIds;
|
|
||||||
const TELEGRAM_API_URL = `https://api.telegram.org/bot${BOT_TOKEN}`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to Telegram bot
|
* Send a message to Telegram bot
|
||||||
|
|
@ -11,14 +7,21 @@ const TELEGRAM_API_URL = `https://api.telegram.org/bot${BOT_TOKEN}`;
|
||||||
* @returns Promise<boolean | null> indicating success, failure, or null if dev mode
|
* @returns Promise<boolean | null> indicating success, failure, or null if dev mode
|
||||||
*/
|
*/
|
||||||
export const sendTelegramMessage = async (message: string): Promise<boolean | null> => {
|
export const sendTelegramMessage = async (message: string): Promise<boolean | null> => {
|
||||||
if (isDevMode) {
|
if (getIsDevMode()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const botToken = getTelegramBotToken()
|
||||||
|
const chatIds = getTelegramChatIds()
|
||||||
|
if (!botToken || chatIds.length === 0) {
|
||||||
|
console.warn('Telegram credentials not configured, skipping notification')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const telegramApiUrl = `https://api.telegram.org/bot${botToken}`
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
CHAT_IDS.map(async (chatId) => {
|
chatIds.map(async (chatId) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${TELEGRAM_API_URL}/sendMessage`, {
|
const response = await axios.post(`${telegramApiUrl}/sendMessage`, {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: message,
|
text: message,
|
||||||
parse_mode: 'HTML',
|
parse_mode: 'HTML',
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue