Compare commits

..

No commits in common. "ed7318f9eef7c2b8b321e767082c8e47afe58d2b" and "5bd0f8ded701b6b4d8710117022091636a435ef8" have entirely different histories.

6 changed files with 568 additions and 489 deletions

View file

@ -106,20 +106,9 @@ export default function SendNotifications() {
return; return;
} }
// Check if sending to all users if (selectedUserIds.length === 0) {
const isSendingToAll = selectedUserIds.length === 0; Alert.alert('Error', 'Please select at least one user');
if (isSendingToAll) { return;
const confirmed = await new Promise<boolean>((resolve) => {
Alert.alert(
'Send to All Users?',
'This will send the notification to all users with push tokens. Continue?',
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Send', style: 'default', onPress: () => resolve(true) },
]
);
});
if (!confirmed) return;
} }
try { try {
@ -164,7 +153,7 @@ export default function SendNotifications() {
}; };
const getDisplayText = () => { const getDisplayText = () => {
if (selectedUserIds.length === 0) return 'All Users'; if (selectedUserIds.length === 0) return 'Select users';
if (selectedUserIds.length === 1) { if (selectedUserIds.length === 1) {
const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0]); const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0]);
return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected'; return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected';
@ -248,23 +237,20 @@ export default function SendNotifications() {
<MyText style={tw`text-gray-500 text-sm mt-2`}> <MyText style={tw`text-gray-500 text-sm mt-2`}>
{getDisplayText()} {getDisplayText()}
</MyText> </MyText>
<MyText style={tw`text-blue-600 text-xs mt-1`}>
Leave empty to send to all users
</MyText>
</View> </View>
{/* Submit Button */} {/* Submit Button */}
<TouchableOpacity <TouchableOpacity
onPress={handleSend} onPress={handleSend}
disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0} disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0 || selectedUserIds.length === 0}
style={tw`${ style={tw`${
sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0 sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0 || selectedUserIds.length === 0
? 'bg-gray-300' ? 'bg-gray-300'
: 'bg-blue-600' : 'bg-blue-600'
} rounded-xl py-4 items-center shadow-sm`} } rounded-xl py-4 items-center shadow-sm`}
> >
<MyText style={tw`text-white font-bold text-base`}> <MyText style={tw`text-white font-bold text-base`}>
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'} {sendNotification.isPending ? 'Sending...' : 'Send Notification'}
</MyText> </MyText>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>

View file

