Compare commits

...
Sign in to create a new pull request.

47 commits

Author SHA1 Message Date
shafi54
4199ff7d9b enh 2026-04-27 21:21:11 +05:30
shafi54
8ea26f5705 enh 2026-04-12 16:48:50 +05:30
shafi54
dc21636b3f enh 2026-04-11 15:39:54 +05:30
shafi54
55bfd1aafa enh 2026-04-11 12:04:27 +05:30
shafi54
b27e05aab0 enh 2026-04-09 00:51:10 +05:30
shafi54
6b4f512d90 enh 2026-04-08 23:31:55 +05:30
shafi54
1f42cfbc5e enh 2026-04-03 21:00:02 +05:30
shafi54
982d3027f8 enh 2026-04-02 00:52:07 +05:30
shafi54
15991f46db enh 2026-03-30 21:59:23 +05:30
shafi54
b86fa8a2e0 enh 2026-03-29 12:12:51 +05:30
shafi54
7432f8dfd5 enh 2026-03-27 18:47:12 +05:30
shafi54
18f36107d8 enh 2026-03-27 01:59:26 +05:30
shafi54
1b042819af enh 2026-03-27 00:34:32 +05:30
shafi54
639428caba enh 2026-03-26 18:59:58 +05:30
shafi54
68103010c6 enh 2026-03-26 17:42:49 +05:30
shafi54
128e3b6a58 enh 2026-03-26 17:36:36 +05:30
shafi54
ca7d8df1c8 enh 2026-03-26 17:16:56 +05:30
shafi54
5e9bc3e38e enh 2026-03-26 13:45:24 +05:30
shafi54
89de986764 enh 2026-03-26 12:07:49 +05:30
shafi54
9137b5e1e6 enh 2026-03-26 00:49:47 +05:30
shafi54
fe05769343 enh 2026-03-26 00:34:31 +05:30
shafi54
4414f9f64b enh 2026-03-25 19:58:40 +05:30
shafi54
3c836e274d enh 2026-03-25 19:30:01 +05:30
shafi54
306244e8df enh 2026-03-25 18:11:46 +05:30
shafi54
038733c14a enh 2026-03-25 09:39:53 +05:30
shafi54
d9652405ca enh 2026-03-25 01:43:02 +05:30
shafi54
97812fa4c5 enh 2026-03-24 20:50:14 +05:30
shafi54
44e53d2978 enh 2026-03-16 22:15:47 +05:30
shafi54
a5bde12f19 enh 2026-03-16 21:18:14 +05:30
shafi54
31029cc3a7 enh 2026-03-16 21:15:07 +05:30
shafi54
a4758ea9cd enh 2026-03-16 21:14:23 +05:30
shafi54
0c84808637 enh 2026-03-16 19:55:06 +05:30
shafi54
f2763b0597 enh 2026-03-16 18:20:40 +05:30
shafi54
8f48ec39c2 enh 2026-03-16 18:10:28 +05:30
shafi54
5d598b0752 enh 2026-03-15 23:23:44 +05:30
shafi54
4aab508286 enh 2026-03-15 23:23:33 +05:30
shafi54
ad2447d14e enh 2026-03-15 22:10:52 +05:30
shafi54
b4caa383b5 enh 2026-03-15 21:26:00 +05:30
shafi54
a7350914e0 enh 2026-03-15 21:11:54 +05:30
76c43d869d Merge pull request 'enh' (#3) from main into api_cache
Reviewed-on: #3
2026-03-14 12:31:48 +00:00
shafi54
2d37726c62 enh 2026-03-14 17:25:41 +05:30
shafi54
5df040de9a enh 2026-03-12 19:26:21 +05:30
shafi54
ca9eb8a7d2 enh 2026-03-11 16:31:23 +05:30
shafi54
aa900db3e1 enh 2026-03-10 14:20:21 +05:30
shafi54
f7c55ea492 enh 2026-03-10 14:20:14 +05:30
shafi54
c14e32522a enh 2026-03-10 13:05:33 +05:30
shafi54
a4218ee1ad enh 2026-03-10 10:03:49 +05:30
531 changed files with 545654 additions and 37390 deletions

8
.dockerignore Normal file
View 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
View file

@ -8,6 +8,7 @@ yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
**/.wrangler/*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View file

@ -1,7 +1,6 @@
# Agent Instructions for Meat Farmer Monorepo # Agent Instructions for Meat Farmer Monorepo
## Important instructions ## Important instructions
- Don't try to build the code or run or compile it. Just make changes and leave the rest for the user.
- Don't run any drizzle migrations. User will handle it. - Don't run any drizzle migrations. User will handle it.
## Code Style Guidelines ## Code Style Guidelines
@ -48,6 +47,4 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
- Database: Drizzle ORM with PostgreSQL - Database: Drizzle ORM with PostgreSQL
## Important Notes ## Important Notes
- **Do not run build, compile, or migration commands** - These should be handled manually by developers
- Avoid running `npm run build`, `tsc`, `drizzle-kit generate`, or similar compilation/migration commands
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user - Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user

4
APIS_TO_REMOVE.md Normal file
View 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

View file

@ -1,32 +1,36 @@
# Optimized Dockerfile for backend and fallback-ui services (project root) # Optimized Dockerfile for backend and fallback-ui services (project root)
# 1. ---- Base Node image # 1. ---- Base Bun image
FROM node:20-slim AS base FROM oven/bun:1.3.10 AS base
WORKDIR /app WORKDIR /app
# 2. ---- Pruner ---- # 2. ---- Pruner ----
FROM base AS pruner FROM base AS pruner
WORKDIR /app WORKDIR /app
# Copy config files first for better caching # Copy config files first for better caching
COPY package.json package-lock.json turbo.json ./ COPY package.json turbo.json ./
COPY apps/backend/package.json ./apps/backend/ COPY apps/backend/package.json ./apps/backend/
COPY apps/fallback-ui/package.json ./apps/fallback-ui/ COPY apps/fallback-ui/package.json ./apps/fallback-ui/
COPY packages/shared/ ./packages/shared
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
RUN npm install -g turbo RUN bun install -g turbo
COPY . . COPY . .
RUN turbo prune --scope=backend --scope=fallback-ui --scope=common-ui --docker RUN turbo prune --scope=backend --scope=fallback-ui --scope=@packages/shared --docker
# RUN find . -path "./node_modules" -prune -o -print
# 3. ---- Builder ---- # 3. ---- Builder ----
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
# Copy package files first to cache npm install # Copy package files first to cache bun install
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/package-lock.json ./package-lock.json #COPY --from=pruner /app/out/bun.lock ./bun.lock
#RUN cat ./bun.lock
COPY --from=pruner /app/turbo.json . COPY --from=pruner /app/turbo.json .
RUN npm ci RUN bun install
# Copy source code after dependencies are installed # Copy source code after dependencies are installed
COPY --from=pruner /app/out/full/ . COPY --from=pruner /app/out/full/ .
RUN npx turbo run build --filter=fallback-ui... --filter=backend... RUN bunx turbo run build --filter=fallback-ui... --filter=backend...
RUN find . -path "./node_modules" -prune -o -print
# 4. ---- Runner ---- # 4. ---- Runner ----
FROM base AS runner FROM base AS runner
@ -34,12 +38,15 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Copy package files and install production deps # Copy package files and install production deps
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/package-lock.json ./package-lock.json #COPY --from=pruner /app/out/bun.lock ./bun.lock
RUN npm ci --production --omit=dev RUN bun install --production
# Copy built applications # Copy built applications
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist COPY --from=builder /app/apps/fallback-ui/dist ./apps/fallback-ui/dist
COPY --from=builder /app/packages/shared ./packages/shared
# RUN ls -R
RUN find . -path "./node_modules" -prune -o -print
EXPOSE 4000 EXPOSE 4000
RUN npm i -g bun CMD ["bun", "apps/backend/dist/apps/backend/index.js"]
CMD ["bun", "apps/backend/dist/index.js"]
# CMD ["node", "apps/backend/dist/index.js"]

83
apps/admin-ui/.detoxrc.js Normal file
View 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'
}
}
};

File diff suppressed because one or more lines are too long

View file

@ -63,7 +63,21 @@
"backgroundColor": "#fff0f6" "backgroundColor": "#fff0f6"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"package": "in.freshyo.adminui" "package": "in.freshyo.adminui",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "ui.freshyo.in",
"pathPrefix": "/manage-orders/order-details"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",

View file

@ -226,9 +226,8 @@ export default function Layout() {
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} /> <Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
<Drawer.Screen name="slots" options={{ title: "Slots" }} /> <Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} /> <Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} /> <Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} /> <Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} /> <Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
<Drawer.Screen name="user-management" options={{ title: "User Management" }} /> <Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} /> <Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />

View file

@ -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

View file

@ -9,6 +9,20 @@ export default function CreateCoupon() {
const router = useRouter(); const router = useRouter();
const createCoupon = trpc.admin.coupon.create.useMutation(); const createCoupon = trpc.admin.coupon.create.useMutation();
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation(); const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const handleCreateCoupon = (values: any) => { const handleCreateCoupon = (values: any) => {
console.log('Form values:', values); // Debug log console.log('Form values:', values); // Debug log
@ -27,7 +41,9 @@ export default function CreateCoupon() {
if (isLoading) return; // Prevent double submission if (isLoading) return; // Prevent double submission
mutation.mutate(payload, { mutation.mutate(payload, {
onSuccess: () => { onSuccess: async () => {
await refetchCoupons()
await refetchReservedCoupons()
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [ Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
{ text: 'OK', onPress: () => router.back() } { text: 'OK', onPress: () => router.back() }
]); ]);
@ -46,4 +62,4 @@ export default function CreateCoupon() {
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -12,7 +12,21 @@ export default function EditCoupon() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const couponId = parseInt(id as string); const couponId = parseInt(id as string);
const { data: coupon, isLoading } = trpc.admin.coupon.getById.useQuery({ id: couponId }); const { data: coupon, isLoading, refetch } = trpc.admin.coupon.getById.useQuery({ id: couponId });
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const updateCoupon = trpc.admin.coupon.update.useMutation(); const updateCoupon = trpc.admin.coupon.update.useMutation();
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => { const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
@ -24,7 +38,10 @@ export default function EditCoupon() {
delete updates.targetUsers; delete updates.targetUsers;
updateCoupon.mutate({ id: couponId, updates }, { updateCoupon.mutate({ id: couponId, updates }, {
onSuccess: () => { onSuccess: async () => {
await refetch()
await refetchCoupons()
await refetchReservedCoupons()
Alert.alert('Success', 'Coupon updated successfully', [ Alert.alert('Success', 'Coupon updated successfully', [
{ text: 'OK', onPress: () => router.back() } { text: 'OK', onPress: () => router.back() }
]); ]);
@ -80,4 +97,4 @@ export default function EditCoupon() {
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -6,14 +6,7 @@ import { trpc } from '../../../src/trpc-client';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface ConstantFormData { type ConstantFormData = Record<string, any>
constants: ConstantItem[];
}
interface ConstantItem {
key: string;
value: any;
}
const CONST_LABELS: Record<string, string> = { const CONST_LABELS: Record<string, string> = {
minRegularOrderValue: 'Minimum Regular Order Value', minRegularOrderValue: 'Minimum Regular Order Value',
@ -37,23 +30,45 @@ const CONST_LABELS: Record<string, string> = {
supportEmail: 'Support Email', supportEmail: 'Support Email',
}; };
const CONST_VISIBILITY: Record<string, boolean> = {
minRegularOrderValue: true,
freeDeliveryThreshold: true,
deliveryCharge: true,
flashFreeDeliveryThreshold: true,
flashDeliveryCharge: true,
platformFeePercent: true,
taxRate: false,
minOrderAmountForCoupon: true,
maxCouponDiscount: false,
flashDeliverySlotId: true,
readableOrderId: false,
versionNum: true,
playStoreUrl: true,
appStoreUrl: true,
popularItems: true,
allItemsOrder: true,
isFlashDeliveryEnabled: true,
supportMobile: true,
supportEmail: true,
tester: false,
};
interface ConstantInputProps { interface ConstantInputProps {
constant: ConstantItem; constantKey: string;
value: any;
setFieldValue: (field: string, value: any) => void; setFieldValue: (field: string, value: any) => void;
index: number;
router: any; router: any;
} }
const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue, index, router }) => { const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
const fieldName = `constants.${index}.value`; const fieldName = constantKey
// Special handling for popularItems - show navigation button instead of input // Special handling for popularItems - show navigation button instead of input
if (constant.key === 'popularItems') { if (constantKey === 'popularItems') {
console.log('key is allItemsOrder')
return ( return (
<View> <View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}> <MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constant.key] || constant.key} {CONST_LABELS[constantKey] || constantKey}
</MyText> </MyText>
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => router.push('/(drawer)/customize-app/popular-items')} onPress={() => router.push('/(drawer)/customize-app/popular-items')}
@ -61,7 +76,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
> >
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} /> <MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
<MyText style={tw`text-blue-700 font-medium`}> <MyText style={tw`text-blue-700 font-medium`}>
Manage Popular Items ({Array.isArray(constant.value) ? constant.value.length : 0} items) Manage Popular Items ({Array.isArray(value) ? value.length : 0} items)
</MyText> </MyText>
<MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} /> <MaterialIcons name="chevron-right" size={20} color="#3b82f6" style={tw`ml-2`} />
</MyTouchableOpacity> </MyTouchableOpacity>
@ -70,12 +85,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
} }
// Special handling for allItemsOrder - show navigation button instead of input // Special handling for allItemsOrder - show navigation button instead of input
if (constant.key === 'allItemsOrder') { if (constantKey === 'allItemsOrder') {
return ( return (
<View> <View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}> <MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constant.key] || constant.key} {CONST_LABELS[constantKey] || constantKey}
</MyText> </MyText>
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => router.push('/(drawer)/customize-app/all-items-order')} onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
@ -83,7 +98,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
> >
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} /> <MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
<MyText style={tw`text-green-700 font-medium`}> <MyText style={tw`text-green-700 font-medium`}>
Manage All Visible Items ({Array.isArray(constant.value) ? constant.value.length : 0} items) Manage All Visible Items ({Array.isArray(value) ? value.length : 0} items)
</MyText> </MyText>
<MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} /> <MaterialIcons name="chevron-right" size={20} color="#16a34a" style={tw`ml-2`} />
</MyTouchableOpacity> </MyTouchableOpacity>
@ -92,20 +107,20 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
} }
// Handle boolean values - show checkbox // Handle boolean values - show checkbox
if (typeof constant.value === 'boolean') { if (typeof value === 'boolean') {
return ( return (
<View> <View>
<MyText style={tw`text-sm font-medium text-gray-700 mb-2`}> <MyText style={tw`text-sm font-medium text-gray-700 mb-2`}>
{CONST_LABELS[constant.key] || constant.key} {CONST_LABELS[constantKey] || constantKey}
</MyText> </MyText>
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
<Checkbox <Checkbox
checked={constant.value} checked={value}
onPress={() => setFieldValue(fieldName, !constant.value)} onPress={() => setFieldValue(fieldName, !value)}
size={28} size={28}
/> />
<MyText style={tw`ml-3 text-gray-700`}> <MyText style={tw`ml-3 text-gray-700`}>
{constant.value ? 'Enabled' : 'Disabled'} {value ? 'Enabled' : 'Disabled'}
</MyText> </MyText>
</View> </View>
</View> </View>
@ -113,11 +128,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
} }
// Handle different value types // Handle different value types
if (typeof constant.value === 'number') { if (typeof value === 'number') {
return ( return (
<MyTextInput <MyTextInput
topLabel={CONST_LABELS[constant.key] || constant.key} topLabel={CONST_LABELS[constantKey] || constantKey}
value={constant.value.toString()} value={value.toString()}
onChangeText={(value) => { onChangeText={(value) => {
const numValue = parseFloat(value); const numValue = parseFloat(value);
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue); setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue);
@ -128,11 +143,11 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
); );
} }
if (Array.isArray(constant.value)) { if (Array.isArray(value)) {
return ( return (
<MyTextInput <MyTextInput
topLabel={CONST_LABELS[constant.key] || constant.key} topLabel={CONST_LABELS[constantKey] || constantKey}
value={constant.value.join(', ')} value={value.join(', ')}
onChangeText={(value) => { onChangeText={(value) => {
const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0); const arrayValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
setFieldValue(fieldName, arrayValue); setFieldValue(fieldName, arrayValue);
@ -145,9 +160,12 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constant, setFieldValue,
// Default to string // Default to string
return ( return (
<MyTextInput <MyTextInput
topLabel={CONST_LABELS[constant.key] || constant.key} topLabel={CONST_LABELS[constantKey] || constantKey}
value={String(constant.value)} // value={value === null || value === undefined ? '' : String(value)}
onChangeText={(value) => setFieldValue(fieldName, value)} value={value}
onChangeText={(value) => {
setFieldValue(fieldName, value)
}}
placeholder="Enter value" placeholder="Enter value"
/> />
); );
@ -161,10 +179,13 @@ export default function CustomizeApp() {
const handleSubmit = (values: ConstantFormData) => { const handleSubmit = (values: ConstantFormData) => {
// Filter out constants that haven't changed // Filter out constants that haven't changed
const changedConstants = values.constants.filter((constant, index) => { const changedConstants = (constants || []).filter((constant) => {
const original = constants?.[index]; const nextValue = values[constant.key]
return original && JSON.stringify(constant.value) !== JSON.stringify(original.value); return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
}); }).map((constant) => ({
key: constant.key,
value: values[constant.key],
}))
if (changedConstants.length === 0) { if (changedConstants.length === 0) {
Alert.alert('No Changes', 'No constants were modified.'); Alert.alert('No Changes', 'No constants were modified.');
@ -202,9 +223,10 @@ export default function CustomizeApp() {
); );
} }
const initialValues: ConstantFormData = { const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
constants: constants.map(c => ({ key: c.key, value: c.value ?? '' } as ConstantItem)), acc[constant.key] = constant.value ?? ''
}; return acc
}, {} as ConstantFormData)
@ -219,11 +241,22 @@ export default function CustomizeApp() {
<Formik initialValues={initialValues} onSubmit={handleSubmit}> <Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ handleSubmit, values, setFieldValue }) => ( {({ handleSubmit, values, setFieldValue }) => (
<View> <View>
{values.constants.map((constant, index) => ( {constants.map((constant) => {
<View key={constant.key} style={tw`mb-4`}> if (!CONST_VISIBILITY[constant.key]) {
<ConstantInput constant={constant} setFieldValue={setFieldValue} index={index} router={router} /> return null
</View> }
))}
return (
<View key={constant.key} style={tw`mb-4`}>
<ConstantInput
constantKey={constant.key}
value={values[constant.key]}
setFieldValue={setFieldValue}
router={router}
/>
</View>
)
})}
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => handleSubmit()} onPress={() => handleSubmit()}
@ -240,4 +273,4 @@ export default function CustomizeApp() {
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -21,6 +21,9 @@ export default function CreateBanner() {
}; };
const createBannerMutation = trpc.admin.banner.createBanner.useMutation(); const createBannerMutation = trpc.admin.banner.createBanner.useMutation();
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
});
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => { const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
if (!imageUrl) { if (!imageUrl) {
@ -39,6 +42,7 @@ export default function CreateBanner() {
redirectUrl: values.redirectUrl || undefined, redirectUrl: values.redirectUrl || undefined,
}); });
await refetchBanners()
Alert.alert('Success', 'Banner created successfully', [ Alert.alert('Success', 'Banner created successfully', [
{ {
text: 'OK', text: 'OK',
@ -79,4 +83,4 @@ export default function CreateBanner() {
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -31,6 +31,9 @@ export default function EditBanner() {
const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({ const {data: bannerData } = trpc.admin.banner.getBanner.useQuery({
id: parseInt(bannerId) id: parseInt(bannerId)
}); });
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
enabled: false,
});
const [banner, setBanner] = useState<typeof bannerData>(undefined); const [banner, setBanner] = useState<typeof bannerData>(undefined);
@ -100,6 +103,7 @@ export default function EditBanner() {
redirectUrl: values.redirectUrl || undefined, redirectUrl: values.redirectUrl || undefined,
}); });
await refetchBanners()
Alert.alert('Success', 'Banner updated successfully', [ Alert.alert('Success', 'Banner updated successfully', [
{ {
text: 'OK', text: 'OK',
@ -160,4 +164,4 @@ export default function EditBanner() {
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -464,4 +464,4 @@ export default function DashboardBanners() {
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -74,7 +74,7 @@ export default function Dashboard() {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
title: 'Manage Orders', title: 'Manage Orderss',
icon: 'shopping-bag', icon: 'shopping-bag',
description: 'View and manage customer orders', description: 'View and manage customer orders',
route: '/(drawer)/manage-orders', route: '/(drawer)/manage-orders',
@ -176,15 +176,6 @@ export default function Dashboard() {
iconColor: '#F97316', iconColor: '#F97316',
iconBg: '#FFEDD5', iconBg: '#FFEDD5',
}, },
{
title: 'Address Management',
icon: 'location-on',
description: 'Manage service areas',
route: '/(drawer)/address-management',
category: 'settings',
iconColor: '#EAB308',
iconBg: '#FEF9C3',
},
{ {
title: 'App Constants', title: 'App Constants',
icon: 'settings-applications', icon: 'settings-applications',
@ -294,4 +285,4 @@ export default function Dashboard() {
</ScrollView> </ScrollView>
</View> </View>
); );
} }

View file

@ -63,7 +63,8 @@ export default function OrderDetails() {
onSuccess: (result) => { onSuccess: (result) => {
Alert.alert( Alert.alert(
"Success", "Success",
`Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}` `Refund initiated successfully!\n\nAmount: `
// `Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
); );
setInitiateRefundDialogOpen(false); setInitiateRefundDialogOpen(false);
}, },

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { View, Alert } from 'react-native'; import { View, Alert } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui'; import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm'; import TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData { interface TagFormData {
tagName: string; tagName: string;
@ -15,50 +15,51 @@ interface TagFormData {
export default function AddTag() { export default function AddTag() {
const router = useRouter(); const router = useRouter();
const { mutate: createTag, isPending: isCreating } = useCreateTag(); const createTag = trpc.admin.product.createProductTag.useMutation();
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const { data: storesData } = trpc.admin.store.getStores.useQuery(); const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => { const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
const formData = new FormData(); try {
let imageUrl: string | null | undefined;
let uploadUrls: string[] = []
// Add text fields const newImage = images.find((image) => image.mimeType !== null)
formData.append('tagName', values.tagName); if (newImage) {
if (values.tagDescription) { const response = await fetch(newImage.imgUrl)
formData.append('tagDescription', values.tagDescription); const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
}
await createTag.mutateAsync({
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
uploadUrls,
})
await refetchTags()
Alert.alert('Success', 'Tag created successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
])
} catch (error: any) {
const errorMessage = error.message || 'Failed to create tag'
Alert.alert('Error', errorMessage)
} }
formData.append('isDashboardTag', values.isDashboardTag.toString()); }
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
createTag(formData, {
onSuccess: (data) => {
Alert.alert('Success', 'Tag created successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to create tag';
Alert.alert('Error', errorMessage);
},
});
};
const initialValues: TagFormData = { const initialValues: TagFormData = {
tagName: '', tagName: '',
@ -76,10 +77,10 @@ export default function AddTag() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isCreating} isLoading={createTag.isPending || isUploading}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { View, Alert } from 'react-native'; import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui'; import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm'; import TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData { interface TagFormData {
tagName: string; tagName: string;
@ -19,53 +19,60 @@ export default function EditTag() {
const { tagId } = useLocalSearchParams<{ tagId: string }>(); const { tagId } = useLocalSearchParams<{ tagId: string }>();
const tagIdNum = tagId ? parseInt(tagId) : null; const tagIdNum = tagId ? parseInt(tagId) : null;
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!); const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag(); { id: tagIdNum || 0 },
{ enabled: !!tagIdNum }
)
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const updateTag = trpc.admin.product.updateProductTag.useMutation();
const { data: storesData } = trpc.admin.store.getStores.useQuery(); const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => { const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
if (!tagIdNum) return; if (!tagIdNum) return;
const formData = new FormData(); try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
// Add text fields const newImage = images.find((image) => image.mimeType !== null)
formData.append('tagName', values.tagName); if (newImage) {
if (values.tagDescription) { const response = await fetch(newImage.imgUrl)
formData.append('tagDescription', values.tagDescription); const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
} else if (removedExisting) {
imageUrl = null
}
await updateTag.mutateAsync({
id: tagIdNum,
tagName: values.tagName,
tagDescription: values.tagDescription || undefined,
imageUrl,
isDashboardTag: values.isDashboardTag,
relatedStores: values.relatedStores,
uploadUrls,
})
await refetchTags()
Alert.alert('Success', 'Tag updated successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
])
} catch (error: any) {
const errorMessage = error.message || 'Failed to update tag'
Alert.alert('Error', errorMessage)
} }
formData.append('isDashboardTag', values.isDashboardTag.toString()); }
// Add related stores
formData.append('relatedStores', JSON.stringify(values.relatedStores));
// Add image if uploaded
if (image?.uri) {
const filename = image.uri.split('/').pop() || 'image.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : 'image/jpeg';
formData.append('image', {
uri: image.uri,
name: filename,
type,
} as any);
}
updateTag({ id: tagIdNum, formData }, {
onSuccess: (data) => {
Alert.alert('Success', 'Tag updated successfully', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
},
onError: (error: any) => {
const errorMessage = error.message || 'Failed to update tag';
Alert.alert('Error', errorMessage);
},
});
};
if (isLoadingTag) { if (isLoadingTag) {
return ( return (
@ -92,7 +99,7 @@ export default function EditTag() {
tagName: tag.tagName, tagName: tag.tagName,
tagDescription: tag.tagDescription || '', tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag, isDashboardTag: tag.isDashboardTag,
relatedStores: tag.relatedStores || [], relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
existingImageUrl: tag.imageUrl || undefined, existingImageUrl: tag.imageUrl || undefined,
}; };
@ -106,10 +113,10 @@ export default function EditTag() {
initialValues={initialValues} initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined} existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isUpdating} isLoading={updateTag.isPending || isUploading}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []} stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/> />
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -5,10 +5,20 @@ import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui'; import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { TagMenu } from '@/src/components/TagMenu'; import { TagMenu } from '@/src/components/TagMenu';
import { useGetTags, Tag } from '@/src/api-hooks/tag.api'; import { trpc } from '@/src/trpc-client';
interface TagItemData {
id: number;
tagName: string;
tagDescription: string | null;
imageUrl: string | null;
isDashboardTag: boolean;
relatedStores?: unknown;
createdAt: string | Date;
}
interface TagItemProps { interface TagItemProps {
item: Tag; item: TagItemData;
onDeleteSuccess: () => void; onDeleteSuccess: () => void;
} }
@ -60,7 +70,7 @@ const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
export default function ProductTags() { export default function ProductTags() {
const router = useRouter(); const router = useRouter();
const { data: tagsData, isLoading, error, refetch } = useGetTags(); const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const tags = tagsData?.tags || []; const tags = tagsData?.tags || [];
@ -129,4 +139,4 @@ export default function ProductTags() {
/> />
</View> </View>
); );
} }

View file

@ -1,62 +1,56 @@
import React from 'react'; import React from 'react';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { AppContainer } from 'common-ui'; import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
import ProductForm from '@/src/components/ProductForm'; import ProductForm from '@/src/components/ProductForm';
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
export default function AddProduct() { export default function AddProduct() {
const { mutate: createProduct, isPending: isCreating } = useCreateProduct(); const createProduct = trpc.admin.product.createProduct.useMutation();
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
});
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => { const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
const payload: CreateProductPayload = { try {
name: values.name, let uploadUrls: string[] = [];
shortDescription: values.shortDescription,
longDescription: values.longDescription,
unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId),
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
};
const formData = new FormData(); if (images.length > 0) {
Object.entries(payload).forEach(([key, value]) => { const blobs = await Promise.all(
if (value !== undefined && value !== null) { images.map(async (img) => {
formData.append(key, value as string); const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
} }
});
// Append tag IDs await createProduct.mutateAsync({
if (values.tagIds && values.tagIds.length > 0) { name: values.name,
values.tagIds.forEach((tagId: number) => { shortDescription: values.shortDescription,
formData.append('tagIds', tagId.toString()); longDescription: values.longDescription,
unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId),
price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1,
productQuantity: values.productQuantity || 1,
isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
uploadUrls,
tagIds: values.tagIds || [],
}); });
}
// Append images await refetchProducts();
if (images) { Alert.alert('Success', 'Product created successfully!');
images.forEach((image, index) => { } catch (error: any) {
if (image.uri) { Alert.alert('Error', error.message || 'Failed to create product');
formData.append('images', {
uri: image.uri,
name: `image-${index}.jpg`,
// type: 'image/jpeg',
type: image.mimeType as any,
} as any);
}
});
} }
createProduct(formData, {
onSuccess: (data) => {
Alert.alert('Success', 'Product created successfully!');
// Reset form or navigate
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to create product');
},
});
}; };
const initialValues = { const initialValues = {
@ -81,9 +75,8 @@ export default function AddProduct() {
mode="create" mode="create"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isCreating} isLoading={createProduct.isPending || isUploading}
existingImages={[]}
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -6,6 +6,7 @@ import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploa
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
@ -23,10 +24,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const [adminResponse, setAdminResponse] = useState(''); const [adminResponse, setAdminResponse] = useState('');
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]); const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]); const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.useMutation(); const respondToReview = trpc.admin.product.respondToReview.useMutation();
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation(); const { upload } = useUploadToObjectStorage();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -62,37 +62,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => { const handleSubmit = async (adminResponse: string) => {
try { try {
const mimeTypes = selectedImages.map(s => s.mimeType); const { keys, presignedUrls } = await upload({
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({ images: selectedImages,
contextString: 'review', contextString: 'review',
mimeTypes,
}); });
const keys = generatedUrls.map(url => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, "");
const decodedKey = decodeURIComponent(rawKey);
const parts = decodedKey.split('/');
parts.shift();
return parts.join('/');
});
setUploadUrls(generatedUrls);
for (let i = 0; i < generatedUrls.length; i++) {
const uploadUrl = generatedUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await respondToReview.mutateAsync({ await respondToReview.mutateAsync({
reviewId, reviewId,
adminResponse, adminResponse,
adminResponseImages: keys, adminResponseImages: keys,
uploadUrls: generatedUrls, uploadUrls: presignedUrls,
}); });
Alert.alert('Success', 'Response submitted'); Alert.alert('Success', 'Response submitted');
@ -100,9 +79,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setAdminResponse(''); setAdminResponse('');
setSelectedImages([]); setSelectedImages([]);
setDisplayImages([]); setDisplayImages([]);
setUploadUrls([]);
} catch (error:any) { } catch (error:any) {
Alert.alert('Error', error.message || 'Failed to submit response.'); Alert.alert('Error', error.message || 'Failed to submit response.');
} }
}; };

View file

@ -1,95 +1,74 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { View, Text, Alert } from 'react-native'; import { View, Alert } from 'react-native';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui'; import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm'; import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
import { useUpdateProduct } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
export default function EditProduct() { export default function EditProduct() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const productId = Number(id); const productId = Number(id);
const productFormRef = useRef<ProductFormRef>(null); const productFormRef = useRef<ProductFormRef>(null);
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery( const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
{ id: productId }, { id: productId },
{ enabled: !!productId } { enabled: !!productId }
); );
// const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct(); enabled: false,
});
const updateProduct = trpc.admin.product.updateProduct.useMutation();
const { upload, isUploading } = useUploadToObjectStorage();
useManualRefresh(() => refetch()); useManualRefresh(() => refetch());
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => { const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
const payload = { try {
// New images have mimeType !== null, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
let uploadUrls: string[] = [];
if (newImages.length > 0) {
const blobs = await Promise.all(
newImages.map(async (img) => {
const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
}
await updateProduct.mutateAsync({
id: productId,
name: values.name, name: values.name,
shortDescription: values.shortDescription, shortDescription: values.shortDescription,
longDescription: values.longDescription, longDescription: values.longDescription,
unitId: parseInt(values.unitId), unitId: parseInt(values.unitId),
storeId: parseInt(values.storeId), storeId: parseInt(values.storeId),
price: parseFloat(values.price), price: parseFloat(values.price),
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined, marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
incrementStep: 1, incrementStep: 1,
productQuantity: values.productQuantity || 1, productQuantity: values.productQuantity || 1,
deals: values.deals?.filter((deal: any) => isSuspended: values.isSuspended || false,
deal.quantity && deal.price && deal.validTill isFlashAvailable: values.isFlashAvailable || false,
).map((deal: any) => ({ flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
quantity: parseInt(deal.quantity), uploadUrls,
price: parseFloat(deal.price), imagesToDelete,
validTill: deal.validTill instanceof Date tagIds: values.tagIds || [],
? deal.validTill.toISOString().split('T')[0]
: deal.validTill, // Convert Date to YYYY-MM-DD string
})),
tagIds: values.tagIds,
};
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (key === 'deals' && Array.isArray(value)) {
formData.append(key, JSON.stringify(value));
} else if (key === 'tagIds' && Array.isArray(value)) {
value.forEach(tagId => {
formData.append('tagIds', tagId.toString());
});
} else if (value !== undefined && value !== null) {
formData.append(key, value as string);
}
});
// Add new images
if (newImages && newImages.length > 0) {
newImages.forEach((image, index) => {
if (image.uri) {
const fileName = image.uri.split('/').pop() || `image_${index}.jpg`;
formData.append('images', {
uri: image.uri,
name: fileName,
type: 'image/jpeg',
} as any);
}
}); });
}
// Add images to delete await refetch();
if (imagesToDelete && imagesToDelete.length > 0) { await refetchProducts();
formData.append('imagesToDelete', JSON.stringify(imagesToDelete)); Alert.alert('Success', 'Product updated successfully!');
productFormRef.current?.clearImages();
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to update product');
} }
updateProduct(
{ id: productId, formData },
{
onSuccess: (data) => {
Alert.alert('Success', 'Product updated successfully!');
// Clear newly added images after successful update
productFormRef.current?.clearImages();
},
onError: (error: any) => {
Alert.alert('Error', error.message || 'Failed to update product');
},
}
);
}; };
if (isFetching) { if (isFetching) {
@ -112,7 +91,13 @@ export default function EditProduct() {
); );
} }
const productData = product.product; // The API returns { product: Product } const productData = product.product;
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
imgUrl: url,
mimeType: null,
}));
const existingImageKeys = productData.imageKeys || [];
const initialValues = { const initialValues = {
name: productData.name, name: productData.name,
@ -125,7 +110,7 @@ export default function EditProduct() {
deals: productData.deals?.map(deal => ({ deals: productData.deals?.map(deal => ({
quantity: deal.quantity, quantity: deal.quantity,
price: deal.price, price: deal.price,
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object validTill: deal.validTill ? new Date(deal.validTill) : null,
})) || [{ quantity: '', price: '', validTill: null }], })) || [{ quantity: '', price: '', validTill: null }],
tagIds: productData.tags?.map((tag: any) => tag.id) || [], tagIds: productData.tags?.map((tag: any) => tag.id) || [],
isSuspended: productData.isSuspended || false, isSuspended: productData.isSuspended || false,
@ -141,9 +126,10 @@ export default function EditProduct() {
mode="edit" mode="edit"
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isUpdating} isLoading={updateProduct.isPending || isUploading}
existingImages={productData.images || []} existingImages={existingImages}
existingImageKeys={existingImageKeys}
/> />
</AppContainer> </AppContainer>
); );
} }

View file

@ -6,7 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui'; import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import { Product } from '@/src/api-hooks/product.api'; import type { AdminProduct } from '@packages/shared';
type FilterType = 'all' | 'in-stock' | 'out-of-stock'; type FilterType = 'all' | 'in-stock' | 'out-of-stock';
@ -54,7 +54,7 @@ export default function Products() {
// const handleToggleStock = (product: any) => { // const handleToggleStock = (product: any) => {
const handleToggleStock = (product: Pick<Product, 'id' | 'name' | 'isOutOfStock'>) => { const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock'; const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
Alert.alert( Alert.alert(
'Update Stock Status', 'Update Stock Status',

View file

@ -18,6 +18,7 @@ import {
} from 'common-ui'; } from 'common-ui';
import { trpc } from '@/src/trpc-client'; import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface User { interface User {
id: number; id: number;
@ -26,12 +27,6 @@ interface User {
isEligibleForNotif: boolean; isEligibleForNotif: boolean;
} }
const extractKeyFromUrl = (url: string): string => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, '');
return decodeURIComponent(rawKey);
};
export default function SendNotifications() { export default function SendNotifications() {
const router = useRouter(); const router = useRouter();
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
@ -46,8 +41,7 @@ export default function SendNotifications() {
search: searchQuery, search: searchQuery,
}); });
// Generate upload URLs mutation const { uploadSingle } = useUploadToObjectStorage();
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
// Send notification mutation // Send notification mutation
const sendNotification = trpc.admin.user.sendNotification.useMutation({ const sendNotification = trpc.admin.user.sendNotification.useMutation({
@ -127,28 +121,8 @@ export default function SendNotifications() {
// Upload image if selected // Upload image if selected
if (selectedImage) { if (selectedImage) {
const { uploadUrls } = await generateUploadUrls.mutateAsync({ const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
contextString: 'notification', imageUrl = key;
mimeTypes: [selectedImage.mimeType],
});
if (uploadUrls.length > 0) {
const uploadUrl = uploadUrls[0];
imageUrl = extractKeyFromUrl(uploadUrl);
// Upload image
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: selectedImage.blob,
headers: {
'Content-Type': selectedImage.mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
}
} }
// Send notification // Send notification

View file

@ -9,10 +9,14 @@ export default function AddStore() {
const router = useRouter(); const router = useRouter();
const createStoreMutation = trpc.admin.store.createStore.useMutation(); const createStoreMutation = trpc.admin.store.createStore.useMutation();
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
enabled: false,
});
const handleSubmit = (values: StoreFormData) => { const handleSubmit = (values: StoreFormData) => {
createStoreMutation.mutate(values, { createStoreMutation.mutate(values, {
onSuccess: (data) => { onSuccess: async (data) => {
await refetchStores();
Alert.alert('Success', data.message); Alert.alert('Success', data.message);
router.push('/stores' as any); // Navigate back to stores list router.push('/stores' as any); // Navigate back to stores list
}, },
@ -35,4 +39,4 @@ export default function AddStore() {
</View> </View>
</AppContainer> </AppContainer>
); );
} }

View file

@ -7,6 +7,7 @@ import { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
import ProductsSelector from './ProductsSelector'; import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client'; import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
export interface BannerFormData { export interface BannerFormData {
@ -52,10 +53,10 @@ export default function BannerForm({
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]); const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]); const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); const { uploadSingle } = useUploadToObjectStorage();
// Fetch products for dropdown // Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({}); const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
const products = productsData?.products || []; const products = productsData?.products || [];
@ -97,33 +98,11 @@ export default function BannerForm({
let imageUrl: string | undefined; let imageUrl: string | undefined;
if (selectedImages.length > 0) { if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store', // Using 'store' for now
mimeTypes,
});
// Upload image
const uploadUrl = uploadUrls[0];
const { blob, mimeType } = selectedImages[0]; const { blob, mimeType } = selectedImages[0];
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
const uploadResponse = await fetch(uploadUrl, { imageUrl = presignedUrl;
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
imageUrl = uploadUrl;
} }
// Call onSubmit with form values and imageUrl
await onSubmit(values, imageUrl); await onSubmit(values, imageUrl);
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
@ -256,4 +235,4 @@ export default function BannerForm({
)} )}
</Formik> </Formik>
); );
} }

View file

@ -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>
);
};

View file

@ -6,6 +6,7 @@ import { MyTextInput, BottomDropdown, MyText, tw, ImageUploader } from 'common-u
import ProductsSelector from './ProductsSelector'; import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client'; import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
export interface StoreFormData { export interface StoreFormData {
name: string; name: string;
@ -59,14 +60,19 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
}); });
}, [initialValues, initialSelectedProducts]); }, [initialValues, initialSelectedProducts]);
const staffOptions = staffData?.staff.map(staff => ({ const existingImageUrls = useMemo(
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
[formInitialValues.imageUrl]
)
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
label: staff.name, label: staff.name,
value: staff.id, value: staff.id,
})) || []; })) || [];
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation(); const { uploadSingle, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({ const handleImagePick = usePickImage({
setFile: async (assets: any) => { setFile: async (assets: any) => {
@ -113,39 +119,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
let imageUrl: string | undefined; let imageUrl: string | undefined;
if (selectedImages.length > 0) { if (selectedImages.length > 0) {
// Generate upload URLs const { blob, mimeType } = selectedImages[0];
const mimeTypes = selectedImages.map(s => s.mimeType); const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
const { uploadUrls } = await generateUploadUrls.mutateAsync({ imageUrl = presignedUrl;
contextString: 'store',
mimeTypes,
});
// Upload images
for (let i = 0; i < uploadUrls.length; i++) {
const uploadUrl = uploadUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
}
// Extract key from first upload URL
// const u = new URL(uploadUrls[0]);
// const rawKey = u.pathname.replace(/^\/+/, "");
// imageUrl = decodeURIComponent(rawKey);
imageUrl = uploadUrls[0];
} }
// Submit form with imageUrl
onSubmit({ ...values, imageUrl }); onSubmit({ ...values, imageUrl });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
@ -195,20 +173,25 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText> <MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader <ImageUploader
images={displayImages} images={displayImages}
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []} existingImageUrls={existingImageUrls}
onAddImage={handleImagePick} onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })} onRemoveExistingImage={() =>
setFormInitialValues((prev) => ({
...prev,
imageUrl: undefined,
}))
}
allowMultiple={false} allowMultiple={false}
/> />
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading || generateUploadUrls.isPending} disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`} style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
> >
<MyText style={tw`text-white text-lg font-bold`}> <MyText style={tw`text-white text-lg font-bold`}>
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')} {isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -220,4 +203,4 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
StoreForm.displayName = 'StoreForm'; StoreForm.displayName = 'StoreForm';
export default StoreForm; export default StoreForm;

View 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,
};

View 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();
});
});

View file

@ -5,8 +5,8 @@
}, },
"build": { "build": {
"development": { "development": {
"developmentClient": true, "distribution": "internal",
"distribution": "internal" "channel": "development"
}, },
"preview": { "preview": {
"distribution": "internal", "distribution": "internal",

View 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);
}

Binary file not shown.

View file

@ -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'] });
},
});
};

View file

@ -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,
});
};

View file

@ -1,13 +1,10 @@
import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle, useMemo } from 'react';
import { View, TouchableOpacity } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik, FieldArray } from 'formik'; import { Formik, FieldArray } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui'; import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client'; import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api';
interface ProductFormData { interface ProductFormData {
name: string; name: string;
@ -38,9 +35,10 @@ export interface ProductFormRef {
interface ProductFormProps { interface ProductFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: ProductFormData; initialValues: ProductFormData;
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void; onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
isLoading: boolean; isLoading: boolean;
existingImages?: string[]; existingImages?: ImageUploaderNeoItem[];
existingImageKeys?: string[];
} }
const unitOptions = [ const unitOptions = [
@ -50,18 +48,22 @@ const unitOptions = [
{ label: 'Unit Piece', value: 4 }, { label: 'Unit Piece', value: 4 },
]; ];
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode, mode,
initialValues, initialValues,
onSubmit, onSubmit,
isLoading, isLoading,
existingImages = [] existingImages:existingImagesRaw,
existingImageKeys = [],
}, ref) => { }, ref) => {
const { theme } = useTheme(); const { theme } = useTheme();
const [images, setImages] = useState<{ uri?: string }[]>([]); const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
const existingImages = existingImagesRaw || []
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
useEffect(() => {
setImages(existingImages);
}, [existingImagesRaw]);
const { data: storesData } = trpc.common.getStoresSummary.useQuery(); const { data: storesData } = trpc.common.getStoresSummary.useQuery();
const storeOptions = storesData?.stores.map(store => ({ const storeOptions = storesData?.stores.map(store => ({
@ -69,44 +71,50 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id, value: store.id,
})) || []; })) || [];
const { data: tagsData } = useGetTags(); const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
const tagOptions = tagsData?.tags.map(tag => ({ const tagOptions = tagsData?.tags.map(tag => ({
label: tag.tagName, label: tag.tagName,
value: tag.id.toString(), value: tag.id.toString(),
})) || []; })) || [];
// Initialize existing images state when existingImages prop changes // Build signed URL -> S3 key mapping for existing images
useEffect(() => { const signedUrlToKey = useMemo(() => {
console.log('changing existing imaes statte') const map: Record<string, string> = {};
existingImages.forEach((img, i) => {
setExistingImagesState(existingImages); if (existingImageKeys[i]) {
}, [existingImages]); map[img.imgUrl] = existingImageKeys[i];
}
const pickImage = usePickImage({ });
setFile: (files) => setImages(prev => [...prev, ...files]), return map;
multiple: true, }, [existingImages, existingImageKeys]);
});
// Calculate which existing images were deleted
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={(values) => onSubmit(values, images, deletedImages)} onSubmit={(values) => {
// New images have mimeType set, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
const deletedImageKeys = existingImages
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
.map(deleted => signedUrlToKey[deleted.imgUrl])
.filter(Boolean);
onSubmit(
values,
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
deletedImageKeys,
);
}}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setImages([]); setImages([]);
setExistingImagesState([]);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
useFocusCallback(clearForm); useFocusCallback(clearForm);
// Update ref with current clearForm function
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
clearImages: clearForm, clearImages: clearForm,
}), [clearForm]); }), [clearForm]);
@ -141,44 +149,18 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
{mode === 'create' && ( <ImageUploaderNeo
<ImageUploader images={images}
images={images} onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
onAddImage={pickImage} onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))} allowMultiple={true}
/> />
)}
{mode === 'edit' && existingImagesState.length > 0 && (
<View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Current Images</MyText>
<ImageGalleryWithDelete
imageUrls={existingImagesState}
setImageUrls={setExistingImagesState}
imageHeight={100}
imageWidth={100}
columns={3}
/>
</View>
)}
{mode === 'edit' && (
<View style={{ marginBottom: 16 }}>
<MyText style={tw`text-lg font-bold mb-2 text-gray-800`}>Add New Images</MyText>
<ImageUploader
images={images}
onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/>
</View>
)}
<BottomDropdown <BottomDropdown
topLabel='Unit' topLabel='Unit'
label="Unit" label="Unit"
value={values.unitId} value={values.unitId}
options={unitOptions} options={unitOptions}
// onValueChange={(value) => handleChange('unitId')(value+'')}
onValueChange={(value) => setFieldValue('unitId', value)} onValueChange={(value) => setFieldValue('unitId', value)}
placeholder="Select unit" placeholder="Select unit"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
@ -188,18 +170,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
placeholder="Enter product quantity" placeholder="Enter product quantity"
keyboardType="numeric" keyboardType="numeric"
value={values.productQuantity.toString()} value={values.productQuantity.toString()}
onChangeText={(text) => { onChangeText={(text) => setFieldValue('productQuantity', text)}
// if(text)
// setFieldValue('productQuantity', text);
// else
setFieldValue('productQuantity', text);
// if (text === '' || text === null || text === undefined) {
// setFieldValue('productQuantity', 1);
// } else {
// const num = parseFloat(text);
// setFieldValue('productQuantity', isNaN(num) ? 1 : num);
// }
}}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<BottomDropdown <BottomDropdown
@ -238,8 +209,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<View style={tw`flex-row items-center mb-4`}> <View style={tw`flex-row items-center mb-4`}>
<Checkbox <Checkbox
checked={values.isSuspended} checked={values.isSuspended}
@ -254,7 +223,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
checked={values.isFlashAvailable} checked={values.isFlashAvailable}
onPress={() => { onPress={() => {
setFieldValue('isFlashAvailable', !values.isFlashAvailable); setFieldValue('isFlashAvailable', !values.isFlashAvailable);
if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled if (values.isFlashAvailable) setFieldValue('flashPrice', '');
}} }}
style={tw`mr-3`} style={tw`mr-3`}
/> />
@ -272,87 +241,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
/> />
)} )}
{/* <FieldArray name="deals">
{({ push, remove, form }) => (
<View style={{ marginBottom: 16 }}>
<View style={tw`flex-row items-center mb-4`}>
<MaterialIcons name="local-offer" size={20} color="#3B82F6" />
<MyText style={tw`text-lg font-bold text-gray-800 ml-2`}>
Special Package Deals
</MyText>
<MyText style={tw`text-sm text-gray-500 ml-1`}>(Optional)</MyText>
</View>
{(form.values.deals || []).map((deal: any, index: number) => (
<View key={index} style={tw`bg-white p-4 rounded-2xl shadow-lg mb-4 border border-gray-100`}>
<View style={tw`mb-3`}>
<View style={tw`flex-row items-end gap-3 mb-3`}>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Quantity"
placeholder="Enter quantity"
keyboardType="numeric"
value={deal.quantity || ''}
onChangeText={form.handleChange(`deals.${index}.quantity`)}
fullWidth={false}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Price"
placeholder="Enter price"
keyboardType="numeric"
value={deal.price || ''}
onChangeText={form.handleChange(`deals.${index}.price`)}
fullWidth={false}
/>
</View>
</View>
<View style={tw`flex-row items-end gap-3`}>
<View style={tw`flex-1`}>
<DatePicker
value={deal.validTill}
setValue={(date) => form.setFieldValue(`deals.${index}.validTill`, date)}
showLabel={true}
placeholder="Valid Till"
/>
</View>
<View style={tw`flex-1`}>
<TouchableOpacity
onPress={() => remove(index)}
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-row items-center justify-center`}
>
<MaterialIcons name="delete" size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Remove</MyText>
</TouchableOpacity>
</View>
</View>
</View>
</View>
))}
{(form.values.deals || []).length === 0 && (
<View style={tw`bg-gray-50 p-6 rounded-2xl border-2 border-dashed border-gray-300 items-center mb-4`}>
<MaterialIcons name="local-offer" size={32} color="#9CA3AF" />
<MyText style={tw`text-gray-500 text-center mt-2`}>
No package deals added yet
</MyText>
<MyText style={tw`text-gray-400 text-sm text-center mt-1`}>
Add special pricing for bulk purchases
</MyText>
</View>
)}
<TouchableOpacity
onPress={() => push({ quantity: '', price: '', validTill: null })}
style={tw`bg-green-500 px-4 py-2 rounded-lg shadow-lg flex-row items-center justify-center mt-4`}
>
<MaterialIcons name="add" size={20} color="white" />
<MyText style={tw`text-white font-bold text-lg ml-2`}>Add Package Deal</MyText>
</TouchableOpacity>
</View>
)}
</FieldArray> */}
<TouchableOpacity <TouchableOpacity
onPress={submit} onPress={submit}
disabled={isLoading} disabled={isLoading}
@ -371,4 +259,4 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
ProductForm.displayName = 'ProductForm'; ProductForm.displayName = 'ProductForm';
export default ProductForm; export default ProductForm;

