Compare commits
No commits in common. "ed7318f9eef7c2b8b321e767082c8e47afe58d2b" and "5bd0f8ded701b6b4d8710117022091636a435ef8" have entirely different histories.
ed7318f9ee
...
5bd0f8ded7
6 changed files with 568 additions and 489 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
title,
|
||||||
|
body: text,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
applicableUsers: userIds.length > 0 ? userIds : null,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
// Queue one job per user
|
||||||
// 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;
|
let queuedCount = 0;
|
||||||
for (const token of tokens) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
await notificationQueue.add('send-admin-notification', {
|
await notificationQueue.add('send-admin-notification', {
|
||||||
token,
|
userId,
|
||||||
title,
|
title,
|
||||||
body: text,
|
body: text,
|
||||||
imageUrl: imageUrl || null,
|
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
|
|
@ -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' } : {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1058,4 +1035,4 @@ export default function CartPage({ isFlashDelivery = false }: CartPageProps) {
|
||||||
</BottomDialog>
|
</BottomDialog>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue