Compare commits

..

No commits in common. "8fe3e4a3011d667322552e884febc90f87d6e49f" and "ffa4a0ed449288ded172300da663fc29ea57e63f" have entirely different histories.

16 changed files with 168 additions and 140 deletions

File diff suppressed because one or more lines are too long

View file

@ -229,6 +229,7 @@ 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,7 +6,6 @@ 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(`/manage-orders/order-details/${selectedOrder.id}`); router.push(`/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.push(target as any); router.replace(target as any);
} }
}, [router, getNavigationTarget]) }, [router, getNavigationTarget])
); );

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(`/manage-orders/order-details/${order.orderId}` as any); router.push(`/order-details/${order.orderId}` as any);
}; };
const handleMenuOption = () => { const handleMenuOption = () => {
setMenuOpen(false); setMenuOpen(false);
router.push(`/manage-orders/order-details/${order.orderId}` as any); router.push(`/order-details/${order.orderId}` as any);
}; };
const handleMarkPackaged = (isPackaged: boolean) => { const handleMarkPackaged = (isPackaged: boolean) => {

View file

@ -348,17 +348,12 @@ export default function OrderDetails() {
)} )}
{/* Customer Details */} {/* Customer Details */}
<TouchableOpacity <View
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`}
> >
<View style={tw`flex-row items-center justify-between mb-4`}> <MyText style={tw`text-base font-bold text-gray-900 mb-4`}>
<MyText style={tw`text-base font-bold text-gray-900`}> Customer Details
Customer Details </MyText>
</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
@ -368,7 +363,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 || 'Unknown User'} {order.customerName}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500`}>Customer</MyText> <MyText style={tw`text-xs text-gray-500`}>Customer</MyText>
</View> </View>
@ -409,7 +404,7 @@ export default function OrderDetails() {
</View> </View>
</View> </View>
</View> </View>
</TouchableOpacity> </View>
{/* Order Items */} {/* Order Items */}
<View <View

View file

@ -187,6 +187,23 @@ 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>
@ -211,8 +228,8 @@ export default function SendNotifications() {
/> />
</View> </View>
{/* Image Upload - Hidden for now */} {/* Image Upload */}
{/* <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] : []}
@ -220,23 +237,6 @@ 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)/manage-orders/order-details/${orderId}`); router.push(`/(drawer)/order-details/${orderId}`);
}, [router]); }, [router]);
const handleSuspensionToggle = useCallback((isSuspended: boolean) => { const handleSuspensionToggle = useCallback((isSuspended: boolean) => {

View file

@ -37,6 +37,7 @@
"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

@ -0,0 +1,14 @@
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,10 +1,5 @@
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,
@ -19,88 +14,21 @@ import {
export const notificationQueue = new Queue(NOTIFS_QUEUE, { export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl }, connection: { url: redisUrl },
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: true, removeOnComplete: 50,
removeOnFail: 10, removeOnFail: 100,
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}`);
const { name, data } = job; // TODO: Implement sendPushNotification
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,7 +304,6 @@ 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,7 +4,14 @@ 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 { notificationQueue } from '../../lib/notif-job'; import { Expo } from 'expo-server-sdk';
// 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)
@ -371,40 +378,125 @@ export const userRouter = {
const { userIds, title, text, imageUrl } = input; const { userIds, title, text, imageUrl } = input;
// Store notification in database // Store notification in database
const [notification] = await db.insert(userNotifications).values({ 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(); });
// Queue one job per user // Fetch push tokens for target users
let queuedCount = 0; const tokens = await db
for (const userId of userIds) { .select({ token: notifCreds.token, userId: notifCreds.userId })
try { .from(notifCreds)
await notificationQueue.add('send-admin-notification', { .where(sql`${notifCreds.userId} IN (${sql.join(userIds, sql`, `)})`);
userId,
title, 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, body: text,
imageUrl: imageUrl || null, },
notificationId: notification.id, data: {
}, { imageUrl: imageUrl || '',
attempts: 3, },
backoff: { android: {
type: 'exponential', notification: {
delay: 2000, imageUrl: signedImageUrl || undefined,
}, },
}); },
queuedCount++; apns: {
payload: {
aps: {
'mutable-content': 1,
},
},
fcm_options: {
image: signedImageUrl || undefined,
},
},
}));
// Send notifications using Firebase Admin SDK
try {
// const response = await messaging.sendEach(messages);
// sentCount = response.successCount;
// failedCount = response.failureCount;
// // Log failed tokens
// response.responses.forEach((resp, idx) => {
// if (!resp.success) {
// console.error(`Failed to send to token ${tokens[idx].token}:`, resp.error);
// }
// });
} catch (error) { } catch (error) {
console.error(`Failed to queue notification for user ${userId}:`, error); console.error('Error sending push notifications:', 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 queued for ${queuedCount} users`, message: `Notification sent to ${sentCount} users${failedCount > 0 ? `, ${failedCount} failed` : ''}`,
notificationId: notification.id,
}; };
}), }),
}; };

View file

@ -40,7 +40,6 @@ 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) {