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*
|
||||
.pnpm-debug.log*
|
||||
|
||||
**/.wrangler/*
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# Agent Instructions for Meat Farmer Monorepo
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
|
|
|||
4
APIS_TO_REMOVE.md
Normal file
4
APIS_TO_REMOVE.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
||||
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
||||
- remove slots from products cache
|
||||
- remove redundant product details like name, description etc from the slots api
|
||||
35
Dockerfile
35
Dockerfile
|
|
@ -1,32 +1,36 @@
|
|||
# Optimized Dockerfile for backend and fallback-ui services (project root)
|
||||
|
||||
# 1. ---- Base Node image
|
||||
FROM node:20-slim AS base
|
||||
# 1. ---- Base Bun image
|
||||
FROM oven/bun:1.3.10 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# 2. ---- Pruner ----
|
||||
FROM base AS pruner
|
||||
WORKDIR /app
|
||||
# Copy config files first for better caching
|
||||
COPY package.json package-lock.json turbo.json ./
|
||||
COPY package.json turbo.json ./
|
||||
COPY apps/backend/package.json ./apps/backend/
|
||||
COPY apps/fallback-ui/package.json ./apps/fallback-ui/
|
||||
COPY packages/shared/ ./packages/shared
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
RUN npm install -g turbo
|
||||
RUN bun install -g turbo
|
||||
COPY . .
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker
|
||||
RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
|
||||
# RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
# 3. ---- Builder ----
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
# Copy package files first to cache npm install
|
||||
# Copy package files first to cache bun install
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
#RUN cat ./bun.lock
|
||||
COPY --from=pruner /app/turbo.json .
|
||||
RUN npm ci
|
||||
RUN bun install
|
||||
# Copy source code after dependencies are installed
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN npx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
# 4. ---- Runner ----
|
||||
FROM base AS runner
|
||||
|
|
@ -34,12 +38,15 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
# Copy package files and install production deps
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci --production --omit=dev
|
||||
#COPY --from=pruner /app/out/bun.lock ./bun.lock
|
||||
RUN bun install --production
|
||||
# Copy built applications
|
||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
|
||||
# RUN ls -R
|
||||
RUN find . -path "./node_modules" -prune -o -print
|
||||
|
||||
EXPOSE 4000
|
||||
RUN npm i -g bun
|
||||
CMD ["bun", "apps/backend/dist/index.js"]
|
||||
# CMD ["node", "apps/backend/dist/index.js"]
|
||||
CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"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": {
|
||||
"bundler": "metro",
|
||||
|
|
|
|||
|
|
@ -227,7 +227,6 @@ export default function Layout() {
|
|||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native'
|
||||
import { BottomDialog , tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import AddressZoneForm from '@/components/AddressZoneForm'
|
||||
import AddressPlaceForm from '@/components/AddressPlaceForm'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
|
||||
const AddressManagement: React.FC = () => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null)
|
||||
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set())
|
||||
|
||||
const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery()
|
||||
const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery()
|
||||
|
||||
const createZone = trpc.admin.address.createZone.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchZones()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const createArea = trpc.admin.address.createArea.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchAreas()
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddZone = () => {
|
||||
setDialogType('zone')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleAddPlace = () => {
|
||||
setDialogType('place')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const toggleZone = (zoneId: number) => {
|
||||
setExpandedZones(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(zoneId)) {
|
||||
newSet.delete(zoneId)
|
||||
} else {
|
||||
newSet.add(zoneId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const groupedAreas = areas?.reduce((acc, area) => {
|
||||
if (area.zoneId) {
|
||||
if (!acc[area.zoneId]) acc[area.zoneId] = []
|
||||
acc[area.zoneId].push(area)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<number, typeof areas[0][]>) || {}
|
||||
|
||||
const unzonedAreas = areas?.filter(a => !a.zoneId) || []
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
<View style={tw`flex-row justify-between p-4`}>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={handleAddZone}>
|
||||
<Text style={tw`text-white`}>Add Zone</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-green1 px-4 py-2 rounded`} onPress={handleAddPlace}>
|
||||
<Text style={tw`text-white`}>Add Place</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={tw`flex-1 p-4`}>
|
||||
{zones?.map(zone => (
|
||||
<View key={zone.id} style={tw`mb-4 border border-gray-300 rounded`}>
|
||||
<TouchableOpacity style={tw`flex-row items-center p-3 bg-gray-100`} onPress={() => toggleZone(zone.id)}>
|
||||
<Text style={tw`flex-1 text-lg font-semibold`}>{zone.zoneName}</Text>
|
||||
<MaterialIcons name={expandedZones.has(zone.id) ? 'expand-less' : 'expand-more'} size={24} />
|
||||
</TouchableOpacity>
|
||||
{expandedZones.has(zone.id) && (
|
||||
<View style={tw`p-3`}>
|
||||
{groupedAreas[zone.id]?.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
)) || <Text style={tw`text-gray-500`}>No places in this zone</Text>}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={tw`mt-6`}>
|
||||
<Text style={tw`text-xl font-bold mb-2`}>Unzoned Places</Text>
|
||||
{unzonedAreas.map(area => (
|
||||
<Text key={area.id} style={tw`text-base mb-1`}>- {area.placeName}</Text>
|
||||
))}
|
||||
{unzonedAreas.length === 0 && <Text style={tw`text-gray-500`}>No unzoned places</Text>}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
{dialogType === 'zone' && <AddressZoneForm onSubmit={createZone.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
{dialogType === 'place' && <AddressPlaceForm onSubmit={createArea.mutate} onClose={() => setDialogOpen(false)} />}
|
||||
</BottomDialog>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressManagement
|
||||
|
|
@ -9,6 +9,20 @@ export default function CreateCoupon() {
|
|||
const router = useRouter();
|
||||
const createCoupon = trpc.admin.coupon.create.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) => {
|
||||
console.log('Form values:', values); // Debug log
|
||||
|
|
@ -27,7 +41,9 @@ export default function CreateCoupon() {
|
|||
if (isLoading) return; // Prevent double submission
|
||||
|
||||
mutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
|
||||
{ text: 'OK', onPress: () => router.back() }
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,21 @@ export default function EditCoupon() {
|
|||
const { id } = useLocalSearchParams();
|
||||
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 handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
|
||||
|
|
@ -24,7 +38,10 @@ export default function EditCoupon() {
|
|||
delete updates.targetUsers;
|
||||
|
||||
updateCoupon.mutate({ id: couponId, updates }, {
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
await refetch()
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
Alert.alert('Success', 'Coupon updated successfully', [
|
||||
{ text: 'OK', onPress: () => router.back() }
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,7 @@ import { trpc } from '../../../src/trpc-client';
|
|||
import { useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface ConstantFormData {
|
||||
constants: ConstantItem[];
|
||||
}
|
||||
|
||||
interface ConstantItem {
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
type ConstantFormData = Record<string, any>
|
||||
|
||||
const CONST_LABELS: Record<string, string> = {
|
||||
minRegularOrderValue: 'Minimum Regular Order Value',
|
||||
|
|
@ -37,23 +30,45 @@ const CONST_LABELS: Record<string, string> = {
|
|||
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 {
|
||||
constant: ConstantItem;
|
||||
constantKey: string;
|
||||
value: any;
|
||||
setFieldValue: (field: string, value: any) => void;
|
||||
index: number;
|
||||
router: any;
|
||||
}
|
||||
|
||||
const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue, index, router }) => {
|
||||
const fieldName = `constants.${index}.value`;
|
||||
const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
|
||||
const fieldName = constantKey
|
||||
|
||||
// Special handling for popularItems - show navigation button instead of input
|
||||
if (constant.key === 'popularItems') {
|
||||
console.log('key is allItemsOrder')
|
||||
if (constantKey === 'popularItems') {
|
||||
return (
|
||||
<View>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
{CONST_LABELS[constant.key] || constant.key}
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<MyTouchableOpacity
|
||||
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`} />
|
||||
<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>
|
||||
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
|
||||
</MyTouchableOpacity>
|
||||
|
|
@ -70,12 +85,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
|||
}
|
||||
|
||||
// Special handling for allItemsOrder - show navigation button instead of input
|
||||
if (constant.key === 'allItemsOrder') {
|
||||
if (constantKey === 'allItemsOrder') {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
{CONST_LABELS[constant.key] || constant.key}
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<MyTouchableOpacity
|
||||
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`} />
|
||||
<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>
|
||||
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
|
||||
</MyTouchableOpacity>
|
||||
|
|
@ -92,20 +107,20 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
|||
}
|
||||
|
||||
// Handle boolean values - show checkbox
|
||||
if (typeof constant.value === 'boolean') {
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<View>
|
||||
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
|
||||
{CONST_LABELS[constant.key] || constant.key}
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<Checkbox
|
||||
checked={constant.value}
|
||||
onPress={() => setFieldValue(fieldName, !constant.value)}
|
||||
checked={value}
|
||||
onPress={() => setFieldValue(fieldName, !value)}
|
||||
size={28}
|
||||
/>
|
||||
<MyText style={tw`ml-3 text-gray-700`}>
|
||||
{constant.value ? 'Enabled' : 'Disabled'}
|
||||
{value ? 'Enabled' : 'Disabled'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -113,11 +128,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
|||
}
|
||||
|
||||
// Handle different value types
|
||||
if (typeof constant.value === 'number') {
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||
value={constant.value.toString()}
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
value={value.toString()}
|
||||
onChangeText={(value) => {
|
||||
const numValue = parseFloat(value);
|
||||
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 (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||
value={constant.value.join(', ')}
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
value={value.join(', ')}
|
||||
onChangeText={(value) => {
|
||||
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
setFieldValue(fieldName, arrayValue);
|
||||
|
|
@ -145,9 +160,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
|
|||
// Default to string
|
||||
return (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constant.key] || constant.key}
|
||||
value={String(constant.value)}
|
||||
onChangeText={(value) => setFieldValue(fieldName, value)}
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
// value={value === null || value === undefined ? '' : String(value)}
|
||||
value={value}
|
||||
onChangeText={(value) => {
|
||||
setFieldValue(fieldName, value)
|
||||
}}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
);
|
||||
|
|
@ -161,10 +179,13 @@ export default function CustomizeApp() {
|
|||
|
||||
const handleSubmit = (values: ConstantFormData) => {
|
||||
// Filter out constants that haven't changed
|
||||
const changedConstants = values.constants.filter((constant, index) => {
|
||||
const original = constants?.[index];
|
||||
return original && JSON.stringify(constant.value) !== JSON.stringify(original.value);
|
||||
});
|
||||
const changedConstants = (constants || []).filter((constant) => {
|
||||
const nextValue = values[constant.key]
|
||||
return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
|
||||
}).map((constant) => ({
|
||||
key: constant.key,
|
||||
value: values[constant.key],
|
||||
}))
|
||||
|
||||
if (changedConstants.length === 0) {
|
||||
Alert.alert('No Changes', 'No constants were modified.');
|
||||
|
|
@ -202,9 +223,10 @@ export default function CustomizeApp() {
|
|||
);
|
||||
}
|
||||
|
||||
const initialValues: ConstantFormData = {
|
||||
constants: constants.map(c => ({ key: c.key, value: c.value ?? '' } as ConstantItem)),
|
||||
};
|
||||
const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
|
||||
acc[constant.key] = constant.value ?? ''
|
||||
return acc
|
||||
}, {} as ConstantFormData)
|
||||
|
||||
|
||||
|
||||
|
|
@ -219,11 +241,22 @@ export default function CustomizeApp() {
|
|||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ handleSubmit, values, setFieldValue }) => (
|
||||
<View>
|
||||
{values.constants.map((constant, index) => (
|
||||
{constants.map((constant) => {
|
||||
if (!CONST_VISIBILITY[constant.key]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={constant.key} style={tw`mb-4`}>
|
||||
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} />
|
||||
<ConstantInput
|
||||
constantKey={constant.key}
|
||||
value={values[constant.key]}
|
||||
setFieldValue={setFieldValue}
|
||||
router={router}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<MyTouchableOpacity
|
||||
onPress={() => handleSubmit()}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export default function CreateBanner() {
|
|||
};
|
||||
|
||||
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) => {
|
||||
if (!imageUrl) {
|
||||
|
|
@ -39,6 +42,7 @@ export default function CreateBanner() {
|
|||
redirectUrl: values.redirectUrl || undefined,
|
||||
});
|
||||
|
||||
await refetchBanners()
|
||||
Alert.alert('Success', 'Banner created successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export default function EditBanner() {
|
|||
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
|
||||
id: parseInt(bannerId)
|
||||
});
|
||||
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
const [banner, setBanner] = useState<typeof bannerData>(undefined);
|
||||
|
||||
|
||||
|
|
@ -100,6 +103,7 @@ export default function EditBanner() {
|
|||
redirectUrl: values.redirectUrl || undefined,
|
||||
});
|
||||
|
||||
await refetchBanners()
|
||||
Alert.alert('Success', 'Banner updated successfully', [
|
||||
{
|
||||
text: 'OK',
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export default function Dashboard() {
|
|||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Manage Orders',
|
||||
title: 'Manage Orderss',
|
||||
icon: 'shopping-bag',
|
||||
description: 'View and manage customer orders',
|
||||
route: '/(drawer)/manage-orders',
|
||||
|
|
@ -175,15 +175,6 @@ export default function Dashboard() {
|
|||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Address Management',
|
||||
icon: 'location-on',
|
||||
description: 'Manage service areas',
|
||||
route: '/(drawer)/address-management',
|
||||
category: 'settings',
|
||||
iconColor: '#EAB308',
|
||||
iconBg: '#FEF9C3',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ export default function OrderDetails() {
|
|||
onSuccess: (result) => {
|
||||
Alert.alert(
|
||||
"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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
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 { useCreateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
|
|
@ -15,50 +15,51 @@ interface TagFormData {
|
|||
|
||||
export default function AddTag() {
|
||||
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 { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const formData = new FormData();
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
|
||||
try {
|
||||
let imageUrl: string | null | undefined;
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
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
|
||||
}
|
||||
|
||||
createTag(formData, {
|
||||
onSuccess: (data) => {
|
||||
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(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error.message || 'Failed to create tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
])
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to create tag'
|
||||
Alert.alert('Error', errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const initialValues: TagFormData = {
|
||||
tagName: '',
|
||||
|
|
@ -76,8 +77,8 @@ export default function AddTag() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
isLoading={createTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
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 { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
|
|
@ -19,53 +19,60 @@ export default function EditTag() {
|
|||
const { tagId } = useLocalSearchParams<{ tagId: string }>();
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null;
|
||||
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
|
||||
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
|
||||
{ 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 { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
|
||||
if (!tagIdNum) return;
|
||||
|
||||
const formData = new FormData();
|
||||
try {
|
||||
let imageUrl: string | null | undefined
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
// Add text fields
|
||||
formData.append('tagName', values.tagName);
|
||||
if (values.tagDescription) {
|
||||
formData.append('tagDescription', values.tagDescription);
|
||||
}
|
||||
formData.append('isDashboardTag', values.isDashboardTag.toString());
|
||||
|
||||
// Add related stores
|
||||
formData.append('relatedStores', JSON.stringify(values.relatedStores));
|
||||
|
||||
// Add image if uploaded
|
||||
if (image?.uri) {
|
||||
const filename = image.uri.split('/').pop() || 'image.jpg';
|
||||
const match = /\.(\w+)$/.exec(filename);
|
||||
const type = match ? `image/${match[1]}` : 'image/jpeg';
|
||||
|
||||
formData.append('image', {
|
||||
uri: image.uri,
|
||||
name: filename,
|
||||
type,
|
||||
} as any);
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
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
|
||||
}
|
||||
|
||||
updateTag({ id: tagIdNum, formData }, {
|
||||
onSuccess: (data) => {
|
||||
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(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error.message || 'Failed to update tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
])
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to update tag'
|
||||
Alert.alert('Error', errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingTag) {
|
||||
return (
|
||||
|
|
@ -92,7 +99,7 @@ export default function EditTag() {
|
|||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: tag.relatedStores || [],
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
};
|
||||
|
||||
|
|
@ -106,8 +113,8 @@ export default function EditTag() {
|
|||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
|
||||
isLoading={updateTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,20 @@ import { useRouter } from 'expo-router';
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||
import { TagMenu } from '@/src/components/TagMenu';
|
||||
import { useGetTags, Tag } from '@/src/api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
interface TagItemData {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: unknown;
|
||||
createdAt: string | Date;
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
item: Tag;
|
||||
item: TagItemData;
|
||||
onDeleteSuccess: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
|||
|
||||
export default function ProductTags() {
|
||||
const router = useRouter();
|
||||
const { data: tagsData, isLoading, error, refetch } = useGetTags();
|
||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const tags = tagsData?.tags || [];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,35 @@
|
|||
import React from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { AppContainer } from 'common-ui';
|
||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
|
||||
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() {
|
||||
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 payload: CreateProductPayload = {
|
||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
|
||||
try {
|
||||
let uploadUrls: string[] = [];
|
||||
|
||||
if (images.length > 0) {
|
||||
const blobs = await Promise.all(
|
||||
images.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 createProduct.mutateAsync({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -18,45 +39,18 @@ export default function AddProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, value as string);
|
||||
}
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
uploadUrls,
|
||||
tagIds: values.tagIds || [],
|
||||
});
|
||||
|
||||
// Append tag IDs
|
||||
if (values.tagIds && values.tagIds.length > 0) {
|
||||
values.tagIds.forEach((tagId: number) => {
|
||||
formData.append('tagIds', tagId.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Append images
|
||||
if (images) {
|
||||
images.forEach((image, index) => {
|
||||
if (image.uri) {
|
||||
formData.append('images', {
|
||||
uri: image.uri,
|
||||
name: `image-${index}.jpg`,
|
||||
// type: 'image/jpeg',
|
||||
type: image.mimeType as any,
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createProduct(formData, {
|
||||
onSuccess: (data) => {
|
||||
await refetchProducts();
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
// Reset form or navigate
|
||||
},
|
||||
onError: (error: any) => {
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create product');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const initialValues = {
|
||||
|
|
@ -81,8 +75,7 @@ export default function AddProduct() {
|
|||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isCreating}
|
||||
existingImages={[]}
|
||||
isLoading={createProduct.isPending || isUploading}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
|
|||
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
import { Formik } from 'formik';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -23,10 +24,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
const [adminResponse, setAdminResponse] = useState('');
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
|
||||
|
||||
const respondToReview = trpc.admin.product.respondToReview.useMutation();
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
const { upload } = useUploadToObjectStorage();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -62,37 +62,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
|
||||
const handleSubmit = async (adminResponse: string) => {
|
||||
try {
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
||||
const { keys, presignedUrls } = await upload({
|
||||
images: selectedImages,
|
||||
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({
|
||||
reviewId,
|
||||
adminResponse,
|
||||
adminResponseImages: keys,
|
||||
uploadUrls: generatedUrls,
|
||||
uploadUrls: presignedUrls,
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Response submitted');
|
||||
|
|
@ -100,9 +79,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
|
|||
setAdminResponse('');
|
||||
setSelectedImages([]);
|
||||
setDisplayImages([]);
|
||||
setUploadUrls([]);
|
||||
} catch (error:any) {
|
||||
|
||||
Alert.alert('Error', error.message || 'Failed to submit response.');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,50 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { View, Text, Alert } from 'react-native';
|
||||
import { View, Alert } from 'react-native';
|
||||
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 { useUpdateProduct } from '@/src/api-hooks/product.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
export default function EditProduct() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
|
||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
//
|
||||
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
|
||||
const payload = {
|
||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
|
||||
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,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
|
|
@ -32,64 +54,21 @@ export default function EditProduct() {
|
|||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
deals: values.deals?.filter((deal: any) =>
|
||||
deal.quantity && deal.price && deal.validTill
|
||||
).map((deal: any) => ({
|
||||
quantity: parseInt(deal.quantity),
|
||||
price: parseFloat(deal.price),
|
||||
validTill: deal.validTill instanceof Date
|
||||
? 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);
|
||||
}
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
|
||||
uploadUrls,
|
||||
imagesToDelete,
|
||||
tagIds: values.tagIds || [],
|
||||
});
|
||||
|
||||
// Add new images
|
||||
if (newImages && newImages.length > 0) {
|
||||
newImages.forEach((image, index) => {
|
||||
if (image.uri) {
|
||||
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
|
||||
formData.append('images', {
|
||||
uri: image.uri,
|
||||
name: fileName,
|
||||
type: 'image/jpeg',
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add images to delete
|
||||
if (imagesToDelete && imagesToDelete.length > 0) {
|
||||
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
|
||||
}
|
||||
|
||||
updateProduct(
|
||||
{ id: productId, formData },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
await refetch();
|
||||
await refetchProducts();
|
||||
Alert.alert('Success', 'Product updated successfully!');
|
||||
// Clear newly added images after successful update
|
||||
productFormRef.current?.clearImages();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to update product');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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 = {
|
||||
name: productData.name,
|
||||
|
|
@ -125,7 +110,7 @@ export default function EditProduct() {
|
|||
deals: productData.deals?.map(deal => ({
|
||||
quantity: deal.quantity,
|
||||
price: deal.price,
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||
})) || [{ quantity: '', price: '', validTill: null }],
|
||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||
isSuspended: productData.isSuspended || false,
|
||||
|
|
@ -141,8 +126,9 @@ export default function EditProduct() {
|
|||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isUpdating}
|
||||
existingImages={productData.images || []}
|
||||
isLoading={updateProduct.isPending || isUploading}
|
||||
existingImages={existingImages}
|
||||
existingImageKeys={existingImageKeys}
|
||||
/>
|
||||
</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 { 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';
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ export default function Products() {
|
|||
|
||||
|
||||
// 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';
|
||||
Alert.alert(
|
||||
'Update Stock Status',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -26,12 +27,6 @@ interface User {
|
|||
isEligibleForNotif: boolean;
|
||||
}
|
||||
|
||||
const extractKeyFromUrl = (url: string): string => {
|
||||
const u = new URL(url);
|
||||
const rawKey = u.pathname.replace(/^\/+/, '');
|
||||
return decodeURIComponent(rawKey);
|
||||
};
|
||||
|
||||
export default function SendNotifications() {
|
||||
const router = useRouter();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
|
|
@ -46,8 +41,7 @@ export default function SendNotifications() {
|
|||
search: searchQuery,
|
||||
});
|
||||
|
||||
// Generate upload URLs mutation
|
||||
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
||||
const { uploadSingle } = useUploadToObjectStorage();
|
||||
|
||||
// Send notification mutation
|
||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||
|
|
@ -127,28 +121,8 @@ export default function SendNotifications() {
|
|||
|
||||
// Upload image if selected
|
||||
if (selectedImage) {
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'notification',
|
||||
mimeTypes: [selectedImage.mimeType],
|
||||
});
|
||||
|
||||
if (uploadUrls.length > 0) {
|
||||
const uploadUrl = uploadUrls[0];
|
||||
imageUrl = extractKeyFromUrl(uploadUrl);
|
||||
|
||||
// Upload image
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: selectedImage.blob,
|
||||
headers: {
|
||||
'Content-Type': selectedImage.mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
}
|
||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
||||
imageUrl = key;
|
||||
}
|
||||
|
||||
// Send notification
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ export default function AddStore() {
|
|||
const router = useRouter();
|
||||
|
||||
const createStoreMutation = trpc.admin.store.createStore.useMutation();
|
||||
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: StoreFormData) => {
|
||||
createStoreMutation.mutate(values, {
|
||||
onSuccess: (data) => {
|
||||
onSuccess: async (data) => {
|
||||
await refetchStores();
|
||||
Alert.alert('Success', data.message);
|
||||
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 { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
export interface BannerFormData {
|
||||
|
|
@ -52,10 +53,10 @@ export default function BannerForm({
|
|||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
const { uploadSingle } = useUploadToObjectStorage();
|
||||
|
||||
// Fetch products for dropdown
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
|
||||
const products = productsData?.products || [];
|
||||
|
||||
|
||||
|
|
@ -97,33 +98,11 @@ export default function BannerForm({
|
|||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store', // Using 'store' for now
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
// Upload image
|
||||
const uploadUrl = uploadUrls[0];
|
||||
const { blob, mimeType } = selectedImages[0];
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||
imageUrl = presignedUrl;
|
||||
}
|
||||
|
||||
imageUrl = uploadUrl;
|
||||
}
|
||||
|
||||
// Call onSubmit with form values and imageUrl
|
||||
await onSubmit(values, imageUrl);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
|
|
|||
|
|
@ -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 { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
|
||||
|
||||
export interface StoreFormData {
|
||||
name: string;
|
||||
|
|
@ -59,14 +60,19 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
});
|
||||
}, [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,
|
||||
value: staff.id,
|
||||
})) || [];
|
||||
|
||||
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
|
|
@ -113,39 +119,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
|||
let imageUrl: string | undefined;
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
// Generate upload URLs
|
||||
const mimeTypes = selectedImages.map(s => s.mimeType);
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString: 'store',
|
||||
mimeTypes,
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
const { blob, mimeType } = selectedImages[0];
|
||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
|
||||
imageUrl = presignedUrl;
|
||||
}
|
||||
|
||||
// Extract key from first upload URL
|
||||
// const u = new URL(uploadUrls[0]);
|
||||
// const rawKey = u.pathname.replace(/^\/+/, "");
|
||||
// imageUrl = decodeURIComponent(rawKey);
|
||||
imageUrl = uploadUrls[0];
|
||||
}
|
||||
|
||||
// Submit form with imageUrl
|
||||
onSubmit({ ...values, imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
|
@ -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>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
|
||||
existingImageUrls={existingImageUrls}
|
||||
onAddImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
|
||||
onRemoveExistingImage={() =>
|
||||
setFormInitialValues((prev) => ({
|
||||
...prev,
|
||||
imageUrl: undefined,
|
||||
}))
|
||||
}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={isLoading || generateUploadUrls.isPending}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
disabled={isLoading || isUploading}
|
||||
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
<MyText style={tw`text-white text-lg font-bold`}>
|
||||
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
|
|||
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": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
"distribution": "internal",
|
||||
"channel": "development"
|
||||
},
|
||||
"preview": {
|
||||
"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 { Image } from 'expo-image';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { trpc } from '../trpc-client';
|
||||
import { useGetTags } from '../api-hooks/tag.api';
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
|
|
@ -38,9 +35,10 @@ export interface ProductFormRef {
|
|||
interface ProductFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialValues: ProductFormData;
|
||||
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
|
||||
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
|
||||
isLoading: boolean;
|
||||
existingImages?: string[];
|
||||
existingImages?: ImageUploaderNeoItem[];
|
||||
existingImageKeys?: string[];
|
||||
}
|
||||
|
||||
const unitOptions = [
|
||||
|
|
@ -50,18 +48,22 @@ const unitOptions = [
|
|||
{ label: 'Unit Piece', value: 4 },
|
||||
];
|
||||
|
||||
|
||||
|
||||
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||
mode,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
existingImages = []
|
||||
existingImages:existingImagesRaw,
|
||||
existingImageKeys = [],
|
||||
}, ref) => {
|
||||
const { theme } = useTheme();
|
||||
const [images, setImages] = useState<{ uri?: string }[]>([]);
|
||||
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
|
||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
|
||||
|
||||
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 storeOptions = storesData?.stores.map(store => ({
|
||||
|
|
@ -69,44 +71,50 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
value: store.id,
|
||||
})) || [];
|
||||
|
||||
const { data: tagsData } = useGetTags();
|
||||
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
|
||||
const tagOptions = tagsData?.tags.map(tag => ({
|
||||
label: tag.tagName,
|
||||
value: tag.id.toString(),
|
||||
})) || [];
|
||||
|
||||
// Initialize existing images state when existingImages prop changes
|
||||
useEffect(() => {
|
||||
console.log('changing existing imaes statte')
|
||||
|
||||
setExistingImagesState(existingImages);
|
||||
}, [existingImages]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => setImages(prev => [...prev, ...files]),
|
||||
multiple: true,
|
||||
// Build signed URL -> S3 key mapping for existing images
|
||||
const signedUrlToKey = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
existingImages.forEach((img, i) => {
|
||||
if (existingImageKeys[i]) {
|
||||
map[img.imgUrl] = existingImageKeys[i];
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate which existing images were deleted
|
||||
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
|
||||
return map;
|
||||
}, [existingImages, existingImageKeys]);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
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
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([]);
|
||||
setExistingImagesState([]);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
||||
useFocusCallback(clearForm);
|
||||
|
||||
// Update ref with current clearForm function
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearImages: clearForm,
|
||||
}), [clearForm]);
|
||||
|
|
@ -141,44 +149,18 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{mode === 'create' && (
|
||||
<ImageUploader
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
|
||||
onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
|
||||
onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
|
||||
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
|
||||
topLabel='Unit'
|
||||
label="Unit"
|
||||
value={values.unitId}
|
||||
options={unitOptions}
|
||||
// onValueChange={(value) => handleChange('unitId')(value+'')}
|
||||
onValueChange={(value) => setFieldValue('unitId', value)}
|
||||
placeholder="Select unit"
|
||||
style={{ marginBottom: 16 }}
|
||||
|
|
@ -188,18 +170,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
placeholder="Enter product quantity"
|
||||
keyboardType="numeric"
|
||||
value={values.productQuantity.toString()}
|
||||
onChangeText={(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);
|
||||
// }
|
||||
}}
|
||||
onChangeText={(text) => setFieldValue('productQuantity', text)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<BottomDropdown
|
||||
|
|
@ -238,8 +209,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<View style={tw`flex-row items-center mb-4`}>
|
||||
<Checkbox
|
||||
checked={values.isSuspended}
|
||||
|
|
@ -254,7 +223,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
|||
checked={values.isFlashAvailable}
|
||||
onPress={() => {
|
||||
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
|
||||
if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled
|
||||
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
|
||||
}}
|
||||
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
|
||||
onPress={submit}
|
||||
disabled={isLoading}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface StoreOption {
|
||||
|
|
@ -23,7 +21,7 @@ interface TagFormProps {
|
|||
mode: 'create' | 'edit';
|
||||
initialValues: TagFormData;
|
||||
existingImageUrl?: string;
|
||||
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
|
||||
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
|
||||
isLoading: boolean;
|
||||
stores?: StoreOption[];
|
||||
}
|
||||
|
|
@ -31,27 +29,28 @@ interface TagFormProps {
|
|||
const TagForm = forwardRef<any, TagFormProps>(({
|
||||
mode,
|
||||
initialValues,
|
||||
existingImageUrl = '',
|
||||
existingImageUrl: existingImageUrlRaw,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
stores = [],
|
||||
stores: storesRaw,
|
||||
}, 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 existingImageUrl = existingImageUrlRaw || ''
|
||||
const stores = storesRaw || []
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
|
||||
existingImageUrl && setImage({uri:existingImageUrl})
|
||||
}, [initialValues.isDashboardTag]);
|
||||
|
||||
const pickImage = usePickImage({
|
||||
setFile: (files) => {
|
||||
|
||||
setImage(files || null)
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
if (existingImageUrl) {
|
||||
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
|
||||
} else {
|
||||
setImages([])
|
||||
}
|
||||
setRemovedExisting(false)
|
||||
}, [existingImageUrlRaw, initialValues.isDashboardTag]);
|
||||
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
|
|
@ -67,14 +66,14 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit(values, image || undefined)}
|
||||
onSubmit={(values) => onSubmit(values, images, removedExisting)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
|
||||
// Clear form when screen comes into focus
|
||||
const clearForm = useCallback(() => {
|
||||
setImage(null);
|
||||
|
||||
setImages([])
|
||||
setRemovedExisting(false)
|
||||
setIsDashboardTagChecked(false);
|
||||
resetForm();
|
||||
}, [resetForm]);
|
||||
|
|
@ -108,10 +107,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
|
|||
</MyText>
|
||||
|
||||
|
||||
<ImageUploader
|
||||
images={image ? [image] : []}
|
||||
onAddImage={pickImage}
|
||||
onRemoveImage={() => setImage(null)}
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
|
|||
import { Entypo } from '@expo/vector-icons';
|
||||
import { MyText, tw, BottomDialog } from 'common-ui';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useDeleteTag } from '../api-hooks/tag.api';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
export interface TagMenuProps {
|
||||
tagId: number;
|
||||
|
|
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
|
||||
const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true);
|
||||
|
|
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
};
|
||||
|
||||
const performDelete = () => {
|
||||
deleteTag(tagId, {
|
||||
deleteTag.mutate({ id: tagId }, {
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Tag deleted successfully');
|
||||
onDeleteSuccess?.();
|
||||
|
|
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
|
|||
const errorMessage = error.message || 'Failed to delete tag';
|
||||
Alert.alert('Error', errorMessage);
|
||||
},
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
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 express, { NextFunction, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
// import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { db } from '@/src/db/db_index';
|
||||
import { staffUsers, userDetails } from '@/src/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import mainRouter from '@/src/main-router';
|
||||
import { serve } from '@hono/node-server';
|
||||
import initFunc from '@/src/lib/init';
|
||||
import { createExpressMiddleware } from '@trpc/server/adapters/express';
|
||||
import { appRouter } from '@/src/trpc/router';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import jwt from 'jsonwebtoken'
|
||||
import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/db/seed';
|
||||
import { createApp } from '@/src/app'
|
||||
// import signedUrlCache from '@/src/lib/signed-url-cache';
|
||||
import { seed } from '@/src/lib/seed';
|
||||
import '@/src/jobs/jobs-index';
|
||||
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
|
||||
|
||||
|
|
@ -23,163 +11,13 @@ seed()
|
|||
initFunc()
|
||||
startAutomatedJobs()
|
||||
|
||||
const app = express();
|
||||
// signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility
|
||||
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5174'
|
||||
}));
|
||||
const app = createApp()
|
||||
|
||||
|
||||
signedUrlCache.loadFromDisk();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Middleware to log all request URLs
|
||||
app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
//cors middleware
|
||||
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Allow requests from any origin (for production, replace * with your domain)
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Allow specific headers clients can send
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
|
||||
);
|
||||
|
||||
// Allow specific HTTP methods
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
|
||||
// Allow credentials if needed (optional)
|
||||
// res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Handle preflight (OPTIONS) requests quickly
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
app.use('/api/trpc', createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => {
|
||||
let user = null;
|
||||
let staffUser = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = 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/");
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: 4000,
|
||||
}, (info) => {
|
||||
console.log(`Server is running on http://localhost:${info.port}/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",
|
||||
"scripts": {
|
||||
"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",
|
||||
"build2": "rimraf ./dist && tsc",
|
||||
"db:push": "drizzle-kit push:pg",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"dev2": "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:push": "docker push mohdshafiuddin54/health_petal:latest"
|
||||
},
|
||||
|
|
@ -22,40 +25,29 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^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",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.63.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.18",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"expo-server-sdk": "^4.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.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",
|
||||
"hono": "^4.12.9",
|
||||
"jose": "^6.2.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@cloudflare/workers-types": "^4.20260401.1",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"rimraf": "^6.1.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.20.5",
|
||||
"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 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
|
||||
router.use(authenticateStaff);
|
||||
|
||||
// Product routes
|
||||
router.use("/products", productRouter);
|
||||
|
||||
// Tag routes
|
||||
router.use("/product-tags", tagRouter);
|
||||
router.use('*', authenticateStaff);
|
||||
|
||||
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 { Request, Response } from "express";
|
||||
import { db } from "@/src/db/db_index"
|
||||
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
|
||||
import { Context } from 'hono';
|
||||
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 result = await db
|
||||
.select({ deliveryTime: deliverySlotInfo.deliveryTime })
|
||||
|
|
@ -22,13 +77,9 @@ const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
|
|||
.orderBy(deliverySlotInfo.deliveryTime)
|
||||
.limit(1);
|
||||
|
||||
|
||||
return result[0]?.deliveryTime || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all products summary for dropdown
|
||||
*/
|
||||
export const getAllProductsSummary = async (req: Request, res: Response) => {
|
||||
try {
|
||||
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" });
|
||||
}
|
||||
};
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Router } from "express";
|
||||
import { Hono } from 'hono';
|
||||
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
|
||||
|
||||
const router = Router();
|
||||
const router = new Hono();
|
||||
|
||||
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"
|
||||
|
||||
const router = Router();
|
||||
const router = new Hono();
|
||||
|
||||
router.use('/products', commonProductsRouter)
|
||||
router.route('/products', commonProductsRouter)
|
||||
|
||||
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'
|
||||
|
||||
const runCombinedJob = async () => {
|
||||
|
|
@ -25,4 +24,4 @@ const runCombinedJob = async () => {
|
|||
runCombinedJob();
|
||||
|
||||
// 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 {
|
||||
payment: typeof payments.$inferSelect;
|
||||
order: typeof orders.$inferSelect;
|
||||
slot: typeof deliverySlotInfo.$inferSelect;
|
||||
payment: any;
|
||||
order: any;
|
||||
slot: any;
|
||||
}
|
||||
|
||||
export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
||||
|
|
@ -18,6 +13,32 @@ export const createPaymentNotification = (record: PendingPaymentRecord) => {
|
|||
console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`);
|
||||
};
|
||||
|
||||
export const checkRefundStatuses = async () => {
|
||||
try {
|
||||
// TODO: Reimplement with helpers from @/src/dbService
|
||||
// This function checks Razorpay refund status and updates database
|
||||
// Requires: getPendingRefunds(), updateRefundStatus()
|
||||
} catch (error) {
|
||||
console.error('Error in checkRefundStatuses:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPendingPayments = async () => {
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
// 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
|
||||
|
|
@ -76,4 +97,4 @@ export const checkPendingPayments = async () => {
|
|||
console.error('Error checking pending payments:', error);
|
||||
}
|
||||
};
|
||||
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ export class ApiError extends Error {
|
|||
this.name = 'ApiError';
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
Error.captureStackTrace?.(this, ApiError);
|
||||
// Error.captureStackTrace?.(this, ApiError);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import * as cron from 'node-cron';
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { productInfo, keyValStore } from '@/src/db/schema'
|
||||
import { inArray, eq } from 'drizzle-orm';
|
||||
// import * as cron from 'node-cron';
|
||||
const cron:any = {}
|
||||
import { toggleFlashDeliveryForItems, toggleKeyVal } from '@/src/dbService';
|
||||
import { CONST_KEYS } from '@/src/lib/const-keys'
|
||||
import { computeConstants } from '@/src/lib/const-store'
|
||||
|
||||
|
|
@ -24,10 +23,7 @@ export const startAutomatedJobs = () => {
|
|||
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));
|
||||
await toggleFlashDeliveryForItems(false, MUTTON_ITEMS);
|
||||
console.log('Flash delivery disabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error disabling flash delivery:', error);
|
||||
|
|
@ -38,10 +34,7 @@ export const startAutomatedJobs = () => {
|
|||
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));
|
||||
await toggleFlashDeliveryForItems(true, MUTTON_ITEMS);
|
||||
console.log('Flash delivery enabled successfully');
|
||||
} catch (error) {
|
||||
console.error('Error enabling flash delivery:', error);
|
||||
|
|
@ -52,10 +45,7 @@ export const startAutomatedJobs = () => {
|
|||
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 toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, false);
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature disabled successfully');
|
||||
} catch (error) {
|
||||
|
|
@ -67,10 +57,7 @@ export const startAutomatedJobs = () => {
|
|||
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 toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, true);
|
||||
await computeConstants(); // Refresh Redis cache
|
||||
console.log('Flash delivery feature enabled successfully');
|
||||
} catch (error) {
|
||||
|
|
@ -81,5 +68,70 @@ export const startAutomatedJobs = () => {
|
|||
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
|
||||
// startAutomatedJobs();
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import axiosParent from "axios";
|
||||
import { phonePeBaseUrl } from "@/src/lib/env-exporter"
|
||||
import { getPhonePeBaseUrl } from "@/src/lib/env-exporter"
|
||||
|
||||
export const phonepeAxios = axiosParent.create({
|
||||
baseURL: phonePeBaseUrl,
|
||||
baseURL: getPhonePeBaseUrl(),
|
||||
timeout: 40000,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import express from 'express';
|
||||
const catchAsync =
|
||||
(fn: express.RequestHandler) =>
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) =>
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
// catchAsync is no longer needed with Hono
|
||||
// Hono handles async errors automatically
|
||||
// This file is kept for backward compatibility but should be removed in the future
|
||||
|
||||
import { Context } from 'hono';
|
||||
|
||||
const catchAsync = (fn: (c: Context) => Promise<Response>) => {
|
||||
return fn;
|
||||
};
|
||||
|
||||
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',
|
||||
readableOrderId: 'readableOrderId',
|
||||
versionNum: 'versionNum',
|
||||
cacheVersion: 'cache_version',
|
||||
playStoreUrl: 'playStoreUrl',
|
||||
appStoreUrl: 'appStoreUrl',
|
||||
popularItems: 'popularItems',
|
||||
|
|
@ -35,6 +36,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
|
|||
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
||||
readableOrderId: 'Readable Order ID',
|
||||
versionNum: 'Version Number',
|
||||
'cache_version': 'Cache Version',
|
||||
playStoreUrl: 'Play Store URL',
|
||||
appStoreUrl: 'App Store URL',
|
||||
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 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 { keyValStore } from '@/src/db/schema'
|
||||
import redisClient from '@/src/lib/redis-client'
|
||||
import { getAllKeyValStore } from '@/src/dbService'
|
||||
// import redisClient from '@/src/lib/redis-client'
|
||||
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> => {
|
||||
try {
|
||||
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);
|
||||
*/
|
||||
|
||||
for (const constant of constants) {
|
||||
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`;
|
||||
const value = JSON.stringify(constant.value);
|
||||
// console.log({redisKey, value})
|
||||
const constants = await getAllKeyValStore();
|
||||
|
||||
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) {
|
||||
console.error('Failed to compute constants:', error);
|
||||
throw error;
|
||||
|
|
@ -27,46 +32,76 @@ export const computeConstants = async (): Promise<void> => {
|
|||
};
|
||||
|
||||
export const getConstant = async <T = any>(key: string): Promise<T | null> => {
|
||||
const redisKey = `${CONST_REDIS_PREFIX}${key}`;
|
||||
const value = await redisClient.get(redisKey);
|
||||
// const redisKey = `${CONST_REDIS_PREFIX}${key}`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as unknown as T;
|
||||
}
|
||||
return entry.value as T;
|
||||
};
|
||||
|
||||
export const getConstants = async <T = any>(keys: string[]): Promise<Record<string, T | null>> => {
|
||||
const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`);
|
||||
const values = await redisClient.MGET(redisKeys);
|
||||
// const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`);
|
||||
// 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> = {};
|
||||
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;
|
||||
for (const key of keys) {
|
||||
const value = constantsMap.get(key);
|
||||
result[key] = (value !== undefined ? value : null) as T | null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
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) {
|
||||
result[key] = await getConstant(key);
|
||||
result[key] = constantsMap.get(key) ?? null;
|
||||
}
|
||||
|
||||
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 { s3Url } from "@/src/lib/env-exporter"
|
||||
import { getS3Url } from "@/src/lib/env-exporter"
|
||||
|
||||
function extractS3Key(url: string): string | null {
|
||||
try {
|
||||
|
|
@ -10,11 +8,11 @@ function extractS3Key(url: string): string | null {
|
|||
|
||||
// Find the index of '.com/' in the URL
|
||||
// const comIndex = originalUrl.indexOf(".com/");
|
||||
const baseUrlIndex = originalUrl.indexOf(s3Url);
|
||||
const baseUrlIndex = originalUrl.indexOf(getS3Url());
|
||||
|
||||
// If '.com/' is found, return everything after it
|
||||
if (baseUrlIndex !== -1) {
|
||||
return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/'
|
||||
return originalUrl.substring(baseUrlIndex + getS3Url().length); // +5 to skip '.com/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error extracting key from URL:", error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
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';
|
||||
import { deleteOrdersWithRelations } from '@/src/dbService'
|
||||
|
||||
/**
|
||||
* Delete orders and all their related records
|
||||
|
|
@ -8,6 +6,26 @@ import { eq, inArray } from 'drizzle-orm';
|
|||
* @returns Promise<void>
|
||||
* @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> => {
|
||||
if (orderIds.length === 0) {
|
||||
return;
|
||||
|
|
@ -43,3 +61,4 @@ export const deleteOrders = async (orderIds: number[]): Promise<void> => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export class DiskPersistedSet {
|
||||
private set: Set<string>;
|
||||
private readonly filePath: string;
|
||||
private dirty = false;
|
||||
|
||||
constructor(filePath: string = "./persister") {
|
||||
this.filePath = path.resolve(filePath);
|
||||
|
||||
// ✅ Ensure file exists
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
fs.writeFileSync(this.filePath, "", "utf8");
|
||||
}
|
||||
|
||||
// ✅ Load existing values from file
|
||||
const contents = fs.readFileSync(this.filePath, "utf8");
|
||||
this.set = new Set(
|
||||
contents.split("\n").map(x => x.trim()).filter(x => x.length > 0)
|
||||
);
|
||||
|
||||
this.registerExitHandlers();
|
||||
}
|
||||
|
||||
private persist() {
|
||||
if (!this.dirty) return;
|
||||
fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8");
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
private markDirty() {
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
add(value: string): void {
|
||||
if (!this.set.has(value)) {
|
||||
this.set.add(value);
|
||||
this.markDirty();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
delete(value: string): void {
|
||||
if (this.set.delete(value)) {
|
||||
this.markDirty();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
has(value: string): boolean {
|
||||
return this.set.has(value);
|
||||
}
|
||||
|
||||
values(): string[] {
|
||||
return Array.from(this.set);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.set.size > 0) {
|
||||
this.set.clear();
|
||||
this.markDirty();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
|
||||
private registerExitHandlers() {
|
||||
const flush = () => this.persist();
|
||||
|
||||
process.on("exit", flush);
|
||||
process.on("SIGINT", () => { flush(); process.exit(); });
|
||||
process.on("SIGTERM", () => { flush(); process.exit(); });
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("Uncaught exception. Flushing DiskPersistedSet:", err);
|
||||
flush();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
// import fs from "fs";
|
||||
// import path from "path";
|
||||
//
|
||||
// export class DiskPersistedSet {
|
||||
// private set: Set<string>;
|
||||
// private readonly filePath: string;
|
||||
// private dirty = false;
|
||||
//
|
||||
// constructor(filePath: string = "./persister") {
|
||||
// this.filePath = path.resolve(filePath);
|
||||
//
|
||||
// // ✅ Ensure file exists
|
||||
// if (!fs.existsSync(this.filePath)) {
|
||||
// fs.writeFileSync(this.filePath, "", "utf8");
|
||||
// }
|
||||
//
|
||||
// // ✅ Load existing values from file
|
||||
// const contents = fs.readFileSync(this.filePath, "utf8");
|
||||
// this.set = new Set(
|
||||
// contents.split("\n").map(x => x.trim()).filter(x => x.length > 0)
|
||||
// );
|
||||
//
|
||||
// this.registerExitHandlers();
|
||||
// }
|
||||
//
|
||||
// private persist() {
|
||||
// if (!this.dirty) return;
|
||||
// fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8");
|
||||
// this.dirty = false;
|
||||
// }
|
||||
//
|
||||
// private markDirty() {
|
||||
// this.dirty = true;
|
||||
// }
|
||||
//
|
||||
// add(value: string): void {
|
||||
// if (!this.set.has(value)) {
|
||||
// this.set.add(value);
|
||||
// this.markDirty();
|
||||
// this.persist();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// delete(value: string): void {
|
||||
// if (this.set.delete(value)) {
|
||||
// this.markDirty();
|
||||
// this.persist();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// has(value: string): boolean {
|
||||
// return this.set.has(value);
|
||||
// }
|
||||
//
|
||||
// values(): string[] {
|
||||
// return Array.from(this.set);
|
||||
// }
|
||||
//
|
||||
// clear(): void {
|
||||
// if (this.set.size > 0) {
|
||||
// this.set.clear();
|
||||
// this.markDirty();
|
||||
// this.persist();
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private registerExitHandlers() {
|
||||
// const flush = () => this.persist();
|
||||
//
|
||||
// process.on("exit", flush);
|
||||
// process.on("SIGINT", () => { flush(); process.exit(); });
|
||||
// process.on("SIGTERM", () => { flush(); process.exit(); });
|
||||
// process.on("uncaughtException", (err) => {
|
||||
// console.error("Uncaught exception. Flushing DiskPersistedSet:", err);
|
||||
// flush();
|
||||
// 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 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> {
|
||||
try {
|
||||
const jsonData = JSON.stringify(eventData);
|
||||
const result = await redisClient.lPush(queueName, jsonData);
|
||||
return result > 0;
|
||||
// const result = await redisClient.lPush(queueName, jsonData);
|
||||
// return result > 0;
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Event enqueue error:', error);
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Expo } from "expo-server-sdk";
|
||||
import { title } from "process";
|
||||
import { expoAccessToken } from "@/src/lib/env-exporter"
|
||||
import { getExpoAccessToken } from "@/src/lib/env-exporter"
|
||||
|
||||
const expo = new Expo({
|
||||
accessToken: expoAccessToken,
|
||||
accessToken: getExpoAccessToken(),
|
||||
useFcmV1: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
|
|||
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
|
||||
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
|
||||
import { deleteOrders } from '@/src/lib/delete-orders'
|
||||
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
|
||||
|
||||
/**
|
||||
* Initialize all application services
|
||||
|
|
@ -25,6 +26,10 @@ export const initFunc = async (): Promise<void> => {
|
|||
startCancellationHandler(),
|
||||
]);
|
||||
|
||||
// Create all cache files after stores are initialized
|
||||
await createAllCacheFiles();
|
||||
console.log('Cache files created successfully');
|
||||
|
||||
console.log('Application initialization completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Application initialization failed:', error);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Queue, Worker } from 'bullmq';
|
||||
// import { Queue, Worker } from 'bullmq';
|
||||
import { Expo } from 'expo-server-sdk';
|
||||
import { redisUrl } from '@/src/lib/env-exporter'
|
||||
import { db } from '@/src/db/db_index'
|
||||
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'
|
||||
// import { db } from '@/src/db/db_index'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||
import {
|
||||
NOTIFS_QUEUE,
|
||||
ORDER_PLACED_MESSAGE,
|
||||
|
|
@ -14,33 +14,37 @@ import {
|
|||
REFUND_INITIATED_MESSAGE
|
||||
} 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) => {
|
||||
if (!job) return;
|
||||
export const notificationQueue:any = {};
|
||||
|
||||
const { name, data } = job;
|
||||
console.log(`Processing notification job ${job.id} - ${name}`);
|
||||
// export const notificationQueue = new Queue(NOTIFS_QUEUE, {
|
||||
// connection: { url: redisUrl },
|
||||
// defaultJobOptions: {
|
||||
// removeOnComplete: true,
|
||||
// removeOnFail: 10,
|
||||
// attempts: 3,
|
||||
// },
|
||||
// });
|
||||
|
||||
if (name === 'send-admin-notification') {
|
||||
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,
|
||||
});
|
||||
export const notificationWorker:any = {};
|
||||
// export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
|
||||
// if (!job) return;
|
||||
//
|
||||
// const { name, data } = job;
|
||||
// console.log(`Processing notification job ${job.id} - ${name}`);
|
||||
//
|
||||
// if (name === 'send-admin-notification') {
|
||||
// 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;
|
||||
title: string;
|
||||
body: string;
|
||||
|
|
@ -55,7 +59,7 @@ async function sendAdminNotification(data: {
|
|||
}
|
||||
|
||||
// Generate signed URL for image if provided
|
||||
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
|
||||
const signedImageUrl = imageUrl ? scaffoldAssetUrl(imageUrl) : null;
|
||||
|
||||
// Send notification
|
||||
const expo = new Expo();
|
||||
|
|
@ -84,16 +88,16 @@ async function sendAdminNotification(data: {
|
|||
}
|
||||
}
|
||||
|
||||
notificationWorker.on('completed', (job) => {
|
||||
if (job) console.log(`Notification job ${job.id} completed`);
|
||||
});
|
||||
notificationWorker.on('failed', (job, err) => {
|
||||
if (job) console.error(`Notification job ${job.id} failed:`, err);
|
||||
});
|
||||
// notificationWorker.on('completed', (job) => {
|
||||
// if (job) console.log(`Notification job ${job.id} completed`);
|
||||
// });
|
||||
// notificationWorker.on('failed', (job, err) => {
|
||||
// if (job) console.error(`Notification job ${job.id} failed:`, err);
|
||||
// });
|
||||
|
||||
export async function scheduleNotification(userId: number, payload: any, options?: { delay?: number; priority?: number }) {
|
||||
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
|
||||
|
|
@ -159,8 +163,8 @@ export async function sendRefundInitiatedNotification(userId: number, orderId?:
|
|||
orderId
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await notificationQueue.close();
|
||||
await notificationWorker.close();
|
||||
});
|
||||
//
|
||||
// process.on('SIGTERM', async () => {
|
||||
// await notificationQueue.close();
|
||||
// await notificationWorker.close();
|
||||
// });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { db } from "@/src/db/db_index"
|
||||
import { sendPushNotificationsMany } from "@/src/lib/expo-service"
|
||||
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
// Core notification dispatch methods (renamed for clarity)
|
||||
export async function dispatchBulkNotification({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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>();
|
||||
|
||||
|
|
@ -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 resp = await fetch(reqUrl, {
|
||||
headers: {
|
||||
authToken: otpSenderAuthToken,
|
||||
authToken: getOtpSenderAuthToken(),
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
|
@ -42,7 +42,7 @@ export async function verifyOtpUtil(mobile: string, otp: string, verifId: string
|
|||
const resp = await fetch(reqUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authToken: otpSenderAuthToken,
|
||||
authToken: getOtpSenderAuthToken(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,59 +1,54 @@
|
|||
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];
|
||||
// import Razorpay from "razorpay";
|
||||
|
||||
export class RazorpayPaymentService {
|
||||
private static instance = new Razorpay({
|
||||
key_id: razorpayId,
|
||||
key_secret: razorpaySecret,
|
||||
});
|
||||
|
||||
// private static instance = new Razorpay({
|
||||
// key_id: razorpayId,
|
||||
// key_secret: razorpaySecret,
|
||||
// });
|
||||
//
|
||||
static async createOrder(orderId: number, amount: string) {
|
||||
// Create Razorpay order
|
||||
const razorpayOrder = await this.instance.orders.create({
|
||||
amount: parseFloat(amount) * 100, // Convert to paisa
|
||||
currency: 'INR',
|
||||
receipt: `order_${orderId}`,
|
||||
notes: {
|
||||
customerOrderId: orderId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return razorpayOrder;
|
||||
// const razorpayOrder = await this.instance.orders.create({
|
||||
// amount: parseFloat(amount) * 100, // Convert to paisa
|
||||
// currency: 'INR',
|
||||
// receipt: `order_${orderId}`,
|
||||
// notes: {
|
||||
// customerOrderId: orderId.toString(),
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// return razorpayOrder;
|
||||
}
|
||||
|
||||
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) {
|
||||
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: unknown) {
|
||||
// Use transaction if provided, otherwise use db
|
||||
const dbInstance = tx || db;
|
||||
|
||||
// Insert payment record
|
||||
const [payment] = await dbInstance
|
||||
.insert(payments)
|
||||
.values({
|
||||
status: 'pending',
|
||||
gateway: 'razorpay',
|
||||
orderId,
|
||||
token: orderId.toString(),
|
||||
merchantOrderId: razorpayOrder.id,
|
||||
payload: razorpayOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return payment;
|
||||
// const dbInstance = tx || db;
|
||||
//
|
||||
// // Insert payment record
|
||||
// const [payment] = await dbInstance
|
||||
// .insert(payments)
|
||||
// .values({
|
||||
// status: 'pending',
|
||||
// gateway: 'razorpay',
|
||||
// orderId,
|
||||
// token: orderId.toString(),
|
||||
// merchantOrderId: razorpayOrder.id,
|
||||
// payload: razorpayOrder,
|
||||
// })
|
||||
// .returning();
|
||||
//
|
||||
// return payment;
|
||||
}
|
||||
|
||||
static async initiateRefund(paymentId: string, amount: number) {
|
||||
const refund = await this.instance.payments.refund(paymentId, {
|
||||
amount,
|
||||
});
|
||||
return refund;
|
||||
// const refund = await this.instance.payments.refund(paymentId, {
|
||||
// amount,
|
||||
// });
|
||||
// return refund;
|
||||
}
|
||||
|
||||
static async fetchRefund(refundId: string) {
|
||||
const refund = await this.instance.refunds.fetch(refundId);
|
||||
return refund;
|
||||
// const refund = await this.instance.refunds.fetch(refundId);
|
||||
// return refund;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { db } from '@/src/db/db_index'
|
||||
import { orders, orderStatus } from '@/src/db/schema'
|
||||
import redisClient from '@/src/lib/redis-client'
|
||||
import {
|
||||
getOrdersByIdsWithFullData,
|
||||
getOrderByIdWithFullData,
|
||||
} from '@/src/dbService'
|
||||
import { sendTelegramMessage } from '@/src/lib/telegram-service'
|
||||
import { inArray, eq } from 'drizzle-orm';
|
||||
|
||||
const ORDER_CHANNEL = 'orders:placed';
|
||||
const CANCELLED_CHANNEL = 'orders:cancelled';
|
||||
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||
import { ensureWorkerInit } from './worker-init';
|
||||
import { getAppUrl } from '@/src/lib/env-exporter'
|
||||
|
||||
interface OrderIdMessage {
|
||||
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 => {
|
||||
console.log('formatting the msg')
|
||||
let message = '🛒 <b>New Order Placed</b>\n\n';
|
||||
|
||||
ordersData.forEach((order, index) => {
|
||||
|
|
@ -35,7 +48,7 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
|||
|
||||
message += '📦 <b>Items:</b>\n';
|
||||
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`;
|
||||
|
|
@ -55,6 +68,8 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
|
|||
message += ` 📞 ${order.address.phone}\n`;
|
||||
}
|
||||
|
||||
message += `\n${buildTelegramLinks(order.id, order.userId)}\n`
|
||||
|
||||
if (index < ordersData.length - 1) {
|
||||
message += '\n---\n\n';
|
||||
}
|
||||
|
|
@ -79,115 +94,63 @@ ${orderData.orderItems?.map((item: any) => ` • ${item.product?.name || 'Unkno
|
|||
|
||||
❓ <b>Reason:</b> ${cancellationData.reason}
|
||||
👤 <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;
|
||||
};
|
||||
|
||||
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
|
||||
* 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> => {
|
||||
console.log('publishing order')
|
||||
try {
|
||||
const message = JSON.stringify(orderDetails);
|
||||
await redisClient.publish(ORDER_CHANNEL, message);
|
||||
await queueDataPusher.pushOrderPlacedQueue({
|
||||
name: 'order-placed',
|
||||
orderIds: orderDetails.orderIds,
|
||||
})
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to publish order:', error);
|
||||
|
|
@ -220,8 +183,11 @@ export const publishCancellation = async (
|
|||
reason,
|
||||
cancelledAt: new Date().toISOString(),
|
||||
};
|
||||
await redisClient.publish(CANCELLED_CHANNEL, JSON.stringify(message));
|
||||
console.log('Cancellation published to Redis:', orderId);
|
||||
await queueDataPusher.pushOrderCancelledQueue({
|
||||
name: 'order-cancelled',
|
||||
...message,
|
||||
})
|
||||
console.log('Cancellation published to queue:', orderId);
|
||||
return true;
|
||||
} catch (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 { redisUrl } from '@/src/lib/env-exporter'
|
||||
// import { createClient, RedisClientType } from 'redis';
|
||||
import { getRedisUrl } from '@/src/lib/env-exporter'
|
||||
|
||||
const createClient = (args:any) => {}
|
||||
class RedisClient {
|
||||
private client: RedisClientType;
|
||||
private subscriberClient: RedisClientType | null = null;
|
||||
private isConnected: boolean = false;
|
||||
// private client: RedisClientType;
|
||||
// private subscriberClient: RedisClientType | null = null;
|
||||
// private isConnected: boolean = false;
|
||||
//
|
||||
private client: any;
|
||||
private subscriberrlient: any;
|
||||
private isConnected: any = false;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.client = createClient({
|
||||
url: redisUrl,
|
||||
url: getRedisUrl(),
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
console.error('Redis Client Error:', err);
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('Redis Client Connected');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.client.on('disconnect', () => {
|
||||
console.log('Redis Client Disconnected');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('ready', () => {
|
||||
console.log('Redis Client Ready');
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
console.log('Redis Client Reconnecting');
|
||||
});
|
||||
// this.client.on('error', (err) => {
|
||||
// console.error('Redis Client Error:', err);
|
||||
// });
|
||||
//
|
||||
// this.client.on('connect', () => {
|
||||
// console.log('Redis Client Connected');
|
||||
// this.isConnected = true;
|
||||
// });
|
||||
//
|
||||
// this.client.on('disconnect', () => {
|
||||
// console.log('Redis Client Disconnected');
|
||||
// this.isConnected = false;
|
||||
// });
|
||||
//
|
||||
// this.client.on('ready', () => {
|
||||
// console.log('Redis Client Ready');
|
||||
// });
|
||||
//
|
||||
// this.client.on('reconnecting', () => {
|
||||
// console.log('Redis Client Reconnecting');
|
||||
// });
|
||||
|
||||
// Connect immediately (fire and forget)
|
||||
this.client.connect().catch((err) => {
|
||||
console.error('Failed to connect Redis:', err);
|
||||
});
|
||||
// this.client.connect().catch((err) => {
|
||||
// console.error('Failed to connect Redis:', err);
|
||||
// });
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
|
||||
|
|
@ -79,41 +85,41 @@ class RedisClient {
|
|||
|
||||
// Subscribe to a channel with callback
|
||||
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
||||
if (!this.subscriberClient) {
|
||||
this.subscriberClient = createClient({
|
||||
url: redisUrl,
|
||||
});
|
||||
|
||||
this.subscriberClient.on('error', (err) => {
|
||||
console.error('Redis Subscriber Error:', err);
|
||||
});
|
||||
|
||||
this.subscriberClient.on('connect', () => {
|
||||
console.log('Redis Subscriber Connected');
|
||||
});
|
||||
|
||||
await this.subscriberClient.connect();
|
||||
}
|
||||
|
||||
await this.subscriberClient.subscribe(channel, callback);
|
||||
// if (!this.subscriberClient) {
|
||||
// this.subscriberClient = createClient({
|
||||
// url: redisUrl,
|
||||
// });
|
||||
//
|
||||
// this.subscriberClient.on('error', (err) => {
|
||||
// console.error('Redis Subscriber Error:', err);
|
||||
// });
|
||||
//
|
||||
// this.subscriberClient.on('connect', () => {
|
||||
// console.log('Redis Subscriber Connected');
|
||||
// });
|
||||
//
|
||||
// await this.subscriberClient.connect();
|
||||
// }
|
||||
//
|
||||
// await this.subscriberClient.subscribe(channel, callback);
|
||||
console.log(`Subscribed to channel: ${channel}`);
|
||||
}
|
||||
|
||||
// Unsubscribe from a channel
|
||||
async unsubscribe(channel: string): Promise<void> {
|
||||
if (this.subscriberClient) {
|
||||
await this.subscriberClient.unsubscribe(channel);
|
||||
console.log(`Unsubscribed from channel: ${channel}`);
|
||||
}
|
||||
// if (this.subscriberClient) {
|
||||
// await this.subscriberClient.unsubscribe(channel);
|
||||
// console.log(`Unsubscribed from channel: ${channel}`);
|
||||
// }
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.isConnected) {
|
||||
this.client.disconnect();
|
||||
}
|
||||
if (this.subscriberClient) {
|
||||
this.subscriberClient.disconnect();
|
||||
}
|
||||
// if (this.isConnected) {
|
||||
// this.client.disconnect();
|
||||
// }
|
||||
// if (this.subscriberClient) {
|
||||
// this.subscriberClient.disconnect();
|
||||
// }
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
240
apps/backend/src/lib/s3-client.ts
Executable file → Normal file
240
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 { 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"
|
||||
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter"
|
||||
import { db } from "@/src/db/db_index"; // Adjust path if needed
|
||||
import { uploadUrlStatus } from "@/src/db/schema"
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Buffer } from 'buffer'
|
||||
import { AwsClient } from 'aws4fetch'
|
||||
import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService'
|
||||
import {
|
||||
getS3AccessKeyId,
|
||||
getS3Region,
|
||||
getS3Url,
|
||||
getS3SecretAccessKey,
|
||||
getS3BucketName,
|
||||
getAssetsDomain,
|
||||
} from '@/src/lib/env-exporter'
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: s3Region,
|
||||
endpoint: s3Url,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: s3AccessKeyId,
|
||||
secretAccessKey: s3SecretAccessKey,
|
||||
},
|
||||
})
|
||||
export default s3Client;
|
||||
let awsClient: AwsClient | null = null
|
||||
let awsClientKey = ''
|
||||
|
||||
export const imageUploadS3 = async(body: Buffer<ArrayBufferLike>, type: string, key:string) => {
|
||||
// const key = `${category}/${Date.now()}`
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: s3BucketName,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: type,
|
||||
const getAwsClient = () => {
|
||||
const region = getS3Region()
|
||||
const endpoint = getS3Url()
|
||||
const accessKeyId = getS3AccessKeyId()
|
||||
const secretAccessKey = getS3SecretAccessKey()
|
||||
const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}`
|
||||
|
||||
if (!awsClient || nextKey !== awsClientKey) {
|
||||
awsClientKey = nextKey
|
||||
awsClient = new AwsClient({
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
region,
|
||||
service: 's3',
|
||||
})
|
||||
const resp = await s3Client.send(command)
|
||||
}
|
||||
|
||||
const imageUrl = `${key}`
|
||||
return imageUrl;
|
||||
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({bucket = s3BucketName, keys}:{bucket?:string, keys: string[]}) {
|
||||
|
||||
export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?: string, keys: string[]}) {
|
||||
if (keys.length === 0) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const deleteParams = {
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: keys.map((key) => ({ Key: key })),
|
||||
Quiet: false,
|
||||
const client = getAwsClient()
|
||||
await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const url = buildObjectUrl(bucket, key)
|
||||
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
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error deleting image:", error)
|
||||
throw new Error("Failed to delete image")
|
||||
return false;
|
||||
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);
|
||||
return input.map(key => scaffoldAssetUrl(key) as string)
|
||||
}
|
||||
if (!input) {
|
||||
return '';
|
||||
return ''
|
||||
}
|
||||
const normalizedKey = input.replace(/^\/+/, '');
|
||||
const normalizedKey = input.replace(/^\/+/, '')
|
||||
const domain = assetsDomain.endsWith('/')
|
||||
? assetsDomain.slice(0, -1)
|
||||
: assetsDomain;
|
||||
return `${domain}/${normalizedKey}`;
|
||||
: 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)
|
||||
|
|
@ -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> {
|
||||
if (!s3UrlRaw) {
|
||||
return '';
|
||||
return ''
|
||||
}
|
||||
|
||||
const s3Url = s3UrlRaw
|
||||
|
||||
try {
|
||||
// Check if we have a cached signed URL
|
||||
const cachedUrl = signedUrlCache.get(s3Url);
|
||||
if (cachedUrl) {
|
||||
// Found in cache, return it
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
// Create the command to get the object
|
||||
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;
|
||||
const client = getAwsClient()
|
||||
const url = buildObjectUrl(getS3BucketName(), s3Url)
|
||||
const signedRequest = await client.sign(url, {
|
||||
method: 'GET',
|
||||
signQuery: true,
|
||||
expires: expiresIn,
|
||||
})
|
||||
return signedRequest.url
|
||||
} catch (error) {
|
||||
console.error("Error generating signed URL:", error);
|
||||
throw new Error("Failed to generate signed URL");
|
||||
console.error('Error generating signed URL:', error)
|
||||
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
|
||||
*/
|
||||
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
|
||||
if (!signedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find the original URL in our cache
|
||||
const originalUrl = signedUrlCache.getOriginalUrl(signedUrl);
|
||||
|
||||
return originalUrl || 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -141,47 +150,43 @@ export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null
|
|||
*/
|
||||
export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> {
|
||||
if (!s3Urls || !s3Urls.length) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
// Process URLs in parallel for better performance
|
||||
const signedUrls = await Promise.all(
|
||||
s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => ''))
|
||||
);
|
||||
)
|
||||
|
||||
return signedUrls;
|
||||
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(() => '');
|
||||
console.error('Error generating multiple signed URLs:', error)
|
||||
return s3Urls.map(() => '')
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
|
||||
try {
|
||||
// Insert record into upload_url_status
|
||||
await db.insert(uploadUrlStatus).values({
|
||||
key: key,
|
||||
status: 'pending',
|
||||
});
|
||||
await createUploadUrlStatus(key)
|
||||
|
||||
// Generate signed upload URL
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: s3BucketName,
|
||||
Key: key,
|
||||
ContentType: mimeType,
|
||||
});
|
||||
const client = getAwsClient()
|
||||
const url = buildObjectUrl(getS3BucketName(), key)
|
||||
const signedRequest = await client.sign(url, {
|
||||
method: 'PUT',
|
||||
signQuery: true,
|
||||
expires: expiresIn,
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
})
|
||||
|
||||
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
|
||||
return signedUrl;
|
||||
return signedRequest.url
|
||||
} catch (error) {
|
||||
console.error('Error generating upload URL:', error);
|
||||
throw new Error('Failed to generate upload URL');
|
||||
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
|
||||
|
|
@ -190,32 +195,27 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
|||
|
||||
// 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);
|
||||
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('/');
|
||||
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);
|
||||
const key = s3BucketName+'/'+ semiKey
|
||||
const semiKey = extractKeyFromPresignedUrl(url)
|
||||
|
||||
// Update status to 'claimed' if currently 'pending'
|
||||
const result = await db
|
||||
.update(uploadUrlStatus)
|
||||
.set({ status: 'claimed' })
|
||||
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
|
||||
.returning();
|
||||
const updated = await claimUploadUrlStatus(semiKey)
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('Upload URL not found or already claimed');
|
||||
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');
|
||||
console.error('Error claiming upload URL:', error)
|
||||
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';
|
||||
import path from 'path';
|
||||
// SIGNED URL CACHE - DISABLED
|
||||
// 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');
|
||||
|
||||
// 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;
|
||||
export default {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
getOriginalUrl: () => undefined,
|
||||
has: () => false,
|
||||
hasSignedUrl: () => false,
|
||||
clear: () => {},
|
||||
clearExpired: () => 0,
|
||||
saveToDisk: () => {},
|
||||
loadFromDisk: () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import axios from 'axios';
|
||||
import { isDevMode, telegramBotToken, telegramChatIds } from '@/src/lib/env-exporter'
|
||||
|
||||
const BOT_TOKEN = telegramBotToken;
|
||||
const CHAT_IDS = telegramChatIds;
|
||||
const TELEGRAM_API_URL = `https://api.telegram.org/bot${BOT_TOKEN}`;
|
||||
import { getIsDevMode, getTelegramBotToken, getTelegramChatIds } from '@/src/lib/env-exporter'
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const sendTelegramMessage = async (message: string): Promise<boolean | null> => {
|
||||
if (isDevMode) {
|
||||
if (getIsDevMode()) {
|
||||
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 {
|
||||
const results = await Promise.all(
|
||||
CHAT_IDS.map(async (chatId) => {
|
||||
chatIds.map(async (chatId) => {
|
||||
try {
|
||||
const response = await axios.post(`${TELEGRAM_API_URL}/sendMessage`, {
|
||||
const response = await axios.post(`${telegramApiUrl}/sendMessage`, {
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
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