Compare commits

...

5 commits

Author SHA1 Message Date
shafi54
8fe3e4a301 enh 2026-02-09 01:04:43 +05:30
shafi54
002b73cf87 enh 2026-02-09 00:59:44 +05:30
shafi54
bce754d0a1 enh 2026-02-09 00:53:57 +05:30
shafi54
31395e5cc7 enh 2026-02-09 00:40:57 +05:30
shafi54
2a106b5467 enh 2026-02-09 00:15:37 +05:30
16 changed files with 141 additions and 169 deletions

File diff suppressed because one or more lines are too long

View file

@ -229,7 +229,6 @@ export default function Layout() {
<Drawer.Screen name="stores" options={{ title: "Stores" }} /> <Drawer.Screen name="stores" options={{ title: "Stores" }} />
<Drawer.Screen name="address-management" options={{ title: "Address Management" }} /> <Drawer.Screen name="address-management" options={{ title: "Address Management" }} />
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} /> <Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
<Drawer.Screen name="order-details/[id]" options={{ title: "Order Details" }} />
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} /> <Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
<Drawer.Screen name="user-management" options={{ title: "User Management" }} /> <Drawer.Screen name="user-management" options={{ title: "User Management" }} />
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} /> <Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />

View file

@ -6,6 +6,7 @@ export default function Layout() {
<Stack.Screen name="index" options={{ title: 'Manage Orders' }} /> <Stack.Screen name="index" options={{ title: 'Manage Orders' }} />
<Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} /> <Stack.Screen name="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
<Stack.Screen name="orders" options={{ title: 'Orders' }} /> <Stack.Screen name="orders" options={{ title: 'Orders' }} />
<Stack.Screen name="order-details" options={{ title: 'Order Details' }} />
</Stack> </Stack>
); );
} }

View file

@ -726,7 +726,7 @@ export default function DeliverySequences() {
}} }}
onViewDetails={() => { onViewDetails={() => {
if (selectedOrder) { if (selectedOrder) {
router.push(`/order-details/${selectedOrder.id}`); router.push(`/manage-orders/order-details/${selectedOrder.id}`);
} }
setShowOrderMenu(false); setShowOrderMenu(false);
}} }}

View file

@ -18,7 +18,7 @@ export default function ManageOrders() {
useCallback(() => { useCallback(() => {
const target = getNavigationTarget(); const target = getNavigationTarget();
if (target) { if (target) {
router.replace(target as any); router.push(target as any);
} }
}, [router, getNavigationTarget]) }, [router, getNavigationTarget])
); );

View file

