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="address-management" options={{ title: "Address Management" }} />
<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="user-management" options={{ title: "User Management" }} />
<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="delivery-sequences" options={{ title: 'Delivery Sequences' }} />
<Stack.Screen name="orders" options={{ title: 'Orders' }} />
<Stack.Screen name="order-details" options={{ title: 'Order Details' }} />
</Stack>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -187,6 +187,23 @@ export default function SendNotifications() {
</View>
<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 */}
<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>
@ -211,8 +228,8 @@ export default function SendNotifications() {
/>
</View>
{/* Image Upload - Hidden for now */}
{/* <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
{/* Image Upload */}
<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>
<ImageUploader
images={displayImage ? [displayImage] : []}
@ -220,23 +237,6 @@ export default function SendNotifications() {
onAddImage={handleImagePick}
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>
{/* Submit Button */}

View file

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

View file

@ -37,6 +37,7 @@
"drizzle-orm": "^0.44.5",
"expo-server-sdk": "^4.0.0",
"express": "^5.1.0",
"firebase-admin": "^13.6.1",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.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 { Expo } from 'expo-server-sdk';
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 {
NOTIFS_QUEUE,
ORDER_PLACED_MESSAGE,
@ -19,88 +14,21 @@ import {
export const notificationQueue = new Queue(NOTIFS_QUEUE, {
connection: { url: redisUrl },
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: 10,
removeOnComplete: 50,
removeOnFail: 100,
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');
}
console.log(`Processing notification job ${job.id}`);
// TODO: Implement sendPushNotification
}, {
connection: { url: redisUrl },
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) => {
if (job) console.log(`Notification job ${job.id} completed`);
});

View file

@ -304,7 +304,6 @@ export const orderRouter = router({
return {
id: orderData.id,
readableId: orderData.id,
userId: orderData.user.id,
customerName: `${orderData.user.name}`,
customerEmail: orderData.user.email,
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 { eq, sql, desc, asc, count, max } from 'drizzle-orm';
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> {
// Clean mobile number (remove non-digits)
@ -371,40 +378,125 @@ export const userRouter = {
const { userIds, title, text, imageUrl } = input;
// Store notification in database
const [notification] = await db.insert(userNotifications).values({
await db.insert(userNotifications).values({
title,
body: text,
imageUrl: imageUrl || null,
applicableUsers: userIds.length > 0 ? userIds : null,
}).returning();
});
// Queue one job per user
let queuedCount = 0;
for (const userId of userIds) {
try {
await notificationQueue.add('send-admin-notification', {
userId,
title,
// Fetch push tokens for target users
const tokens = await db
.select({ token: notifCreds.token, userId: notifCreds.userId })
.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,
imageUrl: imageUrl || null,
notificationId: notification.id,
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
data: {
imageUrl: imageUrl || '',
},
android: {
notification: {
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) {
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 {
success: true,
message: `Notification queued for ${queuedCount} users`,
notificationId: notification.id,
message: `Notification sent to ${sentCount} users${failedCount > 0 ? `, ${failedCount} failed` : ''}`,
};
}),
};

View file

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