@ -2,6 +2,8 @@ import { Queue, Worker } from 'bullmq';
import { Expo } from 'expo-server-sdk'; import { Expo } from 'expo-server-sdk';
import { redisUrl } from './env-exporter'; import { redisUrl } from './env-exporter';
import { db } from '../db/db_index'; import { db } from '../db/db_index';
import { notifCreds } from '../db/schema';
import { eq } from 'drizzle-orm';
import { generateSignedUrlFromS3Url } from './s3-client'; import { generateSignedUrlFromS3Url } from './s3-client';
import { import {
NOTIFS_QUEUE, NOTIFS_QUEUE,
@ -41,17 +43,32 @@ export const notificationWorker = new Worker(NOTIFS_QUEUE, async (job) => {
}); });
async function sendAdminNotification(data: { async function sendAdminNotification(data: {
token: string; userId: number;
title: string; title: string;
body: string; body: string;
imageUrl: string | null; imageUrl: string | null;
notificationId: number;
}) { }) {
const { token, title, body, imageUrl } = data; 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 // Validate Expo push token
if (!Expo.isExpoPushToken(token)) { if (!Expo.isExpoPushToken(token)) {
console.error(`Invalid Expo push token: ${token}`); console.error(`Invalid Expo push token for user ${userId}: ${token}`);
return; throw new Error('Invalid push token');
} }
// Generate signed URL for image if provided // Generate signed URL for image if provided
@ -77,9 +94,9 @@ async function sendAdminNotification(data: {
try { try {
const [ticket] = await expo.sendPushNotificationsAsync([message]); const [ticket] = await expo.sendPushNotificationsAsync([message]);
console.log(`Notification sent:`, ticket); console.log(`Notification sent to user ${userId}:`, ticket);
} catch (error) { } catch (error) {
console.error(`Failed to send notification:`, error); console.error(`Failed to send notification to user ${userId}:`, error);
throw error; throw error;
} }
} }

View file

@ -1,8 +1,8 @@
import { protectedProcedure } from '../trpc-index'; import { protectedProcedure } from '../trpc-index';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../../db/db_index'; import { db } from '../../db/db_index';
import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails } from '../../db/schema'; import { users, complaints, orders, orderItems, notifCreds, userNotifications, userDetails } from '../../db/schema';
import { eq, sql, desc, asc, count, max, inArray } 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 { notificationQueue } from '../../lib/notif-job';
@ -362,7 +362,7 @@ export const userRouter = {
sendNotification: protectedProcedure sendNotification: protectedProcedure
.input(z.object({ .input(z.object({
userIds: z.array(z.number()).default([]), userIds: z.array(z.number()),
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
text: z.string().min(1, 'Message is required'), text: z.string().min(1, 'Message is required'),
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
@ -370,36 +370,24 @@ export const userRouter = {
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { userIds, title, text, imageUrl } = input; const { userIds, title, text, imageUrl } = input;
let tokens: string[] = []; // Store notification in database
const [notification] = await db.insert(userNotifications).values({
if (userIds.length === 0) {
// Send to all users - get tokens from both logged-in and unlogged users
const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds);
const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens);
tokens = [
...loggedInTokens.map(t => t.token),
...unloggedTokens.map(t => t.token)
];
} else {
// Send to specific users - get their tokens
const userTokens = await db
.select({ token: notifCreds.token })
.from(notifCreds)
.where(inArray(notifCreds.userId, userIds));
tokens = userTokens.map(t => t.token);
}
// Queue one job per token
let queuedCount = 0;
for (const token of tokens) {
try {
await notificationQueue.add('send-admin-notification', {
token,
title, title,
body: text, body: text,
imageUrl: imageUrl || null, 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,
body: text,
imageUrl: imageUrl || null,
notificationId: notification.id,
}, { }, {
attempts: 3, attempts: 3,
backoff: { backoff: {
@ -409,13 +397,14 @@ export const userRouter = {
}); });
queuedCount++; queuedCount++;
} catch (error) { } catch (error) {
console.error(`Failed to queue notification for token:`, error); console.error(`Failed to queue notification for user ${userId}:`, error);
} }
} }
return { return {
success: true, success: true,
message: `Notification queued for ${queuedCount} users`, message: `Notification queued for ${queuedCount} users`,
notificationId: notification.id,
}; };
}), }),
}; };

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,6 @@ export default function Layout() {
<Tabs <Tabs
// backBehavior="history" // backBehavior="history"
screenOptions={{ screenOptions={{
lazy: true,
tabBarActiveTintColor: theme.colors.brand500, tabBarActiveTintColor: theme.colors.brand500,
tabBarInactiveTintColor: '#4B5563', tabBarInactiveTintColor: '#4B5563',
tabBarStyle: shouldHideTabs ? { display: 'none' } : { tabBarStyle: shouldHideTabs ? { display: 'none' } : {

View file

@ -18,7 +18,6 @@ import {
BottomDropdown, BottomDialog , Quantifier } from "common-ui"; BottomDropdown, BottomDialog , Quantifier } from "common-ui";
import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { useHideTabNav } from "@/src/hooks/useHideTabNav"; import { useHideTabNav } from "@/src/hooks/useHideTabNav";
import { useAuth } from "@/src/contexts/AuthContext";
import TestingPhaseNote from "@/components/TestingPhaseNote"; import TestingPhaseNote from "@/components/TestingPhaseNote";
@ -35,7 +34,6 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
// Hide tabs when cart page is active // Hide tabs when cart page is active
useHideTabNav(); useHideTabNav();
const { isAuthenticated } = useAuth();
const cartType: "regular" | "flash" = isFlashDelivery ? "flash" : "regular"; const cartType: "regular" | "flash" = isFlashDelivery ? "flash" : "regular";
const [quantities, setQuantities] = useState<Record<number, number>>({}); const [quantities, setQuantities] = useState<Record<number, number>>({});
@ -262,15 +260,6 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
[finalTotal, constsData, isFlashDelivery] [finalTotal, constsData, isFlashDelivery]
); );
const freeDeliveryThreshold = useMemo(
() => {
return isFlashDelivery
? constsData?.flashFreeDeliveryThreshold
: constsData?.freeDeliveryThreshold;
},
[constsData, isFlashDelivery]
);
const finalTotalWithDelivery = finalTotal + deliveryCharge; const finalTotalWithDelivery = finalTotal + deliveryCharge;
const hasAvailableItems = cartItems.some(item => !item.product?.isOutOfStock); const hasAvailableItems = cartItems.some(item => !item.product?.isOutOfStock);
@ -797,18 +786,6 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
</MyText> </MyText>
</MyTouchableOpacity> </MyTouchableOpacity>
)} )}
{!isAuthenticated && (
<MyTouchableOpacity
style={tw`mt-2 ml-1`}
onPress={() => router.push("/(drawer)/(tabs)/me")}
>
<MyText style={tw`text-xs`}>
<MyText style={tw`text-blue-500 underline`}>Log In</MyText>
<MyText style={tw`text-gray-400`}> To find offers and coupons</MyText>
</MyText>
</MyTouchableOpacity>
)}
</View> </View>
)} )}
@ -865,11 +842,11 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
</View> </View>
{/* Free Delivery Nudge */} {/* Free Delivery Nudge */}
{deliveryCharge > 0 && (freeDeliveryThreshold || 0) > 0 && finalTotal < (freeDeliveryThreshold || 0) && ( {deliveryCharge > 0 && (constsData?.freeDeliveryThreshold || 0) > 0 && finalTotal < (constsData?.freeDeliveryThreshold || 0) && (
<View style={tw`bg-blue-50 p-2.5 rounded-lg mb-3 flex-row items-center`}> <View style={tw`bg-blue-50 p-2.5 rounded-lg mb-3 flex-row items-center`}>
<MaterialIcons name="shopping-bag" size={16} color="#2563EB" style={tw`mr-2`} /> <MaterialIcons name="shopping-bag" size={16} color="#2563EB" style={tw`mr-2`} />
<MyText style={tw`text-blue-700 text-xs font-medium flex-1`}> <MyText style={tw`text-blue-700 text-xs font-medium flex-1`}>
Add products worth {((freeDeliveryThreshold || 0) - finalTotal).toFixed(0)} for free delivery Add products worth {((constsData?.freeDeliveryThreshold || 0) - finalTotal).toFixed(0)} for free delivery
</MyText> </MyText>
</View> </View>
)} )}