View file

@ -1,10 +1,8 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react'; import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity } from 'react-native'; import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik'; import { Formik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui'; import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface StoreOption { interface StoreOption {
@ -23,7 +21,7 @@ interface TagFormProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
initialValues: TagFormData; initialValues: TagFormData;
existingImageUrl?: string; existingImageUrl?: string;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void; onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
isLoading: boolean; isLoading: boolean;
stores?: StoreOption[]; stores?: StoreOption[];
} }
@ -31,27 +29,28 @@ interface TagFormProps {
const TagForm = forwardRef<any, TagFormProps>(({ const TagForm = forwardRef<any, TagFormProps>(({
mode, mode,
initialValues, initialValues,
existingImageUrl = '', existingImageUrl: existingImageUrlRaw,
onSubmit, onSubmit,
isLoading, isLoading,
stores = [], stores: storesRaw,
}, ref) => { }, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null); const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false)
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag)); const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const existingImageUrl = existingImageUrlRaw || ''
const stores = storesRaw || []
// Update checkbox when initial values change // Update checkbox when initial values change
useEffect(() => { useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag)); setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl}) if (existingImageUrl) {
}, [initialValues.isDashboardTag]); setImages([{ imgUrl: existingImageUrl, mimeType: null }])
} else {
const pickImage = usePickImage({ setImages([])
setFile: (files) => { }
setRemovedExisting(false)
setImage(files || null) }, [existingImageUrlRaw, initialValues.isDashboardTag]);
},
multiple: false,
});
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
@ -67,17 +66,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, image || undefined)} onSubmit={(values) => onSubmit(values, images, removedExisting)}
enableReinitialize enableReinitialize
> >
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => { {({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus // Clear form when screen comes into focus
const clearForm = useCallback(() => { const clearForm = useCallback(() => {
setImage(null); setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false); setIsDashboardTagChecked(false);
resetForm(); resetForm();
}, [resetForm]); }, [resetForm]);
useFocusCallback(clearForm); useFocusCallback(clearForm);
@ -108,10 +107,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
</MyText> </MyText>
<ImageUploader <ImageUploaderNeo
images={image ? [image] : []} images={images}
onAddImage={pickImage} onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
onRemoveImage={() => setImage(null)} setImages((prev) => [...prev, ...payload.map((img) => ({
imgUrl: img.url,
mimeType: img.mimeType,
}))])
}}
onImageRemove={(payload) => {
if (payload.mimeType === null) {
setRemovedExisting(true)
}
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
}}
allowMultiple={false}
/> />
</View> </View>
@ -167,4 +177,4 @@ const TagForm = forwardRef<any, TagFormProps>(({
TagForm.displayName = 'TagForm'; TagForm.displayName = 'TagForm';
export default TagForm; export default TagForm;

View file

@ -3,7 +3,7 @@ import { View, TouchableOpacity, Alert } from 'react-native';
import { Entypo } from '@expo/vector-icons'; import { Entypo } from '@expo/vector-icons';
import { MyText, tw, BottomDialog } from 'common-ui'; import { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useDeleteTag } from '../api-hooks/tag.api'; import { trpc } from '@/src/trpc-client';
export interface TagMenuProps { export interface TagMenuProps {
tagId: number; tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag(); const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
const handleOpenMenu = () => { const handleOpenMenu = () => {
setIsOpen(true); setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}; };
const performDelete = () => { const performDelete = () => {
deleteTag(tagId, { deleteTag.mutate({ id: tagId }, {
onSuccess: () => { onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully'); Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.(); onDeleteSuccess?.();
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
const errorMessage = error.message || 'Failed to delete tag'; const errorMessage = error.message || 'Failed to delete tag';
Alert.alert('Error', errorMessage); Alert.alert('Error', errorMessage);
}, },
}); })
}; };
const options = [ const options = [
@ -116,4 +116,4 @@ export const TagMenu: React.FC<TagMenuProps> = ({
</BottomDialog> </BottomDialog>
</> </>
); );
}; };

42
apps/backend/.envz Executable file
View 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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

56362
apps/backend/dumps/latest.sql Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,9 @@
import 'dotenv/config'; import 'dotenv/config';
import express, { NextFunction, Request, Response } from "express"; import { serve } from '@hono/node-server';
import cors from "cors";
// import bodyParser from "body-parser";
import multer from "multer";
import path from "path";
import fs from "fs";
import { db } from '@/src/db/db_index';
import { staffUsers, userDetails } from '@/src/db/schema';
import { eq } from 'drizzle-orm';
import mainRouter from '@/src/main-router';
import initFunc from '@/src/lib/init'; import initFunc from '@/src/lib/init';
import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { createApp } from '@/src/app'
import { appRouter } from '@/src/trpc/router'; // import signedUrlCache from '@/src/lib/signed-url-cache';
import { TRPCError } from '@trpc/server'; import { seed } from '@/src/lib/seed';
import jwt from 'jsonwebtoken'
import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/db/seed';
import '@/src/jobs/jobs-index'; import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs'; import { startAutomatedJobs } from '@/src/lib/automatedJobs';
@ -23,163 +11,13 @@ seed()
initFunc() initFunc()
startAutomatedJobs() startAutomatedJobs()
const app = express(); // signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility
app.use(cors({ const app = createApp()
origin: 'http://localhost:5174'
}));
serve({
signedUrlCache.loadFromDisk(); fetch: app.fetch,
port: 4000,
app.use(express.json()); }, (info) => {
app.use(express.urlencoded({ extended: true })); console.log(`Server is running on http://localhost:${info.port}/api/mobile/`);
// Middleware to log all request URLs
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
next();
});
//cors middleware
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
// Allow requests from any origin (for production, replace * with your domain)
res.header('Access-Control-Allow-Origin', '*');
// Allow specific headers clients can send
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Authorization'
);
// Allow specific HTTP methods
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
// Allow credentials if needed (optional)
// res.header('Access-Control-Allow-Credentials', 'true');
// Handle preflight (OPTIONS) requests quickly
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
}
app.use('/api/trpc', createExpressMiddleware({
router: appRouter,
createContext: async ({ req, res }) => {
let user = null;
let staffUser = null;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as any;
// Check if this is a staff token (has staffId)
if (decoded.staffId) {
// This is a staff token, verify staff exists
const staff = await db.query.staffUsers.findFirst({
where: eq(staffUsers.id, decoded.staffId),
});
if (staff) {
user=staffUser
staffUser = {
id: staff.id,
name: staff.name,
};
}
} else {
// This is a regular user token
user = decoded;
// Check if user is suspended
const details = await db.query.userDetails.findFirst({
where: eq(userDetails.userId, user.userId),
});
if (details?.isSuspended) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account suspended',
});
}
}
} catch (err) {
// Invalid token, both user and staffUser remain null
}
}
return { req, res, user, staffUser };
},
onError({ error, path, type, ctx }) {
console.error('🚨 tRPC Error :', {
path,
type,
code: error.code,
message: error.message,
userId: ctx?.user?.userId,
stack: error.stack,
});
},
}));
app.use('/api', mainRouter)
const fallbackUiDirCandidates = [
path.resolve(__dirname, '../fallback-ui/dist'),
path.resolve(__dirname, '../../fallback-ui/dist'),
path.resolve(process.cwd(), '../fallback-ui/dist'),
path.resolve(process.cwd(), './apps/fallback-ui/dist')
]
const fallbackUiDir =
fallbackUiDirCandidates.find((candidate) => fs.existsSync(candidate)) ??
fallbackUiDirCandidates[0]
const fallbackUiIndex = path.join(fallbackUiDir, 'index.html')
// const fallbackUiMountPath = '/admin-web'
const fallbackUiMountPath = '/';
if (fs.existsSync(fallbackUiIndex)) {
app.use(fallbackUiMountPath, express.static(fallbackUiDir))
app.use('/mf'+fallbackUiMountPath, express.static(fallbackUiDir))
const fallbackUiRegex = new RegExp(
`^${fallbackUiMountPath.replace(/\//g, '\\/')}(?:\\/.*)?$`
)
app.get([fallbackUiMountPath, fallbackUiRegex], (req, res) => {
res.sendFile(fallbackUiIndex)
})
app.get(/.*/, (req,res) => {
res.sendFile(fallbackUiIndex)
})
} else {
console.warn(`Fallback UI build not found at ${fallbackUiIndex}`)
}
// Serve /assets/public folder at /assets route
const assetsPublicDir = path.resolve(__dirname, './assets/public');
if (fs.existsSync(assetsPublicDir)) {
app.use('/assets', express.static(assetsPublicDir));
console.log('Serving /assets from', assetsPublicDir);
} else {
console.warn('Assets public folder not found at', assetsPublicDir);
}
// Global error handler
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err);
const status = err.statusCode || err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({ message });
});
app.listen(4000, () => {
console.log("Server is running on http://localhost:4000/api/mobile/");
}); });

