Compare commits

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

56 commits

Author SHA1 Message Date
shafi54
5642c53462 notif after complaint resolution 2026-05-21 08:45:07 +05:30
shafi54
0290c170bb mass notifs working 2026-05-20 21:35:48 +05:30
shafi54
286e6aabcb enh 2026-05-20 09:29:36 +05:30
shafi54
01f6b88392 enh 2026-05-13 00:04:23 +05:30
shafi54
5d7f6b7aab enh 2026-05-11 20:57:13 +05:30
shafi54
5bacc25627 enh 2026-05-11 19:25:44 +05:30
shafi54
4d660e945b enh 2026-05-10 19:44:26 +05:30
shafi54
396eba7c1b enh 2026-05-10 16:45:39 +05:30
shafi54
2759bc1488 enh 2026-05-10 11:06:57 +05:30
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
789 changed files with 596670 additions and 21961 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*
.pnpm-debug.log*
**/.wrangler/*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View file

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

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

83
apps/admin-ui/.detoxrc.js Normal file
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"
},
"edgeToEdgeEnabled": true,
"package": "in.freshyo.adminui"
"package": "in.freshyo.adminui",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "ui.freshyo.in",
"pathPrefix": "/manage-orders/order-details"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"web": {
"bundler": "metro",

View file

@ -226,9 +226,8 @@ export default function Layout() {
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<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 createCoupon = trpc.admin.coupon.create.useMutation();
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation();
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
{ limit: 20, search: '' },
{
enabled: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
)
const handleCreateCoupon = (values: any) => {
console.log('Form values:', values); // Debug log
@ -27,7 +41,9 @@ export default function CreateCoupon() {
if (isLoading) return; // Prevent double submission
mutation.mutate(payload, {
onSuccess: () => {
onSuccess: async () => {
await refetchCoupons()
await refetchReservedCoupons()
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
{ text: 'OK', onPress: () => router.back() }
]);
@ -46,4 +62,4 @@ export default function CreateCoupon() {
/>
</AppContainer>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import React from 'react';
import { View, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui';
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useCreateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
@ -15,50 +15,51 @@ interface TagFormData {
export default function AddTag() {
const router = useRouter();
const { mutate: createTag, isPending: isCreating } = useCreateTag();
const createTag = trpc.admin.product.createProductTag.useMutation();
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
const formData = new FormData();
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
try {
let imageUrl: string | null | undefined;
let uploadUrls: string[] = []
// Add text fields
formData.append('tagName', values.tagName);
if (values.tagDescription) {
formData.append('tagDescription', values.tagDescription);
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
}
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 = {
tagName: '',
@ -76,10 +77,10 @@ export default function AddTag() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isCreating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
isLoading={createTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>
);
}
}

View file

@ -1,10 +1,10 @@
import React from 'react';
import { View, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { AppContainer, MyText, tw } from 'common-ui';
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
import TagForm from '@/src/components/TagForm';
import { useGetTag, useUpdateTag } from '@/src/api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
interface TagFormData {
tagName: string;
@ -19,53 +19,60 @@ export default function EditTag() {
const { tagId } = useLocalSearchParams<{ tagId: string }>();
const tagIdNum = tagId ? parseInt(tagId) : null;
const { data: tagData, isLoading: isLoadingTag, error: tagError } = useGetTag(tagIdNum!);
const { mutate: updateTag, isPending: isUpdating } = useUpdateTag();
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
{ id: tagIdNum || 0 },
{ enabled: !!tagIdNum }
)
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
enabled: false,
});
const updateTag = trpc.admin.product.updateProductTag.useMutation();
const { data: storesData } = trpc.admin.store.getStores.useQuery();
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: TagFormData, image?: { uri?: string }) => {
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
if (!tagIdNum) return;
const formData = new FormData();
try {
let imageUrl: string | null | undefined
let uploadUrls: string[] = []
// Add text fields
formData.append('tagName', values.tagName);
if (values.tagDescription) {
formData.append('tagDescription', values.tagDescription);
const newImage = images.find((image) => image.mimeType !== null)
if (newImage) {
const response = await fetch(newImage.imgUrl)
const blob = await response.blob()
const result = await upload({
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
contextString: 'tags',
})
imageUrl = result.keys[0]
uploadUrls = result.presignedUrls
} else if (removedExisting) {
imageUrl = null
}
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) {
return (
@ -92,7 +99,7 @@ export default function EditTag() {
tagName: tag.tagName,
tagDescription: tag.tagDescription || '',
isDashboardTag: tag.isDashboardTag,
relatedStores: tag.relatedStores || [],
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
existingImageUrl: tag.imageUrl || undefined,
};
@ -106,10 +113,10 @@ export default function EditTag() {
initialValues={initialValues}
existingImageUrl={tag.imageUrl || undefined}
onSubmit={handleSubmit}
isLoading={isUpdating}
stores={storesData?.stores.map(store => ({ id: store.id, name: store.name })) || []}
isLoading={updateTag.isPending || isUploading}
stores={storesData?.stores.map((store: { id: number; name: string }) => ({ id: store.id, name: store.name })) || []}
/>
</View>
</AppContainer>
);
}
}

View file

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

View file

@ -1,62 +1,56 @@
import React from 'react';
import { Alert } from 'react-native';
import { AppContainer } from 'common-ui';
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
import ProductForm from '@/src/components/ProductForm';
import { useCreateProduct, CreateProductPayload } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
export default function AddProduct() {
const { mutate: createProduct, isPending: isCreating } = useCreateProduct();
const createProduct = trpc.admin.product.createProduct.useMutation();
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
});
const { upload, isUploading } = useUploadToObjectStorage();
const handleSubmit = (values: any, images?: { uri?: string, mimeType?: string }[]) => {
const payload: CreateProductPayload = {
name: values.name,
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 handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
try {
let uploadUrls: string[] = [];
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
formData.append(key, value as string);
if (images.length > 0) {
const blobs = await Promise.all(
images.map(async (img) => {
const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
}
});
// Append tag IDs
if (values.tagIds && values.tagIds.length > 0) {
values.tagIds.forEach((tagId: number) => {
formData.append('tagIds', tagId.toString());
await createProduct.mutateAsync({
name: values.name,
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,
isSuspended: values.isSuspended || false,
isFlashAvailable: values.isFlashAvailable || false,
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
uploadUrls,
tagIds: values.tagIds || [],
});
}
// Append images
if (images) {
images.forEach((image, index) => {
if (image.uri) {
formData.append('images', {
uri: image.uri,
name: `image-${index}.jpg`,
// type: 'image/jpeg',
type: image.mimeType as any,
} as any);
}
});
await refetchProducts();
Alert.alert('Success', 'Product created successfully!');
} catch (error: any) {
Alert.alert('Error', error.message || 'Failed to create product');
}
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 = {
@ -81,9 +75,8 @@ export default function AddProduct() {
mode="create"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isCreating}
existingImages={[]}
isLoading={createProduct.isPending || isUploading}
/>
</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 { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
@ -23,10 +24,9 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const [adminResponse, setAdminResponse] = useState('');
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.useMutation();
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
const { upload } = useUploadToObjectStorage();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
@ -62,37 +62,16 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
const handleSubmit = async (adminResponse: string) => {
try {
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
const { keys, presignedUrls } = await upload({
images: selectedImages,
contextString: 'review',
mimeTypes,
});
const keys = generatedUrls.map(url => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, "");
const decodedKey = decodeURIComponent(rawKey);
const parts = decodedKey.split('/');
parts.shift();
return parts.join('/');
});
setUploadUrls(generatedUrls);
for (let i = 0; i < generatedUrls.length; i++) {
const uploadUrl = generatedUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: { 'Content-Type': mimeType },
});
if (!uploadResponse.ok) throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
await respondToReview.mutateAsync({
reviewId,
adminResponse,
adminResponseImages: keys,
uploadUrls: generatedUrls,
uploadUrls: presignedUrls,
});
Alert.alert('Success', 'Response submitted');
@ -100,9 +79,7 @@ const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClo
setAdminResponse('');
setSelectedImages([]);
setDisplayImages([]);
setUploadUrls([]);
} catch (error:any) {
Alert.alert('Error', error.message || 'Failed to submit response.');
}
};

View file

@ -1,95 +1,74 @@
import React, { useRef } from 'react';
import { View, Text, Alert } from 'react-native';
import { View, Alert } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { AppContainer, useManualRefresh, MyText, tw } from 'common-ui';
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
import { useUpdateProduct } from '@/src/api-hooks/product.api';
import { trpc } from '@/src/trpc-client';
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
export default function EditProduct() {
const { id } = useLocalSearchParams();
const productId = Number(id);
const productFormRef = useRef<ProductFormRef>(null);
// const { data: product, isLoading: isFetching, refetch } = useGetProduct(productId);
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
{ id: productId },
{ enabled: !!productId }
);
//
const { mutate: updateProduct, isPending: isUpdating } = useUpdateProduct();
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
enabled: false,
});
const updateProduct = trpc.admin.product.updateProduct.useMutation();
const { upload, isUploading } = useUploadToObjectStorage();
useManualRefresh(() => refetch());
const handleSubmit = (values: any, newImages?: { uri?: string }[], imagesToDelete?: string[]) => {
const payload = {
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
try {
// New images have mimeType !== null, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
let uploadUrls: string[] = [];
if (newImages.length > 0) {
const blobs = await Promise.all(
newImages.map(async (img) => {
const response = await fetch(img.url);
const blob = await response.blob();
return { blob, mimeType: img.mimeType || 'image/jpeg' };
})
);
const result = await upload({ images: blobs, contextString: 'product_info' });
uploadUrls = result.presignedUrls;
}
await updateProduct.mutateAsync({
id: productId,
name: values.name,
shortDescription: values.shortDescription,
longDescription: values.longDescription,
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,
deals: values.deals?.filter((deal: any) =>
deal.quantity && deal.price && deal.validTill
).map((deal: any) => ({
quantity: parseInt(deal.quantity),
price: parseFloat(deal.price),
validTill: deal.validTill instanceof Date
? deal.validTill.toISOString().split('T')[0]
: deal.validTill, // Convert Date to YYYY-MM-DD string
})),
tagIds: values.tagIds,
};
const formData = new FormData();
Object.entries(payload).forEach(([key, value]) => {
if (key === 'deals' && Array.isArray(value)) {
formData.append(key, JSON.stringify(value));
} else if (key === 'tagIds' && Array.isArray(value)) {
value.forEach(tagId => {
formData.append('tagIds', tagId.toString());
});
} else if (value !== undefined && value !== null) {
formData.append(key, value as string);
}
});
// 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);
}
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) : null,
uploadUrls,
imagesToDelete,
tagIds: values.tagIds || [],
});
}
// Add images to delete
if (imagesToDelete && imagesToDelete.length > 0) {
formData.append('imagesToDelete', JSON.stringify(imagesToDelete));
await refetch();
await refetchProducts();
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) {
@ -112,7 +91,13 @@ export default function EditProduct() {
);
}
const productData = product.product; // The API returns { product: Product }
const productData = product.product;
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
imgUrl: url,
mimeType: null,
}));
const existingImageKeys = productData.imageKeys || [];
const initialValues = {
name: productData.name,
@ -125,7 +110,7 @@ export default function EditProduct() {
deals: productData.deals?.map(deal => ({
quantity: deal.quantity,
price: deal.price,
validTill: deal.validTill ? new Date(deal.validTill) : null, // Convert to Date object
validTill: deal.validTill ? new Date(deal.validTill) : null,
})) || [{ quantity: '', price: '', validTill: null }],
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
isSuspended: productData.isSuspended || false,
@ -141,9 +126,10 @@ export default function EditProduct() {
mode="edit"
initialValues={initialValues}
onSubmit={handleSubmit}
isLoading={isUpdating}
existingImages={productData.images || []}
isLoading={updateProduct.isPending || isUploading}
existingImages={existingImages}
existingImageKeys={existingImageKeys}
/>
</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 { trpc } from '@/src/trpc-client';
import { Product } from '@/src/api-hooks/product.api';
import type { AdminProduct } from '@packages/shared';
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
@ -54,7 +54,7 @@ export default function Products() {
// const handleToggleStock = (product: any) => {
const handleToggleStock = (product: Pick<Product, 'id' | 'name' | 'isOutOfStock'>) => {
const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
Alert.alert(
'Update Stock Status',

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
import ProductsSelector from './ProductsSelector';
import { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
export interface BannerFormData {
@ -52,10 +53,10 @@ export default function BannerForm({
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const { uploadSingle } = useUploadToObjectStorage();
// Fetch products for dropdown
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
const products = productsData?.products || [];
@ -97,33 +98,11 @@ export default function BannerForm({
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store', // Using 'store' for now
mimeTypes,
});
// Upload image
const uploadUrl = uploadUrls[0];
const { blob, mimeType } = selectedImages[0];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
imageUrl = uploadUrl;
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl;
}
// Call onSubmit with form values and imageUrl
await onSubmit(values, imageUrl);
} catch (error) {
console.error('Upload error:', error);
@ -256,4 +235,4 @@ export default function BannerForm({
)}
</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 { trpc } from '../src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
export interface StoreFormData {
name: string;
@ -59,14 +60,19 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
});
}, [initialValues, initialSelectedProducts]);
const staffOptions = staffData?.staff.map(staff => ({
const existingImageUrls = useMemo(
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
[formInitialValues.imageUrl]
)
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
label: staff.name,
value: staff.id,
})) || [];
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation();
const { uploadSingle, isUploading } = useUploadToObjectStorage();
const handleImagePick = usePickImage({
setFile: async (assets: any) => {
@ -113,39 +119,11 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
let imageUrl: string | undefined;
if (selectedImages.length > 0) {
// Generate upload URLs
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls } = await generateUploadUrls.mutateAsync({
contextString: 'store',
mimeTypes,
});
// Upload images
for (let i = 0; i < uploadUrls.length; i++) {
const uploadUrl = uploadUrls[i];
const { blob, mimeType } = selectedImages[i];
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: blob,
headers: {
'Content-Type': mimeType,
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status ${uploadResponse.status}`);
}
}
// Extract key from first upload URL
// const u = new URL(uploadUrls[0]);
// const rawKey = u.pathname.replace(/^\/+/, "");
// imageUrl = decodeURIComponent(rawKey);
imageUrl = uploadUrls[0];
const { blob, mimeType } = selectedImages[0];
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store');
imageUrl = presignedUrl;
}
// Submit form with imageUrl
onSubmit({ ...values, imageUrl });
} catch (error) {
console.error('Upload error:', error);
@ -195,20 +173,25 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
<ImageUploader
images={displayImages}
existingImageUrls={formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []}
existingImageUrls={existingImageUrls}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
onRemoveExistingImage={() => setFormInitialValues({ ...formInitialValues, imageUrl: undefined })}
onRemoveExistingImage={() =>
setFormInitialValues((prev) => ({
...prev,
imageUrl: undefined,
}))
}
allowMultiple={false}
/>
</View>
<TouchableOpacity
onPress={submit}
disabled={isLoading || generateUploadUrls.isPending}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || generateUploadUrls.isPending ? 'bg-gray-400' : 'bg-blue-500'}`}
disabled={isLoading || isUploading}
style={tw`px-4 py-2 rounded-lg shadow-lg items-center mt-2 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
>
<MyText style={tw`text-white text-lg font-bold`}>
{generateUploadUrls.isPending ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
</MyText>
</TouchableOpacity>
</View>
@ -220,4 +203,4 @@ const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
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": {
"development": {
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"channel": "development"
},
"preview": {
"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 { Image } from 'expo-image';
import { Formik, FieldArray } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, BottomDropdown, MyText, ImageUploader, ImageGalleryWithDelete, useTheme, DatePicker, tw, useFocusCallback, Checkbox } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { MyTextInput, BottomDropdown, MyText, useTheme, DatePicker, tw, useFocusCallback, Checkbox, ImageUploaderNeo, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { trpc } from '../trpc-client';
import { useGetTags } from '../api-hooks/tag.api';
interface ProductFormData {
name: string;
@ -38,9 +35,10 @@ export interface ProductFormRef {
interface ProductFormProps {
mode: 'create' | 'edit';
initialValues: ProductFormData;
onSubmit: (values: ProductFormData, images?: { uri?: string }[], imagesToDelete?: string[]) => void;
onSubmit: (values: ProductFormData, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => void;
isLoading: boolean;
existingImages?: string[];
existingImages?: ImageUploaderNeoItem[];
existingImageKeys?: string[];
}
const unitOptions = [
@ -50,18 +48,22 @@ const unitOptions = [
{ label: 'Unit Piece', value: 4 },
];
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
mode,
initialValues,
onSubmit,
isLoading,
existingImages = []
existingImages:existingImagesRaw,
existingImageKeys = [],
}, ref) => {
const { theme } = useTheme();
const [images, setImages] = useState<{ uri?: string }[]>([]);
const [existingImagesState, setExistingImagesState] = useState<string[]>(existingImages);
const [images, setImages] = useState<ImageUploaderNeoItem[]>([]);
const existingImages = existingImagesRaw || []
// Sync images state when existingImages prop changes (e.g., when async query data arrives)
useEffect(() => {
setImages(existingImages);
}, [existingImagesRaw]);
const { data: storesData } = trpc.common.getStoresSummary.useQuery();
const storeOptions = storesData?.stores.map(store => ({
@ -69,44 +71,50 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id,
})) || [];
const { data: tagsData } = useGetTags();
const { data: tagsData } = trpc.admin.product.getProductTags.useQuery();
const tagOptions = tagsData?.tags.map(tag => ({
label: tag.tagName,
value: tag.id.toString(),
})) || [];
// Initialize existing images state when existingImages prop changes
useEffect(() => {
console.log('changing existing imaes statte')
setExistingImagesState(existingImages);
}, [existingImages]);
const pickImage = usePickImage({
setFile: (files) => setImages(prev => [...prev, ...files]),
multiple: true,
});
// Calculate which existing images were deleted
const deletedImages = existingImages.filter(img => !existingImagesState.includes(img));
// Build signed URL -> S3 key mapping for existing images
const signedUrlToKey = useMemo(() => {
const map: Record<string, string> = {};
existingImages.forEach((img, i) => {
if (existingImageKeys[i]) {
map[img.imgUrl] = existingImageKeys[i];
}
});
return map;
}, [existingImages, existingImageKeys]);
return (
<Formik
initialValues={initialValues}
onSubmit={(values) => onSubmit(values, images, deletedImages)}
onSubmit={(values) => {
// New images have mimeType set, existing images have mimeType === null
const newImages = images.filter(img => img.mimeType !== null);
const deletedImageKeys = existingImages
.filter(existing => !images.some(current => current.imgUrl === existing.imgUrl))
.map(deleted => signedUrlToKey[deleted.imgUrl])
.filter(Boolean);
onSubmit(
values,
newImages.map(img => ({ url: img.imgUrl, mimeType: img.mimeType })),
deletedImageKeys,
);
}}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImages([]);
setExistingImagesState([]);
resetForm();
}, [resetForm]);
useFocusCallback(clearForm);
// Update ref with current clearForm function
useImperativeHandle(ref, () => ({
clearImages: clearForm,
}), [clearForm]);
@ -141,44 +149,18 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }}
/>
{mode === 'create' && (
<ImageUploader
images={images}
onAddImage={pickImage}
onRemoveImage={(uri) => setImages(prev => prev.filter(img => img.uri !== uri))}
/>
)}
{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>
)}
<ImageUploaderNeo
images={images}
onImageAdd={(payloads) => setImages(prev => [...prev, ...payloads.map(p => ({ imgUrl: p.url, mimeType: p.mimeType }))])}
onImageRemove={(payload) => setImages(prev => prev.filter(img => img.imgUrl !== payload.url))}
allowMultiple={true}
/>
<BottomDropdown
topLabel='Unit'
label="Unit"
value={values.unitId}
options={unitOptions}
// onValueChange={(value) => handleChange('unitId')(value+'')}
onValueChange={(value) => setFieldValue('unitId', value)}
placeholder="Select unit"
style={{ marginBottom: 16 }}
@ -188,18 +170,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
placeholder="Enter product quantity"
keyboardType="numeric"
value={values.productQuantity.toString()}
onChangeText={(text) => {
// if(text)
// setFieldValue('productQuantity', text);
// else
setFieldValue('productQuantity', text);
// if (text === '' || text === null || text === undefined) {
// setFieldValue('productQuantity', 1);
// } else {
// const num = parseFloat(text);
// setFieldValue('productQuantity', isNaN(num) ? 1 : num);
// }
}}
onChangeText={(text) => setFieldValue('productQuantity', text)}
style={{ marginBottom: 16 }}
/>
<BottomDropdown
@ -238,8 +209,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }}
/>
<View style={tw`flex-row items-center mb-4`}>
<Checkbox
checked={values.isSuspended}
@ -254,7 +223,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
checked={values.isFlashAvailable}
onPress={() => {
setFieldValue('isFlashAvailable', !values.isFlashAvailable);
if (values.isFlashAvailable) setFieldValue('flashPrice', ''); // Clear price when disabled
if (values.isFlashAvailable) setFieldValue('flashPrice', '');
}}
style={tw`mr-3`}
/>
@ -272,87 +241,6 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
/>
)}
{/* <FieldArray name="deals">
{({ push, remove, form }) => (
<View style={{ marginBottom: 16 }}>
<View style={tw`flex-row items-center mb-4`}>
<MaterialIcons name="local-offer" size={20} color="#3B82F6" />
<MyText style={tw`text-lg font-bold text-gray-800 ml-2`}>
Special Package Deals
</MyText>
<MyText style={tw`text-sm text-gray-500 ml-1`}>(Optional)</MyText>
</View>
{(form.values.deals || []).map((deal: any, index: number) => (
<View key={index} style={tw`bg-white p-4 rounded-2xl shadow-lg mb-4 border border-gray-100`}>
<View style={tw`mb-3`}>
<View style={tw`flex-row items-end gap-3 mb-3`}>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Quantity"
placeholder="Enter quantity"
keyboardType="numeric"
value={deal.quantity || ''}
onChangeText={form.handleChange(`deals.${index}.quantity`)}
fullWidth={false}
/>
</View>
<View style={tw`flex-1`}>
<MyTextInput
topLabel="Price"
placeholder="Enter price"
keyboardType="numeric"
value={deal.price || ''}
onChangeText={form.handleChange(`deals.${index}.price`)}
fullWidth={false}
/>
</View>
</View>
<View style={tw`flex-row items-end gap-3`}>
<View style={tw`flex-1`}>
<DatePicker
value={deal.validTill}
setValue={(date) => form.setFieldValue(`deals.${index}.validTill`, date)}
showLabel={true}
placeholder="Valid Till"
/>
</View>
<View style={tw`flex-1`}>
<TouchableOpacity
onPress={() => remove(index)}
style={tw`bg-red-500 p-3 rounded-lg shadow-md flex-row items-center justify-center`}
>
<MaterialIcons name="delete" size={16} color="white" />
<MyText style={tw`text-white font-semibold ml-1`}>Remove</MyText>
</TouchableOpacity>
</View>
</View>
</View>
</View>
))}
{(form.values.deals || []).length === 0 && (
<View style={tw`bg-gray-50 p-6 rounded-2xl border-2 border-dashed border-gray-300 items-center mb-4`}>
<MaterialIcons name="local-offer" size={32} color="#9CA3AF" />
<MyText style={tw`text-gray-500 text-center mt-2`}>
No package deals added yet
</MyText>
<MyText style={tw`text-gray-400 text-sm text-center mt-1`}>
Add special pricing for bulk purchases
</MyText>
</View>
)}
<TouchableOpacity
onPress={() => push({ quantity: '', price: '', validTill: null })}
style={tw`bg-green-500 px-4 py-2 rounded-lg shadow-lg flex-row items-center justify-center mt-4`}
>
<MaterialIcons name="add" size={20} color="white" />
<MyText style={tw`text-white font-bold text-lg ml-2`}>Add Package Deal</MyText>
</TouchableOpacity>
</View>
)}
</FieldArray> */}
<TouchableOpacity
onPress={submit}
disabled={isLoading}
@ -371,4 +259,4 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
ProductForm.displayName = 'ProductForm';
export default ProductForm;
export default ProductForm;