@ -348,12 +348,17 @@ export default function OrderDetails() {
)} )}
{/* Customer Details */} {/* Customer Details */}
<View <TouchableOpacity
onPress={() => order.userId && router.push(`/(drawer)/user-management/${order.userId}`)}
activeOpacity={0.7}
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`} style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
> >
<MyText style={tw`text-base font-bold text-gray-900 mb-4`}> <View style={tw`flex-row items-center justify-between mb-4`}>
Customer Details <MyText style={tw`text-base font-bold text-gray-900`}>
</MyText> Customer Details
</MyText>
<MaterialIcons name="chevron-right" size={20} color="#6B7280" />
</View>
<View style={tw`flex-row items-center mb-4`}> <View style={tw`flex-row items-center mb-4`}>
<View <View
@ -363,7 +368,7 @@ export default function OrderDetails() {
</View> </View>
<View> <View>
<MyText style={tw`text-sm font-bold text-gray-900`}> <MyText style={tw`text-sm font-bold text-gray-900`}>
{order.customerName} {order.customerName || 'Unknown User'}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText> <MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
</View> </View>
@ -404,7 +409,7 @@ export default function OrderDetails() {
</View> </View>
</View> </View>
</View> </View>
</View> </TouchableOpacity>
{/* Order Items */} {/* Order Items */}
<View <View

View file

@ -100,12 +100,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation(); const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
const handleOrderPress = () => { const handleOrderPress = () => {
router.push(`/order-details/${order.orderId}` as any); router.push(`/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMenuOption = () => { const handleMenuOption = () => {
setMenuOpen(false); setMenuOpen(false);
router.push(`/order-details/${order.orderId}` as any); router.push(`/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMarkPackaged = (isPackaged: boolean) => { const handleMarkPackaged = (isPackaged: boolean) => {

View file

@ -187,23 +187,6 @@ export default function SendNotifications() {
</View> </View>
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`p-4`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`p-4`}>
{/* User Selection */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users</MyText>
<BottomDropdown
label="Select Users"
value={selectedUserIds}
options={dropdownOptions}
onValueChange={(value) => setSelectedUserIds(value as number[])}
multiple={true}
placeholder="Select users"
onSearch={(query) => setSearchQuery(query)}
/>
<MyText style={tw`text-gray-500 text-sm mt-2`}>
{getDisplayText()}
</MyText>
</View>
{/* Title Input */} {/* Title Input */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}> <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Title</MyText> <MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Title</MyText>
@ -228,8 +211,8 @@ export default function SendNotifications() {
/> />
</View> </View>
{/* Image Upload */} {/* Image Upload - Hidden for now */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}> {/* <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText> <MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText>
<ImageUploader <ImageUploader
images={displayImage ? [displayImage] : []} images={displayImage ? [displayImage] : []}
@ -237,6 +220,23 @@ export default function SendNotifications() {
onAddImage={handleImagePick} onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
/> />
</View> */}
{/* User Selection */}
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users (Optional)</MyText>
<BottomDropdown
label="Select Users"
value={selectedUserIds}
options={dropdownOptions}
onValueChange={(value) => setSelectedUserIds(value as number[])}
multiple={true}
placeholder="Select users"
onSearch={(query) => setSearchQuery(query)}
/>
<MyText style={tw`text-gray-500 text-sm mt-2`}>
{getDisplayText()}
</MyText>
</View> </View>
{/* Submit Button */} {/* Submit Button */}

View file

@ -111,7 +111,7 @@ export default function UserDetails() {
}); });
const handleOrderPress = useCallback((orderId: number) => { const handleOrderPress = useCallback((orderId: number) => {
router.push(`/(drawer)/order-details/${orderId}`); router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
}, [router]); }, [router]);
const handleSuspensionToggle = useCallback((isSuspended: boolean) => { const handleSuspensionToggle = useCallback((isSuspended: boolean) => {

View file

@ -37,7 +37,6 @@
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0", "expo-server-sdk": "^4.0.0",
"express": "^5.1.0", "express": "^5.1.0",
"firebase-admin": "^13.6.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.0.2", "multer": "^2.0.2",

View file

@ -1,14 +0,0 @@
import admin from 'firebase-admin';
import path from 'path';
// // Initialize Firebase Admin SDK
// const serviceAccountPath = path.join('.', 'creds', 'fcm-v1-account.json');
// if (!admin.apps.length) {
// admin.initializeApp({
// credential: admin.credential.cert(serviceAccountPath),
// });
// }
// export const messaging = admin.messaging();
// export default admin;

View file

@ -1,5 +1,10 @@
import { Queue, Worker } from 'bullmq'; import { Queue, Worker } from 'bullmq';
import { Expo } from 'expo-server-sdk';
import { redisUrl } from './env-exporter'; import { redisUrl } from './env-exporter';
import { db } from '../db/db_index';
import { notifCreds } from '../db/schema';
import { eq } from 'drizzle-orm';
import { generateSignedUrlFromS3Url } from './s3-client';
import { import {
NOTIFS_QUEUE, NOTIFS_QUEUE,
ORDER_PLACED_MESSAGE, ORDER_PLACED_MESSAGE,
@ -14,21 +19,88 @@ import {
export const notificationQueue = new Queue(NOTIFS_QUEUE, { export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl }, connection: { url: redisUrl },
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 50, removeOnComplete: true,
removeOnFail: 100, removeOnFail: 10,
attempts: 3, attempts: 3,
}, },
}); });
export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => { export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
if (!job) return; if (!job) return;
console.log(`Processing notification job ${job.id}`);
// TODO: Implement sendPushNotification 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 }, connection: { url: redisUrl },
concurrency: 5, concurrency: 5,
}); });
async function sendAdminNotification(data: {
userId: number;
title: string;
body: string;
imageUrl: string | null;
notificationId: number;
}) {
const { userId, title, body, imageUrl } = data;
// Get user's push token
const [cred] = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(eq(notifCreds.userId, userId))
.limit(1);
if (!cred || !cred.token) {
console.log(`No push token found for user ${userId}`);
return;
}
const token = cred.token;
// Validate Expo push token
if (!Expo.isExpoPushToken(token)) {
console.error(`Invalid Expo push token for user ${userId}: ${token}`);
throw new Error('Invalid push token');
}
// Generate signed URL for image if provided
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
// Send notification
const expo = new Expo();
const message = {
to: token,
sound: 'default',
title,
body,
data: { imageUrl },
...(signedImageUrl ? {
attachments: [
{
url: signedImageUrl,
contentType: 'image/jpeg',
}
]
} : {}),
};
try {
const [ticket] = await expo.sendPushNotificationsAsync([message]);
console.log(`Notification sent to user ${userId}:`, ticket);
} catch (error) {
console.error(`Failed to send notification to user ${userId}:`, error);
throw error;
}
}
notificationWorker.on('completed', (job) => { notificationWorker.on('completed', (job) => {
if (job) console.log(`Notification job ${job.id} completed`); if (job) console.log(`Notification job ${job.id} completed`);
}); });

View file

@ -304,6 +304,7 @@ export const orderRouter = router({
return { return {
id: orderData.id, id: orderData.id,
readableId: orderData.id, readableId: orderData.id,
userId: orderData.user.id,
customerName: `${orderData.user.name}`, customerName: `${orderData.user.name}`,
customerEmail: orderData.user.email, customerEmail: orderData.user.email,
customerMobile: orderData.user.mobile, customerMobile: orderData.user.mobile,

View file

@ -4,14 +4,7 @@ import { db } from '../../db/db_index';
import { users, complaints, orders, orderItems, notifCreds, userNotifications, userDetails } from '../../db/schema'; import { users, complaints, orders, orderItems, notifCreds, userNotifications, userDetails } from '../../db/schema';
import { eq, sql, desc, asc, count, max } from 'drizzle-orm'; import { eq, sql, desc, asc, count, max } from 'drizzle-orm';
import { ApiError } from '../../lib/api-error'; import { ApiError } from '../../lib/api-error';
import { Expo } from 'expo-server-sdk'; import { notificationQueue } from '../../lib/notif-job';
// import { messaging } from '../../lib/firebase';
import { generateSignedUrlFromS3Url } from '../../lib/s3-client';
// Toggle between notification providers: 'expo' | 'fcm'
// const NOTIFICATION_PROVIDER: 'expo' | 'fcm' = 'fcm';
// let NOTIFICATION_PROVIDER: 'expo' | 'fcm' = 'expo';
let NOTIFICATION_PROVIDER: string = 'expo';
async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> { async function createUserByMobile(mobile: string): Promise<typeof users.$inferSelect> {
// Clean mobile number (remove non-digits) // Clean mobile number (remove non-digits)
@ -378,125 +371,40 @@ export const userRouter = {
const { userIds, title, text, imageUrl } = input; const { userIds, title, text, imageUrl } = input;
// Store notification in database // Store notification in database
await db.insert(userNotifications).values({ const [notification] = await db.insert(userNotifications).values({
title, title,
body: text, body: text,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
applicableUsers: userIds.length > 0 ? userIds : null, applicableUsers: userIds.length > 0 ? userIds : null,
}); }).returning();
// Fetch push tokens for target users // Queue one job per user
const tokens = await db let queuedCount = 0;
.select({ token: notifCreds.token, userId: notifCreds.userId }) for (const userId of userIds) {
.from(notifCreds)
.where(sql`${notifCreds.userId} IN (${sql.join(userIds, sql`, `)})`);
if (tokens.length === 0) {
return {
success: true,
message: 'Notification saved but no push tokens found'
};
}
// Generate signed URL for image if provided
const signedImageUrl = imageUrl ? await generateSignedUrlFromS3Url(imageUrl) : null;
let sentCount = 0;
let failedCount = 0;
if (NOTIFICATION_PROVIDER === 'fcm') {
// Build FCM messages
const messages = tokens.map(({ token }) => ({
token: token,
notification: {
title: title,
body: text,
},
data: {
imageUrl: imageUrl || '',
},
android: {
notification: {
imageUrl: signedImageUrl || undefined,
},
},
apns: {
payload: {
aps: {
'mutable-content': 1,
},
},
fcm_options: {
image: signedImageUrl || undefined,
},
},
}));
// Send notifications using Firebase Admin SDK
try { try {
// const response = await messaging.sendEach(messages); await notificationQueue.add('send-admin-notification', {
// sentCount = response.successCount; userId,
// failedCount = response.failureCount; title,
body: text,
// // Log failed tokens imageUrl: imageUrl || null,
// response.responses.forEach((resp, idx) => { notificationId: notification.id,
// if (!resp.success) { }, {
// console.error(`Failed to send to token ${tokens[idx].token}:`, resp.error); attempts: 3,
// } backoff: {
// }); type: 'exponential',
delay: 2000,
},
});
queuedCount++;
} catch (error) { } catch (error) {
console.error('Error sending push notifications:', error); console.error(`Failed to queue notification for user ${userId}:`, error);
throw new ApiError('Failed to send push notifications', 500);
}
} else {
// Send using Expo (legacy)
const expo = new Expo();
// Helper function to chunk array
const chunkArray = (array: any[], size: number) => {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
};
const chunks = chunkArray(tokens, 50);
for (const chunk of chunks) {
const messages = chunk
.filter(({ token }) => Expo.isExpoPushToken(token))
.map(({ token }) => ({
to: token,
sound: 'default',
title,
body: text,
data: { imageUrl },
...(signedImageUrl ? {
attachments: [
{
url: signedImageUrl,
contentType: 'image/jpeg',
}
]
} : {}),
}));
if (messages.length > 0) {
try {
await expo.sendPushNotificationsAsync(messages);
sentCount += messages.length;
} catch (error) {
console.error('Error sending push notifications:', error);
failedCount += chunk.length;
}
}
} }
} }
return { return {
success: true, success: true,
message: `Notification sent to ${sentCount} users${failedCount > 0 ? `, ${failedCount} failed` : ''}`, message: `Notification queued for ${queuedCount} users`,
notificationId: notification.id,
}; };
}), }),
}; };

View file

@ -40,6 +40,7 @@ export async function registerForPushNotificationsAsync() {
projectId, projectId,
}) })
).data; ).data;
return pushTokenString;
// const pushTokenString = await Notifications.getDevicePushTokenAsync(); // const pushTokenString = await Notifications.getDevicePushTokenAsync();
// return pushTokenString.data; // return pushTokenString.data;
} catch (e: unknown) { } catch (e: unknown) {