This commit is contained in:
shafi54 2026-02-08 21:36:45 +05:30
parent dc11e77707
commit 637c90a771
15 changed files with 4966 additions and 15 deletions

View file

@ -35,6 +35,7 @@ const extractKeyFromUrl = (url: string): string => {
export default function SendNotifications() { export default function SendNotifications() {
const router = useRouter(); const router = useRouter();
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [title, setTitle] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: string } | null>(null); const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: string } | null>(null);
const [displayImage, setDisplayImage] = useState<{ uri?: string } | null>(null); const [displayImage, setDisplayImage] = useState<{ uri?: string } | null>(null);
@ -54,6 +55,7 @@ export default function SendNotifications() {
Alert.alert('Success', 'Notification sent successfully!'); Alert.alert('Success', 'Notification sent successfully!');
// Reset form // Reset form
setSelectedUserIds([]); setSelectedUserIds([]);
setTitle('');
setMessage(''); setMessage('');
setSelectedImage(null); setSelectedImage(null);
setDisplayImage(null); setDisplayImage(null);
@ -94,6 +96,11 @@ export default function SendNotifications() {
}; };
const handleSend = async () => { const handleSend = async () => {
if (title.trim().length === 0) {
Alert.alert('Error', 'Please enter a title');
return;
}
if (message.trim().length === 0) { if (message.trim().length === 0) {
Alert.alert('Error', 'Please enter a message'); Alert.alert('Error', 'Please enter a message');
return; return;
@ -136,6 +143,7 @@ export default function SendNotifications() {
// Send notification // Send notification
await sendNotification.mutateAsync({ await sendNotification.mutateAsync({
userIds: selectedUserIds, userIds: selectedUserIds,
title: title.trim(),
text: message.trim(), text: message.trim(),
imageUrl, imageUrl,
}); });
@ -196,6 +204,17 @@ export default function SendNotifications() {
</MyText> </MyText>
</View> </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>
<MyTextInput
value={title}
onChangeText={setTitle}
placeholder="Enter notification title..."
style={tw`text-gray-900`}
/>
</View>
{/* Message Input */} {/* Message 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`}>Message</MyText> <MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Message</MyText>
@ -223,9 +242,9 @@ export default function SendNotifications() {
{/* Submit Button */} {/* Submit Button */}
<TouchableOpacity <TouchableOpacity
onPress={handleSend} onPress={handleSend}
disabled={sendNotification.isPending || message.trim().length === 0 || selectedUserIds.length === 0} disabled={sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0 || selectedUserIds.length === 0}
style={tw`${ style={tw`${
sendNotification.isPending || message.trim().length === 0 || selectedUserIds.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`}

View file

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "freshyo-cefb2",
"private_key_id": "dcdb3d9edb6505567db69bbd24e447df78c82dc7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDE3TSKEL9CF7yP\nUiSIvQC024yQGrERz1wtErH5Xff4pie1LSL1pXJHTpyWojp6dotkmCpxM36XjS+O\nO5pnJhSVwiYgvSxeO3EL6oNQwLP36pxQ7YwmoaFx9Jipau+OK+VY8Y/eMx4cWUJH\n7WeUDGwwJlMKE6CpEsbbBiAY5bF9wwe7v1YlkAnMm5ZZcujCqW1aShWKXuYoUMoP\n6egEiclCdQrHZ5IQCHRWruFTAOBuJ7v0A/9WM1gi7UM3VU3/8ccswP8DDoCrgrmh\nerUhFAEFMEjsns0B8SmwQ8v3GH5/SG0SCDwJniPFPnzdSxksaEB51OTaBcROJlED\nkwZZ+2u/AgMBAAECggEAPYL2vysjb6XWC5w5gSY5Ocmd/orwh+WYYhcE2CuV5zIX\nlyM+2K106zXzdJfFGO3AeVKYdF2IMRdy5AjYomFCLlcHLdSeL+V32abRmCJWOWEr\nrZfD4nA/b0ljiBA7QNuTYnq8HswvHOGA9dOGuTo2dccLzEq8uQd+bgJYdh8TGf2R\nqOpYHdRUJqDl+EuCDqLLqnq4l8E981GN78iVVL4DnYFE/3wb7tmuONww2+grq7ou\ntDtPQf4yNE2Vfx+5JnMsvU+J+iF/4vCI/9Oyg0keW/C8q9rbDdmeyefGRGWHtCCI\nH1wMzYTn2xw7EBH9O1NHDzTWkSTUfeo2dnaR0loVsQKBgQDnuwk180u1QXoDLUzj\ngd86CRnP/zqijdt+Y2oZnT+uHJrHCJbYNt3bdKRUEBU5KEcMdzMaeR8A1YEYK2oD\nd8M42nsOn22VymT0fIqwrHsf9e5mgGV+novqw848aEmgTIEmBnSYKc3Xa3px3wge\nJWLKlX/+y2uvI9En8u1FGQ0wcQKBgQDZe1uLqd6koPh/+VSAx2OtjCDgCAvlYoUh\nwIH3tFab/p41DyR+VDx6z18MACsSmyiewV4xUBmu1o+H/iiOxPXQvf7QchY+fYYb\nzOMJXM4ddcGUdLF8CPapbIFcLKemQIb0PIlrCQQeXq2E74JacP8kdqNmCQ8J/GZF\nMPapRTt3LwKBgQCU5jLJ7tZD1pnO9snEGkxUn0ptw0Nq9hoGwVyIrukfOJQfth4v\nOjoebHm25kqs2nukv+cfaJqKT6ZO4H6TUd4oZwLRZ5HjwRRToL8BPSM0azNPu8r7\nrGadaEnZuO0uSlpmE5nRuHLiq9YW20f9DurG339KOm2sMSiRMeBSGQHHkQKBgGTZ\nFQxgiwOgOVtujMbirtAtKJl6YbnOw5lxIVNx5q+TlF1aVjvWZ+0y+Aoikdag6Gcl\nl74aPK6chBY1vyzlHG/diqmyHaqAno2JpsYSqOl0T3291weDSI4r6JiLhHpNdccP\nw1FE7wn+MUxxm+rAdy+7a+3GyZiB2BLBr7+ygO61AoGBALySZ9m4hgX6uZtJvv3R\nrl8AWoG65NHCZ4694aEGTJDVDlPByV+Sd5iBOQ5dvhgA12Py2uj5ZHQXbuo0IGfJ\ngH8AZMIKX9UrhbE5BWYncg2ZR8uvKow8w36mLNnQhGZ71IZ9MXbWbpEK8CbCEvzZ\nyw0rKVgrrSRihW3stnl16Zs5\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@freshyo-cefb2.iam.gserviceaccount.com",
"client_id": "117456013812283364643",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40freshyo-cefb2.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View file

@ -0,0 +1 @@
ALTER TABLE "mf"."user_notifications" ADD COLUMN "title" varchar(255) NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -512,6 +512,13 @@
"when": 1770546741428, "when": 1770546741428,
"tag": "0072_flowery_deathbird", "tag": "0072_flowery_deathbird",
"breakpoints": true "breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1770561175889,
"tag": "0073_faithful_gravity",
"breakpoints": true
} }
] ]
} }

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

@ -421,6 +421,7 @@ export const notifCreds = mf.table('notif_creds', {
export const userNotifications = mf.table('user_notifications', { export const userNotifications = mf.table('user_notifications', {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar('title', { length: 255 }).notNull(),
imageUrl: varchar('image_url', { length: 500 }), imageUrl: varchar('image_url', { length: 500 }),
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
body: text('body').notNull(), body: text('body').notNull(),

View file

@ -0,0 +1,14 @@
import admin from 'firebase-admin';
import path from 'path';
// Initialize Firebase Admin SDK
const serviceAccountPath = path.join(process.cwd(), 'assets', '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

@ -4,6 +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 { 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)
@ -362,24 +370,133 @@ export const userRouter = {
sendNotification: protectedProcedure sendNotification: protectedProcedure
.input(z.object({ .input(z.object({
userIds: z.array(z.number()), userIds: z.array(z.number()),
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(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { userIds, text, imageUrl } = input; const { userIds, title, text, imageUrl } = input;
// Store notification in database // Store notification in database
await db.insert(userNotifications).values({ await db.insert(userNotifications).values({
title,
body: text, body: text,
imageUrl: imageUrl || null, imageUrl: imageUrl || null,
applicableUsers: userIds.length > 0 ? userIds : null, applicableUsers: userIds.length > 0 ? userIds : null,
}); });
// TODO: Implement actual push notification logic // 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,
},
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 {
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('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 sent to ${userIds.length > 0 ? userIds.length + ' users' : 'all users'}`, message: `Notification sent to ${sentCount} users${failedCount > 0 ? `, ${failedCount} failed` : ''}`,
}; };
}), }),
}; };

View file

@ -6,7 +6,7 @@ import { ApiError } from '../../lib/api-error';
export const fileUploadRouter = router({ export const fileUploadRouter = router({
generateUploadUrls: protectedProcedure generateUploadUrls: protectedProcedure
.input(z.object({ .input(z.object({
contextString: z.enum(['review', 'product_info']), contextString: z.enum(['review', 'product_info', 'notification']),
mimeTypes: z.array(z.string()), mimeTypes: z.array(z.string()),
})) }))
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
@ -24,6 +24,8 @@ export const fileUploadRouter = router({
folder = 'product-images'; folder = 'product-images';
} else if(contextString === 'review_response') { } else if(contextString === 'review_response') {
folder = 'review-response-images' folder = 'review-response-images'
} else if(contextString === 'notification') {
folder = 'notification-images'
} else { } else {
folder = ''; folder = '';
} }

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Freshyo", "name": "Freshyo",
"slug": "freshyo", "slug": "freshyo",
"version": "1.1.0", "version": "1.2.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/freshyo-logo.png", "icon": "./assets/images/freshyo-logo.png",
"scheme": "freshyo", "scheme": "freshyo",

View file

@ -355,7 +355,7 @@ export default function Dashboard() {
<MyText <MyText
style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`} style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}
> >
Our Storess Our Stores
</MyText> </MyText>
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}> <MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
Fresh from our locations Fresh from our locations

View file

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

1095
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,7 @@
"expo": "~53.0.22", "expo": "~53.0.22",
"expo-auth-session": "~6.2.1", "expo-auth-session": "~6.2.1",
"expo-crypto": "~14.1.5", "expo-crypto": "~14.1.5",
"expo-server-sdk": "^5.0.0",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~14.2.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "19.0.0", "react": "19.0.0",