From 889a9d750df35bd3d5383454fb61a8fcaa1713e0 Mon Sep 17 00:00:00 2001 From: Remy Date: Thu, 25 Jun 2026 02:07:31 +0100 Subject: [PATCH] add cron jobs and notification api --- .vscode/settings.json | 3 + client/src/lib/utils.js | 3 +- client/src/pages/dashboard/Notification.jsx | 99 ++++++++++++++++++- server/package-lock.json | 50 ++++++++++ server/package.json | 1 + .../migration.sql | 30 ++++++ server/prisma/schema.prisma | 24 ++++- server/server.js | 2 + server/src/config/env.js | 17 ++-- .../controllers/notification.controller.js | 49 +++++++++ server/src/jobs/sendExpired.js | 23 +++++ server/src/jobs/sendReminder.js | 49 +++++++++ server/src/jobs/syncRate.js | 26 +++++ server/src/routes/notification.route.js | 7 ++ server/src/services/email.service.js | 55 +++++++++++ 15 files changed, 420 insertions(+), 18 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 server/prisma/migrations/20260624230150_create_notification_model_and_updated_user_model/migration.sql create mode 100644 server/src/controllers/notification.controller.js create mode 100644 server/src/jobs/sendExpired.js create mode 100644 server/src/jobs/sendReminder.js create mode 100644 server/src/jobs/syncRate.js create mode 100644 server/src/routes/notification.route.js create mode 100644 server/src/services/email.service.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b0e5ab --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} \ No newline at end of file diff --git a/client/src/lib/utils.js b/client/src/lib/utils.js index b2e7b10..7cec5d5 100644 --- a/client/src/lib/utils.js +++ b/client/src/lib/utils.js @@ -7,7 +7,8 @@ export function cn(...inputs) { } export function getApiBaseUrl() { - return import.meta.env.VITE_API_URL; + // return import.meta.env.VITE_API_URL; + return "http://localhost:3000"; } export function formatMoney(amountInMinorUnit, currency) { diff --git a/client/src/pages/dashboard/Notification.jsx b/client/src/pages/dashboard/Notification.jsx index c00d4fe..5a297d6 100644 --- a/client/src/pages/dashboard/Notification.jsx +++ b/client/src/pages/dashboard/Notification.jsx @@ -1,10 +1,99 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +import { Bell, Trash2 } from "lucide-react"; + +import { fetchWithAuth } from "@/lib/utils"; + +export default function NotificationPage() { + const queryClient = useQueryClient(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["notifications"], + queryFn: () => + fetchWithAuth("/api/notification", { + credentials: "include", + }).then((res) => res.json()), + }); + + const deleteMutation = useMutation({ + mutationFn: (id) => + fetchWithAuth(`/api/notification/delete/${id}`, { + method: "DELETE", + credentials: "include", + }).then((res) => res.json()), + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["notifications"], + }); + }, + }); + + if (isLoading) { + return
Loading notifications...
; + } + + if (isError) { + return ( +
Failed to load notifications.
+ ); + } + + const notifications = data?.data ?? []; -const Notification = () => { return ( -
Notification
- ) -} +
+
+

Notifications

+ +

+ Stay updated on subscription reminders and activity. +