54550
apps/backend/migrated.sql Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,17 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"migrate": "drizzle-kit generate:pg",
"build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json", "build": "rimraf ./dist && tsc --project tsconfig.json && tsc-alias -p tsconfig.json",
"build2": "rimraf ./dist && tsc", "build2": "rimraf ./dist && tsc",
"db:push": "drizzle-kit push:pg",
"db:seed": "tsx src/db/seed.ts", "db:seed": "tsx src/db/seed.ts",
"dev2": "tsx watch index.ts", "dev2": "tsx watch index.ts",
"dev_node": "tsx watch index.ts", "dev_node": "tsx watch index.ts",
"dev": "bun --watch index.ts", "dev_prev": "bun --watch index.ts",
"dev": "wrangler dev --config wrangler.dev.toml --ip 0.0.0.0",
"deploy": "wrangler deploy --config wrangler.prod.toml",
"wrangler:dev": "wrangler dev worker.ts --config wrangler.toml",
"wrangler:deploy": "wrangler deploy worker.ts --config wrangler.toml",
"pull_db": "wrangler d1 export freshyo-dev --config wrangler.prod.toml --remote --output ./dumps/latest.sql && bash ./scripts/populate_localdb.sh",
"docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .", "docker:build": "cd .. && docker buildx build --platform linux/amd64 -t mohdshafiuddin54/health_petal:latest --progress=plain -f backend/Dockerfile .",
"docker:push": "docker push mohdshafiuddin54/health_petal:latest" "docker:push": "docker push mohdshafiuddin54/health_petal:latest"
}, },
@ -22,40 +25,29 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0", "@aws-sdk/client-s3": "^3.888.0",
"@aws-sdk/s3-request-presigner": "^3.888.0", "@aws-sdk/s3-request-presigner": "^3.888.0",
"@hono/node-server": "^1.19.11",
"@hono/trpc-server": "^0.4.2",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"@turf/turf": "^7.2.0", "@turf/turf": "^7.2.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bullmq": "^5.63.0",
"cors": "^2.8.5",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0", "expo-server-sdk": "^4.0.0",
"express": "^5.1.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2", "hono": "^4.12.9",
"multer": "^2.0.2", "jose": "^6.2.2",
"node-cron": "^4.2.1",
"pg": "^8.16.3",
"pg-sdk-node": "https://phonepe.mycloudrepo.io/public/repositories/phonepe-pg-sdk-node/releases/v2/phonepe-pg-sdk-node.tgz",
"razorpay": "^2.9.6",
"redis": "^5.9.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.3", "@cloudflare/workers-types": "^4.20260401.1",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"@types/pg": "^8.15.5",
"drizzle-kit": "^0.31.4",
"rimraf": "^6.1.2", "rimraf": "^6.1.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"tsx": "^4.20.5",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
"typescript": "^5.9.2" "tsx": "^4.20.5",
"typescript": "^5.9.2",
"wrangler": "^3.114.0"
} }
} }