View file

@ -1,10 +1,8 @@
import React, { useState, useEffect, forwardRef, useCallback } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MyTextInput, MyText, Checkbox, ImageUploader, tw, useFocusCallback, BottomDropdown } from 'common-ui';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { MyTextInput, MyText, Checkbox, ImageUploaderNeo, tw, useFocusCallback, BottomDropdown, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
interface StoreOption {
@ -23,7 +21,7 @@ interface TagFormProps {
mode: 'create' | 'edit';
initialValues: TagFormData;
existingImageUrl?: string;
onSubmit: (values: TagFormData, image?: { uri?: string }) => void;
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void;
isLoading: boolean;
stores?: StoreOption[];
}
@ -31,27 +29,28 @@ interface TagFormProps {
const TagForm = forwardRef<any, TagFormProps>(({
mode,
initialValues,
existingImageUrl = '',
existingImageUrl: existingImageUrlRaw,
onSubmit,
isLoading,
stores = [],
stores: storesRaw,
}, ref) => {
const [image, setImage] = useState<{ uri?: string } | null>(null);
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
const [removedExisting, setRemovedExisting] = useState(false)
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag));
const existingImageUrl = existingImageUrlRaw || ''
const stores = storesRaw || []
// Update checkbox when initial values change
useEffect(() => {
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag));
existingImageUrl && setImage({uri:existingImageUrl})
}, [initialValues.isDashboardTag]);
const pickImage = usePickImage({
setFile: (files) => {
setImage(files || null)
},
multiple: false,
});
if (existingImageUrl) {
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
} else {
setImages([])
}
setRemovedExisting(false)
}, [existingImageUrlRaw, initialValues.isDashboardTag]);
const validationSchema = Yup.object().shape({
@ -67,17 +66,17 @@ const TagForm = forwardRef<any, TagFormProps>(({
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values) => onSubmit(values, image || undefined)}
onSubmit={(values) => onSubmit(values, images, removedExisting)}
enableReinitialize
>
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, setFieldValue: formikSetFieldValue, resetForm }) => {
// Clear form when screen comes into focus
const clearForm = useCallback(() => {
setImage(null);
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
const clearForm = useCallback(() => {
setImages([])
setRemovedExisting(false)
setIsDashboardTagChecked(false);
resetForm();
}, [resetForm]);
useFocusCallback(clearForm);
@ -108,10 +107,21 @@ const TagForm = forwardRef<any, TagFormProps>(({
</MyText>
<ImageUploader
images={image ? [image] : []}
onAddImage={pickImage}
onRemoveImage={() => setImage(null)}
<ImageUploaderNeo
images={images}
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
setImages((prev) => [...prev, ...payload.map((img) => ({
imgUrl: img.url,
mimeType: img.mimeType,
}))])
}}
onImageRemove={(payload) => {
if (payload.mimeType === null) {
setRemovedExisting(true)
}
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
}}
allowMultiple={false}
/>
</View>
@ -167,4 +177,4 @@ const TagForm = forwardRef<any, TagFormProps>(({
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 { MyText, tw, BottomDialog } from 'common-ui';
import { useRouter } from 'expo-router';
import { useDeleteTag } from '../api-hooks/tag.api';
import { trpc } from '@/src/trpc-client';
export interface TagMenuProps {
tagId: number;
@ -22,7 +22,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { mutate: deleteTag, isPending: isDeleting } = useDeleteTag();
const deleteTag = trpc.admin.product.deleteProductTag.useMutation();
const handleOpenMenu = () => {
setIsOpen(true);
@ -54,7 +54,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
};
const performDelete = () => {
deleteTag(tagId, {
deleteTag.mutate({ id: tagId }, {
onSuccess: () => {
Alert.alert('Success', 'Tag deleted successfully');
onDeleteSuccess?.();
@ -63,7 +63,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
const errorMessage = error.message || 'Failed to delete tag';
Alert.alert('Error', errorMessage);
},
});
})
};
const options = [
@ -116,4 +116,4 @@ export const TagMenu: React.FC<TagMenuProps> = ({
</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

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

File diff suppressed because it is too large Load diff

10705
apps/backend/dumps/old1.sql Normal file

File diff suppressed because it is too large Load diff

View file

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

54550
apps/backend/migrated.sql Normal file

File diff suppressed because it is too large Load diff

View file

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

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

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"
const router = Router();
const router = new Hono();
router.get("/summary", getAllProductsSummary);
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"
const router = Router();
const router = new Hono();
router.use('/products', commonProductsRouter)
router.route('/products', commonProductsRouter)
const commonRouter = router;
export default commonRouter;
export default commonRouter;

144
apps/backend/src/app.ts Normal file
View file

@ -0,0 +1,144 @@
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', 'http://localhost:4174', 'https://ui.freshyo.in', 'https://www.freshyo.in', 'https://webui.freshyo.in', 'https://app.freshyo.in'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'Caller-Interface'],
credentials: true,
}))
// Logger middleware
app.use(logger())
// tRPC middleware
app.use('/api/trpc/*', trpcServer({
router: appRouter,
createContext: async ({ req, c }) => {
let user = null
let staffUser = null
const authHeader = req.headers.get('authorization')
const callerInterface = req.headers.get('caller-interface')
let token: string | null = null
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7)
} else {
// Fallback: try reading token from cookie
const cookieHeader = req.headers.get('cookie')
if (cookieHeader) {
const cookies = Object.fromEntries(
cookieHeader.split(';').map((pair) => {
const [k, ...v] = pair.trim().split('=')
return [k, v.join('=')]
})
)
token = cookies['auth_token'] || null
}
}
if (token) {
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, c, user, staffUser, callerInterface }
},
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'
const runCombinedJob = async () => {
@ -25,4 +24,4 @@ const runCombinedJob = async () => {
runCombinedJob();
// Schedule combined cron job every 10 minutes
cron.schedule('*/10 * * * *', runCombinedJob);
// cron.schedule('*/10 * * * *', runCombinedJob);

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

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 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> {
try {
const jsonData = JSON.stringify(eventData);
const result = await redisClient.lPush(queueName, jsonData);
return result > 0;
// const result = await redisClient.lPush(queueName, jsonData);
// return result > 0;
return false;
} catch (error) {
console.error('Event enqueue error:', error);
return false;

View file

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

View file

@ -0,0 +1,30 @@
import { toggleKeyVal } from '@/src/dbService'
import { CONST_KEYS } from '@/src/lib/const-keys'
import { computeConstants } from '@/src/lib/const-store'
import { ensureWorkerInit } from '@/src/lib/worker-init'
const CRON_TURN_OFF_FLASH_DELIVERY = '0 16 * * *' // 9:30 PM IST
const CRON_TURN_ON_FLASH_DELIVERY = '0 1 * * *' // 6:30 PM IST
export async function runFlashDeliveryToggleCron(params: {
cron: string
env: any
}) {
console.log('from the cron job top level')
const { cron, env } = params
// Ensure DB bindings are initialized for this worker invocation
ensureWorkerInit(env)
if (cron !== CRON_TURN_OFF_FLASH_DELIVERY && cron !== CRON_TURN_ON_FLASH_DELIVERY) {
console.log('flash delivery cron: ignoring unknown cron', cron)
return
}
const enabled = cron === CRON_TURN_ON_FLASH_DELIVERY
console.log('flash delivery cron: toggling isFlashDeliveryEnabled', { cron, enabled })
await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, enabled)
await computeConstants()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,55 @@
import { sendAdminNotification } from '@/src/lib/notif-job'
import { handleOrderCancelled, handleOrderPlaced } from '@/src/lib/post-order-handler'
export const handleNotifQueue =async (batch: any) => {
for (const message of batch.messages || []) {
const body = message?.body
if (!body) {
console.log('notif_queue message received with empty body')
continue
}
if (body.name === 'send-admin-notification' && body.jobData?.token) {
await sendAdminNotification({
token: body.jobData.token,
title: body.jobData.title,
body: body.jobData.body,
imageUrl: body.jobData.imageUrl ?? null,
})
continue
}
// console.log('notif_queue', 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 { redisUrl } from '@/src/lib/env-exporter'
// import { createClient, RedisClientType } from 'redis';
import { getRedisUrl } from '@/src/lib/env-exporter'
const createClient = (args:any) => {}
class RedisClient {
private client: RedisClientType;
private subscriberClient: RedisClientType | null = null;
private isConnected: boolean = false;
// private client: RedisClientType;
// private subscriberClient: RedisClientType | null = null;
// private isConnected: boolean = false;
//
private client: any;
private subscriberrlient: any;
private isConnected: any = false;
constructor() {
this.client = createClient({
url: redisUrl,
url: getRedisUrl(),
});
this.client.on('error', (err) => {
console.error('Redis Client Error:', err);
});
this.client.on('connect', () => {
console.log('Redis Client Connected');
this.isConnected = true;
});
this.client.on('disconnect', () => {
console.log('Redis Client Disconnected');
this.isConnected = false;
});
this.client.on('ready', () => {
console.log('Redis Client Ready');
});
this.client.on('reconnecting', () => {
console.log('Redis Client Reconnecting');
});
// this.client.on('error', (err) => {
// console.error('Redis Client Error:', err);
// });
//
// this.client.on('connect', () => {
// console.log('Redis Client Connected');
// this.isConnected = true;
// });
//
// this.client.on('disconnect', () => {
// console.log('Redis Client Disconnected');
// this.isConnected = false;
// });
//
// this.client.on('ready', () => {
// console.log('Redis Client Ready');
// });
//
// this.client.on('reconnecting', () => {
// console.log('Redis Client Reconnecting');
// });
// Connect immediately (fire and forget)
this.client.connect().catch((err) => {
console.error('Failed to connect Redis:', err);
});
// this.client.connect().catch((err) => {
// console.error('Failed to connect Redis:', err);
// });
}
async set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
@ -79,41 +85,41 @@ class RedisClient {
// Subscribe to a channel with callback
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
if (!this.subscriberClient) {
this.subscriberClient = createClient({
url: redisUrl,
});
this.subscriberClient.on('error', (err) => {
console.error('Redis Subscriber Error:', err);
});
this.subscriberClient.on('connect', () => {
console.log('Redis Subscriber Connected');
});
await this.subscriberClient.connect();
}
await this.subscriberClient.subscribe(channel, callback);
// if (!this.subscriberClient) {
// this.subscriberClient = createClient({
// url: redisUrl,
// });
//
// this.subscriberClient.on('error', (err) => {
// console.error('Redis Subscriber Error:', err);
// });
//
// this.subscriberClient.on('connect', () => {
// console.log('Redis Subscriber Connected');
// });
//
// await this.subscriberClient.connect();
// }
//
// await this.subscriberClient.subscribe(channel, callback);
console.log(`Subscribed to channel: ${channel}`);
}
// Unsubscribe from a channel
async unsubscribe(channel: string): Promise<void> {
if (this.subscriberClient) {
await this.subscriberClient.unsubscribe(channel);
console.log(`Unsubscribed from channel: ${channel}`);
}
// if (this.subscriberClient) {
// await this.subscriberClient.unsubscribe(channel);
// console.log(`Unsubscribed from channel: ${channel}`);
// }
}
disconnect(): void {
if (this.isConnected) {
this.client.disconnect();
}
if (this.subscriberClient) {
this.subscriberClient.disconnect();
}
// if (this.isConnected) {
// this.client.disconnect();
// }
// if (this.subscriberClient) {
// this.subscriberClient.disconnect();
// }
}
get isClientConnected(): boolean {

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
*/

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

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

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