+
+ + {!notifications.length ? ( + + + -export default Notification \ No newline at end of file +

+ No notifications available. +

+
+
+ ) : ( +
+ {notifications.map((notification) => ( + + +
+ {notification.title} + +

+ {new Date(notification.createdAt).toLocaleDateString()} +

+
+ + +
+ + +

{notification.message}

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/server/package-lock.json b/server/package-lock.json index bef161b..638ed46 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,6 +21,7 @@ "express-rate-limit": "^8.5.2", "jsonwebtoken": "^9.0.3", "pg": "^8.21.0", + "resend": "^6.14.0", "zod": "^4.4.3" }, "devDependencies": { @@ -917,6 +918,12 @@ } } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1851,6 +1858,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -2857,6 +2870,12 @@ "pathe": "^2.0.3" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -3117,6 +3136,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.14.0.tgz", + "integrity": "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -3384,6 +3424,16 @@ "node": ">= 0.6" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index ec73be1..eeca6a4 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "express-rate-limit": "^8.5.2", "jsonwebtoken": "^9.0.3", "pg": "^8.21.0", + "resend": "^6.14.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/server/prisma/migrations/20260624230150_create_notification_model_and_updated_user_model/migration.sql b/server/prisma/migrations/20260624230150_create_notification_model_and_updated_user_model/migration.sql new file mode 100644 index 0000000..60be2fb --- /dev/null +++ b/server/prisma/migrations/20260624230150_create_notification_model_and_updated_user_model/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `reminderDaysBefore` on the `Subscription` table. All the data in the column will be lost. + - You are about to drop the column `reminderEnabled` on the `Subscription` table. All the data in the column will be lost. + - You are about to drop the column `reminderSentAt` on the `Subscription` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Subscription" DROP COLUMN "reminderDaysBefore", +DROP COLUMN "reminderEnabled", +DROP COLUMN "reminderSentAt"; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "emailNofiticationEnabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "reminderDaysBefore" INTEGER NOT NULL DEFAULT 3; + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5de8347..54c9742 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -24,6 +24,10 @@ model User { role Role @default(USER) subscriptions Subscription[] + notifications Notification[] + + reminderDaysBefore Int @default(3) + emailNofiticationEnabled Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -49,10 +53,9 @@ model Subscription { status Status @default(ACTIVE) nextBillingDate DateTime - reminderEnabled Boolean @default(true) - reminderDaysBefore Int @default(3) + reminderSent Boolean @default(false) - reminderSentAt DateTime? + expiredEmailSent Boolean @default(false) expiredEmailSentAt DateTime? @@ -99,6 +102,18 @@ model History { } +model Notification{ + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + title String + message String + + + createdAt DateTime @default(now()) +} + model Rates{ id String @id @default(cuid()) @@ -139,4 +154,5 @@ enum Type { EDITED RENEWED -} \ No newline at end of file +} + diff --git a/server/server.js b/server/server.js index b8d3bca..07c624c 100644 --- a/server/server.js +++ b/server/server.js @@ -15,6 +15,7 @@ import { refreshRouter } from "./src/routes/refresh.route.js"; import { currencyRouters } from "./src/routes/rate.route.js"; import { historyRouter } from "./src/routes/history.route.js"; import cors from "cors"; +import { notificationRouter } from "./src/routes/notification.route.js"; @@ -71,6 +72,7 @@ app.use("/api/refresh", apiLimiter, refreshRouter); app.use("/api/subscription", requireAuth, subscriptionRouter); app.use("/api/history", requireAuth, historyRouter); app.use("/api/admin", apiLimiter, requireAuth, requireRole, adminRouter); +app.use("/api/notification", requireAuth, notificationRouter); app.use((err, req, res, next) => { if (err) { diff --git a/server/src/config/env.js b/server/src/config/env.js index abe2b0c..e137391 100644 --- a/server/src/config/env.js +++ b/server/src/config/env.js @@ -3,11 +3,12 @@ import dotenv from 'dotenv' dotenv.config() export const env = { - PORT: process.env.PORT || 3001, - ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET, - REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET, - NODE_ENV: process.env.NODE_ENV, - DATABASE_URL: process.env.DATABASE_URL, - CLIENT_URL: process.env.CLIENT_URL, - CORS_ORIGINS: process.env.CORS_ORIGINS, -} + PORT: process.env.PORT || 3001, + ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET, + REFRESH_TOKEN_SECRET: process.env.REFRESH_TOKEN_SECRET, + NODE_ENV: process.env.NODE_ENV, + DATABASE_URL: process.env.DATABASE_URL, + CLIENT_URL: process.env.CLIENT_URL, + CORS_ORIGINS: process.env.CORS_ORIGINS, + RESEND_API_KEY: process.env.RESEND_API_KEY, +}; diff --git a/server/src/controllers/notification.controller.js b/server/src/controllers/notification.controller.js new file mode 100644 index 0000000..2707ebd --- /dev/null +++ b/server/src/controllers/notification.controller.js @@ -0,0 +1,49 @@ +import { prisma } from "../libs/prisma.js" +export async function getNotification(req, res, next) { + try { + const userId = req.user.id + + const notifications = await prisma.notification.findMany({ + where: { + userId + } + }) + + return res.json({data: notifications, count:notifications.length , message: 'notification fetched successfully '}) + + } + catch (err) { + next(err) + } +} + +export async function deleteNotification(req, res, next) { + try { + const userId = req.user.id + + const id = req.params.id + + const existingNotification = await prisma.notification.findFirst({ + where: { + userId, + id + } + }) + + if (!existingNotification) { + return res.status(404).json({message: "notification not found"}) + } + + await prisma.notification.delete({ + where: { + id: existingNotification.id + } + }) + + return res.status(200).json({ + message: "Notification deleted successfully", + }); + } catch (err) { + next(err) + } +} \ No newline at end of file diff --git a/server/src/jobs/sendExpired.js b/server/src/jobs/sendExpired.js new file mode 100644 index 0000000..7c4f42b --- /dev/null +++ b/server/src/jobs/sendExpired.js @@ -0,0 +1,23 @@ +import { prisma } from "../libs/prisma.js"; + +async function sendExpired() { + const now = new Date() + + const result = await prisma.subscription.updateMany({ + where: { + status: 'ACTIVE', + nextBillingDate: { + lt: now + } + }, + data: { + status:'EXPIRED' + } + }) + + +} + +sendExpired().catch(console.error).finally(async () => { + await prisma.$disconnect() +}) \ No newline at end of file diff --git a/server/src/jobs/sendReminder.js b/server/src/jobs/sendReminder.js new file mode 100644 index 0000000..081dd21 --- /dev/null +++ b/server/src/jobs/sendReminder.js @@ -0,0 +1,49 @@ +import { prisma } from "../libs/prisma.js"; +import { sendReminderEmail } from "../services/email.service.js"; + +async function sendReminder() { + const users = await prisma.user.findMany({ + where: { + emailNofiticationEnabled: true, + }, + include: { + subscriptions: { + where: { + status: "ACTIVE", + }, + }, + }, + }); + + for (const user of users) { + const reminderDate = new Date(); + + reminderDate.setDate(reminderDate.getDate() + user.reminderDaysBefore); + + const dueSubscription = user.subscriptions.filter( + (sub) => !sub.reminderDaysBefore && sub.nextBillingDate <= reminderDate, + ); + + + + if (!dueSubscription.length) continue; + + await sendReminderEmail(user, dueSubscription); + + //creating notification + + await prisma.notification.create({ + data: { + userId: user.id, + title: "Subscriptions about to be renewed", + message: `${dueSubscription.length} of your subscription is about to expire`, + }, + }); + } +} + +sendReminder() + .catch(console.error) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/server/src/jobs/syncRate.js b/server/src/jobs/syncRate.js new file mode 100644 index 0000000..071dfa4 --- /dev/null +++ b/server/src/jobs/syncRate.js @@ -0,0 +1,26 @@ +import { prisma } from "../libs/prisma.js"; + +import axios from 'axios'; + +async function syncRate() { + const response = await axios.get("https://api.exchangerate-api.com/v4/latest/EUR"); + + await prisma.rates.upsert({ + + where: { + baseCurrency: "EUR", + }, + update: { + rates: response.data.rates, + }, + create: { + baseCurrency: "EUR", + rates: response.data.rates, + }, + + }) +} + +syncRate().catch(console.error).finally(async () => { + await prisma.$disconnect() +}) \ No newline at end of file diff --git a/server/src/routes/notification.route.js b/server/src/routes/notification.route.js new file mode 100644 index 0000000..8aa5c41 --- /dev/null +++ b/server/src/routes/notification.route.js @@ -0,0 +1,7 @@ +import express from 'express' +import { getNotification, deleteNotification } from '../controllers/notification.controller.js'; + +export const notificationRouter = express.Router(); + +notificationRouter.get('/', getNotification) +notificationRouter.delete('/delete/:id', deleteNotification) \ No newline at end of file diff --git a/server/src/services/email.service.js b/server/src/services/email.service.js new file mode 100644 index 0000000..58304aa --- /dev/null +++ b/server/src/services/email.service.js @@ -0,0 +1,55 @@ +import { Resend } from "resend"; +import { env } from "../config/env.js"; +const resend = new Resend(env.RESEND_API_KEY) + +export async function sendReminderEmail(user, subscriptions) { + const items = subscriptions.map(sub => `
  • ${sub.name} - ${sub.nextBillingDate}
  • `).join('') + + await resend.emails.send({ + from: "Subly ", + to: user.email, + subject: `${subscriptions.length} Subscription is about to expire in ${user.reminderDaysBefore}`, + html: ` +

    Hello ${user.username}

    + +

    + The following subscriptions are renewing soon: +

    + + + +

    + Open Subly to manage your subscriptions. +

    + `, + }); + +} + +export async function sendExpired(user, subscriptions) { + const items = subscriptions.map(sub => `
  • ${sub.name} - ${sub.nextBillingDate}
  • `).join('') + + await resend.emails.send({ + from: "Subly ", + to: user.email, + subject: `Your subscription just expired`, + html: ` +

    Hello ${user.username}

    + +

    + The following subscriptions just expired: +

    + +
      + ${items} +
    + +

    + Open Subly to manage your subscriptions. +

    + `, + }); + +} \ No newline at end of file