notification system implemented
need refinements
This commit is contained in:
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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user