28
apps/backend/reset-remote-db.sh Executable file
View 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"

View 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"

View file

@ -1,19 +1,11 @@
import { Router } from "express"; import { Hono } from 'hono';
import { authenticateStaff } from "@/src/middleware/staff-auth"; import { authenticateStaff } from "@/src/middleware/staff-auth";
import productRouter from "@/src/apis/admin-apis/apis/product.router"
import tagRouter from "@/src/apis/admin-apis/apis/tag.router"
const router = Router(); const router = new Hono();
// Apply staff authentication to all admin routes // Apply staff authentication to all admin routes
router.use(authenticateStaff); router.use('*', authenticateStaff);
// Product routes
router.use("/products", productRouter);
// Tag routes
router.use("/product-tags", tagRouter);
const avRouter = router; const avRouter = router;
export default avRouter; export default avRouter;

View file

@ -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",
});
};

View file

@ -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",
});
};

View file

@ -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;

View file

@ -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;

View file

@ -1,12 +1,67 @@
import { eq, gt, and, sql, inArray } from "drizzle-orm"; import { Context } from 'hono';
import { Request, Response } from "express";
import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
import { scaffoldAssetUrl } from "@/src/lib/s3-client" import { scaffoldAssetUrl } from "@/src/lib/s3-client"
import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"
import {
getAllProductsWithUnits,
type ProductSummaryData,
} from "@/src/dbService"
/** /**
* Get next delivery date for a product * Get all products summary for dropdown
*/ */
export const getAllProductsSummary = async (c: Context) => {
try {
const tagId = c.req.query('tagId');
const tagIdNum = tagId ? parseInt(tagId) : undefined;
// If tagId is provided but no products found, return empty array
if (tagIdNum) {
const products = await getAllProductsWithUnits(tagIdNum);
if (products.length === 0) {
return c.json({
products: [],
count: 0,
}, 200);
}
}
const productsWithUnits = await getAllProductsWithUnits(tagIdNum);
// Generate signed URLs for product images
const formattedProducts = await Promise.all(
productsWithUnits.map(async (product: ProductSummaryData) => {
const nextDeliveryDate = await getNextDeliveryDate(product.id);
return {
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
price: product.price,
marketPrice: product.marketPrice,
unit: product.unitShortNotation,
productQuantity: product.productQuantity,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: scaffoldAssetUrl((product.images as string[]) || []),
};
})
);
return c.json({
products: formattedProducts,
count: formattedProducts.length,
}, 200);
} catch (error) {
console.error("Get products summary error:", error);
return c.json({ error: "Failed to fetch products summary" }, 500);
}
};
/*
// Old implementation - direct DB queries:
import { eq, gt, and, sql, inArray } from "drizzle-orm";
import { db } from "@/src/db/db_index"
import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema"
const getNextDeliveryDate = async (productId: number): Promise<Date | null> => { const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
const result = await db const result = await db
.select({ deliveryTime: deliverySlotInfo.deliveryTime }) .select({ deliveryTime: deliverySlotInfo.deliveryTime })
@ -22,13 +77,9 @@ const getNextDeliveryDate = async (productId: number): Promise<Date | null> => {
.orderBy(deliverySlotInfo.deliveryTime) .orderBy(deliverySlotInfo.deliveryTime)
.limit(1); .limit(1);
return result[0]?.deliveryTime || null; return result[0]?.deliveryTime || null;
}; };
/**
* Get all products summary for dropdown
*/
export const getAllProductsSummary = async (req: Request, res: Response) => { export const getAllProductsSummary = async (req: Request, res: Response) => {
try { try {
const { tagId } = req.query; const { tagId } = req.query;
@ -103,3 +154,4 @@ export const getAllProductsSummary = async (req: Request, res: Response) => {
return res.status(500).json({ error: "Failed to fetch products summary" }); return res.status(500).json({ error: "Failed to fetch products summary" });
} }
}; };
*/

View file

@ -1,10 +1,10 @@
import { Router } from "express"; import { Hono } from 'hono';
import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller" import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller"
const router = Router(); const router = new Hono();
router.get("/summary", getAllProductsSummary); router.get("/summary", getAllProductsSummary);
const commonProductsRouter= router; const commonProductsRouter= router;
export default commonProductsRouter; export default commonProductsRouter;

View file

@ -1,10 +1,10 @@
import { Router } from "express"; import { Hono } from 'hono';
import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router" import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router"
const router = Router(); const router = new Hono();
router.use('/products', commonProductsRouter) router.route('/products', commonProductsRouter)
const commonRouter = router; const commonRouter = router;
export default commonRouter; export default commonRouter;

126
apps/backend/src/app.ts Normal file
View 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
}

View 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';

View 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()
}
}
}

View file

