notification system implemented

need refinements
This commit is contained in:
dimitar committed 2026-03-11 06:07:16 +01:00
1 parent 612259f020
commit 1143f8ca02
33 files changed
+2221 -222

No files matched your search

@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
/**
* PUT /api/notifications/[id]
* Mark a notification as read
*/
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
// Verify the notification belongs to the user
const notifications = await db.getNotificationsByUserId(userId);
const notification = notifications.find((n) => n.id === id);
if (!notification) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 },
);
}
const updatedNotification = await db.markNotificationAsRead(id);
return NextResponse.json({
success: true,
data: updatedNotification,
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to mark notification as read", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/notifications/[id]
* Delete a notification
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const db = await getDatabase();
// Verify the notification belongs to the user
const notifications = await db.getNotificationsByUserId(userId);
const notification = notifications.find((n) => n.id === id);
if (!notification) {
return NextResponse.json(
{ error: "Notification not found" },
{ status: 404 },
);
}
const deleted = await db.deleteNotification(id);
if (!deleted) {
return NextResponse.json(
{ error: "Failed to delete notification" },
{ status: 500 },
);
}
return NextResponse.json({
success: true,
data: { id },
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to delete notification", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
/**
* POST /api/notifications/mark-all-read
* Mark all notifications as read for the authenticated user
*/
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
await db.markAllNotificationsAsRead(userId);
log.info("All notifications marked as read", { userId });
return NextResponse.json({
success: true,
data: { message: "All notifications marked as read" },
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to mark all notifications as read", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
/**
* GET /api/notifications
* Get all notifications for the authenticated user
*/
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
log.warn("Unauthorized notification fetch attempt");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
log.debug("Fetching notifications for user", { userId });
const db = await getDatabase();
const notifications = await db.getNotificationsByUserId(userId);
log.debug("Notifications fetched successfully", {
userId,
count: notifications.length,
});
return NextResponse.json({
success: true,
data: notifications,
meta: {
timestamp: new Date().toISOString(),
count: notifications.length,
},
});
} catch (error) {
log.error("Failed to fetch notifications", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* POST /api/notifications
* Create a new notification (admin/system only)
*/
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { targetUserId, title, message, type } = body;
if (!targetUserId || !title || !message || !type) {
return NextResponse.json(
{
error: "Missing required fields: targetUserId, title, message, type",
},
{ status: 400 },
);
}
// Validate notification type
const validTypes = [
"payment_reminder",
"attendance",
"promotion",
"system",
];
if (!validTypes.includes(type)) {
return NextResponse.json(
{
error: `Invalid notification type. Must be one of: ${validTypes.join(", ")}`,
},
{ status: 400 },
);
}
const db = await getDatabase();
const notification = await db.createNotification({
id: crypto.randomUUID(),
userId: targetUserId,
title,
message,
type,
read: false,
});
log.info("Notification created", {
id: notification.id,
targetUserId,
type,
});
return NextResponse.json({
success: true,
data: notification,
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to create notification", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
/**
* POST /api/notifications/save-token
* Save the user's Expo push notification token
*/
export async function POST(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { expoPushToken, deviceType } = body;
if (!expoPushToken) {
return NextResponse.json(
{ error: "Missing required field: expoPushToken" },
{ status: 400 },
);
}
// Validate device type
if (deviceType && !["ios", "android"].includes(deviceType)) {
return NextResponse.json(
{ error: "Invalid deviceType. Must be 'ios' or 'android'" },
{ status: 400 },
);
}
const db = await getDatabase();
// Update user with push token
const updatedUser = await db.updateUser(userId, {
expoPushToken,
deviceType: deviceType || undefined,
});
if (!updatedUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
log.info("Push token saved", {
userId,
deviceType,
tokenPrefix: expoPushToken.substring(0, 20) + "...",
});
return NextResponse.json({
success: true,
data: {
message: "Push token saved successfully",
deviceType: updatedUser.deviceType,
},
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to save push token", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
/**
* GET /api/notifications/unread-count
* Get count of unread notifications for the authenticated user
*/
export async function GET(req: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const db = await getDatabase();
const count = await db.getUnreadNotificationCount(userId);
return NextResponse.json({
success: true,
data: { count },
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed to fetch unread notification count", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -1,49 +1,87 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
export async function POST(req: Request) {
try {
const { recommendationId, status, approvedBy } = await req.json();
try {
const body = await req.json();
log.debug("Approve recommendation request body", { body });
if (!recommendationId || !status) {
return NextResponse.json(
{ error: "Recommendation ID and status are required" },
{ status: 400 }
);
}
const { recommendationId, status, approvedBy } = body;
const db = await getDatabase();
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
};
// Remove undefined keys
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
if (!updatedRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 }
);
}
// If approved, create a notification for the user
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
// or we need to add it to IDatabase/SQLiteDatabase
// For now, let's assume the notification is handled elsewhere or add it later
return NextResponse.json(updatedRecommendation);
} catch (error) {
console.error("Error approving recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
if (!recommendationId || !status) {
log.error("Missing required fields", {
recommendationId,
status,
receivedBody: body,
});
return NextResponse.json(
{ error: "Recommendation ID and status are required" },
{ status: 400 },
);
}
const db = await getDatabase();
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
};
// Remove undefined keys
Object.keys(updates).forEach(
(key) => updates[key] === undefined && delete updates[key],
);
const updatedRecommendation = await db.updateRecommendation(
recommendationId,
updates,
);
if (!updatedRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 },
);
}
// If approved, create a notification for the user
if (status === "approved") {
try {
await db.createNotification({
id: crypto.randomUUID(),
userId: updatedRecommendation.userId,
title: "Recommendation Approved! 🎉",
message:
"Your AI-powered fitness recommendation has been approved by your trainer. Check it out now!",
type: "system",
read: false,
});
log.info("Notification created for approved recommendation", {
recommendationId,
userId: updatedRecommendation.userId,
});
} catch (notificationError) {
// Log error but don't fail the approval
log.error("Failed to create notification", notificationError);
}
}
return NextResponse.json({
success: true,
data: updatedRecommendation,
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Error approving recommendation", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -15,18 +15,27 @@ export async function POST(req: Request) {
);
}
log.debug("Generating recommendation for user", {
userId,
modelProvider,
useExternalModel,
});
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
log.error("Fitness profile not found", undefined, { userId });
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 },
);
}
log.debug("Fitness profile found", { profileId: profile.id });
// Build AI context with goals and recommendations
let prompt: string;
try {
@@ -270,21 +279,45 @@ export async function POST(req: Request) {
}
// Save to database
log.debug("Saving recommendation to database", {
userId,
profileId: profile.id,
hasRecommendationText: !!parsedResponse.recommendationText,
hasActivityPlan: !!parsedResponse.activityPlan,
hasDietPlan: !!parsedResponse.dietPlan,
});
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
recommendationText: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
recommendationText: parsedResponse.recommendationText || "",
activityPlan: parsedResponse.activityPlan || "",
dietPlan: parsedResponse.dietPlan || "",
status: "pending",
generatedAt: new Date(),
updatedAt: new Date(),
});
return NextResponse.json(recommendation);
log.info("Recommendation generated successfully", {
recommendationId: recommendation.id,
userId,
});
// Return in standardized format
return NextResponse.json({
success: true,
data: recommendation,
meta: {
timestamp: new Date().toISOString(),
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
},
});
} catch (error) {
log.error("Failed to generate recommendation", error);
log.error("Failed to generate recommendation", error, {
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
});
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
+110 -29
View File
@@ -28,7 +28,7 @@ interface AttendanceStats {
thisMonth: number;
recentCheckIns: Array<{
id: string;
checkInTime: string;
checkInTime: string; // ISO string
checkOutTime: string | null;
type: string;
duration?: number; // in minutes
@@ -134,8 +134,8 @@ export async function GET(request: NextRequest) {
)
.all(userId) as Array<{
id: string;
checkInTime: string;
checkOutTime: string | null;
checkInTime: string | number; // Can be ISO string or Unix timestamp
checkOutTime: string | number | null;
type: string;
}>;
@@ -144,13 +144,33 @@ export async function GET(request: NextRequest) {
// Get recent check-ins (last 10)
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
let duration: number | undefined;
// Convert Unix timestamps to milliseconds for calculation
const checkInMs =
typeof record.checkInTime === "number"
? record.checkInTime * 1000
: new Date(record.checkInTime).getTime();
if (record.checkOutTime) {
const checkIn = new Date(record.checkInTime).getTime();
const checkOut = new Date(record.checkOutTime).getTime();
duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes
const checkOutMs =
typeof record.checkOutTime === "number"
? record.checkOutTime * 1000
: new Date(record.checkOutTime).getTime();
duration = Math.round((checkOutMs - checkInMs) / (1000 * 60)); // minutes
}
// Return with ISO string timestamps for consistency
return {
...record,
id: record.id,
checkInTime: new Date(checkInMs).toISOString(),
checkOutTime: record.checkOutTime
? new Date(
typeof record.checkOutTime === "number"
? record.checkOutTime * 1000
: record.checkOutTime,
).toISOString()
: null,
type: record.type,
duration,
};
});
@@ -161,15 +181,25 @@ export async function GET(request: NextRequest) {
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
startOfWeek.setHours(0, 0, 0, 0);
const thisWeekCheckIns = attendanceRecords.filter(
(r) => new Date(r.checkInTime) >= startOfWeek,
).length;
const thisWeekCheckIns = attendanceRecords.filter((r) => {
// checkInTime is Unix timestamp in seconds, convert to milliseconds
const checkInDate =
typeof r.checkInTime === "number"
? new Date(r.checkInTime * 1000)
: new Date(r.checkInTime);
return checkInDate >= startOfWeek;
}).length;
// Calculate this month's check-ins
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const thisMonthCheckIns = attendanceRecords.filter(
(r) => new Date(r.checkInTime) >= startOfMonth,
).length;
const thisMonthCheckIns = attendanceRecords.filter((r) => {
// checkInTime is Unix timestamp in seconds, convert to milliseconds
const checkInDate =
typeof r.checkInTime === "number"
? new Date(r.checkInTime * 1000)
: new Date(r.checkInTime);
return checkInDate >= startOfMonth;
}).length;
// Calculate current streak (consecutive days with check-ins)
let currentStreak = 0;
@@ -177,14 +207,24 @@ export async function GET(request: NextRequest) {
let tempStreak = 0;
let lastDate: Date | null = null;
const sortedRecords = [...attendanceRecords].sort(
(a, b) =>
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
);
const sortedRecords = [...attendanceRecords].sort((a, b) => {
const dateA =
typeof a.checkInTime === "number"
? new Date(a.checkInTime * 1000)
: new Date(a.checkInTime);
const dateB =
typeof b.checkInTime === "number"
? new Date(b.checkInTime * 1000)
: new Date(b.checkInTime);
return dateB.getTime() - dateA.getTime();
});
const uniqueDays = new Set<string>();
sortedRecords.forEach((record) => {
const date = new Date(record.checkInTime);
const date =
typeof record.checkInTime === "number"
? new Date(record.checkInTime * 1000)
: new Date(record.checkInTime);
const dateStr = date.toISOString().split("T")[0];
uniqueDays.add(dateStr);
});
@@ -257,10 +297,32 @@ export async function GET(request: NextRequest) {
weekEnd.setDate(weekStart.getDate() + 7);
const weekCheckIns = attendanceRecords.filter((r) => {
const checkInDate = new Date(r.checkInTime);
// checkInTime is Unix timestamp in seconds, convert to milliseconds
const checkInDate =
typeof r.checkInTime === "number"
? new Date(r.checkInTime * 1000)
: new Date(r.checkInTime);
return checkInDate >= weekStart && checkInDate < weekEnd;
}).length;
log.debug("Weekly calculation", {
weekIndex: i,
weekStart: weekStart.toISOString(),
weekEnd: weekEnd.toISOString(),
weekCheckIns,
sampleRecord: attendanceRecords[0]
? {
checkInTime: attendanceRecords[0].checkInTime,
converted:
typeof attendanceRecords[0].checkInTime === "number"
? new Date(
attendanceRecords[0].checkInTime * 1000,
).toISOString()
: attendanceRecords[0].checkInTime,
}
: null,
});
const weekGoalsCompleted = db
.prepare(
`SELECT COUNT(*) as count
@@ -308,14 +370,42 @@ export async function GET(request: NextRequest) {
userId,
totalGoals: goalStats.total,
totalCheckIns,
weeklyTrendLength: weeklyTrend.length,
weeklyTrendSample: weeklyTrend[0],
});
// Return statistics in standardized format that matches mobile app expectations
return NextResponse.json({
success: true,
data: {
statistics: {
userId,
...statistics,
goals: {
totalGoals: goalStats.total,
activeGoals: goalStats.active,
completedGoals: goalStats.completed,
averageProgress: goalStats.avgProgress,
goalsByType: Object.entries(goalStats.byType).map(
([goalType, count]) => ({
goalType,
count,
}),
),
},
attendance: {
totalCheckIns: attendanceStats.totalCheckIns,
currentStreak: attendanceStats.currentStreak,
longestStreak: attendanceStats.longestStreak,
checkInsThisWeek: attendanceStats.thisWeek,
checkInsThisMonth: attendanceStats.thisMonth,
recentCheckIns: attendanceStats.recentCheckIns,
},
weeklyTrend: weeklyTrend.map((week) => ({
weekLabel: week.week,
checkIns: week.checkIns,
goalsCompleted: week.goalsCompleted,
averageProgress: week.avgProgress,
})),
},
},
meta: {
@@ -323,15 +413,6 @@ export async function GET(request: NextRequest) {
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
},
});
return NextResponse.json({
success: true,
data: { statistics },
meta: {
timestamp: new Date().toISOString(),
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
},
});
} catch (error) {
log.error("Failed to fetch statistics", error);
return NextResponse.json(
@@ -83,7 +83,10 @@ export function Recommendations({ userId }: RecommendationsProps) {
const response = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recommendationId }),
body: JSON.stringify({
recommendationId,
status: "approved",
}),
});
if (response.ok) {
+92
View File
@@ -6,6 +6,7 @@ import {
Attendance,
Recommendation,
FitnessGoal,
Notification,
DatabaseConfig,
} from "./types";
import {
@@ -16,6 +17,7 @@ import {
attendance,
recommendations,
fitnessGoals,
notifications,
eq,
and,
desc,
@@ -1419,4 +1421,94 @@ export class DrizzleDatabase implements IDatabase {
updatedAt: new Date(row.updatedAt as number | Date),
};
}
// Notification operations
async createNotification(
notification: Omit<Notification, "createdAt">,
): Promise<Notification> {
const newNotification = {
...notification,
createdAt: new Date(),
};
const result = await this.db.insert(notifications).values(newNotification);
log.debug("Notification created", { id: notification.id });
return newNotification;
}
async getNotificationsByUserId(userId: string): Promise<Notification[]> {
const rows = await this.db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.all();
return rows.map((row) => this.mapNotification(row));
}
async getUnreadNotificationCount(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(notifications)
.where(
and(eq(notifications.userId, userId), eq(notifications.read, false)),
);
return Number(result[0]?.count || 0);
}
async markNotificationAsRead(id: string): Promise<Notification | null> {
await this.db
.update(notifications)
.set({ read: true })
.where(eq(notifications.id, id));
const rows = await this.db
.select()
.from(notifications)
.where(eq(notifications.id, id));
if (rows.length === 0) return null;
return this.mapNotification(rows[0]);
}
async markAllNotificationsAsRead(userId: string): Promise<void> {
await this.db
.update(notifications)
.set({ read: true })
.where(
and(eq(notifications.userId, userId), eq(notifications.read, false)),
);
log.debug("All notifications marked as read", { userId });
}
async deleteNotification(id: string): Promise<boolean> {
const result = await this.db
.delete(notifications)
.where(eq(notifications.id, id));
return result.changes > 0;
}
private mapNotification(row: Record<string, unknown>): Notification {
const createdAtValue = row.createdAt as number | Date;
const createdAt =
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000) // SQLite stores in seconds, convert to milliseconds
: createdAtValue;
return {
id: String(row.id),
userId: String(row.userId || row.user_id),
title: String(row.title),
message: String(row.message),
type: String(row.type) as Notification["type"],
read: Boolean(row.read),
createdAt,
};
}
}
+19 -1
View File
@@ -5,6 +5,7 @@ import {
Attendance,
Recommendation,
FitnessGoal,
Notification,
} from "@fitai/shared";
import type { SortConfig, FilterCondition } from "../filtering";
@@ -15,7 +16,14 @@ export interface User extends SharedUser {
password: string;
}
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
export type {
Client,
FitnessProfile,
Attendance,
Recommendation,
FitnessGoal,
Notification,
};
// Database Interface - allows us to swap implementations
export interface IDatabase {
@@ -163,6 +171,16 @@ export interface IDatabase {
): Promise<FitnessGoal | null>;
completeGoal(id: string): Promise<FitnessGoal | null>;
// Notification operations
createNotification(
notification: Omit<Notification, "createdAt">,
): Promise<Notification>;
getNotificationsByUserId(userId: string): Promise<Notification[]>;
getUnreadNotificationCount(userId: string): Promise<number>;
markNotificationAsRead(id: string): Promise<Notification | null>;
markAllNotificationsAsRead(userId: string): Promise<void>;
deleteNotification(id: string): Promise<boolean>;
// Dashboard operations
getDashboardStats(): Promise<{
totalUsers: number;
+1 -1
View File
@@ -83,7 +83,7 @@ export const prioritySchema = z.enum(["low", "medium", "high"]);
export const goalStatusSchema = z.enum(["active", "completed", "abandoned"]);
export const fitnessGoalSchema = z.object({
userId: z.string().min(1, "User ID is required"),
userId: z.string().min(1, "User ID is required").optional(), // Optional for authenticated requests
goalType: goalTypeSchema,
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).optional(),