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.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:
+
+
+
+
+
+ Open Subly to manage your subscriptions.
+
+ `,
+ });
+
+}
\ No newline at end of file