@ -1,4 +1,3 @@
import * as cron from 'node-cron';
import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker' import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker'
const runCombinedJob = async () => { const runCombinedJob = async () => {
@ -25,4 +24,4 @@ const runCombinedJob = async () => {
runCombinedJob(); runCombinedJob();
// Schedule combined cron job every 10 minutes // Schedule combined cron job every 10 minutes
cron.schedule('*/10 * * * *', runCombinedJob); // cron.schedule('*/10 * * * *', runCombinedJob);

View file

@ -1,13 +1,8 @@
import * as cron from 'node-cron';
import { db } from '@/src/db/db_index'
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
import { eq, and, gt, isNotNull } from 'drizzle-orm';
import { RazorpayPaymentService } from '@/src/lib/payments-utils'
interface PendingPaymentRecord { interface PendingPaymentRecord {
payment: typeof payments.$inferSelect; payment: any;
order: typeof orders.$inferSelect; order: any;
slot: typeof deliverySlotInfo.$inferSelect; slot: any;
} }
export const createPaymentNotification = (record: PendingPaymentRecord) => { export const createPaymentNotification = (record: PendingPaymentRecord) => {
@ -20,21 +15,47 @@ export const createPaymentNotification = (record: PendingPaymentRecord) => {
export const checkRefundStatuses = async () => { export const checkRefundStatuses = async () => {
try { try {
const initiatedRefunds = await db // TODO: Reimplement with helpers from @/src/dbService
.select() // This function checks Razorpay refund status and updates database
.from(refunds) // Requires: getPendingRefunds(), updateRefundStatus()
.where(and( } catch (error) {
eq(refunds.refundStatus, 'initiated'), console.error('Error in checkRefundStatuses:', error);
isNotNull(refunds.merchantRefundId) }
)); };
// Process refunds concurrently using Promise.allSettled export const checkPendingPayments = async () => {
const promises = initiatedRefunds.map(async (refund) => { try {
if (!refund.merchantRefundId) return; // TODO: Reimplement with helpers from @/src/dbService
// This function finds pending payments and sends notifications
// Requires: getPendingPaymentsWithOrders()
} catch (error) {
console.error('Error checking pending payments:', error);
}
};
/*
// Old implementation - direct DB queries:
import { db } from '@/src/db/db_index'
import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema'
import { eq, and, gt, isNotNull } from 'drizzle-orm';
export const checkRefundStatuses = async () => {
try {
const initiatedRefunds = await db
.select()
.from(refunds)
.where(and(
eq(refunds.refundStatus, 'initiated'),
isNotNull(refunds.merchantRefundId)
));
// Process refunds concurrently using Promise.allSettled
const promises = initiatedRefunds.map(async (refund) => {
if (!refund.merchantRefundId) return;
try {
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
try {
const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId);
if (razorpayRefund.status === 'processed') { if (razorpayRefund.status === 'processed') {
await db await db
.update(refunds) .update(refunds)
@ -76,4 +97,4 @@ export const checkPendingPayments = async () => {
console.error('Error checking pending payments:', error); console.error('Error checking pending payments:', error);
} }
}; };
*/

View file

@ -9,6 +9,6 @@ export class ApiError extends Error {
this.name = 'ApiError'; this.name = 'ApiError';
this.statusCode = statusCode; this.statusCode = statusCode;
this.details = details; this.details = details;
Error.captureStackTrace?.(this, ApiError); // Error.captureStackTrace?.(this, ApiError);
} }
} }

View file

@ -1,7 +1,6 @@
import * as cron from 'node-cron'; // import * as cron from 'node-cron';
import { db } from '@/src/db/db_index' const cron:any = {}
import { productInfo, keyValStore } from '@/src/db/schema' import { toggleFlashDeliveryForItems, toggleKeyVal } from '@/src/dbService';
import { inArray, eq } from 'drizzle-orm';
import { CONST_KEYS } from '@/src/lib/const-keys' import { CONST_KEYS } from '@/src/lib/const-keys'
import { computeConstants } from '@/src/lib/const-store' import { computeConstants } from '@/src/lib/const-store'
@ -24,10 +23,7 @@ export const startAutomatedJobs = () => {
cron.schedule('0 12 * * *', async () => { cron.schedule('0 12 * * *', async () => {
try { try {
console.log('Disabling flash delivery for products at 12 PM'); console.log('Disabling flash delivery for products at 12 PM');
await db await toggleFlashDeliveryForItems(false, MUTTON_ITEMS);
.update(productInfo)
.set({ isFlashAvailable: false })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery disabled successfully'); console.log('Flash delivery disabled successfully');
} catch (error) { } catch (error) {
console.error('Error disabling flash delivery:', error); console.error('Error disabling flash delivery:', error);
@ -38,10 +34,7 @@ export const startAutomatedJobs = () => {
cron.schedule('0 6 * * *', async () => { cron.schedule('0 6 * * *', async () => {
try { try {
console.log('Enabling flash delivery for products at 5 AM'); console.log('Enabling flash delivery for products at 5 AM');
await db await toggleFlashDeliveryForItems(true, MUTTON_ITEMS);
.update(productInfo)
.set({ isFlashAvailable: true })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery enabled successfully'); console.log('Flash delivery enabled successfully');
} catch (error) { } catch (error) {
console.error('Error enabling flash delivery:', error); console.error('Error enabling flash delivery:', error);
@ -52,10 +45,7 @@ export const startAutomatedJobs = () => {
cron.schedule('0 21 * * *', async () => { cron.schedule('0 21 * * *', async () => {
try { try {
console.log('Disabling flash delivery feature at 9 PM'); console.log('Disabling flash delivery feature at 9 PM');
await db await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, false);
.update(keyValStore)
.set({ value: false })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature disabled successfully'); console.log('Flash delivery feature disabled successfully');
} catch (error) { } catch (error) {
@ -67,10 +57,7 @@ export const startAutomatedJobs = () => {
cron.schedule('0 6 * * *', async () => { cron.schedule('0 6 * * *', async () => {
try { try {
console.log('Enabling flash delivery feature at 6 AM'); console.log('Enabling flash delivery feature at 6 AM');
await db await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, true);
.update(keyValStore)
.set({ value: true })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature enabled successfully'); console.log('Flash delivery feature enabled successfully');
} catch (error) { } catch (error) {
@ -81,5 +68,70 @@ export const startAutomatedJobs = () => {
console.log('Automated jobs scheduled'); console.log('Automated jobs scheduled');
}; };
/*
// Old implementation - direct DB queries:
import { db } from '@/src/db/db_index'
import { productInfo, keyValStore } from '@/src/db/schema'
import { inArray, eq } from 'drizzle-orm';
// Job to disable flash delivery for mutton at 12 PM daily
cron.schedule('0 12 * * *', async () => {
try {
console.log('Disabling flash delivery for products at 12 PM');
await db
.update(productInfo)
.set({ isFlashAvailable: false })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery disabled successfully');
} catch (error) {
console.error('Error disabling flash delivery:', error);
}
});
// Job to enable flash delivery for mutton at 6 AM daily
cron.schedule('0 6 * * *', async () => {
try {
console.log('Enabling flash delivery for products at 5 AM');
await db
.update(productInfo)
.set({ isFlashAvailable: true })
.where(inArray(productInfo.id, MUTTON_ITEMS));
console.log('Flash delivery enabled successfully');
} catch (error) {
console.error('Error enabling flash delivery:', error);
}
});
// Job to disable flash delivery feature at 9 PM daily
cron.schedule('0 21 * * *', async () => {
try {
console.log('Disabling flash delivery feature at 9 PM');
await db
.update(keyValStore)
.set({ value: false })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature disabled successfully');
} catch (error) {
console.error('Error disabling flash delivery feature:', error);
}
});
// Job to enable flash delivery feature at 6 AM daily
cron.schedule('0 6 * * *', async () => {
try {
console.log('Enabling flash delivery feature at 6 AM');
await db
.update(keyValStore)
.set({ value: true })
.where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled));
await computeConstants(); // Refresh Redis cache
console.log('Flash delivery feature enabled successfully');
} catch (error) {
console.error('Error enabling flash delivery feature:', error);
}
});
*/
// Optional: Call on import if desired, or export and call in main app // Optional: Call on import if desired, or export and call in main app
// startAutomatedJobs(); // startAutomatedJobs();

View file

@ -1,8 +1,8 @@
import axiosParent from "axios"; import axiosParent from "axios";
import { phonePeBaseUrl } from "@/src/lib/env-exporter" import { getPhonePeBaseUrl } from "@/src/lib/env-exporter"
export const phonepeAxios = axiosParent.create({ export const phonepeAxios = axiosParent.create({
baseURL: phonePeBaseUrl, baseURL: getPhonePeBaseUrl(),
timeout: 40000, timeout: 40000,
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",

View file

@ -1,6 +1,11 @@
import express from 'express'; // catchAsync is no longer needed with Hono
const catchAsync = // Hono handles async errors automatically
(fn: express.RequestHandler) => // This file is kept for backward compatibility but should be removed in the future
(req: express.Request, res: express.Response, next: express.NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next); import { Context } from 'hono';
export default catchAsync;
const catchAsync = (fn: (c: Context) => Promise<Response>) => {
return fn;
};
export default catchAsync;

View 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] }
}
}

View file

@ -12,6 +12,7 @@ export const CONST_KEYS = {
flashDeliverySlotId: 'flashDeliverySlotId', flashDeliverySlotId: 'flashDeliverySlotId',
readableOrderId: 'readableOrderId', readableOrderId: 'readableOrderId',
versionNum: 'versionNum', versionNum: 'versionNum',
cacheVersion: 'cache_version',
playStoreUrl: 'playStoreUrl', playStoreUrl: 'playStoreUrl',
appStoreUrl: 'appStoreUrl', appStoreUrl: 'appStoreUrl',
popularItems: 'popularItems', popularItems: 'popularItems',
@ -35,6 +36,7 @@ export const CONST_LABELS: Record<ConstKey, string> = {
flashDeliverySlotId: 'Flash Delivery Slot ID', flashDeliverySlotId: 'Flash Delivery Slot ID',
readableOrderId: 'Readable Order ID', readableOrderId: 'Readable Order ID',
versionNum: 'Version Number', versionNum: 'Version Number',
'cache_version': 'Cache Version',
playStoreUrl: 'Play Store URL', playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
@ -47,3 +49,53 @@ export const CONST_LABELS: Record<ConstKey, string> = {
export type ConstKey = (typeof CONST_KEYS)[keyof typeof CONST_KEYS]; export type ConstKey = (typeof CONST_KEYS)[keyof typeof CONST_KEYS];
export const CONST_KEYS_ARRAY = Object.values(CONST_KEYS) as ConstKey[]; export const CONST_KEYS_ARRAY = Object.values(CONST_KEYS) as ConstKey[];
export type ConstValueType = 'string' | 'boolean' | 'number'
export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
minRegularOrderValue: 'number',
freeDeliveryThreshold: 'number',
deliveryCharge: 'number',
flashFreeDeliveryThreshold: 'number',
flashDeliveryCharge: 'number',
platformFeePercent: 'number',
taxRate: 'number',
tester: 'string',
minOrderAmountForCoupon: 'number',
maxCouponDiscount: 'number',
flashDeliverySlotId: 'number',
readableOrderId: 'number',
versionNum: 'string',
'cache_version': 'number',
playStoreUrl: 'string',
appStoreUrl: 'string',
popularItems: 'string',
allItemsOrder: 'string',
isFlashDeliveryEnabled: 'boolean',
supportMobile: 'string',
supportEmail: 'string',
};
export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
minRegularOrderValue: true,
freeDeliveryThreshold: true,
deliveryCharge: true,
flashFreeDeliveryThreshold: true,
flashDeliveryCharge: true,
platformFeePercent: true,
taxRate: false,
tester: false,
minOrderAmountForCoupon: true,
maxCouponDiscount: false,
flashDeliverySlotId: true,
readableOrderId: false,
versionNum: true,
'cache_version': false,
playStoreUrl: true,
appStoreUrl: true,
popularItems: true,
allItemsOrder: true,
isFlashDeliveryEnabled: true,
supportMobile: true,
supportEmail: true,
};

View file

@ -1,25 +1,30 @@
import { db } from '@/src/db/db_index' import { getAllKeyValStore } from '@/src/dbService'
import { keyValStore } from '@/src/db/schema' // import redisClient from '@/src/lib/redis-client'
import redisClient from '@/src/lib/redis-client'
import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys' import { CONST_KEYS, CONST_KEYS_ARRAY, type ConstKey } from '@/src/lib/const-keys'
const CONST_REDIS_PREFIX = 'const:'; // const CONST_REDIS_PREFIX = 'const:';
export const computeConstants = async (): Promise<void> => { export const computeConstants = async (): Promise<void> => {
try { try {
console.log('Computing constants from database...'); console.log('Computing constants from database...');
const constants = await db.select().from(keyValStore); /*
// Old implementation - direct DB queries:
for (const constant of constants) { import { db } from '@/src/db/db_index'
const redisKey = `${CONST_REDIS_PREFIX}${constant.key}`; import { keyValStore } from '@/src/db/schema'
const value = JSON.stringify(constant.value);
// console.log({redisKey, value})
await redisClient.set(redisKey, value);
}
console.log(`Computed and stored ${constants.length} constants in Redis`); const constants = await db.select().from(keyValStore);
*/
const constants = await getAllKeyValStore();
// 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 ${constants.length} constants from DB`);
} catch (error) { } catch (error) {
console.error('Failed to compute constants:', error); console.error('Failed to compute constants:', error);
throw error; throw error;
@ -27,48 +32,78 @@ export const computeConstants = async (): Promise<void> => {
}; };
export const getConstant = async <T = any>(key: string): Promise<T | null> => { export const getConstant = async <T = any>(key: string): Promise<T | null> => {
const redisKey = `${CONST_REDIS_PREFIX}${key}`; // const redisKey = `${CONST_REDIS_PREFIX}${key}`;
const value = await redisClient.get(redisKey); // const value = await redisClient.get(redisKey);
//
if (!value) { // if (!value) {
// return null;
// }
//
// try {
// return JSON.parse(value) as T;
// } catch {
// return value as unknown as T;
// }
const constants = await getAllKeyValStore();
const entry = constants.find(c => c.key === key);
if (!entry) {
return null; return null;
} }
try { return entry.value as T;
return JSON.parse(value) as T;
} catch {
return value as unknown as T;
}
}; };
export const getConstants = async <T = any>(keys: string[]): Promise<Record<string, T | null>> => { export const getConstants = async <T = any>(keys: string[]): Promise<Record<string, T | null>> => {
const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`); // const redisKeys = keys.map(key => `${CONST_REDIS_PREFIX}${key}`);
const values = await redisClient.MGET(redisKeys); // const values = await redisClient.MGET(redisKeys);
//
// const result: Record<string, T | null> = {};
// keys.forEach((key, index) => {
// const value = values[index];
// if (!value) {
// result[key] = null;
// } else {
// try {
// result[key] = JSON.parse(value) as T;
// } catch {
// result[key] = value as unknown as T;
// }
// }
// });
//
// return result;
const constants = await getAllKeyValStore();
const constantsMap = new Map(constants.map(c => [c.key, c.value]));
const result: Record<string, T | null> = {}; const result: Record<string, T | null> = {};
keys.forEach((key, index) => { for (const key of keys) {
const value = values[index]; const value = constantsMap.get(key);
if (!value) { result[key] = (value !== undefined ? value : null) as T | null;
result[key] = null; }
} else {
try {
result[key] = JSON.parse(value) as T;
} catch {
result[key] = value as unknown as T;
}
}
});
return result; return result;
}; };
export const getAllConstValues = async (): Promise<Record<ConstKey, any>> => { export const getAllConstValues = async (): Promise<Record<ConstKey, any>> => {
// const result: Record<string, any> = {};
//
// 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> = {}; const result: Record<string, any> = {};
for (const key of CONST_KEYS_ARRAY) { for (const key of CONST_KEYS_ARRAY) {
result[key] = await getConstant(key); result[key] = constantsMap.get(key) ?? null;
} }
return result as Record<ConstKey, any>; return result as Record<ConstKey, any>;
}; };

View file

@ -1,7 +1,5 @@
import { eq } from "drizzle-orm";
import { db } from "@/src/db/db_index"
import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client" import { deleteImageUtil, getOriginalUrlFromSignedUrl } from "@/src/lib/s3-client"
import { s3Url } from "@/src/lib/env-exporter" import { getS3Url } from "@/src/lib/env-exporter"
function extractS3Key(url: string): string | null { function extractS3Key(url: string): string | null {
try { try {
@ -10,11 +8,11 @@ function extractS3Key(url: string): string | null {
// Find the index of '.com/' in the URL // Find the index of '.com/' in the URL
// const comIndex = originalUrl.indexOf(".com/"); // const comIndex = originalUrl.indexOf(".com/");
const baseUrlIndex = originalUrl.indexOf(s3Url); const baseUrlIndex = originalUrl.indexOf(getS3Url());
// If '.com/' is found, return everything after it // If '.com/' is found, return everything after it
if (baseUrlIndex !== -1) { if (baseUrlIndex !== -1) {
return originalUrl.substring(baseUrlIndex + s3Url.length); // +5 to skip '.com/' return originalUrl.substring(baseUrlIndex + getS3Url().length); // +5 to skip '.com/'
} }
} catch (error) { } catch (error) {
console.error("Error extracting key from URL:", error); console.error("Error extracting key from URL:", error);

View file

@ -1,6 +1,4 @@
import { db } from '@/src/db/db_index' import { deleteOrdersWithRelations } from '@/src/dbService'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
/** /**
* Delete orders and all their related records * Delete orders and all their related records
@ -8,6 +6,26 @@ import { eq, inArray } from 'drizzle-orm';
* @returns Promise<void> * @returns Promise<void>
* @throws Error if deletion fails * @throws Error if deletion fails
*/ */
export const deleteOrders = async (orderIds: number[]): Promise<void> => {
if (orderIds.length === 0) {
return;
}
try {
await deleteOrdersWithRelations(orderIds);
console.log(`Successfully deleted ${orderIds.length} orders and all related records`);
} catch (error) {
console.error(`Failed to delete orders ${orderIds.join(', ')}:`, error);
throw error;
}
};
/*
// Old implementation - direct DB queries:
import { db } from '@/src/db/db_index'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '@/src/db/schema'
import { eq, inArray } from 'drizzle-orm';
export const deleteOrders = async (orderIds: number[]): Promise<void> => { export const deleteOrders = async (orderIds: number[]): Promise<void> => {
if (orderIds.length === 0) { if (orderIds.length === 0) {
return; return;
@ -43,3 +61,4 @@ export const deleteOrders = async (orderIds: number[]): Promise<void> => {
throw error; throw error;
} }
}; };
*/

View file

@ -1,79 +1,79 @@
import fs from "fs"; // import fs from "fs";
import path from "path"; // import path from "path";
//
export class DiskPersistedSet { // export class DiskPersistedSet {
private set: Set<string>; // private set: Set<string>;
private readonly filePath: string; // private readonly filePath: string;
private dirty = false; // private dirty = false;
//
constructor(filePath: string = "./persister") { // constructor(filePath: string = "./persister") {
this.filePath = path.resolve(filePath); // this.filePath = path.resolve(filePath);
//
// ✅ Ensure file exists // // ✅ Ensure file exists
if (!fs.existsSync(this.filePath)) { // if (!fs.existsSync(this.filePath)) {
fs.writeFileSync(this.filePath, "", "utf8"); // fs.writeFileSync(this.filePath, "", "utf8");
} // }
//
// ✅ Load existing values from file // // ✅ Load existing values from file
const contents = fs.readFileSync(this.filePath, "utf8"); // const contents = fs.readFileSync(this.filePath, "utf8");
this.set = new Set( // this.set = new Set(
contents.split("\n").map(x => x.trim()).filter(x => x.length > 0) // contents.split("\n").map(x => x.trim()).filter(x => x.length > 0)
); // );
//
this.registerExitHandlers(); // this.registerExitHandlers();
} // }
//
private persist() { // private persist() {
if (!this.dirty) return; // if (!this.dirty) return;
fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8"); // fs.writeFileSync(this.filePath, Array.from(this.set).join("\n"), "utf8");
this.dirty = false; // this.dirty = false;
} // }
//
private markDirty() { // private markDirty() {
this.dirty = true; // this.dirty = true;
} // }
//
add(value: string): void { // add(value: string): void {
if (!this.set.has(value)) { // if (!this.set.has(value)) {
this.set.add(value); // this.set.add(value);
this.markDirty(); // this.markDirty();
this.persist(); // this.persist();
} // }
} // }
//
delete(value: string): void { // delete(value: string): void {
if (this.set.delete(value)) { // if (this.set.delete(value)) {
this.markDirty(); // this.markDirty();
this.persist(); // this.persist();
} // }
} // }
//
has(value: string): boolean { // has(value: string): boolean {
return this.set.has(value); // return this.set.has(value);
} // }
//
values(): string[] { // values(): string[] {
return Array.from(this.set); // return Array.from(this.set);
} // }
//
clear(): void { // clear(): void {
if (this.set.size > 0) { // if (this.set.size > 0) {
this.set.clear(); // this.set.clear();
this.markDirty(); // this.markDirty();
this.persist(); // this.persist();
} // }
} // }
//
private registerExitHandlers() { // private registerExitHandlers() {
const flush = () => this.persist(); // const flush = () => this.persist();
//
process.on("exit", flush); // process.on("exit", flush);
process.on("SIGINT", () => { flush(); process.exit(); }); // process.on("SIGINT", () => { flush(); process.exit(); });
process.on("SIGTERM", () => { flush(); process.exit(); }); // process.on("SIGTERM", () => { flush(); process.exit(); });
process.on("uncaughtException", (err) => { // process.on("uncaughtException", (err) => {
console.error("Uncaught exception. Flushing DiskPersistedSet:", err); // console.error("Uncaught exception. Flushing DiskPersistedSet:", err);
flush(); // flush();
process.exit(1); // process.exit(1);
}); // });
} // }
} // }

View file

@ -1,51 +1,120 @@
export const appUrl = process.env.APP_URL as string; // Old env loading (Node only)
// export const appUrl = process.env.APP_URL as string;
//
// export const jwtSecret: string = process.env.JWT_SECRET as string
//
// export const defaultRoleName = 'gen_user';
//
// export const encodedJwtSecret = new TextEncoder().encode(jwtSecret)
//
// export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string
//
// export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string
//
// export const s3BucketName = process.env.S3_BUCKET_NAME as string
//
// export const s3Region = process.env.S3_REGION as string
//
// export const assetsDomain = process.env.ASSETS_DOMAIN as string;
//
// export const apiCacheKey = process.env.API_CACHE_KEY as string;
//
// export const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN as string;
//
// export const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID as string;
//
// export const s3Url = process.env.S3_URL as string
//
// export const redisUrl = process.env.REDIS_URL as string
//
//
// export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string;
//
// export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string;
//
// export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string;
//
// export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string);
//
// export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string;
//
// export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string;
//
// export const razorpayId = process.env.RAZORPAY_KEY as string;
//
// export const razorpaySecret = process.env.RAZORPAY_SECRET as string;
//
// export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string;
//
// export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string);
//
// export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string);
//
// export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string;
//
// export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || [];
//
// export const isDevMode = (process.env.ENV_MODE as string) === 'dev';
export const jwtSecret: string = process.env.JWT_SECRET as string const getRuntimeEnv = () => (globalThis as any).ENV || (globalThis as any).process?.env || {}
export const getAppUrl = () => getRuntimeEnv().APP_URL as string
export const getJwtSecret = () => getRuntimeEnv().JWT_SECRET as string
export const defaultRoleName = 'gen_user'; export const defaultRoleName = 'gen_user';
export const encodedJwtSecret = new TextEncoder().encode(jwtSecret) export const getEncodedJwtSecret = () => {
const env = getRuntimeEnv()
const secret = (env.JWT_SECRET as string) || ''
return new TextEncoder().encode(secret)
}
export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID as string export const getS3AccessKeyId = () => getRuntimeEnv().S3_ACCESS_KEY_ID as string
export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY as string export const getS3SecretAccessKey = () => getRuntimeEnv().S3_SECRET_ACCESS_KEY as string
export const s3BucketName = process.env.S3_BUCKET_NAME as string export const getS3BucketName = () => getRuntimeEnv().S3_BUCKET_NAME as string
export const s3Region = process.env.S3_REGION as string export const getS3Region = () => getRuntimeEnv().S3_REGION as string
export const assetsDomain = process.env.ASSETS_DOMAIN as string; export const getAssetsDomain = () => getRuntimeEnv().ASSETS_DOMAIN as string
export const s3Url = process.env.S3_URL as string export const getApiCacheKey = () => getRuntimeEnv().API_CACHE_KEY as string
export const redisUrl = process.env.REDIS_URL as string export const getCloudflareApiToken = () => getRuntimeEnv().CLOUDFLARE_API_TOKEN as string
export const getCloudflareZoneId = () => getRuntimeEnv().CLOUDFLARE_ZONE_ID as string
export const expoAccessToken = process.env.EXPO_ACCESS_TOKEN as string; export const getS3Url = () => getRuntimeEnv().S3_URL as string
export const phonePeBaseUrl = process.env.PHONE_PE_BASE_URL as string; export const getRedisUrl = () => getRuntimeEnv().REDIS_URL as string
export const phonePeClientId = process.env.PHONE_PE_CLIENT_ID as string; export const getExpoAccessToken = () => getRuntimeEnv().EXPO_ACCESS_TOKEN as string
export const phonePeClientVersion = Number(process.env.PHONE_PE_CLIENT_VERSION as string); export const getPhonePeBaseUrl = () => getRuntimeEnv().PHONE_PE_BASE_URL as string
export const phonePeClientSecret = process.env.PHONE_PE_CLIENT_SECRET as string; export const getPhonePeClientId = () => getRuntimeEnv().PHONE_PE_CLIENT_ID as string
export const phonePeMerchantId = process.env.PHONE_PE_MERCHANT_ID as string; export const getPhonePeClientVersion = () => Number(getRuntimeEnv().PHONE_PE_CLIENT_VERSION as string)
export const razorpayId = process.env.RAZORPAY_KEY as string; export const getPhonePeClientSecret = () => getRuntimeEnv().PHONE_PE_CLIENT_SECRET as string
export const razorpaySecret = process.env.RAZORPAY_SECRET as string; export const getPhonePeMerchantId = () => getRuntimeEnv().PHONE_PE_MERCHANT_ID as string
export const otpSenderAuthToken = process.env.OTP_SENDER_AUTH_TOKEN as string; export const getRazorpayId = () => getRuntimeEnv().RAZORPAY_KEY as string
export const minOrderValue = Number(process.env.MIN_ORDER_VALUE as string); export const getRazorpaySecret = () => getRuntimeEnv().RAZORPAY_SECRET as string
export const deliveryCharge = Number(process.env.DELIVERY_CHARGE as string); export const getOtpSenderAuthToken = () => getRuntimeEnv().OTP_SENDER_AUTH_TOKEN as string
export const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN as string; export const getMinOrderValue = () => Number(getRuntimeEnv().MIN_ORDER_VALUE as string)
export const telegramChatIds = (process.env.TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || []; export const getDeliveryCharge = () => Number(getRuntimeEnv().DELIVERY_CHARGE as string)
export const isDevMode = (process.env.ENV_MODE as string) === 'dev'; export const getTelegramBotToken = () => getRuntimeEnv().TELEGRAM_BOT_TOKEN as string
export const getTelegramChatIds = () => (getRuntimeEnv().TELEGRAM_CHAT_IDS as string)?.split(',').map(id => id.trim()) || []
export const getIsDevMode = () => (getRuntimeEnv().ENV_MODE as string) === 'dev'

View file

@ -1,10 +1,11 @@
import redisClient from '@/src/lib/redis-client' // import redisClient from '@/src/lib/redis-client'
export async function enqueue(queueName: string, eventData: any): Promise<boolean> { export async function enqueue(queueName: string, eventData: any): Promise<boolean> {
try { try {
const jsonData = JSON.stringify(eventData); const jsonData = JSON.stringify(eventData);
const result = await redisClient.lPush(queueName, jsonData); // const result = await redisClient.lPush(queueName, jsonData);
return result > 0; // return result > 0;
return false;
} catch (error) { } catch (error) {
console.error('Event enqueue error:', error); console.error('Event enqueue error:', error);
return false; return false;

View file

@ -1,9 +1,8 @@
import { Expo } from "expo-server-sdk"; import { Expo } from "expo-server-sdk";
import { title } from "process"; import { getExpoAccessToken } from "@/src/lib/env-exporter"
import { expoAccessToken } from "@/src/lib/env-exporter"
const expo = new Expo({ const expo = new Expo({
accessToken: expoAccessToken, accessToken: getExpoAccessToken(),
useFcmV1: true, useFcmV1: true,
}); });

View file

@ -3,6 +3,7 @@ import { initializeAllStores } from '@/src/stores/store-initializer'
import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store' import { initializeUserNegativityStore } from '@/src/stores/user-negativity-store'
import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler' import { startOrderHandler, startCancellationHandler, publishOrder } from '@/src/lib/post-order-handler'
import { deleteOrders } from '@/src/lib/delete-orders' import { deleteOrders } from '@/src/lib/delete-orders'
import { createAllCacheFiles } from '@/src/lib/cloud_cache'
/** /**
* Initialize all application services * Initialize all application services
@ -25,6 +26,10 @@ export const initFunc = async (): Promise<void> => {
startCancellationHandler(), startCancellationHandler(),
]); ]);
// Create all cache files after stores are initialized
await createAllCacheFiles();
console.log('Cache files created successfully');
console.log('Application initialization completed successfully'); console.log('Application initialization completed successfully');
} catch (error) { } catch (error) {
console.error('Application initialization failed:', error); console.error('Application initialization failed:', error);

View file

@ -1,8 +1,8 @@
import { Queue, Worker } from 'bullmq'; // import { Queue, Worker } from 'bullmq';
import { Expo } from 'expo-server-sdk'; import { Expo } from 'expo-server-sdk';
import { redisUrl } from '@/src/lib/env-exporter' // import { db } from '@/src/db/db_index'
import { db } from '@/src/db/db_index' import { scaffoldAssetUrl } from '@/src/lib/s3-client'
import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client' import { queueDataPusher } from '@/src/lib/queue-data-pusher'
import { import {
NOTIFS_QUEUE, NOTIFS_QUEUE,
ORDER_PLACED_MESSAGE, ORDER_PLACED_MESSAGE,
@ -14,33 +14,37 @@ import {
REFUND_INITIATED_MESSAGE REFUND_INITIATED_MESSAGE
} from '@/src/lib/const-strings'; } from '@/src/lib/const-strings';
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl },
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10,
attempts: 3,
},
});
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => { export const notificationQueue:any = {};
if (!job) return;
const { name, data } = job;
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 const notificationQueue = new Queue(NOTIFS_QUEUE, {
// connection: { url: redisUrl },
// defaultJobOptions: {
// removeOnComplete: true,
// removeOnFail: 10,
// attempts: 3,
// },
// });
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,
// });
export async function sendAdminNotification(data: {
token: string; token: string;
title: string; title: string;
body: string; body: string;
@ -55,7 +59,7 @@ async function sendAdminNotification(data: {
} }
// Generate signed URL for image if provided // Generate signed URL for image if provided
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null; const signedImageUrl = imageUrl ? scaffoldAssetUrl(imageUrl) : null;
// Send notification // Send notification
const expo = new Expo(); const expo = new Expo();
@ -84,16 +88,16 @@ async function sendAdminNotification(data: {
} }
} }
notificationWorker.on('completed', (job) => { // notificationWorker.on('completed', (job) => {
if (job) console.log(`Notification job ${job.id} completed`); // if (job) console.log(`Notification job ${job.id} completed`);
}); // });
notificationWorker.on('failed', (job, err) => { // notificationWorker.on('failed', (job, err) => {
if (job) console.error(`Notification job ${job.id} failed:`, err); // if (job) console.error(`Notification job ${job.id} failed:`, err);
}); // });
export async function scheduleNotification(userId: number, payload: any, options?: { delay?: number; priority?: number }) { export async function scheduleNotification(userId: number, payload: any, options?: { delay?: number; priority?: number }) {
const jobData = { userId, ...payload }; const jobData = { userId, ...payload };
await notificationQueue.add('send-notification', jobData, options); await queueDataPusher.pushNotifQueue({ name: 'send-notification', jobData, options })
} }
// Utility methods for specific notification events // Utility methods for specific notification events
@ -159,8 +163,8 @@ export async function sendRefundInitiatedNotification(userId: number, orderId?:
orderId orderId
}); });
} }
//
process.on('SIGTERM', async () => { // process.on('SIGTERM', async () => {
await notificationQueue.close(); // await notificationQueue.close();
await notificationWorker.close(); // await notificationWorker.close();
}); // });

View file

@ -1,7 +1,5 @@
import { db } from "@/src/db/db_index"
import { sendPushNotificationsMany } from "@/src/lib/expo-service" import { sendPushNotificationsMany } from "@/src/lib/expo-service"
// import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema"; // import { usersTable, notifCredsTable, notificationTable } from "@/src/db/schema";
import { eq, inArray } from "drizzle-orm";
// Core notification dispatch methods (renamed for clarity) // Core notification dispatch methods (renamed for clarity)
export async function dispatchBulkNotification({ export async function dispatchBulkNotification({

View file

@ -1,5 +1,5 @@
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { otpSenderAuthToken } from '@/src/lib/env-exporter' import { getOtpSenderAuthToken } from '@/src/lib/env-exporter'
const otpStore = new Map<string, string>(); const otpStore = new Map<string, string>();
@ -20,7 +20,7 @@ export const sendOtp = async (phone: string) => {
const reqUrl = `https://cpaas.messagecentral.com/verification/v3/send?countryCode=91&flowType=SMS&mobileNumber=${phone}&timeout=300`; const reqUrl = `https://cpaas.messagecentral.com/verification/v3/send?countryCode=91&flowType=SMS&mobileNumber=${phone}&timeout=300`;
const resp = await fetch(reqUrl, { const resp = await fetch(reqUrl, {
headers: { headers: {
authToken: otpSenderAuthToken, authToken: getOtpSenderAuthToken(),
}, },
method: "POST", method: "POST",
}); });
@ -42,7 +42,7 @@ export async function verifyOtpUtil(mobile: string, otp: string, verifId: string
const resp = await fetch(reqUrl, { const resp = await fetch(reqUrl, {
method: "GET", method: "GET",
headers: { headers: {
authToken: otpSenderAuthToken, authToken: getOtpSenderAuthToken(),
}, },
}); });
@ -52,4 +52,4 @@ export async function verifyOtpUtil(mobile: string, otp: string, verifId: string
return true; return true;
} }
return false; return false;
} }

View file

@ -1,59 +1,54 @@
import Razorpay from "razorpay"; // import Razorpay from "razorpay";
import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"
import { db } from "@/src/db/db_index"
import { payments } from "@/src/db/schema"
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export class RazorpayPaymentService { export class RazorpayPaymentService {
private static instance = new Razorpay({ // private static instance = new Razorpay({
key_id: razorpayId, // key_id: razorpayId,
key_secret: razorpaySecret, // key_secret: razorpaySecret,
}); // });
//
static async createOrder(orderId: number, amount: string) { static async createOrder(orderId: number, amount: string) {
// Create Razorpay order // Create Razorpay order
const razorpayOrder = await this.instance.orders.create({ // const razorpayOrder = await this.instance.orders.create({
amount: parseFloat(amount) * 100, // Convert to paisa // amount: parseFloat(amount) * 100, // Convert to paisa
currency: 'INR', // currency: 'INR',
receipt: `order_${orderId}`, // receipt: `order_${orderId}`,
notes: { // notes: {
customerOrderId: orderId.toString(), // customerOrderId: orderId.toString(),
}, // },
}); // });
//
return razorpayOrder; // return razorpayOrder;
} }
static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) { static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: unknown) {
// Use transaction if provided, otherwise use db // Use transaction if provided, otherwise use db
const dbInstance = tx || db; // const dbInstance = tx || db;
//
// Insert payment record // // Insert payment record
const [payment] = await dbInstance // const [payment] = await dbInstance
.insert(payments) // .insert(payments)
.values({ // .values({
status: 'pending', // status: 'pending',
gateway: 'razorpay', // gateway: 'razorpay',
orderId, // orderId,
token: orderId.toString(), // token: orderId.toString(),
merchantOrderId: razorpayOrder.id, // merchantOrderId: razorpayOrder.id,
payload: razorpayOrder, // payload: razorpayOrder,
}) // })
.returning(); // .returning();
//
return payment; // return payment;
} }
static async initiateRefund(paymentId: string, amount: number) { static async initiateRefund(paymentId: string, amount: number) {
const refund = await this.instance.payments.refund(paymentId, { // const refund = await this.instance.payments.refund(paymentId, {
amount, // amount,
}); // });
return refund; // return refund;
} }
static async fetchRefund(refundId: string) { static async fetchRefund(refundId: string) {
const refund = await this.instance.refunds.fetch(refundId); // const refund = await this.instance.refunds.fetch(refundId);
return refund; // return refund;
} }
} }

View file

@ -1,11 +1,11 @@
import { db } from '@/src/db/db_index' import {
import { orders, orderStatus } from '@/src/db/schema' getOrdersByIdsWithFullData,
import redisClient from '@/src/lib/redis-client' getOrderByIdWithFullData,
} from '@/src/dbService'
import { sendTelegramMessage } from '@/src/lib/telegram-service' import { sendTelegramMessage } from '@/src/lib/telegram-service'
import { inArray, eq } from 'drizzle-orm'; import { queueDataPusher } from '@/src/lib/queue-data-pusher'
import { ensureWorkerInit } from './worker-init';
const ORDER_CHANNEL = 'orders:placed'; import { getAppUrl } from '@/src/lib/env-exporter'
const CANCELLED_CHANNEL = 'orders:cancelled';
interface OrderIdMessage { interface OrderIdMessage {
orderIds: number[]; orderIds: number[];
@ -27,7 +27,20 @@ const formatDateTime = (dateStr: string | null | undefined): string => {
}); });
}; };
const buildTelegramLinks = (orderId: number, userId?: number | null): string => {
const baseUrl = getAppUrl() || 'https://ui.freshyo.in'
const orderUrl = `${baseUrl}/manage-orders/order-details/${orderId}`
const orderLink = `↪ <a href="${orderUrl}">Order</a>`
if (!userId) {
return orderLink
}
const userUrl = `${baseUrl}/user-management/${userId}`
const userLink = `↪ <a href="${userUrl}">User</a>`
return `${orderLink} | ${userLink}`
}
const formatOrderMessageWithFullData = (ordersData: any[]): string => { const formatOrderMessageWithFullData = (ordersData: any[]): string => {
console.log('formatting the msg')
let message = '🛒 <b>New Order Placed</b>\n\n'; let message = '🛒 <b>New Order Placed</b>\n\n';
ordersData.forEach((order, index) => { ordersData.forEach((order, index) => {
@ -35,7 +48,7 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
message += '📦 <b>Items:</b>\n'; message += '📦 <b>Items:</b>\n';
order.orderItems?.forEach((item: any) => { order.orderItems?.forEach((item: any) => {
message += `${item.product?.name || 'Unknown'} x${item.quantity}\n`; message += `${item.product?.name || 'Unknown'} ${item.product.productQuantity}${item.product.unit?.shortNotation}x${item.quantity}\n`;
}); });
message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`; message += `\n💰 <b>Total:</b> ₹${order.totalAmount}\n`;
@ -55,6 +68,8 @@ const formatOrderMessageWithFullData = (ordersData: any[]): string => {
message += ` 📞 ${order.address.phone}\n`; message += ` 📞 ${order.address.phone}\n`;
} }
message += `\n${buildTelegramLinks(order.id, order.userId)}\n`
if (index < ordersData.length - 1) { if (index < ordersData.length - 1) {
message += '\n---\n\n'; message += '\n---\n\n';
} }
@ -79,115 +94,63 @@ ${orderData.orderItems?.map((item: any) => ` • ${item.product?.name || 'Unkno
<b>Reason:</b> ${cancellationData.reason} <b>Reason:</b> ${cancellationData.reason}
👤 <b>Cancelled by:</b> ${cancellationData.cancelledBy === 'admin' ? 'Admin' : 'User'} 👤 <b>Cancelled by:</b> ${cancellationData.cancelledBy === 'admin' ? 'Admin' : 'User'}
<b>Time:</b> ${formatDateTime(cancellationData.cancelledAt)} <b>Time:</b> ${formatDateTime(cancellationData.cancelledAt)}
${buildTelegramLinks(orderData.id, orderData.userId)}
`; `;
return message; return message;
}; };
export const handleOrderPlaced = async (orderIds: number[], rawMessage?: string): Promise<void> => {
try {
const ordersData = await getOrdersByIdsWithFullData(orderIds)
const telegramMessage = formatOrderMessageWithFullData(ordersData)
await sendTelegramMessage(telegramMessage)
} catch (error) {
console.error('Failed to process order message:', error)
const fallback = rawMessage ? `⚠️ Error parsing order: ${rawMessage}` : '⚠️ Error parsing order'
await sendTelegramMessage(fallback)
}
}
export const handleOrderCancelled = async (
cancellationData: CancellationMessage,
rawMessage?: string
): Promise<void> => {
try {
console.log('Order cancellation received, sending to Telegram...')
const orderData = await getOrderByIdWithFullData(cancellationData.orderId)
if (!orderData) {
console.error('Order not found for cancellation:', cancellationData.orderId)
await sendTelegramMessage(`⚠️ Order ${cancellationData.orderId} was cancelled but could not be found in database`)
return
}
const refundStatus = orderData.refunds?.[0]?.refundStatus || 'pending'
const telegramMessage = formatCancellationMessage({ ...orderData, refundStatus }, cancellationData)
await sendTelegramMessage(telegramMessage)
} catch (error) {
console.error('Failed to process cancellation message:', error)
const fallback = rawMessage ? `⚠️ Error processing cancellation: ${rawMessage}` : '⚠️ Error processing cancellation'
await sendTelegramMessage(fallback)
}
}
/** /**
* Start the post order handler * Start the post order handler
* Subscribes to the orders:placed channel and sends to Telegram * Subscribes to the orders:placed channel and sends to Telegram
*/ */
export const startOrderHandler = async (): Promise<void> => {
try {
console.log('Starting post order handler...');
await redisClient.subscribe(ORDER_CHANNEL, async (message: string) => {
try {
const { orderIds }: OrderIdMessage = JSON.parse(message);
console.log('New order received, sending to Telegram...');
const ordersData = await db.query.orders.findMany({
where: inArray(orders.id, orderIds),
with: {
address: true,
orderItems: { with: { product: true } },
slot: true,
},
});
const telegramMessage = formatOrderMessageWithFullData(ordersData);
await sendTelegramMessage(telegramMessage);
} catch (error) {
console.error('Failed to process order message:', error);
await sendTelegramMessage(`⚠️ Error parsing order: ${message}`);
}
});
console.log('Post order handler started successfully');
} catch (error) {
console.error('Failed to start post order handler:', error);
throw error;
}
};
/**
* Stop the post order handler
*/
export const stopOrderHandler = async (): Promise<void> => {
try {
await redisClient.unsubscribe(ORDER_CHANNEL);
console.log('Post order handler stopped');
} catch (error) {
console.error('Error stopping post order handler:', error);
}
};
export const startCancellationHandler = async (): Promise<void> => {
try {
console.log('Starting cancellation handler...');
await redisClient.subscribe(CANCELLED_CHANNEL, async (message: string) => {
try {
const cancellationData: CancellationMessage = JSON.parse(message);
console.log('Order cancellation received, sending to Telegram...');
const orderData = await db.query.orders.findFirst({
where: eq(orders.id, cancellationData.orderId),
with: {
address: true,
orderItems: { with: { product: true } },
refunds: true,
},
});
if (!orderData) {
console.error('Order not found for cancellation:', cancellationData.orderId);
await sendTelegramMessage(`⚠️ Order ${cancellationData.orderId} was cancelled but could not be found in database`);
return;
}
const refundStatus = orderData.refunds?.[0]?.refundStatus || 'pending';
const telegramMessage = formatCancellationMessage({ ...orderData, refundStatus }, cancellationData);
await sendTelegramMessage(telegramMessage);
} catch (error) {
console.error('Failed to process cancellation message:', error);
await sendTelegramMessage(`⚠️ Error processing cancellation: ${message}`);
}
});
console.log('Cancellation handler started successfully');
} catch (error) {
console.error('Failed to start cancellation handler:', error);
throw error;
}
};
export const stopCancellationHandler = async (): Promise<void> => {
try {
await redisClient.unsubscribe(CANCELLED_CHANNEL);
console.log('Cancellation handler stopped');
} catch (error) {
console.error('Error stopping cancellation handler:', error);
}
};
export const publishOrder = async (orderDetails: OrderIdMessage): Promise<boolean> => { export const publishOrder = async (orderDetails: OrderIdMessage): Promise<boolean> => {
console.log('publishing order')
try { try {
const message = JSON.stringify(orderDetails); await queueDataPusher.pushOrderPlacedQueue({
await redisClient.publish(ORDER_CHANNEL, message); name: 'order-placed',
orderIds: orderDetails.orderIds,
})
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to publish order:', error); console.error('Failed to publish order:', error);
@ -220,8 +183,11 @@ export const publishCancellation = async (
reason, reason,
cancelledAt: new Date().toISOString(), cancelledAt: new Date().toISOString(),
}; };
await redisClient.publish(CANCELLED_CHANNEL, JSON.stringify(message)); await queueDataPusher.pushOrderCancelledQueue({
console.log('Cancellation published to Redis:', orderId); name: 'order-cancelled',
...message,
})
console.log('Cancellation published to queue:', orderId);
return true; return true;
} catch (error) { } catch (error) {
console.error('Failed to publish cancellation:', error); console.error('Failed to publish cancellation:', error);

View 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,
})
}
}

View 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()

View file

@ -1,42 +1,48 @@
import { createClient, RedisClientType } from 'redis'; // import { createClient, RedisClientType } from 'redis';
import { redisUrl } from '@/src/lib/env-exporter' import { getRedisUrl } from '@/src/lib/env-exporter'
const createClient = (args:any) => {}
class RedisClient { class RedisClient {
private client: RedisClientType; // private client: RedisClientType;
private subscriberClient: RedisClientType | null = null; // private subscriberClient: RedisClientType | null = null;
private isConnected: boolean = false; // private isConnected: boolean = false;
//
private client: any;
private subscriberrlient: any;
private isConnected: any = false;
constructor() { constructor() {
this.client = createClient({ this.client = createClient({
url: redisUrl, url: getRedisUrl(),
}); });
this.client.on('error', (err) => { // this.client.on('error', (err) => {
console.error('Redis Client Error:', err); // console.error('Redis Client Error:', err);
}); // });
//
this.client.on('connect', () => { // this.client.on('connect', () => {
console.log('Redis Client Connected'); // console.log('Redis Client Connected');
this.isConnected = true; // this.isConnected = true;
}); // });
//
this.client.on('disconnect', () => { // this.client.on('disconnect', () => {
console.log('Redis Client Disconnected'); // console.log('Redis Client Disconnected');
this.isConnected = false; // this.isConnected = false;
}); // });
//
this.client.on('ready', () => { // this.client.on('ready', () => {
console.log('Redis Client Ready'); // console.log('Redis Client Ready');
}); // });
//
this.client.on('reconnecting', () => { // this.client.on('reconnecting', () => {
console.log('Redis Client Reconnecting'); // console.log('Redis Client Reconnecting');
}); // });
// Connect immediately (fire and forget) // Connect immediately (fire and forget)
this.client.connect().catch((err) => { // this.client.connect().catch((err) => {
console.error('Failed to connect Redis:', err); // console.error('Failed to connect Redis:', err);
}); // });
} }
async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> { async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
@ -79,41 +85,41 @@ class RedisClient {
// Subscribe to a channel with callback // Subscribe to a channel with callback
async subscribe(channel: string, callback: (message: string) => void): Promise<void> { async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
if (!this.subscriberClient) { // if (!this.subscriberClient) {
this.subscriberClient = createClient({ // this.subscriberClient = createClient({
url: redisUrl, // url: redisUrl,
}); // });
//
this.subscriberClient.on('error', (err) => { // this.subscriberClient.on('error', (err) => {
console.error('Redis Subscriber Error:', err); // console.error('Redis Subscriber Error:', err);
}); // });
//
this.subscriberClient.on('connect', () => { // this.subscriberClient.on('connect', () => {
console.log('Redis Subscriber Connected'); // console.log('Redis Subscriber Connected');
}); // });
//
await this.subscriberClient.connect(); // await this.subscriberClient.connect();
} // }
//
await this.subscriberClient.subscribe(channel, callback); // await this.subscriberClient.subscribe(channel, callback);
console.log(`Subscribed to channel: ${channel}`); console.log(`Subscribed to channel: ${channel}`);
} }
// Unsubscribe from a channel // Unsubscribe from a channel
async unsubscribe(channel: string): Promise<void> { async unsubscribe(channel: string): Promise<void> {
if (this.subscriberClient) { // if (this.subscriberClient) {
await this.subscriberClient.unsubscribe(channel); // await this.subscriberClient.unsubscribe(channel);
console.log(`Unsubscribed from channel: ${channel}`); // console.log(`Unsubscribed from channel: ${channel}`);
} // }
} }
disconnect(): void { disconnect(): void {
if (this.isConnected) { // if (this.isConnected) {
this.client.disconnect(); // this.client.disconnect();
} // }
if (this.subscriberClient) { // if (this.subscriberClient) {
this.subscriberClient.disconnect(); // this.subscriberClient.disconnect();
} // }
} }
get isClientConnected(): boolean { get isClientConnected(): boolean {

View 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
}

View file

@ -1,5 +1,3 @@
import { db } from "@/src/db/db_index"
/** /**
* Constants for role names to avoid hardcoding and typos * Constants for role names to avoid hardcoding and typos
*/ */

246
apps/backend/src/lib/s3-client.ts Executable file → Normal file
View file

@ -1,82 +1,108 @@
// import { s3A, awsBucketName, awsRegion, awsSecretAccessKey } from "@/src/lib/env-exporter" import type { Buffer } from 'buffer'
import { DeleteObjectCommand, DeleteObjectsCommand, PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3" import { AwsClient } from 'aws4fetch'
import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { claimUploadUrlStatus, createUploadUrlStatus } from '@/src/dbService'
import signedUrlCache from "@/src/lib/signed-url-cache" import {
import { s3AccessKeyId, s3Region, s3Url, s3SecretAccessKey, s3BucketName, assetsDomain } from "@/src/lib/env-exporter" getS3AccessKeyId,
import { db } from "@/src/db/db_index"; // Adjust path if needed getS3Region,
import { uploadUrlStatus } from "@/src/db/schema" getS3Url,
import { and, eq } from 'drizzle-orm'; getS3SecretAccessKey,
getS3BucketName,
getAssetsDomain,
} from '@/src/lib/env-exporter'
const s3Client = new S3Client({ let awsClient: AwsClient | null = null
region: s3Region, let awsClientKey = ''
endpoint: s3Url,
forcePathStyle: true,
credentials: {
accessKeyId: s3AccessKeyId,
secretAccessKey: s3SecretAccessKey,
},
})
export default s3Client;
export const imageUploadS3 = async(body: Buffer<ArrayBufferLike>, type: string, key:string) => { const getAwsClient = () => {
// const key = `${category}/${Date.now()}` const region = getS3Region()
const command = new PutObjectCommand({ const endpoint = getS3Url()
Bucket: s3BucketName, const accessKeyId = getS3AccessKeyId()
Key: key, const secretAccessKey = getS3SecretAccessKey()
Body: body, const nextKey = `${region}|${endpoint}|${accessKeyId}|${secretAccessKey}`
ContentType: type,
}) if (!awsClient || nextKey !== awsClientKey) {
const resp = await s3Client.send(command) awsClientKey = nextKey
awsClient = new AwsClient({
const imageUrl = `${key}` accessKeyId,
return imageUrl; secretAccessKey,
region,
service: 's3',
})
}
return awsClient
} }
const buildObjectUrl = (bucket: string, key: string) => {
const endpoint = getS3Url()
const normalizedEndpoint = endpoint.endsWith('/')
? endpoint.slice(0, -1)
: endpoint
const normalizedKey = key.replace(/^\/+/, '')
return `${normalizedEndpoint}/${bucket}/${normalizedKey}`
}
export const imageUploadS3 = async(body: Buffer, type: string, key: string) => {
const client = getAwsClient()
const url = buildObjectUrl(getS3BucketName(), key)
const resp = await client.fetch(url, {
method: 'PUT',
headers: {
'Content-Type': type,
},
body,
})
if (!resp.ok) {
const responseBody = await resp.text().catch(() => '')
throw new Error(`Failed to upload image: ${resp.status} ${responseBody}`)
}
const imageUrl = `${key}`
return imageUrl
}
// export async function deleteImageUtil(...keys:string[]):Promise<boolean>; // export async function deleteImageUtil(...keys:string[]):Promise<boolean>;
export async function deleteImageUtil({bucket = s3BucketName, keys}:{bucket?:string, keys: string[]}) { export async function deleteImageUtil({bucket = getS3BucketName(), keys}:{bucket?: string, keys: string[]}) {
if (keys.length === 0) { if (keys.length === 0) {
return true; return true
} }
try { try {
const deleteParams = { const client = getAwsClient()
Bucket: bucket, await Promise.all(
Delete: { keys.map(async (key) => {
Objects: keys.map((key) => ({ Key: key })), const url = buildObjectUrl(bucket, key)
Quiet: false, const resp = await client.fetch(url, { method: 'DELETE' })
} if (!resp.ok && resp.status !== 404) {
} const body = await resp.text().catch(() => '')
throw new Error(`Failed to delete image: ${resp.status} ${body}`)
const deleteCommand = new DeleteObjectsCommand(deleteParams) }
await s3Client.send(deleteCommand) })
)
return true return true
} }
catch (error) { catch (error) {
console.error("Error deleting image:", error) console.error('Error deleting image:', error)
throw new Error("Failed to delete image") throw new Error('Failed to delete image')
return false;
} }
} }
export function scaffoldAssetUrl(input: string | null): string export function scaffoldAssetUrl(input: string | null): string
export function scaffoldAssetUrl(input: (string | null)[]): string[] export function scaffoldAssetUrl(input: (string | null)[]): string[]
export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] { export function scaffoldAssetUrl(input: string | null | (string | null)[]): string | string[] {
const assetsDomain = getAssetsDomain()
if (Array.isArray(input)) { if (Array.isArray(input)) {
return input.map(key => scaffoldAssetUrl(key) as string); return input.map(key => scaffoldAssetUrl(key) as string)
} }
if (!input) { if (!input) {
return ''; return ''
} }
const normalizedKey = input.replace(/^\/+/, ''); const normalizedKey = input.replace(/^\/+/, '')
const domain = assetsDomain.endsWith('/') const domain = assetsDomain.endsWith('/')
? assetsDomain.slice(0, -1) ? assetsDomain.slice(0, -1)
: assetsDomain; : assetsDomain
return `${domain}/${normalizedKey}`; return `${domain}/${normalizedKey}`
} }
/** /**
* Generate a signed URL from an S3 URL * Generate a signed URL from an S3 URL
* @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object) * @param s3Url The full S3 URL (e.g., https://bucket-name.s3.region.amazonaws.com/path/to/object)
@ -85,35 +111,23 @@ export function scaffoldAssetUrl(input: string | null | (string | null)[]): stri
*/ */
export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise<string> { export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresIn: number = 259200): Promise<string> {
if (!s3UrlRaw) { if (!s3UrlRaw) {
return ''; return ''
} }
const s3Url = s3UrlRaw const s3Url = s3UrlRaw
try { try {
// Check if we have a cached signed URL const client = getAwsClient()
const cachedUrl = signedUrlCache.get(s3Url); const url = buildObjectUrl(getS3BucketName(), s3Url)
if (cachedUrl) { const signedRequest = await client.sign(url, {
// Found in cache, return it method: 'GET',
return cachedUrl; signQuery: true,
} expires: expiresIn,
})
// Create the command to get the object return signedRequest.url
const command = new GetObjectCommand({
Bucket: s3BucketName,
Key: s3Url,
});
// Generate the signed URL
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
// Cache the signed URL with TTL matching the expiration time (convert seconds to milliseconds)
signedUrlCache.set(s3Url, signedUrl, (expiresIn * 1000) - 60000); // Subtract 1 minute to ensure it doesn't expire before use
return signedUrl;
} catch (error) { } catch (error) {
console.error("Error generating signed URL:", error); console.error('Error generating signed URL:', error)
throw new Error("Failed to generate signed URL"); throw new Error('Failed to generate signed URL')
} }
} }
@ -123,14 +137,9 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
* @returns The original S3 URL if found in cache, otherwise null * @returns The original S3 URL if found in cache, otherwise null
*/ */
export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null { export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null {
if (!signedUrl) { // Cache disabled for Workers compatibility - cannot retrieve original URL without cache
return null; // To re-enable, migrate signed-url-cache to object storage (R2/S3)
} return null
// Try to find the original URL in our cache
const originalUrl = signedUrlCache.getOriginalUrl(signedUrl);
return originalUrl || null;
} }
/** /**
@ -141,47 +150,43 @@ export function getOriginalUrlFromSignedUrl(signedUrl: string|null): string|null
*/ */
export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> { export async function generateSignedUrlsFromS3Urls(s3Urls: (string|null)[], expiresIn: number = 259200): Promise<string[]> {
if (!s3Urls || !s3Urls.length) { if (!s3Urls || !s3Urls.length) {
return []; return []
} }
try { try {
// Process URLs in parallel for better performance
const signedUrls = await Promise.all( const signedUrls = await Promise.all(
s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => '')) s3Urls.map(url => generateSignedUrlFromS3Url(url, expiresIn).catch(() => ''))
); )
return signedUrls; return signedUrls
} catch (error) { } catch (error) {
console.error("Error generating multiple signed URLs:", error); console.error('Error generating multiple signed URLs:', error)
// Return an array of empty strings with the same length as input return s3Urls.map(() => '')
return s3Urls.map(() => '');
} }
} }
export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> { export async function generateUploadUrl(key: string, mimeType: string, expiresIn: number = 180): Promise<string> {
try { try {
// Insert record into upload_url_status await createUploadUrlStatus(key)
await db.insert(uploadUrlStatus).values({
key: key,
status: 'pending',
});
// Generate signed upload URL const client = getAwsClient()
const command = new PutObjectCommand({ const url = buildObjectUrl(getS3BucketName(), key)
Bucket: s3BucketName, const signedRequest = await client.sign(url, {
Key: key, method: 'PUT',
ContentType: mimeType, signQuery: true,
}); expires: expiresIn,
headers: {
'Content-Type': mimeType,
},
})
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn }); return signedRequest.url
return signedUrl;
} catch (error) { } catch (error) {
console.error('Error generating upload URL:', error); console.error('Error generating upload URL:', error)
throw new Error('Failed to generate upload URL'); throw new Error('Failed to generate upload URL')
} }
} }
// export function extractKeyFromPresignedUrl(url:string) { // export function extractKeyFromPresignedUrl(url:string) {
// const u = new URL(url); // const u = new URL(url);
// const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash // const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash
@ -190,32 +195,27 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
// New function (excludes bucket name) // New function (excludes bucket name)
export function extractKeyFromPresignedUrl(url: string): string { export function extractKeyFromPresignedUrl(url: string): string {
const u = new URL(url); const u = new URL(url)
const rawKey = u.pathname.replace(/^\/+/, ""); // remove leading slash const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash
const decodedKey = decodeURIComponent(rawKey); const decodedKey = decodeURIComponent(rawKey)
// Remove bucket prefix // Remove bucket prefix
const parts = decodedKey.split('/'); const parts = decodedKey.split('/')
parts.shift(); // Remove bucket name parts.shift() // Remove bucket name
return parts.join('/'); return parts.join('/')
} }
export async function claimUploadUrl(url: string): Promise<void> { export async function claimUploadUrl(url: string): Promise<void> {
try { try {
const semiKey = extractKeyFromPresignedUrl(url); const semiKey = extractKeyFromPresignedUrl(url)
const key = s3BucketName+'/'+ semiKey
// Update status to 'claimed' if currently 'pending' // Update status to 'claimed' if currently 'pending'
const result = await db const updated = await claimUploadUrlStatus(semiKey)
.update(uploadUrlStatus)
.set({ status: 'claimed' })
.where(and(eq(uploadUrlStatus.key, semiKey), eq(uploadUrlStatus.status, 'pending')))
.returning();
if (result.length === 0) { if (!updated) {
throw new Error('Upload URL not found or already claimed'); throw new Error('Upload URL not found or already claimed')
} }
} catch (error) { } catch (error) {
console.error('Error claiming upload URL:', error); console.error('Error claiming upload URL:', error)
throw new Error('Failed to claim upload URL'); throw new Error('Failed to claim upload URL')
} }
} }

View 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');
}
}

View 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.");
}

View 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
View file

@ -1,263 +1,24 @@
import fs from 'fs'; // SIGNED URL CACHE - DISABLED
import path from 'path'; // This file has been disabled to make the backend compatible with Cloudflare Workers.
// File system operations are not available in the Workers environment.
//
// To re-enable caching, migrate to Cloudflare R2 or another object storage solution.
// Original file saved as: signed-url-cache-old.ts
//
// Impact of disabling:
// - S3 signed URLs are generated fresh on every request
// - Increased AWS API calls (higher costs)
// - Slightly slower image loading
// - No file system dependencies (Workers-compatible)
const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json'); export default {
get: () => undefined,
// Interface for cache entries with TTL set: () => {},
interface CacheEntry { getOriginalUrl: () => undefined,
value: string; has: () => false,
expiresAt: number; // Timestamp when this entry expires hasSignedUrl: () => false,
} clear: () => {},
clearExpired: () => 0,
class SignedURLCache { saveToDisk: () => {},
private originalToSignedCache: Map<string, CacheEntry>; loadFromDisk: () => {},
private signedToOriginalCache: Map<string, CacheEntry>; };
constructor() {
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
// Create cache directory if it doesn't exist
const cacheDir = path.dirname(CACHE_FILE_PATH);
if (!fs.existsSync(cacheDir)) {
console.log('creating the directory')
fs.mkdirSync(cacheDir, { recursive: true });
}
else {
console.log('the directory is already present')
}
}
/**
* Get a signed URL from the cache using an original URL as the key
*/
get(originalUrl: string): string | undefined {
const entry = this.originalToSignedCache.get(originalUrl);
// If no entry or entry has expired, return undefined
if (!entry || Date.now() > entry.expiresAt) {
if (entry) {
// Remove expired entry
this.originalToSignedCache.delete(originalUrl);
// Also remove from reverse mapping if it exists
this.signedToOriginalCache.delete(entry.value);
}
return undefined;
}
return entry.value;
}
/**
* Get the original URL from the cache using a signed URL as the key
*/
getOriginalUrl(signedUrl: string): string | undefined {
const entry = this.signedToOriginalCache.get(signedUrl);
// If no entry or entry has expired, return undefined
if (!entry || Date.now() > entry.expiresAt) {
if (entry) {
// Remove expired entry
this.signedToOriginalCache.delete(signedUrl);
// Also remove from primary mapping if it exists
this.originalToSignedCache.delete(entry.value);
}
return undefined;
}
return entry.value;
}
/**
* Set a value in the cache with a TTL (Time To Live)
* @param originalUrl The original S3 URL
* @param signedUrl The signed URL
* @param ttlMs Time to live in milliseconds (default: 3 days)
*/
set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void {
const expiresAt = Date.now() + ttlMs;
const entry: CacheEntry = {
value: signedUrl,
expiresAt
};
const reverseEntry: CacheEntry = {
value: originalUrl,
expiresAt
};
this.originalToSignedCache.set(originalUrl, entry);
this.signedToOriginalCache.set(signedUrl, reverseEntry);
}
has(originalUrl: string): boolean {
const entry = this.originalToSignedCache.get(originalUrl);
// Entry exists and hasn't expired
return !!entry && Date.now() <= entry.expiresAt;
}
hasSignedUrl(signedUrl: string): boolean {
const entry = this.signedToOriginalCache.get(signedUrl);
// Entry exists and hasn't expired
return !!entry && Date.now() <= entry.expiresAt;
}
clear(): void {
this.originalToSignedCache.clear();
this.signedToOriginalCache.clear();
this.saveToDisk();
}
/**
* Remove all expired entries from the cache
* @returns The number of expired entries that were removed
*/
clearExpired(): number {
const now = Date.now();
let removedCount = 0;
// Clear expired entries from original to signed cache
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
if (now > entry.expiresAt) {
this.originalToSignedCache.delete(originalUrl);
removedCount++;
}
}
// Clear expired entries from signed to original cache
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
if (now > entry.expiresAt) {
this.signedToOriginalCache.delete(signedUrl);
// No need to increment removedCount as we've already counted these in the first loop
}
}
if (removedCount > 0) {
console.log(`SignedURLCache: Cleared ${removedCount} expired entries`);
}
return removedCount;
}
/**
* Save the cache to disk
*/
saveToDisk(): void {
try {
// Remove expired entries before saving
const removedCount = this.clearExpired();
// Convert Maps to serializable objects
const serializedOriginalToSigned: Record<string, { value: string; expiresAt: number }> = {};
const serializedSignedToOriginal: Record<string, { value: string; expiresAt: number }> = {};
for (const [originalUrl, entry] of this.originalToSignedCache.entries()) {
serializedOriginalToSigned[originalUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) {
serializedSignedToOriginal[signedUrl] = {
value: entry.value,
expiresAt: entry.expiresAt
};
}
const serializedCache = {
originalToSigned: serializedOriginalToSigned,
signedToOriginal: serializedSignedToOriginal
};
// Write to file
fs.writeFileSync(
CACHE_FILE_PATH,
JSON.stringify(serializedCache),
'utf8'
);
console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`);
} catch (error) {
console.error('Error saving SignedURLCache to disk:', error);
}
}
/**
* Load the cache from disk
*/
loadFromDisk(): void {
try {
if (fs.existsSync(CACHE_FILE_PATH)) {
// Read from file
const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8');
// Parse the data
const parsedData = JSON.parse(data) as {
originalToSigned: Record<string, { value: string; expiresAt: number }>,
signedToOriginal: Record<string, { value: string; expiresAt: number }>
};
// Only load entries that haven't expired yet
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
// Load original to signed mappings
if (parsedData.originalToSigned) {
for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) {
if (now <= entry.expiresAt) {
this.originalToSignedCache.set(originalUrl, entry);
loadedCount++;
} else {
expiredCount++;
}
}
}
// Load signed to original mappings
if (parsedData.signedToOriginal) {
for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) {
if (now <= entry.expiresAt) {
this.signedToOriginalCache.set(signedUrl, entry);
// Don't increment loadedCount as these are pairs of what we already counted
} else {
// Don't increment expiredCount as these are pairs of what we already counted
}
}
}
console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`);
} else {
console.log('SignedURLCache: No cache file found, starting with empty cache');
}
} catch (error) {
console.error('Error loading SignedURLCache from disk:', error);
// Start with empty caches if loading fails
this.originalToSignedCache = new Map();
this.signedToOriginalCache = new Map();
}
}
}
// Create a singleton instance to be used throughout the application
const signedUrlCache = new SignedURLCache();
process.on('SIGINT', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('SignedURLCache: Saving cache before shutdown...');
signedUrlCache.saveToDisk();
process.exit(0);
});
export default signedUrlCache;

Some files were not shown because too many files have changed in this diff Show more