Files
LexiChain/lib/services/notification.service.ts

702 lines
19 KiB
TypeScript
Raw Normal View History

2026-03-25 13:52:45 +01:00
/**
* Notification Service
*
* Handles all notification-related operations including:
* - Creating notifications for user actions
* - Retrieving notifications with filtering
* - Marking notifications as read
* - Checking for upcoming contract renewals/deadlines
* - Cleaning up expired notifications
*
* Integrates with Prisma ORM for database operations.
* Supports multiple notification types: SUCCESS, WARNING, ERROR, INFO, DEADLINE
*/
import { prisma } from "@/lib/db/prisma";
let hasWarnedMissingNotificationTable = false;
const isNotificationTableMissingError = (error: unknown): boolean => {
if (!error || typeof error !== "object") return false;
const maybePrismaError = error as {
code?: string;
meta?: { table?: string };
message?: string;
};
if (maybePrismaError.code !== "P2021") return false;
const tableFromMeta = maybePrismaError.meta?.table ?? "";
const message = maybePrismaError.message ?? "";
return (
tableFromMeta.includes("Notification") ||
message.includes("public.Notification")
);
};
const warnMissingNotificationTableOnce = () => {
if (hasWarnedMissingNotificationTable) return;
hasWarnedMissingNotificationTable = true;
console.warn(
"Notification table is missing. Notification features are temporarily disabled until schema is synced.",
);
};
/**
* Notification type for creating new notifications
*/
interface CreateNotificationInput {
userId: string;
type: "SUCCESS" | "WARNING" | "ERROR" | "INFO" | "DEADLINE";
title: string;
message: string;
contractId?: string;
actionType?: string;
actionData?: Record<string, any>;
icon?: string;
expiresIn?: number; // milliseconds
}
/**
* Response type for notification operations
*/
interface NotificationResponse {
success: boolean;
message?: string;
data?: any;
error?: string;
}
export class NotificationService {
2026-03-28 23:46:45 +01:00
static async cleanupReadNonDeadline(
userId: string,
): Promise<NotificationResponse> {
try {
const result = await prisma.notification.deleteMany({
where: {
userId,
read: true,
NOT: {
type: "DEADLINE",
},
},
});
return {
success: true,
message: `Cleaned ${result.count} seen non-deadline notifications`,
data: { count: result.count },
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Read cleanup skipped.",
data: { count: 0 },
};
}
console.error("Error cleaning read non-deadline notifications:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to cleanup seen notifications",
};
}
}
2026-03-25 13:52:45 +01:00
/**
* Creates a new notification for a user
*
* @param input - Notification creation parameters
* @returns Promise with success status and notification data
*
* Steps:
* 1. Calculate expiration time if provided (default: 30 days)
* 2. Insert notification into database
* 3. Return created notification with metadata
*
* Example:
* ```typescript
* await NotificationService.create({
* userId: "user123",
* type: "SUCCESS",
* title: "Contract Uploaded",
* message: "Your contract has been uploaded successfully"
* });
* ```
*/
static async create(
input: CreateNotificationInput,
): Promise<NotificationResponse> {
try {
// Calculate expiration time: default to 30 days if not specified
const expiresAt = input.expiresIn
? new Date(Date.now() + input.expiresIn)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Create notification in database
const notification = await prisma.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
message: input.message,
contractId: input.contractId ?? undefined,
actionType: input.actionType ?? undefined,
actionData: input.actionData ?? undefined,
icon: input.icon ?? undefined,
expiresAt,
},
});
return {
success: true,
data: notification,
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification skipped: table not available yet.",
};
}
console.error("Error creating notification:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to create notification",
};
}
}
/**
* Retrieves all unread notifications for a user
*
* @param userId - The user's ID
* @param limit - Maximum number of notifications to return (default: 10)
* @returns Promise with array of notifications sorted by creation date (newest first)
*
* Steps:
* 1. Query database for unread notifications
* 2. Filter out expired notifications (expiresAt < now)
* 3. Sort by creation date (descending)
* 4. Limit results to specified count
*
* Example:
* ```typescript
* const notifications = await NotificationService.getUnread("user123", 15);
* ```
*/
static async getUnread(
userId: string,
limit: number = 10,
): Promise<NotificationResponse> {
try {
2026-03-28 23:46:45 +01:00
// Enforce retention policy on each read path.
await this.cleanupReadNonDeadline(userId);
2026-03-25 13:52:45 +01:00
const notifications = await prisma.notification.findMany({
where: {
userId,
read: false,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
include: {
contract: {
select: {
id: true,
title: true,
fileName: true,
endDate: true,
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
});
return {
success: true,
data: notifications,
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
data: [],
};
}
console.error("Error fetching unread notifications:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to fetch notifications",
};
}
}
/**
* Retrieves all notifications for a user (read and unread)
*
* @param userId - The user's ID
* @param limit - Maximum number of notifications to return (default: 50)
* @returns Promise with array of all notifications sorted by creation date
*
* Used for displaying complete notification history/log
*/
static async getAll(
userId: string,
limit: number = 50,
): Promise<NotificationResponse> {
try {
2026-03-28 23:46:45 +01:00
// Enforce retention policy on each read path.
await this.cleanupReadNonDeadline(userId);
2026-03-25 13:52:45 +01:00
const notifications = await prisma.notification.findMany({
where: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
include: {
contract: {
select: {
id: true,
title: true,
fileName: true,
endDate: true,
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
});
return {
success: true,
data: notifications,
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
data: [],
};
}
console.error("Error fetching all notifications:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to fetch notifications",
};
}
}
/**
* Marks a notification as read
*
* @param notificationId - The ID of the notification to mark as read
* @returns Promise with success status
*
* Steps:
* 1. Update notification read flag to true
* 2. Return updated notification
*/
static async markAsRead(
notificationId: string,
): Promise<NotificationResponse> {
try {
const notification = await prisma.notification.update({
where: { id: notificationId },
data: { read: true },
});
2026-03-28 23:46:45 +01:00
if (notification.type !== "DEADLINE") {
await prisma.notification.delete({
where: { id: notificationId },
});
return {
success: true,
message: "Notification marked as read and auto-deleted",
data: {
id: notificationId,
deleted: true,
},
};
}
2026-03-25 13:52:45 +01:00
return {
success: true,
data: notification,
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Mark-as-read skipped.",
};
}
console.error("Error marking notification as read:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to mark notification as read",
};
}
}
/**
* Marks all unread notifications as read for a user
*
* @param userId - The user's ID
* @returns Promise with count of updated notifications
*/
static async markAllAsRead(userId: string): Promise<NotificationResponse> {
try {
const result = await prisma.notification.updateMany({
where: {
userId,
read: false,
},
data: { read: true },
});
2026-03-28 23:46:45 +01:00
const cleanup = await this.cleanupReadNonDeadline(userId);
const cleanupCount = cleanup.success ? cleanup.data?.count || 0 : 0;
2026-03-25 13:52:45 +01:00
return {
success: true,
2026-03-28 23:46:45 +01:00
message: `Marked ${result.count} notifications as read and auto-deleted ${cleanupCount} non-deadline items`,
data: { count: result.count, deletedCount: cleanupCount },
2026-03-25 13:52:45 +01:00
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Mark-all-as-read skipped.",
data: { count: 0 },
};
}
console.error("Error marking all notifications as read:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to mark notifications as read",
};
}
}
/**
* Deletes a notification
*
* @param notificationId - The ID of the notification to delete
* @returns Promise with success status
*/
static async delete(notificationId: string): Promise<NotificationResponse> {
try {
await prisma.notification.delete({
where: { id: notificationId },
});
return {
success: true,
message: "Notification deleted successfully",
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Delete skipped.",
};
}
console.error("Error deleting notification:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to delete notification",
};
}
}
/**
* Cleans up expired notifications from the database
*
* Called periodically to remove old notifications
* Only deletes notifications where expiresAt < current time
*
* @returns Promise with count of deleted notifications
*
* Example: Run daily via cron job or scheduled background task
*/
static async cleanupExpired(): Promise<NotificationResponse> {
try {
const result = await prisma.notification.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
});
return {
success: true,
message: `Cleaned up ${result.count} expired notifications`,
data: { count: result.count },
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Cleanup skipped.",
data: { count: 0 },
};
}
console.error("Error cleaning up expired notifications:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to cleanup notifications",
};
}
}
/**
* Gets unread notification count for a user
*
* Used for badge display on notification icon
*
* @param userId - The user's ID
* @returns Promise with unread count
*
* Example:
* ```typescript
* const count = await NotificationService.getUnreadCount("user123");
* // Display badge with count on notification icon
* ```
*/
static async getUnreadCount(userId: string): Promise<NotificationResponse> {
try {
2026-03-28 23:46:45 +01:00
// Enforce retention policy on count path as well.
await this.cleanupReadNonDeadline(userId);
2026-03-25 13:52:45 +01:00
const count = await prisma.notification.count({
where: {
userId,
read: false,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
return {
success: true,
data: { count },
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
data: { count: 0 },
};
}
console.error("Error getting unread count:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to get unread count",
};
}
}
/**
* Checks for upcoming contract renewals/expirations and creates notifications
*
* Scans all contracts for a user and creates DEADLINE notifications for:
* - 30 days before expiration (CRITICAL)
* - 15 days before expiration (WARNING)
* - 7 days before expiration (URGENT)
*
* @param userId - The user's ID
* @returns Promise with count of created notifications
*
* Steps:
* 1. Query all COMPLETED contracts with endDate for the user
* 2. Calculate days until expiration
* 3. Create notification if contract expiring in 30, 15, or 7 days
* 4. Check for existing notification to avoid duplicates
* 5. Return summary of created notifications
*
* Example: Run daily via cron job
* ```typescript
* await NotificationService.checkUpcomingDeadlines("user123");
* ```
*/
static async checkUpcomingDeadlines(
userId: string,
): Promise<NotificationResponse> {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Query all contracts with endDate for this user
const contracts = await prisma.contract.findMany({
where: {
userId,
status: "COMPLETED",
endDate: {
not: null,
},
},
select: {
id: true,
title: true,
endDate: true,
provider: true,
},
});
const createdNotifications: string[] = [];
// Process each contract
for (const contract of contracts) {
if (!contract.endDate) continue;
// Calculate days until expiration
const contractEnd = new Date(contract.endDate);
contractEnd.setHours(0, 0, 0, 0);
const daysUntilExpiration = Math.ceil(
(contractEnd.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
// Define deadline thresholds and notification levels
let shouldNotify = false;
let level = "";
if (daysUntilExpiration === 7) {
shouldNotify = true;
level = "URGENT";
} else if (daysUntilExpiration === 15) {
shouldNotify = true;
level = "WARNING";
} else if (daysUntilExpiration === 30) {
shouldNotify = true;
level = "CRITICAL";
}
if (shouldNotify) {
// Check if notification already exists for this deadline
const existingNotification = await prisma.notification.findFirst({
where: {
userId,
contractId: contract.id,
actionType: `RENEWAL_${level}`,
createdAt: {
gte: new Date(today.getTime() - 24 * 60 * 60 * 1000), // Within last 24 hours
},
},
});
// Only create if not already notified today
if (!existingNotification) {
const notificationTitle =
level === "CRITICAL"
? `🔴 Contract Expiring in 30 Days`
: level === "WARNING"
? `🟠 Contract Expiring in 15 Days`
: `🟡 Contract Expiring in 7 Days`;
const notificationMessage =
level === "CRITICAL"
? `${contract.title} from ${contract.provider} will expire on ${contractEnd.toLocaleDateString()}. Time to renew!`
: level === "WARNING"
? `${contract.title} from ${contract.provider} expires in 15 days. Consider scheduling renewal.`
: `${contract.title} from ${contract.provider} expires in 7 days. Renew now!`;
const result = await this.create({
userId,
type: "DEADLINE",
title: notificationTitle,
message: notificationMessage,
contractId: contract.id,
actionType: `RENEWAL_${level}`,
icon: level === "CRITICAL" ? "AlertCircle" : "AlertTriangle",
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
actionData: {
level,
daysUntilExpiration,
expirationDate: contractEnd.toISOString(),
contractTitle: contract.title,
contractProvider: contract.provider,
},
});
if (result.success) {
createdNotifications.push(contract.id);
}
}
}
}
return {
success: true,
message: `Created ${createdNotifications.length} deadline notifications`,
data: {
count: createdNotifications.length,
contractIds: createdNotifications,
},
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Deadline scan skipped.",
data: { count: 0, contractIds: [] },
};
}
console.error("Error checking upcoming deadlines:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to check deadlines",
};
}
}
}