backup page done
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiLogger } from "./middlewares/logger.middleware";
|
|||||||
import authRoutes from "./routes/auth";
|
import authRoutes from "./routes/auth";
|
||||||
import { authenticateJWT } from "./middlewares/auth.middleware";
|
import { authenticateJWT } from "./middlewares/auth.middleware";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import { startBackupCron } from "./cron/backupCheck";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL;
|
const FRONTEND_URL = process.env.FRONTEND_URL;
|
||||||
@@ -30,4 +31,7 @@ app.use("/api", authenticateJWT, routes);
|
|||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
//startig cron job
|
||||||
|
startBackupCron();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
50
apps/Backend/src/cron/backupCheck.ts
Normal file
50
apps/Backend/src/cron/backupCheck.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import { NotificationTypes } from "@repo/db/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily cron job to check if users haven't backed up in 7 days
|
||||||
|
* Creates a backup notification if overdue
|
||||||
|
*/
|
||||||
|
export const startBackupCron = () => {
|
||||||
|
cron.schedule("0 9 * * *", async () => {
|
||||||
|
console.log("🔄 Running daily backup check...");
|
||||||
|
|
||||||
|
const userBatchSize = 100;
|
||||||
|
let userOffset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Fetch a batch of users
|
||||||
|
const users = await storage.getUsers(userBatchSize, userOffset);
|
||||||
|
if (!users || users.length === 0) break;
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
if (user.id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lastBackup = await storage.getLastBackup(user.id);
|
||||||
|
const daysSince = lastBackup?.createdAt
|
||||||
|
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24)
|
||||||
|
: Infinity;
|
||||||
|
|
||||||
|
if (daysSince >= 7) {
|
||||||
|
await storage.createNotification(
|
||||||
|
user.id,
|
||||||
|
"BACKUP" as NotificationTypes,
|
||||||
|
"⚠️ It has been more than 7 days since your last backup."
|
||||||
|
);
|
||||||
|
console.log(`Notification created for user ${user.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error processing user ${user.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userOffset += userBatchSize; // next user batch
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Daily backup check completed.");
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
import app from './app';
|
import app from "./app";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const HOST = process.env.HOST;
|
const HOST = process.env.HOST;
|
||||||
const PORT = process.env.PORT;
|
const PORT = process.env.PORT;
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
console.log(`Server running at http://${HOST}:${PORT}`);
|
console.log(`✅ Server running at http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle startup errors
|
||||||
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "EADDRINUSE") {
|
||||||
|
console.error(`❌ Port ${PORT} is already in use`);
|
||||||
|
} else {
|
||||||
|
console.error("❌ Server failed to start:", err);
|
||||||
|
}
|
||||||
|
process.exit(1); // Exit with failure
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
console.log(`⚡ Received ${signal}, shutting down gracefully...`);
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
console.log("✅ HTTP server closed");
|
||||||
|
|
||||||
|
// TODO: Close DB connections if needed
|
||||||
|
// db.$disconnect().then(() => console.log("✅ Database disconnected"));
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { prisma } from "@repo/db/client";
|
import { prisma } from "@repo/db/client";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -11,8 +12,13 @@ const router = Router();
|
|||||||
* Create a database backup
|
* Create a database backup
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.post("/backup", async (req: Request, res: Response) => {
|
router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = `dental_backup_${Date.now()}.dump`;
|
const fileName = `dental_backup_${Date.now()}.dump`;
|
||||||
const tmpFile = path.join(os.tmpdir(), fileName);
|
const tmpFile = path.join(os.tmpdir(), fileName);
|
||||||
|
|
||||||
@@ -56,8 +62,16 @@ router.post("/backup", async (req: Request, res: Response) => {
|
|||||||
const fileStream = fs.createReadStream(tmpFile);
|
const fileStream = fs.createReadStream(tmpFile);
|
||||||
fileStream.pipe(res);
|
fileStream.pipe(res);
|
||||||
|
|
||||||
fileStream.on("close", () => {
|
fileStream.on("close", async () => {
|
||||||
fs.unlink(tmpFile, () => {}); // cleanup temp file
|
fs.unlink(tmpFile, () => {}); // cleanup temp file
|
||||||
|
|
||||||
|
// ✅ Then, in background, update DB
|
||||||
|
try {
|
||||||
|
await storage.createBackup(userId);
|
||||||
|
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Backup saved but metadata update failed:", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error("pg_dump failed:", errorMessage);
|
console.error("pg_dump failed:", errorMessage);
|
||||||
@@ -91,18 +105,25 @@ router.post("/backup", async (req: Request, res: Response) => {
|
|||||||
/**
|
/**
|
||||||
* Get database status (connected, size, records count)
|
* Get database status (connected, size, records count)
|
||||||
*/
|
*/
|
||||||
router.get("/status", async (req: Request, res: Response) => {
|
router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
const size = await prisma.$queryRawUnsafe<{ size: string }[]>(
|
const size = await prisma.$queryRawUnsafe<{ size: string }[]>(
|
||||||
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
||||||
);
|
);
|
||||||
|
|
||||||
const patientsCount = await prisma.patient.count();
|
const patientsCount = await storage.getTotalPatientCount();
|
||||||
|
const lastBackup = await storage.getLastBackup(userId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
connected: true,
|
connected: true,
|
||||||
size: size[0]?.size,
|
size: size[0]?.size,
|
||||||
patients: patientsCount,
|
patients: patientsCount,
|
||||||
|
lastBackup: lastBackup?.createdAt ?? null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Status error:", err);
|
console.error("Status error:", err);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import documentsRoutes from "./documents";
|
|||||||
import insuranceEligibilityRoutes from "./insuranceEligibility";
|
import insuranceEligibilityRoutes from "./insuranceEligibility";
|
||||||
import paymentsRoutes from "./payments";
|
import paymentsRoutes from "./payments";
|
||||||
import databaseManagementRoutes from "./database-management";
|
import databaseManagementRoutes from "./database-management";
|
||||||
|
import notificationsRoutes from "./notifications";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -24,5 +25,6 @@ router.use("/documents", documentsRoutes);
|
|||||||
router.use("/insuranceEligibility", insuranceEligibilityRoutes);
|
router.use("/insuranceEligibility", insuranceEligibilityRoutes);
|
||||||
router.use("/payments", paymentsRoutes);
|
router.use("/payments", paymentsRoutes);
|
||||||
router.use("/database-management", databaseManagementRoutes);
|
router.use("/database-management", databaseManagementRoutes);
|
||||||
|
router.use("/notifications", notificationsRoutes);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
34
apps/Backend/src/routes/notifications.ts
Normal file
34
apps/Backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { prisma } from "@repo/db/client";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", async (req, res) => {
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
res.json(notifications);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:id/read", async (req, res) => {
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { id: Number(req.params.id), userId },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/read-all", async (req, res) => {
|
||||||
|
const userId = (req as any).user?.id;
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Appointment,
|
Appointment,
|
||||||
Claim,
|
Claim,
|
||||||
ClaimWithServiceLines,
|
ClaimWithServiceLines,
|
||||||
|
DatabaseBackup,
|
||||||
InsertAppointment,
|
InsertAppointment,
|
||||||
InsertClaim,
|
InsertClaim,
|
||||||
InsertInsuranceCredential,
|
InsertInsuranceCredential,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
InsertPayment,
|
InsertPayment,
|
||||||
InsertUser,
|
InsertUser,
|
||||||
InsuranceCredential,
|
InsuranceCredential,
|
||||||
|
Notification,
|
||||||
|
NotificationTypes,
|
||||||
Patient,
|
Patient,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentWithExtras,
|
PaymentWithExtras,
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// User methods
|
// User methods
|
||||||
getUser(id: number): Promise<User | undefined>;
|
getUser(id: number): Promise<User | undefined>;
|
||||||
|
getUsers(limit: number, offset: number): Promise<User[]>;
|
||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getUserByUsername(username: string): Promise<User | undefined>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
createUser(user: InsertUser): Promise<User>;
|
||||||
updateUser(id: number, updates: Partial<User>): Promise<User | undefined>;
|
updateUser(id: number, updates: Partial<User>): Promise<User | undefined>;
|
||||||
@@ -176,10 +180,7 @@ export interface IStorage {
|
|||||||
|
|
||||||
// Payment methods:
|
// Payment methods:
|
||||||
createPayment(data: InsertPayment): Promise<Payment>;
|
createPayment(data: InsertPayment): Promise<Payment>;
|
||||||
updatePayment(
|
updatePayment(id: number, updates: UpdatePayment): Promise<Payment>;
|
||||||
id: number,
|
|
||||||
updates: UpdatePayment,
|
|
||||||
): Promise<Payment>;
|
|
||||||
deletePayment(id: number, userId: number): Promise<void>;
|
deletePayment(id: number, userId: number): Promise<void>;
|
||||||
getPaymentById(id: number): Promise<PaymentWithExtras | null>;
|
getPaymentById(id: number): Promise<PaymentWithExtras | null>;
|
||||||
getRecentPaymentsByPatientId(
|
getRecentPaymentsByPatientId(
|
||||||
@@ -188,19 +189,41 @@ export interface IStorage {
|
|||||||
offset: number
|
offset: number
|
||||||
): Promise<PaymentWithExtras[] | null>;
|
): Promise<PaymentWithExtras[] | null>;
|
||||||
getTotalPaymentCountByPatient(patientId: number): Promise<number>;
|
getTotalPaymentCountByPatient(patientId: number): Promise<number>;
|
||||||
getPaymentsByClaimId(
|
getPaymentsByClaimId(claimId: number): Promise<PaymentWithExtras | null>;
|
||||||
claimId: number,
|
|
||||||
): Promise<PaymentWithExtras | null>;
|
|
||||||
getRecentPaymentsByUser(
|
getRecentPaymentsByUser(
|
||||||
userId: number,
|
userId: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
offset: number
|
offset: number
|
||||||
): Promise<PaymentWithExtras[]>;
|
): Promise<PaymentWithExtras[]>;
|
||||||
getPaymentsByDateRange(
|
getPaymentsByDateRange(from: Date, to: Date): Promise<PaymentWithExtras[]>;
|
||||||
from: Date,
|
|
||||||
to: Date
|
|
||||||
): Promise<PaymentWithExtras[]>;
|
|
||||||
getTotalPaymentCountByUser(userId: number): Promise<number>;
|
getTotalPaymentCountByUser(userId: number): Promise<number>;
|
||||||
|
|
||||||
|
// Database Backup methods
|
||||||
|
createBackup(userId: number): Promise<DatabaseBackup>;
|
||||||
|
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
||||||
|
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
||||||
|
deleteBackups(userId: number): Promise<number>; // clears all for user
|
||||||
|
|
||||||
|
// Notification methods
|
||||||
|
createNotification(
|
||||||
|
userId: number,
|
||||||
|
type: NotificationTypes,
|
||||||
|
message: string
|
||||||
|
): Promise<Notification>;
|
||||||
|
getNotifications(
|
||||||
|
userId: number,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<Notification[]>;
|
||||||
|
markNotificationRead(
|
||||||
|
userId: number,
|
||||||
|
notificationId: number
|
||||||
|
): Promise<boolean>;
|
||||||
|
markAllNotificationsRead(userId: number): Promise<number>;
|
||||||
|
deleteNotificationsByType(
|
||||||
|
userId: number,
|
||||||
|
type: NotificationTypes
|
||||||
|
): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage: IStorage = {
|
export const storage: IStorage = {
|
||||||
@@ -210,6 +233,10 @@ export const storage: IStorage = {
|
|||||||
return user ?? undefined;
|
return user ?? undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getUsers(limit: number, offset: number): Promise<User[]> {
|
||||||
|
return await db.user.findMany({ skip: offset, take: limit });
|
||||||
|
},
|
||||||
|
|
||||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||||
const user = await db.user.findUnique({ where: { username } });
|
const user = await db.user.findUnique({ where: { username } });
|
||||||
return user ?? undefined;
|
return user ?? undefined;
|
||||||
@@ -736,10 +763,7 @@ export const storage: IStorage = {
|
|||||||
return db.payment.create({ data: payment as Payment });
|
return db.payment.create({ data: payment as Payment });
|
||||||
},
|
},
|
||||||
|
|
||||||
async updatePayment(
|
async updatePayment(id: number, updates: UpdatePayment): Promise<Payment> {
|
||||||
id: number,
|
|
||||||
updates: UpdatePayment,
|
|
||||||
): Promise<Payment> {
|
|
||||||
const existing = await db.payment.findFirst({ where: { id } });
|
const existing = await db.payment.findFirst({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new Error("Payment not found");
|
throw new Error("Payment not found");
|
||||||
@@ -799,9 +823,7 @@ export const storage: IStorage = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPaymentById(
|
async getPaymentById(id: number): Promise<PaymentWithExtras | null> {
|
||||||
id: number,
|
|
||||||
): Promise<PaymentWithExtras | null> {
|
|
||||||
const payment = await db.payment.findFirst({
|
const payment = await db.payment.findFirst({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -830,7 +852,7 @@ export const storage: IStorage = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getPaymentsByClaimId(
|
async getPaymentsByClaimId(
|
||||||
claimId: number,
|
claimId: number
|
||||||
): Promise<PaymentWithExtras | null> {
|
): Promise<PaymentWithExtras | null> {
|
||||||
const payment = await db.payment.findFirst({
|
const payment = await db.payment.findFirst({
|
||||||
where: { claimId },
|
where: { claimId },
|
||||||
@@ -930,4 +952,76 @@ export const storage: IStorage = {
|
|||||||
async getTotalPaymentCountByUser(userId: number): Promise<number> {
|
async getTotalPaymentCountByUser(userId: number): Promise<number> {
|
||||||
return db.payment.count({ where: { userId } });
|
return db.payment.count({ where: { userId } });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Database Backup methods
|
||||||
|
// ==============================
|
||||||
|
async createBackup(userId) {
|
||||||
|
return await db.databaseBackup.create({ data: { userId } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLastBackup(userId) {
|
||||||
|
return await db.databaseBackup.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBackups(userId, limit = 10) {
|
||||||
|
return await db.databaseBackup.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteBackups(userId) {
|
||||||
|
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
||||||
|
return result.count;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Notification methods
|
||||||
|
// ==============================
|
||||||
|
async createNotification(userId, type, message) {
|
||||||
|
return await db.notification.create({
|
||||||
|
data: { userId, type, message },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getNotifications(
|
||||||
|
userId: number,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
return await db.notification.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async markNotificationRead(userId, notificationId) {
|
||||||
|
const result = await db.notification.updateMany({
|
||||||
|
where: { id: notificationId, userId },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllNotificationsRead(userId) {
|
||||||
|
const result = await db.notification.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNotificationsByType(userId, type) {
|
||||||
|
const result = await db.notification.deleteMany({
|
||||||
|
where: { userId, type },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
287
apps/Frontend/src/components/layout/notification-bell.tsx
Normal file
287
apps/Frontend/src/components/layout/notification-bell.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Bell, Check, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Notification } from "@repo/db/types";
|
||||||
|
import { formatDateToHumanReadable} from "@/utils/dateUtils";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
|
export function NotificationsBell() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// dialog / pagination state (client-side over fetched 20)
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0); // 0..N (each page size 5)
|
||||||
|
|
||||||
|
// ------- Single load (no polling): fetch up to 20 latest notifications -------
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["/notifications"],
|
||||||
|
queryFn: async (): Promise<Notification[]> => {
|
||||||
|
const res = await apiRequest("GET", "/api/notifications");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch notifications");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = listQuery.data ?? [];
|
||||||
|
const unread = useMemo(() => all.filter((n) => !n.read), [all]);
|
||||||
|
const unreadCount = unread.length;
|
||||||
|
|
||||||
|
// latest unread for spotlight
|
||||||
|
const latestUnread = unread[0] ?? null;
|
||||||
|
|
||||||
|
// client-side dialog pagination over the fetched 20
|
||||||
|
const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
|
||||||
|
const currentPageItems = useMemo(() => {
|
||||||
|
const start = pageIndex * PAGE_SIZE;
|
||||||
|
return all.slice(start, start + PAGE_SIZE);
|
||||||
|
}, [all, pageIndex]);
|
||||||
|
|
||||||
|
// ------- mutations -------
|
||||||
|
const markRead = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const res = await apiRequest("POST", `/api/notifications/${id}/read`);
|
||||||
|
if (!res.ok) throw new Error("Failed to mark as read");
|
||||||
|
},
|
||||||
|
onMutate: async (id) => {
|
||||||
|
// optimistic update in cache
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["/notifications"] });
|
||||||
|
const prev = queryClient.getQueryData<Notification[]>(["/notifications"]);
|
||||||
|
if (prev) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["/notifications"],
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_e, _id, ctx) => {
|
||||||
|
if (ctx?.prev) queryClient.setQueryData(["/notifications"], ctx.prev);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update notification",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAllRead = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest("POST", "/api/notifications/read-all");
|
||||||
|
if (!res.ok) throw new Error("Failed to mark all as read");
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["/notifications"] });
|
||||||
|
const prev = queryClient.getQueryData<Notification[]>(["/notifications"]);
|
||||||
|
if (prev) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["/notifications"],
|
||||||
|
prev.map((n) => ({ ...n, read: true }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_e, _id, ctx) => {
|
||||||
|
if (ctx?.prev) queryClient.setQueryData(["/notifications"], ctx.prev);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to mark all as read",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// when opening dialog, reset to first page
|
||||||
|
const onOpenChange = async (v: boolean) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (v) {
|
||||||
|
setPageIndex(0);
|
||||||
|
await listQuery.refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Bell + unread badge */}
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label="Notifications"
|
||||||
|
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 transition"
|
||||||
|
>
|
||||||
|
<Bell className="h-6 w-6 text-gray-700" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 inline-flex min-w-5 h-5 items-center justify-center rounded-full text-xs font-semibold bg-red-600 text-white px-1">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
{/* Dialog (client-side pagination over the 20 we already fetched) */}
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notifications</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : all.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No notifications yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
|
{currentPageItems.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-lg border p-3 hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm">{n.message}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{formatDateToHumanReadable(n.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!n.read ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => markRead.mutate(Number(n.id))}
|
||||||
|
disabled={markRead.isPending}
|
||||||
|
>
|
||||||
|
{markRead.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Mark read
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">Read</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pageIndex === 0}
|
||||||
|
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Page {pageIndex + 1} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pageIndex >= totalPages - 1}
|
||||||
|
onClick={() => setPageIndex((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
>
|
||||||
|
{markAllRead.isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
)}
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Spotlight: ONE latest unread (animates in; collapses when marked read) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{latestUnread && (
|
||||||
|
<motion.div
|
||||||
|
key={latestUnread.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: -6, filter: "blur(6px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -6, filter: "blur(6px)" }}
|
||||||
|
transition={{ type: "spring", stiffness: 220, damping: 22 }}
|
||||||
|
className="absolute z-50 top-12 right-0 w-[min(92vw,28rem)]"
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border shadow-xl bg-white">
|
||||||
|
{/* animated halo */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0.15, scale: 0.8 }}
|
||||||
|
animate={{ opacity: [0.15, 0.35, 0.15], scale: [0.8, 1, 0.8] }}
|
||||||
|
transition={{
|
||||||
|
duration: 2.2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className="pointer-events-none absolute inset-0 bg-yellow-200"
|
||||||
|
style={{ mixBlendMode: "multiply" }}
|
||||||
|
/>
|
||||||
|
<div className="relative p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
{/* ping dot */}
|
||||||
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-yellow-500" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{latestUnread.message}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{formatDateToHumanReadable(latestUnread.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markRead.mutate(Number(latestUnread.id))}
|
||||||
|
disabled={markRead.isPending}
|
||||||
|
>
|
||||||
|
{markRead.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Mark as read
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
import { NotificationsBell } from "@/components/layout/notification-bell";
|
||||||
|
|
||||||
interface TopAppBarProps {
|
interface TopAppBarProps {
|
||||||
toggleMobileMenu: () => void;
|
toggleMobileMenu: () => void;
|
||||||
@@ -49,14 +50,7 @@ export function TopAppBar({ toggleMobileMenu }: TopAppBarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
<NotificationsBell />
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="relative p-0 h-9 w-9 rounded-full"
|
|
||||||
>
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
<span className="absolute top-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-white"></span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -46,6 +46,7 @@
|
|||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
@@ -9378,6 +9379,15 @@
|
|||||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ model User {
|
|||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
insuranceCredentials InsuranceCredential[]
|
insuranceCredentials InsuranceCredential[]
|
||||||
// reverse relations
|
|
||||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||||
|
backups DatabaseBackup[]
|
||||||
|
notifications Notification[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Patient {
|
model Patient {
|
||||||
@@ -258,3 +259,35 @@ enum PaymentMethod {
|
|||||||
CARD
|
CARD
|
||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DatabaseBackup {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
type NotificationTypes
|
||||||
|
message String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
read Boolean @default(false)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationTypes {
|
||||||
|
BACKUP
|
||||||
|
CLAIM
|
||||||
|
PAYMENT
|
||||||
|
ETC
|
||||||
|
}
|
||||||
|
|||||||
6
packages/db/types/databaseBackup-types.ts
Normal file
6
packages/db/types/databaseBackup-types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { DatabaseBackupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type DatabaseBackup = z.infer<
|
||||||
|
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
@@ -6,3 +6,5 @@ export * from "./payment-types";
|
|||||||
export * from "./pdf-types";
|
export * from "./pdf-types";
|
||||||
export * from "./staff-types";
|
export * from "./staff-types";
|
||||||
export * from "./user-types";
|
export * from "./user-types";
|
||||||
|
export * from "./databaseBackup-types";
|
||||||
|
export * from "./notifications-types";
|
||||||
|
|||||||
11
packages/db/types/notifications-types.ts
Normal file
11
packages/db/types/notifications-types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import {
|
||||||
|
NotificationTypesSchema,
|
||||||
|
NotificationUncheckedCreateInputObjectSchema,
|
||||||
|
} from "@repo/db/usedSchemas";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type Notification = z.infer<
|
||||||
|
typeof NotificationUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type NotificationTypes = z.infer<typeof NotificationTypesSchema>;
|
||||||
@@ -13,3 +13,6 @@ export * from '../shared/schemas/objects/PaymentUncheckedCreateInput.schema'
|
|||||||
export * from '../shared/schemas/objects/ServiceLineTransactionCreateInput.schema'
|
export * from '../shared/schemas/objects/ServiceLineTransactionCreateInput.schema'
|
||||||
export * from '../shared/schemas/enums/PaymentMethod.schema'
|
export * from '../shared/schemas/enums/PaymentMethod.schema'
|
||||||
export * from '../shared/schemas/enums/PaymentStatus.schema'
|
export * from '../shared/schemas/enums/PaymentStatus.schema'
|
||||||
|
export * from '../shared/schemas/enums/NotificationTypes.schema'
|
||||||
|
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
||||||
|
|||||||
Reference in New Issue
Block a user