diff --git a/.env.example b/.env.example index c222596..40e97ed 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,45 @@ +# Example .env.local +# Copy this file to .env.local and fill in the values for local development + +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your_secret_here + +GITHUB_ID=your_github_id_here +GITHUB_SECRET=your_github_secret_here + +MONGODB_URI=mongodb://localhost:27017 +NODE_ENV=development + +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# Firebase (client) - used in the browser +NEXT_PUBLIC_FIREBASE_DATABASE_URL= +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= +NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= +NEXT_PUBLIC_FIREBASE_VAPID_KEY= + +# Firebase Admin (server) - used to send push notifications from the server +# If you paste a multi-line JSON private key here, replace newlines with \n +FIREBASE_PROJECT_ID= +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= + +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +ALL_GATHERING_ID= + +GOOGLE_GENERATIVE_AI_API_KEY= + +RZP_PROD_KEY_ID= +RZP_PROD_KEY_SECRET= NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=your_secret_here @@ -12,6 +54,7 @@ CLOUDINARY_CLOUD_NAME= CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= +# Firebase Client Configuration NEXT_PUBLIC_FIREBASE_DATABASE_URL= NEXT_PUBLIC_FIREBASE_API_KEY= NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= @@ -20,6 +63,12 @@ NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= NEXT_PUBLIC_FIREBASE_MESSENGER_ID= NEXT_PUBLIC_FIREBASE_APP_ID= NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= +NEXT_PUBLIC_FIREBASE_VAPID_KEY= + +# Firebase Admin Configuration (for push notifications) +FIREBASE_PROJECT_ID= +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b9be207..a82b547 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,24 @@ + + + +Summary: + +--- + +Checklist: +- [ ] I added/updated documentation where relevant (see `PUSH_NOTIFICATIONS.md`) +- [ ] I updated `.env.example` with any required env vars +- [ ] The app builds and starts locally (`npm run dev`) without secrets (guarded behavior) +- [ ] For features that require external services, I documented how to test (service worker/Firebase instructions) +- [ ] I did not commit secrets or private keys + +Testing steps: +1. Run `npm run dev` +2. Open http://localhost:3000 +3. Sign in and test notification flows when env vars are provided, or run the /api/notifications/test helper if present + +Notes for reviewers: +- This PR includes push notification setup and defensive guards so local runs without Firebase/Mongo credentials do not crash the app. # 🚀 Pull Request Overview ## 📄 Description diff --git a/PUSH_NOTIFICATIONS.md b/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..ffeafd3 --- /dev/null +++ b/PUSH_NOTIFICATIONS.md @@ -0,0 +1,87 @@ +# Push Notifications (FCM) — Setup & Testing + +This document explains how to set up and test browser push notifications for CodeNearby using Firebase Cloud Messaging (FCM). It also contains notes for reviewers and a small PR checklist. + +## Quick summary +- Client: FCM Web SDK (config via NEXT_PUBLIC_* env vars) +- Server: Firebase Admin SDK (requires service account credentials in env) +- Service worker: `public/firebase-messaging-sw.js` — must contain real Firebase config at build time + +## Required env vars (local) +Fill these in `.env.local` (see `.env.example`): +- NEXT_PUBLIC_FIREBASE_API_KEY +- NEXT_PUBLIC_FIREBASE_PROJECT_ID +- NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID +- NEXT_PUBLIC_FIREBASE_APP_ID +- NEXT_PUBLIC_FIREBASE_DATABASE_URL +- NEXT_PUBLIC_FIREBASE_VAPID_KEY + +Server-side (for sending pushes): +- FIREBASE_PROJECT_ID +- FIREBASE_CLIENT_EMAIL +- FIREBASE_PRIVATE_KEY (replace newlines with `\\n` when pasting) + +Also ensure `MONGODB_URI` is set and a running MongoDB is reachable for testing user token storage. + +## Service worker +- `public/firebase-messaging-sw.js` currently contains placeholder config and comments. +- Service workers can't read process.env at runtime; replace the placeholders during build or deploy. Example techniques: + - Template + build script that injects NEXT_PUBLIC_FIREBASE_* values into the file + - Keep a separate `firebase-messaging-sw.prod.js` generated in CI + +## Local testing steps +1. Populate `.env.local` with the env vars above and run `npm run dev`. +2. Open http://localhost:3000 in Chrome/Edge/Firefox (HTTPS is required for production; `localhost` is allowed during dev). +3. Sign in and open Profile → Edit → Notifications. Click "Enable notifications" and grant permission in the browser. +4. From another account or by using the test route (see below) send a message or friend request. Verify you receive a notification while: + - App is in foreground (in-app toast / native notification) + - App is backgrounded or closed (OS notification via service worker) +5. Click the notification to ensure it navigates to the expected page. + +## Developer test helper (recommended for reviewers without Firebase keys) +If you want reviewers to try the UI without Firebase access, add a small temporary route that returns a simulated push payload to the client and opens the notification UI. This repo intentionally avoids shipping such a route; if you'd like, I can add a `/api/notifications/test` route that: +- Accepts an authenticated user +- Returns a simulated notification payload +- Does not call Firebase Admin + +## PR checklist for this feature +- [ ] `.env.example` updated with required keys and clear instructions +- [ ] `public/firebase-messaging-sw.js` documented and note about build-time replacement included +- [ ] Server-side send functions gracefully no-op when admin credentials are missing (so reviewers can run the site without secrets) +- [ ] Unit or integration tests for the server send helper (optional) +- [ ] Documentation included (this file) + +## Notes for reviewers +- The code contains guards around Firebase and Mongo initialization to avoid fatal startup errors when envs are missing. This is deliberate so contributors can run locally. +- Background notifications depend on your deployment injecting real Firebase config into the service worker at build time. +- Server sends will be no-ops if admin credentials are not present; check server logs for messages when testing with credentials. + +--- + +If you'd like, I can: +- Add a small `/api/notifications/test` endpoint so reviewers can exercise the UI without Firebase credentials, or +- Implement a build-time script (simple Node script) to generate `public/firebase-messaging-sw.js` from env vars during `npm run build`. + +Tell me which option you prefer and I'll implement it and open the PR. +## Future Enhancements + +- 🔔 **Rich Notifications**: Add images, actions, and rich content +- 📊 **Analytics**: Track notification open rates and effectiveness +- 🎯 **Targeting**: Send notifications based on user activity/location +- 📱 **Mobile App**: Extend to native mobile app notifications +- 🔕 **Quiet Hours**: Respect user timezone and quiet hours +- 🔄 **Sync**: Better cross-device notification sync + +## Contributing + +When adding new notification types: + +1. Add the notification type to `NotificationSettings` interface +2. Update server-side notification functions in `push-notifications-server.ts` +3. Add UI controls in `notification-settings.tsx` +4. Update service worker click handlers in `firebase-messaging-sw.js` +5. Test across different browsers and devices + +--- + +Built with ❤️ for the CodeNearby community diff --git a/app/api/friends/request/route.ts b/app/api/friends/request/route.ts index 22908ae..30ff5b6 100644 --- a/app/api/friends/request/route.ts +++ b/app/api/friends/request/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth/next"; import clientPromise from "@/lib/mongodb"; import { authOptions } from "@/app/options"; +import { sendFriendRequestNotification } from "@/lib/push-notifications-server"; export async function POST(request: Request) { try { @@ -52,6 +53,20 @@ export async function POST(request: Request) { receiverInCodeNearby: !!receiverUser, }); + // Send push notification if the receiver is a CodeNearby user and has FCM token + if (receiverUser?.fcmToken && receiverUser?.notificationSettings?.friendRequests !== false) { + try { + await sendFriendRequestNotification( + receiverUser.fcmToken, + session.user.name || session.user.githubUsername || "Someone", + session.user.githubUsername || "unknown" + ); + } catch (error) { + console.error("Error sending friend request push notification:", error); + // Don't fail the request if notification fails + } + } + return NextResponse.json({ id: result.insertedId }); } catch (error) { console.error("Error creating friend request:", error); diff --git a/app/api/gathering/[slug]/chat/route.ts b/app/api/gathering/[slug]/chat/route.ts index 6350397..e48904f 100644 --- a/app/api/gathering/[slug]/chat/route.ts +++ b/app/api/gathering/[slug]/chat/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/app/options"; import { ref, push } from "firebase/database"; import clientPromise from "@/lib/mongodb"; import { db as firebaseDb } from "@/lib/firebase"; +import { sendGatheringMessageNotification } from "@/lib/push-notifications-server"; export async function POST( request: Request, @@ -55,8 +56,50 @@ export async function POST( : null, }; - const messagesRef = ref(firebaseDb, `messages/${params.slug}`); - await push(messagesRef, message); + if (!firebaseDb) { + console.warn("Firebase database is not initialized. Skipping push to realtime DB."); + } else { + const messagesRef = ref(firebaseDb, `messages/${params.slug}`); + await push(messagesRef, message); + } + + // Send push notifications to gathering participants (except sender) + try { + // Get gathering participants with FCM tokens + const participantsWithTokens = await db + .collection("users") + .find( + { + _id: { $in: gathering.participants.filter((id: string) => id !== session.user.id) }, + fcmToken: { $exists: true, $ne: null }, + "notificationSettings.gatheringMessages": { $ne: false }, + }, + { projection: { fcmToken: 1 } } + ) + .toArray(); + + const fcmTokens = participantsWithTokens + .map((user) => user.fcmToken) + .filter(Boolean); + + if (fcmTokens.length > 0) { + // Create message preview (limit to 100 characters) + const messagePreview = content.length > 100 + ? content.substring(0, 97) + "..." + : content; + + await sendGatheringMessageNotification( + fcmTokens, + isAnonymous ? "Anonymous" : (session.user.name || "Someone"), + gathering.title || "Gathering", + messagePreview, + params.slug + ); + } + } catch (error) { + console.error("Error sending gathering message push notifications:", error); + // Don't fail the request if notification fails + } return NextResponse.json({ success: true }); } catch (error) { diff --git a/app/api/messages/notify/route.ts b/app/api/messages/notify/route.ts new file mode 100644 index 0000000..4b33718 --- /dev/null +++ b/app/api/messages/notify/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/options"; +import clientPromise from "@/lib/mongodb"; +import { sendMessageNotification } from "@/lib/push-notifications-server"; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { recipientId, content, chatId } = await request.json(); + + if (!recipientId || !content || !chatId) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const client = await clientPromise; + const db = client.db(); + + // Get recipient user data + const recipientUser = await db + .collection("users") + .findOne({ githubId: parseInt(recipientId) }); + + if (!recipientUser) { + return NextResponse.json( + { error: "Recipient not found" }, + { status: 404 } + ); + } + + // Check if recipient has notifications enabled and has FCM token + const shouldSendNotification = + recipientUser.fcmToken && + recipientUser.notificationSettings?.messages !== false; + + if (shouldSendNotification) { + try { + // Create message preview (limit to 100 characters) + const messagePreview = content.length > 100 + ? content.substring(0, 97) + "..." + : content; + + await sendMessageNotification( + recipientUser.fcmToken, + session.user.name || session.user.githubUsername || "Someone", + messagePreview, + chatId + ); + } catch (error) { + console.error("Error sending message push notification:", error); + // Don't fail the request if notification fails + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error processing message notification:", error); + return NextResponse.json( + { error: "Failed to process notification" }, + { status: 500 } + ); + } +} diff --git a/app/api/notifications/settings/route.ts b/app/api/notifications/settings/route.ts new file mode 100644 index 0000000..2d00256 --- /dev/null +++ b/app/api/notifications/settings/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/options"; +import clientPromise from "@/lib/mongodb"; + +// Get notification settings for a user +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const client = await clientPromise; + const db = client.db(); + + const user = await db.collection("users").findOne( + { _id: session.user.id }, + { projection: { notificationSettings: 1 } } + ); + + const defaultSettings = { + messages: true, + friendRequests: true, + gatheringMessages: true, + posts: true, + events: true, + pushEnabled: false, + }; + + return NextResponse.json({ + settings: user?.notificationSettings || defaultSettings, + }); + } catch (error) { + console.error("Error fetching notification settings:", error); + return NextResponse.json( + { error: "Failed to fetch settings" }, + { status: 500 } + ); + } +} + +// Update notification settings for a user +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const settings = await request.json(); + const client = await clientPromise; + const db = client.db(); + + // Update user notification settings + await db.collection("users").updateOne( + { _id: session.user.id }, + { + $set: { + notificationSettings: settings, + notificationSettingsUpdatedAt: new Date(), + }, + } + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error updating notification settings:", error); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/notifications/token/route.ts b/app/api/notifications/token/route.ts new file mode 100644 index 0000000..f4f708f --- /dev/null +++ b/app/api/notifications/token/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/options"; +import clientPromise from "@/lib/mongodb"; + +// Store FCM token for a user +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { token } = await request.json(); + if (!token) { + return NextResponse.json({ error: "Token is required" }, { status: 400 }); + } + + const client = await clientPromise; + const db = client.db(); + + // Update user with FCM token + await db.collection("users").updateOne( + { _id: session.user.id }, + { + $set: { + fcmToken: token, + fcmTokenUpdatedAt: new Date(), + }, + } + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error storing FCM token:", error); + return NextResponse.json( + { error: "Failed to store token" }, + { status: 500 } + ); + } +} + +// Get FCM token for a user +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const client = await clientPromise; + const db = client.db(); + + const user = await db.collection("users").findOne( + { _id: session.user.id }, + { projection: { fcmToken: 1 } } + ); + + return NextResponse.json({ token: user?.fcmToken || null }); + } catch (error) { + console.error("Error fetching FCM token:", error); + return NextResponse.json( + { error: "Failed to fetch token" }, + { status: 500 } + ); + } +} + +// Remove FCM token for a user +export async function DELETE() { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const client = await clientPromise; + const db = client.db(); + + // Remove FCM token from user + await db.collection("users").updateOne( + { _id: session.user.id }, + { + $unset: { + fcmToken: "", + fcmTokenUpdatedAt: "", + }, + } + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error removing FCM token:", error); + return NextResponse.json( + { error: "Failed to remove token" }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 9875ff4..eca39b1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,8 @@ import { ThemeProvider } from "@/components/theme-provider"; import Header from "@/components/header"; import { NextAuthProvider } from "@/components/providers"; import Footer from "@/components/footer"; +import { PushNotificationProvider } from "@/components/push-notification-provider"; +import { NotificationPrompt } from "@/components/notification-prompt"; import type { Metadata } from "next"; import { Toaster } from "@/components/ui/sonner"; import { Analytics } from "@vercel/analytics/next"; @@ -169,21 +171,23 @@ export default function RootLayout({