enh
This commit is contained in:
parent
dc11e77707
commit
637c90a771
15 changed files with 4966 additions and 15 deletions
|
|
@ -35,6 +35,7 @@ const extractKeyFromUrl = (url: string): string => {
|
|||
export default function SendNotifications() {
|
||||
const router = useRouter();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: 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!');
|
||||
// Reset form
|
||||
setSelectedUserIds([]);
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setSelectedImage(null);
|
||||
setDisplayImage(null);
|
||||
|
|
@ -94,6 +96,11 @@ export default function SendNotifications() {
|
|||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (title.trim().length === 0) {
|
||||
Alert.alert('Error', 'Please enter a title');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.trim().length === 0) {
|
||||
Alert.alert('Error', 'Please enter a message');
|
||||
return;
|
||||
|
|
@ -136,6 +143,7 @@ export default function SendNotifications() {
|
|||
// Send notification
|
||||
await sendNotification.mutateAsync({
|
||||
userIds: selectedUserIds,
|
||||
title: title.trim(),
|
||||
text: message.trim(),
|
||||
imageUrl,
|
||||
});
|
||||
|
|
@ -196,6 +204,17 @@ export default function SendNotifications() {
|
|||
</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>
|
||||
<MyTextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Enter notification title..."
|
||||
style={tw`text-gray-900`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Message 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`}>Message</MyText>
|
||||
|
|
@ -223,9 +242,9 @@ export default function SendNotifications() {
|
|||
{/* Submit Button */}
|
||||
<TouchableOpacity
|
||||
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`${
|
||||
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-blue-600'
|
||||
} rounded-xl py-4 items-center shadow-sm`}
|
||||
|
|
|
|||
13
apps/backend/assets/fcm-v1-account.json
Normal file
13
apps/backend/assets/fcm-v1-account.json
Normal 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"
|
||||
}
|
||||
1
apps/backend/drizzle/0073_faithful_gravity.sql
Normal file
1
apps/backend/drizzle/0073_faithful_gravity.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "mf"."user_notifications" ADD COLUMN "title" varchar(255) NOT NULL;
|
||||
3691
apps/backend/drizzle/meta/0073_snapshot.json
Normal file
3691
apps/backend/drizzle/meta/0073_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -512,6 +512,13 @@
|
|||
"when": 1770546741428,
|
||||
"tag": "0072_flowery_deathbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1770561175889,
|
||||
"tag": "0073_faithful_gravity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ export const notifCreds = mf.table('notif_creds', {
|
|||
|
||||
export const userNotifications = mf.table('user_notifications', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
imageUrl: varchar('image_url', { length: 500 }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
body: text('body').notNull(),
|
||||
|
|
|
|||
14
apps/backend/src/lib/firebase.ts
Normal file
14
apps/backend/src/lib/firebase.ts
Normal 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;
|
||||
|
|
@ -4,6 +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 { 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)
|
||||
|
|
@ -362,24 +370,133 @@ export const userRouter = {
|
|||
sendNotification: protectedProcedure
|
||||
.input(z.object({
|
||||
userIds: z.array(z.number()),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
text: z.string().min(1, 'Message is required'),
|
||||
imageUrl: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { userIds, text, imageUrl } = input;
|
||||
const { userIds, title, text, imageUrl } = input;
|
||||
|
||||
// Store notification in database
|
||||
await db.insert(userNotifications).values({
|
||||
title,
|
||||
body: text,
|
||||
imageUrl: imageUrl || 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 {
|
||||
success: true,
|
||||
message: `Notification sent to ${userIds.length > 0 ? userIds.length + ' users' : 'all users'}`,
|
||||
message: `Notification sent to ${sentCount} users${failedCount > 0 ? `, ${failedCount} failed` : ''}`,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ import { ApiError } from '../../lib/api-error';
|
|||
export const fileUploadRouter = router({
|
||||
generateUploadUrls: protectedProcedure
|
||||
.input(z.object({
|
||||
contextString: z.enum(['review', 'product_info']),
|
||||
contextString: z.enum(['review', 'product_info', 'notification']),
|
||||
mimeTypes: z.array(z.string()),
|
||||
}))
|
||||
.mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => {
|
||||
|
|
@ -24,6 +24,8 @@ export const fileUploadRouter = router({
|
|||
folder = 'product-images';
|
||||
} else if(contextString === 'review_response') {
|
||||
folder = 'review-response-images'
|
||||
} else if(contextString === 'notification') {
|
||||
folder = 'notification-images'
|
||||
} else {
|
||||
folder = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Freshyo",
|
||||
"slug": "freshyo",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/freshyo-logo.png",
|
||||
"scheme": "freshyo",
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ export default function Dashboard() {
|
|||
<MyText
|
||||
style={tw`text-xl font-extrabold text-white tracking-tight drop-shadow-md text-neutral-800`}
|
||||
>
|
||||
Our Storess
|
||||
Our Stores
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
|
||||
Fresh from our locations
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ export async function registerForPushNotificationsAsync() {
|
|||
projectId,
|
||||
})
|
||||
).data;
|
||||
return pushTokenString;
|
||||
// const pushTokenString = await Notifications.getDevicePushTokenAsync();
|
||||
// return pushTokenString.data;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`${e}`);
|
||||
}
|
||||
|
|
|
|||
1095
package-lock.json
generated
1095
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,7 @@
|
|||
"expo": "~53.0.22",
|
||||
"expo-auth-session": "~6.2.1",
|
||||
"expo-crypto": "~14.1.5",
|
||||
"expo-server-sdk": "^5.0.0",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"react": "19.0.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue