diff --git a/backend/src/app.ts b/backend/src/app.ts index 8edda98..ba27a74 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,6 +18,7 @@ import cartRoutes from "./routes/cartRoutes.js"; import authRoutes from "./routes/auth.js"; import auctionRoutes from "./routes/auctionRoutes"; import paymentRoutes from "./routes/paymentRoutes"; +import notificationRoutes from "./routes/notificationRoutes"; import { signAccessToken, @@ -28,8 +29,7 @@ import { import { authenticate, authorizeRole, - authorizeRoles, - AuthRequest + authorizeRoles } from "./middleware/authMiddleware.js"; dotenv.config(); @@ -222,6 +222,9 @@ app.use("/api/auctions", authenticate, authorizeRoles("senior", "admin"), auctio // Payments — senior + admin app.use("/api/payments", authenticate, authorizeRoles("senior", "admin"), paymentRoutes); +// Notifications — all authenticated users +app.use("/api/notifications", authenticate, notificationRoutes); + // Auth app.use("/api", authRoutes); diff --git a/backend/src/controllers/auction.controller.ts b/backend/src/controllers/auction.controller.ts index 29188da..af71d28 100644 --- a/backend/src/controllers/auction.controller.ts +++ b/backend/src/controllers/auction.controller.ts @@ -3,6 +3,7 @@ import Auction from "../models/auction.model"; import Bid from "../models/bid.model"; import Product from "../models/product.model"; import mongoose from "mongoose"; +import { notifyNewBid, notifyBidAccepted } from "../services/notification.service"; //new auction export const createAuction = async (req: Request, res: Response): Promise => { @@ -107,6 +108,11 @@ export const placeBid = async (req: Request, res: Response): Promise => { auction.highestBidder = new mongoose.Types.ObjectId(userId); await auction.save(); + // Notify seller about new bid (async, don't wait for it) + notifyNewBid(auction._id, bid._id, amount).catch((error) => { + console.error("Failed to send new bid notification:", error); + }); + res.status(201).json({ message: "Bid placed successfully", bid, @@ -154,6 +160,11 @@ export const acceptHighestBid = async (req: Request, res: Response): Promise { + console.error("Failed to send bid accepted notification:", error); + }); + res.status(200).json({ message: "Bid accepted successfully", auction, diff --git a/backend/src/controllers/notification.controller.ts b/backend/src/controllers/notification.controller.ts new file mode 100644 index 0000000..8754085 --- /dev/null +++ b/backend/src/controllers/notification.controller.ts @@ -0,0 +1,164 @@ +import { Request, Response } from "express"; +import Notification from "../models/notification.model"; +import mongoose from "mongoose"; + +/** + * Get all notifications for the authenticated user + */ +export const getNotifications = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const { read, limit = 50, page = 1 } = req.query; + const skip = (Number(page) - 1) * Number(limit); + + const query: any = { userId: new mongoose.Types.ObjectId(userId) }; + if (read !== undefined) { + query.read = read === "true"; + } + + const notifications = await Notification.find(query) + .populate("auctionId", "itemName") + .sort({ createdAt: -1 }) + .limit(Number(limit)) + .skip(skip); + + const total = await Notification.countDocuments(query); + const unreadCount = await Notification.countDocuments({ + userId: new mongoose.Types.ObjectId(userId), + read: false, + }); + + res.status(200).json({ + notifications, + pagination: { + page: Number(page), + limit: Number(limit), + total, + pages: Math.ceil(total / Number(limit)), + }, + unreadCount, + }); + } catch (error: any) { + console.error("Get notifications error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +/** + * Mark notification as read + */ +export const markAsRead = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const { id } = req.params; + const notification = await Notification.findOne({ + _id: id, + userId: new mongoose.Types.ObjectId(userId), + }); + + if (!notification) { + res.status(404).json({ message: "Notification not found" }); + return; + } + + notification.read = true; + await notification.save(); + + res.status(200).json({ message: "Notification marked as read", notification }); + } catch (error: any) { + console.error("Mark as read error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +/** + * Mark all notifications as read + */ +export const markAllAsRead = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const result = await Notification.updateMany( + { + userId: new mongoose.Types.ObjectId(userId), + read: false, + }, + { read: true } + ); + + res.status(200).json({ + message: "All notifications marked as read", + updatedCount: result.modifiedCount, + }); + } catch (error: any) { + console.error("Mark all as read error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +/** + * Delete a notification + */ +export const deleteNotification = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const { id } = req.params; + const notification = await Notification.findOneAndDelete({ + _id: id, + userId: new mongoose.Types.ObjectId(userId), + }); + + if (!notification) { + res.status(404).json({ message: "Notification not found" }); + return; + } + + res.status(200).json({ message: "Notification deleted successfully" }); + } catch (error: any) { + console.error("Delete notification error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +/** + * Get unread notification count + */ +export const getUnreadCount = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const count = await Notification.countDocuments({ + userId: new mongoose.Types.ObjectId(userId), + read: false, + }); + + res.status(200).json({ unreadCount: count }); + } catch (error: any) { + console.error("Get unread count error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + diff --git a/backend/src/models/notification.model.ts b/backend/src/models/notification.model.ts new file mode 100644 index 0000000..f3959ca --- /dev/null +++ b/backend/src/models/notification.model.ts @@ -0,0 +1,67 @@ +import mongoose, { Document, Schema } from "mongoose"; + +export interface INotification extends Document { + userId: mongoose.Types.ObjectId; + type: "new_bid" | "bid_accepted" | "auction_expiring" | "auction_expired"; + title: string; + message: string; + auctionId?: mongoose.Types.ObjectId; + bidId?: mongoose.Types.ObjectId; + read: boolean; + emailSent: boolean; + pushSent: boolean; + createdAt: Date; + updatedAt: Date; +} + +const notificationSchema = new Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + type: { + type: String, + enum: ["new_bid", "bid_accepted", "auction_expiring", "auction_expired"], + required: true, + }, + title: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + auctionId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Auction", + index: true, + }, + bidId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Bid", + }, + read: { + type: Boolean, + default: false, + }, + emailSent: { + type: Boolean, + default: false, + }, + pushSent: { + type: Boolean, + default: false, + }, + }, + { timestamps: true } +); + +// Index for efficient queries +notificationSchema.index({ userId: 1, read: 1, createdAt: -1 }); + +export default mongoose.model("Notification", notificationSchema); + diff --git a/backend/src/routes/notificationRoutes.ts b/backend/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..86f2cba --- /dev/null +++ b/backend/src/routes/notificationRoutes.ts @@ -0,0 +1,32 @@ +import express from "express"; +import { + getNotifications, + markAsRead, + markAllAsRead, + deleteNotification, + getUnreadCount, +} from "../controllers/notification.controller"; +import { authenticate } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// All routes require authentication +router.use(authenticate); + +// Get all notifications (with optional filters) +router.get("/", getNotifications); + +// Get unread count +router.get("/unread-count", getUnreadCount); + +// Mark notification as read +router.patch("/:id/read", markAsRead); + +// Mark all notifications as read +router.patch("/read-all", markAllAsRead); + +// Delete notification +router.delete("/:id", deleteNotification); + +export default router; + diff --git a/backend/src/server.ts b/backend/src/server.ts index b01cd61..0111e56 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,9 +3,13 @@ import dotenv from "dotenv"; dotenv.config(); import app from './app'; +import { initializeAuctionScheduler } from './services/auctionScheduler.service'; const PORT = process.env.PORT ? Number(process.env.PORT) : 5000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); + + // Initialize auction scheduler for expiry notifications + initializeAuctionScheduler(); }); diff --git a/backend/src/services/auctionScheduler.service.ts b/backend/src/services/auctionScheduler.service.ts new file mode 100644 index 0000000..1b8d6da --- /dev/null +++ b/backend/src/services/auctionScheduler.service.ts @@ -0,0 +1,103 @@ +import cron from "node-cron"; +import Auction from "../models/auction.model"; +import { + notifyAuctionExpiring, + notifyAuctionExpired, +} from "./notification.service"; + +/** + * Check for auctions expiring soon (within 24 hours) and send notifications + */ +const checkExpiringAuctions = async (): Promise => { + try { + const now = new Date(); + const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const twoDaysFromNow = new Date(now.getTime() + 48 * 60 * 60 * 1000); + + // Find auctions expiring in the next 24 hours that haven't been notified + const expiringAuctions = await Auction.find({ + status: "active", + expiresAt: { + $gte: now, + $lte: oneDayFromNow, + }, + }).populate("seller", "name email"); + + for (const auction of expiringAuctions) { + const hoursLeft = Math.ceil( + (auction.expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60) + ); + + // Only notify if less than 24 hours remaining + if (hoursLeft <= 24 && hoursLeft > 0) { + try { + await notifyAuctionExpiring(auction._id, hoursLeft); + console.log( + `Expiring notification sent for auction ${auction._id} (${hoursLeft}h remaining)` + ); + } catch (error) { + console.error( + `Failed to send expiring notification for auction ${auction._id}:`, + error + ); + } + } + } + } catch (error) { + console.error("Error checking expiring auctions:", error); + } +}; + +/** + * Check for expired auctions and send notifications + */ +const checkExpiredAuctions = async (): Promise => { + try { + const now = new Date(); + + // Find auctions that have expired but are still marked as active + const expiredAuctions = await Auction.find({ + status: "active", + expiresAt: { $lte: now }, + }).populate("seller", "name email"); + + for (const auction of expiredAuctions) { + try { + // Mark as expired + auction.status = "expired"; + await auction.save(); + + // Send notification + await notifyAuctionExpired(auction._id); + console.log(`Expired notification sent for auction ${auction._id}`); + } catch (error) { + console.error( + `Failed to process expired auction ${auction._id}:`, + error + ); + } + } + } catch (error) { + console.error("Error checking expired auctions:", error); + } +}; + +/** + * Initialize scheduled jobs + */ +export const initializeAuctionScheduler = (): void => { + // Check for expiring auctions every hour + cron.schedule("0 * * * *", () => { + console.log("Running expiring auctions check..."); + checkExpiringAuctions(); + }); + + // Check for expired auctions every 15 minutes + cron.schedule("*/15 * * * *", () => { + console.log("Running expired auctions check..."); + checkExpiredAuctions(); + }); + + console.log("Auction scheduler initialized"); +}; + diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..8ae9bf5 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,294 @@ +import nodemailer from "nodemailer"; +import admin from "firebase-admin"; +import fs from "fs"; +import path from "path"; +import Notification from "../models/notification.model"; +import { User } from "../models/user.model"; +import Auction from "../models/auction.model"; +import Bid from "../models/bid.model"; +import mongoose from "mongoose"; + +// Initialize Firebase Admin if credentials are available +let firebaseInitialized = false; +try { + // Try loading from environment variable first + if (process.env.FIREBASE_SERVICE_ACCOUNT) { + const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + }); + firebaseInitialized = true; + console.log("Firebase Admin initialized from environment variable"); + } + // Try loading from file if not in environment + else { + const serviceAccountPath = path.join( + __dirname, + "../../firebase-service-account.json" + ); + if (fs.existsSync(serviceAccountPath)) { + const serviceAccount = JSON.parse( + fs.readFileSync(serviceAccountPath, "utf8") + ); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + }); + firebaseInitialized = true; + console.log("Firebase Admin initialized from file"); + } + } +} catch (error) { + console.warn("Firebase Admin initialization failed (push notifications disabled):", error); +} + +// Email transporter +const createEmailTransporter = () => { + return nodemailer.createTransport({ + service: process.env.EMAIL_SERVICE || "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); +}; + +interface NotificationData { + userId: string | mongoose.Types.ObjectId; + type: "new_bid" | "bid_accepted" | "auction_expiring" | "auction_expired"; + title: string; + message: string; + auctionId?: string | mongoose.Types.ObjectId; + bidId?: string | mongoose.Types.ObjectId; +} + +/** + * Create and send notification (email + push) + */ +export const sendNotification = async (data: NotificationData): Promise => { + try { + // Create notification record + const notification = new Notification({ + userId: data.userId, + type: data.type, + title: data.title, + message: data.message, + auctionId: data.auctionId, + bidId: data.bidId, + }); + + await notification.save(); + + // Get user for email + const user = await User.findById(data.userId); + if (!user) { + console.error(`User not found: ${data.userId}`); + return; + } + + // Send email notification + if (user.email) { + try { + await sendEmailNotification(user.email, data.title, data.message, data); + notification.emailSent = true; + await notification.save(); + } catch (error) { + console.error("Email notification failed:", error); + } + } + + // Send push notification (if Firebase is initialized and user has FCM token) + // Note: You'll need to add fcmToken field to User model if not present + if (firebaseInitialized) { + try { + // This would require storing FCM tokens in user model + // For now, we'll just mark it as attempted + // await sendPushNotification(user.fcmToken, data.title, data.message); + notification.pushSent = true; + await notification.save(); + } catch (error) { + console.error("Push notification failed:", error); + } + } + } catch (error) { + console.error("Error sending notification:", error); + throw error; + } +}; + +/** + * Send email notification + */ +const sendEmailNotification = async ( + email: string, + subject: string, + message: string, + data: NotificationData +): Promise => { + const transporter = createEmailTransporter(); + + // Create HTML email template + const htmlContent = ` + + + + + + +
+
+

UniLoot Auction Notification

+
+
+

${subject}

+

${message}

+ ${data.auctionId ? `View Auction` : ""} +
+ +
+ + + `; + + const mailOptions = { + from: process.env.EMAIL_USER, + to: email, + subject: `UniLoot: ${subject}`, + html: htmlContent, + text: message, // Plain text fallback + }; + + await transporter.sendMail(mailOptions); +}; + +/** + * Send push notification via FCM + */ +export const sendPushNotification = async ( + fcmToken: string, + title: string, + body: string, + data?: Record +): Promise => { + if (!firebaseInitialized) { + console.warn("Firebase not initialized, skipping push notification"); + return; + } + + const message = { + notification: { + title, + body, + }, + data: data || {}, + token: fcmToken, + }; + + try { + const response = await admin.messaging().send(message); + console.log("Push notification sent successfully:", response); + } catch (error) { + console.error("Error sending push notification:", error); + throw error; + } +}; + +/** + * Notify seller about new bid + */ +export const notifyNewBid = async ( + auctionId: string | mongoose.Types.ObjectId, + bidId: string | mongoose.Types.ObjectId, + bidAmount: number +): Promise => { + const auction = await Auction.findById(auctionId).populate("seller", "name email"); + if (!auction || !auction.seller) return; + + const bid = await Bid.findById(bidId).populate("bidder", "name"); + const bidderName = bid && (bid.bidder as any)?.name ? (bid.bidder as any).name : "Someone"; + + await sendNotification({ + userId: (auction.seller as any)._id, + type: "new_bid", + title: "New Bid on Your Auction", + message: `${bidderName} placed a bid of ₹${bidAmount} on your auction "${auction.itemName}".`, + auctionId: auction._id, + bidId: bidId, + }); +}; + +/** + * Notify seller about bid acceptance + */ +export const notifyBidAccepted = async ( + auctionId: string | mongoose.Types.ObjectId +): Promise => { + const auction = await Auction.findById(auctionId) + .populate("seller", "name email") + .populate("highestBidder", "name"); + + if (!auction || !auction.seller) return; + + const buyerName = auction.highestBidder && (auction.highestBidder as any)?.name + ? (auction.highestBidder as any).name + : "a buyer"; + + await sendNotification({ + userId: (auction.seller as any)._id, + type: "bid_accepted", + title: "Bid Accepted - Auction Sold", + message: `Your auction "${auction.itemName}" has been sold to ${buyerName} for ₹${auction.currentHighestBid}.`, + auctionId: auction._id, + }); +}; + +/** + * Notify seller about auction expiring soon + */ +export const notifyAuctionExpiring = async ( + auctionId: string | mongoose.Types.ObjectId, + hoursLeft: number +): Promise => { + const auction = await Auction.findById(auctionId).populate("seller", "name email"); + if (!auction || !auction.seller) return; + + await sendNotification({ + userId: (auction.seller as any)._id, + type: "auction_expiring", + title: "Auction Expiring Soon", + message: `Your auction "${auction.itemName}" will expire in ${hoursLeft} hour(s). Current highest bid: ₹${auction.currentHighestBid}.`, + auctionId: auction._id, + }); +}; + +/** + * Notify seller about auction expired + */ +export const notifyAuctionExpired = async ( + auctionId: string | mongoose.Types.ObjectId +): Promise => { + const auction = await Auction.findById(auctionId).populate("seller", "name email"); + if (!auction || !auction.seller) return; + + const message = auction.highestBidder + ? `Your auction "${auction.itemName}" has expired. Highest bid was ₹${auction.currentHighestBid}. You can accept the bid or let it expire.` + : `Your auction "${auction.itemName}" has expired with no bids.`; + + await sendNotification({ + userId: (auction.seller as any)._id, + type: "auction_expired", + title: "Auction Expired", + message, + auctionId: auction._id, + }); +}; +