diff --git a/cinetrack-app/.gitignore b/cinetrack-app/.gitignore index d292b8d..9a8512b 100644 --- a/cinetrack-app/.gitignore +++ b/cinetrack-app/.gitignore @@ -16,6 +16,7 @@ dist-ssr !.env.example infra/data server/benchmarks +server/scripts # Editor directories and files .vscode/* diff --git a/cinetrack-app/client/src/App.tsx b/cinetrack-app/client/src/App.tsx index 3dc8638..a0a809f 100644 --- a/cinetrack-app/client/src/App.tsx +++ b/cinetrack-app/client/src/App.tsx @@ -8,11 +8,13 @@ import { useDemoWelcome } from "./hooks/useDemoWelcome"; import { RouterProvider } from "react-router-dom"; import { router } from "./router"; import { useWatchlistInit } from "./store/useWatchlistStore"; +import { useNotificationInit } from "./store/useNotificationStore"; const AuthPage = lazy(() => import("./pages/AuthPage").then((m) => ({ default: m.AuthPage }))); const WatchlistInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { useWatchlistInit(); + useNotificationInit(); return <>{children}>; }; diff --git a/cinetrack-app/client/src/components/common/NotificationsModal.tsx b/cinetrack-app/client/src/components/common/NotificationsModal.tsx index 5c6d601..ec1259e 100644 --- a/cinetrack-app/client/src/components/common/NotificationsModal.tsx +++ b/cinetrack-app/client/src/components/common/NotificationsModal.tsx @@ -1,12 +1,41 @@ import React from "react"; -import { FiBell, FiX } from "react-icons/fi"; +import { FiBell, FiX, FiCheck, FiTrash2, FiInfo, FiAward, FiTv } from "react-icons/fi"; +import { useNotificationStore } from "../../store/useNotificationStore"; +import type { Notification } from "../../services/dbService"; interface NotificationsModalProps { isOpen: boolean; onClose: () => void; } +const timeAgo = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return "Just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +}; + +const NotificationIcon = ({ type }: { type: Notification["type"] }) => { + switch (type) { + case "milestone": + return ; + case "premiere": + return ; + default: + return ; + } +}; + export const NotificationsModal: React.FC = ({ isOpen, onClose }) => { + const { notifications, markAsRead, markAllAsRead, removeNotification } = useNotificationStore(); + if (!isOpen) return null; return ( @@ -17,39 +46,107 @@ export const NotificationsModal: React.FC = ({ isOpen, role="dialog" > e.stopPropagation()} > - + {/* Header */} + Notifications - - - - - - - - + + {notifications.some((n) => !n.isRead) && ( + markAllAsRead()} + className="text-xs font-medium text-brand-primary hover:text-brand-primary/80 transition-colors px-3 py-1.5 rounded-full bg-brand-primary/10" + > + Mark all read + + )} + + + - Coming Soon - - Notifications are not available yet. Work in progress!! - - - Got it - + {/* List */} + + {notifications.length === 0 ? ( + + + + + No notifications yet + + ) : ( + notifications.map((n) => ( + + + + + + + + + {n.title} + + + {timeAgo(n.createdAt)} + + + + {n.message} + + + + + {/* Actions */} + + {!n.isRead && ( + { + e.stopPropagation(); + markAsRead(n._id); + }} + className="p-1.5 rounded-lg text-brand-text-dim hover:text-brand-primary hover:bg-brand-primary/10 transition-colors" + title="Mark as read" + > + + + )} + { + e.stopPropagation(); + removeNotification(n._id); + }} + className="p-1.5 rounded-lg text-brand-text-dim hover:text-red-400 hover:bg-red-400/10 transition-colors" + title="Delete" + > + + + + + )) + )} + ); diff --git a/cinetrack-app/client/src/components/layout/SideNavBar.tsx b/cinetrack-app/client/src/components/layout/SideNavBar.tsx index e59abf6..3a279bc 100644 --- a/cinetrack-app/client/src/components/layout/SideNavBar.tsx +++ b/cinetrack-app/client/src/components/layout/SideNavBar.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { useAuthContext } from "../../contexts/AuthContext"; +import { useNotificationStore } from "../../store/useNotificationStore"; import { ConfirmModal } from "../common/ConfirmModal"; import { FiCompass, @@ -63,6 +64,7 @@ export const SideNavBar: React.FC = ({ onOpenNotifications, }) => { const { user, logout } = useAuthContext(); + const { unreadCount } = useNotificationStore(); const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -156,9 +158,11 @@ export const SideNavBar: React.FC = ({ > {isExpanded && Notifications} - + {unreadCount > 0 && ( + + )} { } }; + const unreadCount = useNotificationStore((state) => state.unreadCount); + return ( @@ -96,7 +99,11 @@ const Header: React.FC = memo(() => { aria-label="Notifications" > - + {unreadCount > 0 && ( + + + + )} { aria-label="Notifications" > - + {unreadCount > 0 && ( + + + + )} => { + return apiFetch("/notifications"); +}; + +export const markNotificationAsRead = async (id: string): Promise => { + await apiFetch(`/notifications/${id}/read`, { method: "PATCH" }); +}; + +export const markAllNotificationsAsRead = async (): Promise => { + await apiFetch(`/notifications/read-all`, { method: "PATCH" }); +}; + +export const deleteNotification = async (id: string): Promise => { + await apiFetch(`/notifications/${id}`, { method: "DELETE" }); +}; diff --git a/cinetrack-app/client/src/services/socketService.ts b/cinetrack-app/client/src/services/socketService.ts index 74b4ccb..8983c28 100644 --- a/cinetrack-app/client/src/services/socketService.ts +++ b/cinetrack-app/client/src/services/socketService.ts @@ -1,15 +1,18 @@ import { io, Socket } from "socket.io-client"; import type { WatchlistItem } from "../types/types"; +import type { Notification } from "./dbService"; type WatchlistUpdateHandler = (item: WatchlistItem) => void; type WatchlistDeleteHandler = (data: { id: number }) => void; type WatchlistSyncHandler = (data: { trigger: string }) => void; +type NotificationHandler = (notification: Notification) => void; class SocketService { private socket: Socket | null = null; private updateHandlers: WatchlistUpdateHandler[] = []; private deleteHandlers: WatchlistDeleteHandler[] = []; private syncHandlers: WatchlistSyncHandler[] = []; + private notificationHandlers: NotificationHandler[] = []; connect() { if (this.socket?.connected) return; @@ -47,6 +50,10 @@ class SocketService { this.socket.on("watchlist:sync", (data: { trigger: string }) => { this.syncHandlers.forEach((handler) => handler(data)); }); + + this.socket.on("notification:new", (notification: Notification) => { + this.notificationHandlers.forEach((handler) => handler(notification)); + }); } disconnect() { @@ -77,6 +84,13 @@ class SocketService { }; } + onNotification(handler: NotificationHandler) { + this.notificationHandlers.push(handler); + return () => { + this.notificationHandlers = this.notificationHandlers.filter((h) => h !== handler); + }; + } + isConnected(): boolean { return this.socket?.connected ?? false; } diff --git a/cinetrack-app/client/src/store/useNotificationStore.ts b/cinetrack-app/client/src/store/useNotificationStore.ts new file mode 100644 index 0000000..e799c29 --- /dev/null +++ b/cinetrack-app/client/src/store/useNotificationStore.ts @@ -0,0 +1,129 @@ +import { create } from "zustand"; +import { toast } from "sonner"; +import { + getNotifications, + markNotificationAsRead, + markAllNotificationsAsRead, + deleteNotification, + type Notification, +} from "../services/dbService"; +import { socketService } from "../services/socketService"; +import { useEffect } from "react"; + +interface NotificationState { + notifications: Notification[]; + unreadCount: number; + isLoading: boolean; + + // Actions + loadNotifications: () => Promise; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; + removeNotification: (id: string) => Promise; + addNotification: (notification: Notification) => void; +} + +export const useNotificationStore = create((set, get) => ({ + notifications: [], + unreadCount: 0, + isLoading: false, + + loadNotifications: async () => { + set({ isLoading: true }); + try { + const response = await getNotifications(); + set({ + notifications: response.notifications, + unreadCount: response.unreadCount, + isLoading: false, + }); + } catch (err) { + console.error("Failed to load notifications:", err); + set({ isLoading: false }); + } + }, + + markAsRead: async (id: string) => { + const { notifications } = get(); + // Optimistic update + const updated = notifications.map((n) => (n._id === id ? { ...n, isRead: true } : n)); + const unreadCount = updated.filter((n) => !n.isRead).length; + + set({ notifications: updated, unreadCount }); + + try { + await markNotificationAsRead(id); + } catch (err) { + console.error("Failed to mark notification as read:", err); + // Revert if needed? Usually OK to leave it read locally. + } + }, + + markAllAsRead: async () => { + const { notifications } = get(); + const updated = notifications.map((n) => ({ ...n, isRead: true })); + set({ notifications: updated, unreadCount: 0 }); + + try { + await markAllNotificationsAsRead(); + } catch (err) { + console.error("Failed to mark all as read:", err); + toast.error("Failed to update notifications"); + // Revert could be complicated, just reload + get().loadNotifications(); + } + }, + + removeNotification: async (id: string) => { + const { notifications } = get(); + const target = notifications.find((n) => n._id === id); + const updated = notifications.filter((n) => n._id !== id); + const unreadCount = updated.filter((n) => !n.isRead).length; + + set({ notifications: updated, unreadCount }); + + try { + await deleteNotification(id); + } catch (err) { + console.error("Failed to delete notification:", err); + if (target) { + set({ notifications: notifications, unreadCount: get().unreadCount }); // Revert + } + toast.error("Failed to delete notification"); + } + }, + + addNotification: (notification: Notification) => { + const { notifications, unreadCount } = get(); + // Avoid duplicates + if (notifications.some((n) => n._id === notification._id)) return; + + set({ + notifications: [notification, ...notifications], + unreadCount: unreadCount + 1, + }); + + // Show toast for new notification + toast(notification.title, { + description: notification.message, + }); + }, +})); + +// Hook to initialize notifications and subscriptions +export const useNotificationInit = () => { + const loadNotifications = useNotificationStore((state) => state.loadNotifications); + const addNotification = useNotificationStore((state) => state.addNotification); + + useEffect(() => { + loadNotifications(); + + const unsub = socketService.onNotification((notification) => { + addNotification(notification); + }); + + return () => { + unsub(); + }; + }, [loadNotifications, addNotification]); +}; diff --git a/cinetrack-app/package-lock.json b/cinetrack-app/package-lock.json index 6d893c4..55b163a 100644 --- a/cinetrack-app/package-lock.json +++ b/cinetrack-app/package-lock.json @@ -4725,6 +4725,15 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6368,6 +6377,7 @@ "jsonwebtoken": "^9.0.3", "mongodb": "^6.8.0", "morgan": "^1.10.1", + "node-cron": "^4.2.1", "socket.io": "^4.8.1", "zod": "^4.1.13" }, diff --git a/cinetrack-app/server/package.json b/cinetrack-app/server/package.json index a83192c..9f9bb40 100644 --- a/cinetrack-app/server/package.json +++ b/cinetrack-app/server/package.json @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.3", "mongodb": "^6.8.0", "morgan": "^1.10.1", + "node-cron": "^4.2.1", "socket.io": "^4.8.1", "zod": "^4.1.13" }, diff --git a/cinetrack-app/server/src/routes/notificationRoutes.js b/cinetrack-app/server/src/routes/notificationRoutes.js new file mode 100644 index 0000000..95f3f90 --- /dev/null +++ b/cinetrack-app/server/src/routes/notificationRoutes.js @@ -0,0 +1,90 @@ +const express = require("express"); +const { authMiddleware } = require("../middleware/authMiddleware"); +const { asyncHandler, AppError } = require("../middleware/errorHandler"); +const { ObjectId } = require("mongodb"); + +const router = express.Router(); + +module.exports = (notificationCollection) => { + // GET /api/notifications + router.get( + "/", + authMiddleware, + asyncHandler(async (req, res) => { + const notifications = await notificationCollection + .find({ userId: req.userId }) + .sort({ createdAt: -1 }) + .limit(50) + .toArray(); + + const unreadCount = await notificationCollection.countDocuments({ + userId: req.userId, + isRead: false, + }); + + res.json({ notifications, unreadCount }); + }) + ); + + // PATCH /api/notifications/:id/read + router.patch( + "/:id/read", + authMiddleware, + asyncHandler(async (req, res) => { + const id = req.params.id; + if (!ObjectId.isValid(id)) { + throw new AppError("Invalid ID format.", 400); + } + + const result = await notificationCollection.updateOne( + { _id: new ObjectId(id), userId: req.userId }, + { $set: { isRead: true } } + ); + + if (result.matchedCount === 0) { + throw new AppError("Notification not found.", 404); + } + + res.json({ success: true }); + }) + ); + + // PATCH /api/notifications/read-all + router.patch( + "/read-all", + authMiddleware, + asyncHandler(async (req, res) => { + await notificationCollection.updateMany( + { userId: req.userId, isRead: false }, + { $set: { isRead: true } } + ); + + res.json({ success: true }); + }) + ); + + // DELETE /api/notifications/:id + router.delete( + "/:id", + authMiddleware, + asyncHandler(async (req, res) => { + const id = req.params.id; + if (!ObjectId.isValid(id)) { + throw new AppError("Invalid ID format.", 400); + } + + const result = await notificationCollection.deleteOne({ + _id: new ObjectId(id), + userId: req.userId, + }); + + if (result.deletedCount === 0) { + throw new AppError("Notification not found.", 404); + } + + res.status(204).send(); + }) + ); + + return router; +}; diff --git a/cinetrack-app/server/src/routes/watchlistRoutes.js b/cinetrack-app/server/src/routes/watchlistRoutes.js index 2c09b79..97d5536 100644 --- a/cinetrack-app/server/src/routes/watchlistRoutes.js +++ b/cinetrack-app/server/src/routes/watchlistRoutes.js @@ -5,6 +5,10 @@ const { validate } = require("../middleware/validate"); const { watchlistItemSchema, watchlistImportSchema } = require("../validation/schemas"); const { cache } = require("../config"); const { ObjectId } = require("mongodb"); +const { + updateWatchTimeAndCheckMilestones, + checkAndTriggerMilestonesFull, +} = require("../services/milestoneService"); const router = express.Router(); @@ -56,7 +60,8 @@ module.exports = ( broadcastToUser, client, usersCollection, - demoUsersCollection + demoUsersCollection, + notificationCollection ) => { const getWatchlistCollection = async (userId) => { const demoUser = await demoUsersCollection.findOne({ _id: new ObjectId(userId) }); @@ -194,9 +199,12 @@ module.exports = ( authMiddleware, validate(watchlistItemSchema), asyncHandler(async (req, res) => { - const { collection } = await getWatchlistCollection(req.userId); + const { collection, isDemo } = await getWatchlistCollection(req.userId); const item = req.body; + // Fetch old item for incremental calculation + const oldItem = await collection.findOne({ id: item.id, userId: req.userId }); + const { _id, ...itemWithoutId } = item; const watchlistStatus = computeWatchlistStatus(itemWithoutId); const itemWithUser = { @@ -212,6 +220,19 @@ module.exports = ( broadcastToUser(req.userId, "watchlist:update", itemWithUser); res.status(200).json(itemWithUser); + + // Check for milestones (async, don't wait) + if (!isDemo && notificationCollection) { + updateWatchTimeAndCheckMilestones( + req.userId, + oldItem, + itemWithUser, + collection, + usersCollection, + notificationCollection, + broadcastToUser + ).catch((err) => console.error("Milestone check failed:", err)); + } }) ); @@ -226,6 +247,9 @@ module.exports = ( throw new AppError("Invalid ID format.", 400); } + // Fetch item before delete for milestone calc + const oldItem = await collection.findOne({ id, userId: req.userId }); + const result = await collection.deleteOne({ id, userId: req.userId }); if (result.deletedCount !== 1) { throw new AppError("Item not found.", 404); @@ -233,6 +257,20 @@ module.exports = ( broadcastToUser(req.userId, "watchlist:delete", { id }); res.status(204).send(); + + // Check milestones (decrement watch time) + const { isDemo } = await getWatchlistCollection(req.userId); + if (!isDemo && notificationCollection && oldItem) { + updateWatchTimeAndCheckMilestones( + req.userId, + oldItem, + null, // newItem is null + collection, + usersCollection, + notificationCollection, + broadcastToUser + ).catch((err) => console.error("Milestone check failed:", err)); + } }) ); @@ -277,6 +315,17 @@ module.exports = ( broadcastToUser(req.userId, "watchlist:sync", { trigger: "import" }); res.status(200).json({ message: `Import successful. ${items.length} items imported.` }); + + // Check for milestones + if (notificationCollection) { + checkAndTriggerMilestonesFull( + req.userId, + watchlistCollection, + usersCollection, + notificationCollection, + broadcastToUser + ).catch((err) => console.error("Milestone check failed:", err)); + } }) ); diff --git a/cinetrack-app/server/src/server.js b/cinetrack-app/server/src/server.js index 298a157..b46b26b 100644 --- a/cinetrack-app/server/src/server.js +++ b/cinetrack-app/server/src/server.js @@ -17,6 +17,8 @@ const { errorHandler } = require("./middleware/errorHandler"); const authRoutes = require("./routes/authRoutes"); const watchlistRoutes = require("./routes/watchlistRoutes"); const tmdbRoutes = require("./routes/tmdbRoutes"); +const notificationRoutes = require("./routes/notificationRoutes"); +const { initCronJobs } = require("./services/cronService"); const app = express(); const server = http.createServer(app); @@ -65,7 +67,6 @@ io.on("connection", (socket) => { }); }); -// Helper to broadcast watchlist changes to all user's devices const broadcastToUser = (userId, event, data) => { io.to(`user:${userId}`).emit(event, data); }; @@ -128,6 +129,7 @@ const client = new MongoClient(config.mongo.uri, { let watchlistCollection; let usersCollection; +let notificationCollection; let demoUsersCollection; let demoWatchlistCollection; @@ -137,6 +139,7 @@ async function connectToDb() { const db = client.db("scenestackDB"); watchlistCollection = db.collection("watchlist"); usersCollection = db.collection("users"); + notificationCollection = db.collection("notifications"); demoUsersCollection = db.collection("demoUsers"); demoWatchlistCollection = db.collection("demoWatchlist"); console.log("Successfully connected to MongoDB."); @@ -145,18 +148,32 @@ async function connectToDb() { await watchlistCollection.createIndex({ userId: 1, id: 1 }, { unique: true }); await watchlistCollection.createIndex({ userId: 1, watchlistStatus: 1 }); await usersCollection.createIndex({ email: 1 }, { unique: true }); + await notificationCollection.createIndex({ userId: 1, createdAt: -1 }); + await notificationCollection.createIndex( + { createdAt: 1 }, + { expireAfterSeconds: 30 * 24 * 60 * 60 } // 30 days TTL + ); + + const ensureTTLIndex = async (collection, field, ttlSeconds) => { + const indexName = `${field}_1`; + try { + await collection.createIndex({ [field]: 1 }, { expireAfterSeconds: ttlSeconds }); + } catch (err) { + if (err.code === 85 || err.codeName === "IndexOptionsConflict") { + console.log(`Dropping conflicting TTL index on ${collection.collectionName}.${field}`); + await collection.dropIndex(indexName); + await collection.createIndex({ [field]: 1 }, { expireAfterSeconds: ttlSeconds }); + } else { + throw err; + } + } + }; // Create indexes for demo collections with TTL for auto-cleanup await demoUsersCollection.createIndex({ email: 1 }, { unique: true }); - await demoUsersCollection.createIndex( - { createdAt: 1 }, - { expireAfterSeconds: config.demoTtlSeconds } - ); + await ensureTTLIndex(demoUsersCollection, "createdAt", config.demoTtlSeconds); await demoWatchlistCollection.createIndex({ userId: 1, id: 1 }, { unique: true }); - await demoWatchlistCollection.createIndex( - { createdAt: 1 }, - { expireAfterSeconds: config.demoTtlSeconds } - ); + await ensureTTLIndex(demoWatchlistCollection, "createdAt", config.demoTtlSeconds); console.log(`Demo TTL indexes created (${config.demoTtlSeconds}s)`); } catch (err) { console.error("Failed to connect to MongoDB:", err.message); @@ -164,7 +181,6 @@ async function connectToDb() { } } -// MongoDB connection event handlers client.on("error", (err) => { console.error("MongoDB connection error:", err.message); }); @@ -190,25 +206,47 @@ app.use("/api/watchlist", (req, res, next) => { broadcastToUser, client, usersCollection, - demoUsersCollection + demoUsersCollection, + notificationCollection )(req, res, next); }); +// --- Notification Routes --- +app.use("/api/notifications", (req, res, next) => { + notificationRoutes(notificationCollection)(req, res, next); +}); + // --- TMDB Proxy Routes --- app.use("/api/tmdb", tmdbRoutes); -// --- Catch-all for SPA --- app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "../../client/dist/index.html")); }); +// --- Testing for if notifications were working... --- +/* +app.post("/api/debug/premiere-check", async (req, res) => { + const { checkPremiereAlerts } = require("./services/cronService"); + const { cache } = require("./config"); + + if (!watchlistCollection || !notificationCollection) { + return res.status(500).json({ error: "DB not initialized yet" }); + } + + await checkPremiereAlerts(watchlistCollection, notificationCollection, broadcastToUser); + res.json({ message: "Premiere check triggered! Check server logs." }); +}); +*/ + // --- Global Error Handler --- app.use(errorHandler); -// --- Start Server --- connectToDb().then(() => { server.listen(port, () => { console.log(`Scene Stack server running on port ${port}`); console.log(`Socket.IO enabled for real-time sync`); + + // Initialize Cron Jobs + initCronJobs(watchlistCollection, notificationCollection, broadcastToUser); }); }); diff --git a/cinetrack-app/server/src/services/cronService.js b/cinetrack-app/server/src/services/cronService.js new file mode 100644 index 0000000..03489a2 --- /dev/null +++ b/cinetrack-app/server/src/services/cronService.js @@ -0,0 +1,170 @@ +const cron = require("node-cron"); +const { ObjectId } = require("mongodb"); +const { cache } = require("../config"); + +const TMDB_API_BASE_URL = "https://api.themoviedb.org/3"; +const TMDB_API_TOKEN = process.env.TMDB_API_READ_ACCESS_TOKEN; +const CONCURRENCY_LIMIT = 5; + +const fetchFromTMDB = async (endpoint) => { + const url = `${TMDB_API_BASE_URL}/${endpoint}`; + try { + const response = await fetch(url, { + method: "GET", + headers: { + accept: "application/json", + Authorization: `Bearer ${TMDB_API_TOKEN}`, + }, + }); + if (!response.ok) return null; + return response.json(); + } catch (err) { + console.error(`TMDB fetch error for ${endpoint}:`, err.message); + return null; + } +}; + +const getCachedTVDetails = async (tvId) => { + const cacheKey = `tmdb:tv:${tvId}`; + const cached = await cache.get(cacheKey); + if (cached) return cached; + + const details = await fetchFromTMDB(`tv/${tvId}`); + if (details) { + await cache.set(cacheKey, details, 86400); + } + return details; +}; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const checkPremiereAlerts = async ( + watchlistCollection, + notificationCollection, + broadcastToUser +) => { + console.log("Starting Premiere Alert check..."); + const today = new Date().toISOString().split("T")[0]; + + try { + const pipeline = [ + { + $match: { + media_type: "tv", + watchlistStatus: "watching", + }, + }, + { + $group: { + _id: "$id", + title: { $first: "$name" }, + }, + }, + ]; + + const showCursor = watchlistCollection.aggregate(pipeline); + + let showsProcessed = 0; + let alertsSent = 0; + + for await (const show of showCursor) { + showsProcessed++; + const tvId = show._id; + + try { + const details = await getCachedTVDetails(tvId); + + if (!details) continue; + + if (details.status === "Ended" || details.status === "Canceled") { + if (!details.next_episode_to_air) continue; + } + + const nextEp = details.next_episode_to_air; + let notificationMessage = null; + let notificationTitle = "Premiere Alert!"; + + if (nextEp && nextEp.air_date === today) { + if (nextEp.episode_number === 1) { + notificationMessage = `New Season ${nextEp.season_number} of ${details.name} starts today!`; + } + } + + if (notificationMessage) { + const subscriberCursor = watchlistCollection.find({ + id: tvId, + watchlistStatus: { $in: ["watching", "watched"] }, + }); + + let batch = []; + const BATCH_SIZE = 500; + + for await (const subscriber of subscriberCursor) { + const userId = subscriber.userId; + + const exists = await notificationCollection.findOne({ + userId, + type: "premiere", + "data.mediaId": tvId, + message: notificationMessage, + }); + + if (!exists) { + batch.push({ + userId, + type: "premiere", + title: notificationTitle, + message: notificationMessage, + data: { + mediaId: tvId, + mediaType: "tv", + }, + isRead: false, + createdAt: new Date(), + }); + } + + if (batch.length >= BATCH_SIZE) { + await notificationCollection.insertMany(batch); + batch.forEach(n => broadcastToUser(n.userId, "notification:new", n)); + alertsSent += batch.length; + batch = []; + } + } + + if (batch.length > 0) { + await notificationCollection.insertMany(batch); + batch.forEach(n => broadcastToUser(n.userId, "notification:new", n)); + alertsSent += batch.length; + batch = []; + } + + console.log(`Sent premiere alert for ${details.name} (Season ${nextEp.season_number}).`); + } + } catch (innerErr) { + console.error(`Error processing show ${show.title} (${tvId}):`, innerErr.message); + } + } + + console.log(`Premiere Alert check complete. Processed ${showsProcessed} shows. Sent ${alertsSent} alerts.`); + } catch (err) { + console.error("Critical Error in checkPremiereAlerts:", err); + } +}; + +const initCronJobs = (watchlistCollection, notificationCollection, broadcastToUser) => { + console.log("Running startup Premiere Alert check..."); + checkPremiereAlerts(watchlistCollection, notificationCollection, broadcastToUser); + + // Schedule to run 3 times daily - 8 AM, 2 PM, 8 PM UTC + cron.schedule("0 8,14,20 * * *", () => { + checkPremiereAlerts(watchlistCollection, notificationCollection, broadcastToUser); + }); + + console.log("Cron jobs initialized: Premiere Alerts scheduled for 8 AM, 2 PM, 8 PM UTC daily."); +}; + +module.exports = { + initCronJobs, + checkPremiereAlerts, +}; diff --git a/cinetrack-app/server/src/services/milestoneService.js b/cinetrack-app/server/src/services/milestoneService.js new file mode 100644 index 0000000..65373ca --- /dev/null +++ b/cinetrack-app/server/src/services/milestoneService.js @@ -0,0 +1,194 @@ +const { ObjectId } = require("mongodb"); + +const AVG_EPISODE_RUNTIME = 45; +const MILESTONE_INCREMENT = 100; // Notify every 100 hours + +const calculateItemRuntime = (item) => { + if (!item) return 0; + + if (item.media_type === "movie") { + return item.watched ? item.runtime || 0 : 0; + } else if (item.media_type === "tv") { + const watchedEpisodesMap = item.watchedEpisodes || {}; + const episodeCount = Object.values(watchedEpisodesMap).reduce( + (acc, eps) => acc + (Array.isArray(eps) ? eps.length : 0), + 0 + ); + + if (episodeCount > 0) { + const episodeRuntime = + Array.isArray(item.episode_run_time) && item.episode_run_time.length > 0 + ? item.episode_run_time[0] + : AVG_EPISODE_RUNTIME; + return episodeCount * episodeRuntime; + } + } + return 0; +}; + +const calculateTotalWatchTimeMinutes = (watchlistItems) => { + let totalMinutes = 0; + for (const item of watchlistItems) { + totalMinutes += calculateItemRuntime(item); + } + return totalMinutes; +}; + +const updateWatchTimeAndCheckMilestones = async ( + userId, + oldItem, + newItem, + watchlistCollection, + usersCollection, + notificationCollection, + broadcastToUser +) => { + try { + const userObjectId = new ObjectId(userId); + + // 1. Get current state (or calculate initial) + let user = await usersCollection.findOne({ _id: userObjectId }); + if (!user) return; + + let newTotalMinutes = 0; + + // A. Initial Calculation (Rare: only for new/legacy users) + if (typeof user.totalWatchTimeMinutes !== "number") { + console.log(`Calculating initial watch time for user ${userId}...`); + const allItems = await watchlistCollection.find({ userId }).toArray(); + newTotalMinutes = calculateTotalWatchTimeMinutes(allItems); + + const result = await usersCollection.findOneAndUpdate( + { _id: userObjectId }, + { $set: { totalWatchTimeMinutes: newTotalMinutes } }, + { returnDocument: "after" } + ); + // Depending on driver version, result might be the doc or { value: doc } + // Using defensive check assuming standard { value: ... } or direct doc + user = result.value || result; + if (user) newTotalMinutes = user.totalWatchTimeMinutes; + + } else { + // B. Atomic Increment (Common Path) + const oldRuntime = calculateItemRuntime(oldItem); + const newRuntime = calculateItemRuntime(newItem); + const delta = newRuntime - oldRuntime; + + if (delta === 0) return; + + const result = await usersCollection.findOneAndUpdate( + { _id: userObjectId }, + { $inc: { totalWatchTimeMinutes: delta } }, + { returnDocument: "after" } // Ensure we get the updated value + ); + + user = result.value || result; + if (!user) return; // Should not happen + newTotalMinutes = user.totalWatchTimeMinutes; + } + + // 2. Check Milestones safely + const totalHours = Math.floor(newTotalMinutes / 60); + const currentMilestone = Math.floor(totalHours / MILESTONE_INCREMENT) * MILESTONE_INCREMENT; + + // 3. Conditional Update (The "Notification Lock") + // Only update if we haven't notified for this milestone (or higher) yet. + // This prevents race conditions where two concurrent requests both calculate 100h. + // Treat undefined unique lastNotified as 0 via $lt check if field missing, + // but MongoDB comparison with null/missing is tricky. + // We rely on the initial findOne 'user' fallback for the $lt check? No, must be atomic. + // We can use { $lt: currentMilestone } on the field. If field is missing, it's not less than number? + // Actually, $lt comparison with null/missing: null < numbers. + // So if lastNotifiedMilestone is missing, it is "less than" 100. + + if (currentMilestone > 0) { + // Query: Update IF currentMilestone is strictly greater than what's in DB + // Effectively: "Claim this milestone" + const updateResult = await usersCollection.updateOne( + { + _id: userObjectId, + $or: [ + { lastNotifiedMilestone: { $lt: currentMilestone } }, + { lastNotifiedMilestone: { $exists: false } } + ] + }, + { $set: { lastNotifiedMilestone: currentMilestone } } + ); + + if (updateResult.modifiedCount > 0) { + // We won the race. Send the notification. + const notification = { + userId, + type: "milestone", + title: "Milestone Reached!", + message: `Congrats! You’ve officially spent ${currentMilestone} hours watching content.`, + data: { + hours: currentMilestone, + }, + isRead: false, + createdAt: new Date(), + }; + + await notificationCollection.insertOne(notification); + broadcastToUser(userId, "notification:new", notification); + console.log(`Milestone notification triggered for user ${userId}: ${currentMilestone} hours`); + } + } + } catch (err) { + console.error("Error in updateWatchTimeAndCheckMilestones:", err); + } +}; + +const checkAndTriggerMilestonesFull = async ( + userId, + watchlistCollection, + usersCollection, + notificationCollection, + broadcastToUser +) => { + try { + const userObjectId = new ObjectId(userId); + const allItems = await watchlistCollection.find({ userId }).toArray(); + const totalMinutes = calculateTotalWatchTimeMinutes(allItems); + + await usersCollection.updateOne( + { _id: userObjectId }, + { $set: { totalWatchTimeMinutes: totalMinutes } } + ); + + const user = await usersCollection.findOne({ _id: userObjectId }); + const totalHours = Math.floor(totalMinutes / 60); + const lastNotified = user.lastNotifiedMilestone || 0; + const currentMilestone = Math.floor(totalHours / MILESTONE_INCREMENT) * MILESTONE_INCREMENT; + + if (currentMilestone > lastNotified && currentMilestone > 0) { + const notification = { + userId, + type: "milestone", + title: "Milestone Reached!", + message: `Congrats! You’ve officially spent ${currentMilestone} hours watching content.`, + data: { + hours: currentMilestone, + }, + isRead: false, + createdAt: new Date(), + }; + + await notificationCollection.insertOne(notification); + + await usersCollection.updateOne( + { _id: userObjectId }, + { $set: { lastNotifiedMilestone: currentMilestone } } + ); + + broadcastToUser(userId, "notification:new", notification); + } + } catch (err) { + console.error("Error in checkAndTriggerMilestonesFull:", err); + } +}; + +module.exports = { + updateWatchTimeAndCheckMilestones, + checkAndTriggerMilestonesFull, +};
- Notifications are not available yet. Work in progress!! -
No notifications yet
+ {n.message} +