Dental Care
This commit is contained in:
410
lib/actions/admin-actions.ts
Normal file
410
lib/actions/admin-actions.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// Helper to check if user is admin
|
||||
async function isAdmin() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user || session.user.role !== "admin") {
|
||||
throw new Error("Unauthorized: Admin access required");
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
// ==================== APPOINTMENT ACTIONS ====================
|
||||
|
||||
export async function confirmAppointments(appointmentIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.updateMany({
|
||||
where: { id: { in: appointmentIds } },
|
||||
data: { status: "confirmed" },
|
||||
});
|
||||
|
||||
// Revalidate all relevant paths
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/appointments");
|
||||
revalidatePath("/patient/appointments");
|
||||
revalidatePath("/dentist/appointments");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${appointmentIds.length} appointment(s) confirmed`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error confirming appointments:", error);
|
||||
return { success: false, message: "Failed to confirm appointments" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAppointments(appointmentIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.updateMany({
|
||||
where: { id: { in: appointmentIds } },
|
||||
data: { status: "cancelled", cancelReason: "Cancelled by admin" },
|
||||
});
|
||||
|
||||
// Revalidate all relevant paths
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/appointments");
|
||||
revalidatePath("/patient/appointments");
|
||||
revalidatePath("/dentist/appointments");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${appointmentIds.length} appointment(s) cancelled`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error cancelling appointments:", error);
|
||||
return { success: false, message: "Failed to cancel appointments" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeAppointments(appointmentIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.updateMany({
|
||||
where: { id: { in: appointmentIds } },
|
||||
data: { status: "completed" },
|
||||
});
|
||||
|
||||
// Revalidate all relevant paths
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/appointments");
|
||||
revalidatePath("/patient/appointments");
|
||||
revalidatePath("/dentist/appointments");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${appointmentIds.length} appointment(s) marked as completed`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error completing appointments:", error);
|
||||
return { success: false, message: "Failed to complete appointments" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAppointments(appointmentIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.deleteMany({
|
||||
where: { id: { in: appointmentIds } },
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
return {
|
||||
success: true,
|
||||
message: `${appointmentIds.length} appointment(s) deleted`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting appointments:", error);
|
||||
return { success: false, message: "Failed to delete appointments" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAppointment(appointmentId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.delete({
|
||||
where: { id: appointmentId },
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
return { success: true, message: "Appointment deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting appointment:", error);
|
||||
return { success: false, message: "Failed to delete appointment" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAppointment(
|
||||
appointmentId: string,
|
||||
data: Prisma.AppointmentUpdateInput
|
||||
) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.appointment.update({
|
||||
where: { id: appointmentId },
|
||||
data,
|
||||
});
|
||||
|
||||
// Revalidate all relevant paths
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/appointments");
|
||||
revalidatePath("/patient/appointments");
|
||||
revalidatePath("/dentist/appointments");
|
||||
|
||||
return { success: true, message: "Appointment updated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating appointment:", error);
|
||||
return { success: false, message: "Failed to update appointment" };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DENTIST ACTIONS ====================
|
||||
|
||||
export async function updateDentistAvailability(
|
||||
dentistIds: string[],
|
||||
isAvailable: boolean
|
||||
) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
id: { in: dentistIds },
|
||||
role: "dentist",
|
||||
},
|
||||
data: { isAvailable },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dentist-management");
|
||||
return {
|
||||
success: true,
|
||||
message: `${dentistIds.length} dentist(s) updated`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating dentist availability:", error);
|
||||
return { success: false, message: "Failed to update dentist availability" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDentist(dentistId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: dentistId,
|
||||
role: "dentist",
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/dentist-management");
|
||||
return { success: true, message: "Dentist deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting dentist:", error);
|
||||
return { success: false, message: "Failed to delete dentist" };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PATIENT ACTIONS ====================
|
||||
|
||||
export async function deletePatients(patientIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: { in: patientIds },
|
||||
role: "patient",
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/patient-management");
|
||||
return {
|
||||
success: true,
|
||||
message: `${patientIds.length} patient(s) deleted`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting patients:", error);
|
||||
return { success: false, message: "Failed to delete patients" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePatient(patientId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: patientId,
|
||||
role: "patient",
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/patient-management");
|
||||
return { success: true, message: "Patient deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting patient:", error);
|
||||
return { success: false, message: "Failed to delete patient" };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SERVICE ACTIONS ====================
|
||||
|
||||
export async function updateServiceStatus(
|
||||
serviceIds: string[],
|
||||
isActive: boolean
|
||||
) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.service.updateMany({
|
||||
where: { id: { in: serviceIds } },
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/service-management");
|
||||
return {
|
||||
success: true,
|
||||
message: `${serviceIds.length} service(s) updated`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating service status:", error);
|
||||
return { success: false, message: "Failed to update service status" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServices(serviceIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.service.deleteMany({
|
||||
where: { id: { in: serviceIds } },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/service-management");
|
||||
return {
|
||||
success: true,
|
||||
message: `${serviceIds.length} service(s) deleted`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting services:", error);
|
||||
return { success: false, message: "Failed to delete services" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteService(serviceId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.service.delete({
|
||||
where: { id: serviceId },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/service-management");
|
||||
return { success: true, message: "Service deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting service:", error);
|
||||
return { success: false, message: "Failed to delete service" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateService(serviceId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
return { success: false, message: "Service not found" };
|
||||
}
|
||||
|
||||
// Generate a unique ID for the duplicated service
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 7);
|
||||
const newId = `${service.id}-copy-${timestamp}-${randomSuffix}`;
|
||||
|
||||
await prisma.service.create({
|
||||
data: {
|
||||
id: newId,
|
||||
name: `${service.name} (Copy)`,
|
||||
description: service.description,
|
||||
duration: service.duration,
|
||||
price: service.price,
|
||||
category: service.category,
|
||||
isActive: false, // Duplicated services start as inactive
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/service-management");
|
||||
return { success: true, message: "Service duplicated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error duplicating service:", error);
|
||||
return { success: false, message: "Failed to duplicate service" };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== USER ACTIONS ====================
|
||||
|
||||
export async function updateUserEmailVerification(
|
||||
userIds: string[],
|
||||
emailVerified: boolean
|
||||
) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
await prisma.user.updateMany({
|
||||
where: { id: { in: userIds } },
|
||||
data: { emailVerified },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/user-management");
|
||||
return { success: true, message: `${userIds.length} user(s) updated` };
|
||||
} catch (error) {
|
||||
console.error("Error updating email verification:", error);
|
||||
return { success: false, message: "Failed to update email verification" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUsers(userIds: string[]) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
// Don't allow deleting admin users
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
role: { not: "admin" },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/user-management");
|
||||
return { success: true, message: `${userIds.length} user(s) deleted` };
|
||||
} catch (error) {
|
||||
console.error("Error deleting users:", error);
|
||||
return { success: false, message: "Failed to delete users" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await isAdmin();
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (user?.role === "admin") {
|
||||
return { success: false, message: "Cannot delete admin users" };
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/user-management");
|
||||
return { success: true, message: "User deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return { success: false, message: "Failed to delete user" };
|
||||
}
|
||||
}
|
||||
291
lib/actions/settings-actions.ts
Normal file
291
lib/actions/settings-actions.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
|
||||
// Helper to get current user
|
||||
async function getCurrentUser() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await import("next/headers").then((mod) => mod.headers()),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error("Unauthorized: Please login");
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
// ==================== USER PROFILE ACTIONS ====================
|
||||
|
||||
export async function updateUserProfile(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
dateOfBirth?: string;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone || null,
|
||||
address: data.address || null,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/patient/settings");
|
||||
revalidatePath("/dentist/settings");
|
||||
revalidatePath("/admin/settings");
|
||||
|
||||
return { success: true, message: "Profile updated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating profile:", error);
|
||||
return { success: false, message: "Failed to update profile" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProfileImage(imageUrl: string) {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
image: imageUrl,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/patient/settings");
|
||||
revalidatePath("/dentist/settings");
|
||||
revalidatePath("/admin/settings");
|
||||
|
||||
return { success: true, message: "Profile image updated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error uploading profile image:", error);
|
||||
return { success: false, message: "Failed to upload profile image" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(data: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}) {
|
||||
await getCurrentUser();
|
||||
|
||||
try {
|
||||
// In a real implementation, you would:
|
||||
// 1. Verify the current password against data.currentPassword
|
||||
// 2. Hash the new password (data.newPassword)
|
||||
// 3. Update the password in the account table
|
||||
|
||||
// For now, we'll just simulate success
|
||||
// You'll need to implement proper password hashing with bcrypt or similar
|
||||
|
||||
console.log(
|
||||
"Password change requested for:",
|
||||
data.currentPassword ? "***" : ""
|
||||
);
|
||||
|
||||
return { success: true, message: "Password changed successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error changing password:", error);
|
||||
return { success: false, message: "Failed to change password" };
|
||||
}
|
||||
} // ==================== USER SETTINGS ACTIONS ====================
|
||||
|
||||
export async function getUserSettings(userId: string) {
|
||||
try {
|
||||
// For now, return default settings since we don't have a settings table
|
||||
// In a real app, you'd fetch from a UserSettings table using userId
|
||||
console.log("Fetching settings for user:", userId);
|
||||
|
||||
return {
|
||||
emailNotifications: true,
|
||||
smsNotifications: true,
|
||||
appointmentReminders: true,
|
||||
promotionalEmails: false,
|
||||
reminderTiming: "24",
|
||||
profileVisibility: "private",
|
||||
shareData: false,
|
||||
twoFactorAuth: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting user settings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserSettings(data: {
|
||||
emailNotifications?: boolean;
|
||||
smsNotifications?: boolean;
|
||||
appointmentReminders?: boolean;
|
||||
promotionalEmails?: boolean;
|
||||
reminderTiming?: string;
|
||||
profileVisibility?: string;
|
||||
shareData?: boolean;
|
||||
twoFactorAuth?: boolean;
|
||||
}) {
|
||||
await getCurrentUser();
|
||||
|
||||
try {
|
||||
// In a real implementation, you would save these to a UserSettings table
|
||||
// For now, we'll just log and return success
|
||||
console.log("Updating user settings:", Object.keys(data).join(", "));
|
||||
|
||||
return { success: true, message: "Settings saved successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating settings:", error);
|
||||
return { success: false, message: "Failed to save settings" };
|
||||
}
|
||||
} // ==================== ADMIN SETTINGS ACTIONS ====================
|
||||
|
||||
export async function getAdminSettings() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (user.role !== "admin") {
|
||||
throw new Error("Unauthorized: Admin access required");
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real app, you'd fetch from a ClinicSettings table
|
||||
// For now, return default settings
|
||||
return {
|
||||
clinicName: "Dental U-Care",
|
||||
clinicEmail: "info@dentalucare.com",
|
||||
clinicPhone: "+1 (555) 123-4567",
|
||||
clinicAddress: "123 Medical Plaza, Suite 100",
|
||||
timezone: "America/New_York",
|
||||
appointmentDuration: "60",
|
||||
bufferTime: "15",
|
||||
maxAdvanceBooking: "90",
|
||||
cancellationDeadline: "24",
|
||||
autoConfirmAppointments: false,
|
||||
emailNotifications: true,
|
||||
smsNotifications: true,
|
||||
appointmentReminders: true,
|
||||
reminderHoursBefore: "24",
|
||||
newBookingNotifications: true,
|
||||
cancellationNotifications: true,
|
||||
requirePaymentUpfront: false,
|
||||
allowPartialPayment: true,
|
||||
depositPercentage: "50",
|
||||
acceptCash: true,
|
||||
acceptCard: true,
|
||||
acceptEWallet: true,
|
||||
twoFactorAuth: false,
|
||||
sessionTimeout: "60",
|
||||
passwordExpiry: "90",
|
||||
loginAttempts: "5",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting admin settings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminSettings(data: {
|
||||
clinicName?: string;
|
||||
clinicEmail?: string;
|
||||
clinicPhone?: string;
|
||||
clinicAddress?: string;
|
||||
timezone?: string;
|
||||
appointmentDuration?: string;
|
||||
bufferTime?: string;
|
||||
maxAdvanceBooking?: string;
|
||||
cancellationDeadline?: string;
|
||||
autoConfirmAppointments?: boolean;
|
||||
emailNotifications?: boolean;
|
||||
smsNotifications?: boolean;
|
||||
appointmentReminders?: boolean;
|
||||
reminderHoursBefore?: string;
|
||||
newBookingNotifications?: boolean;
|
||||
cancellationNotifications?: boolean;
|
||||
requirePaymentUpfront?: boolean;
|
||||
allowPartialPayment?: boolean;
|
||||
depositPercentage?: string;
|
||||
acceptCash?: boolean;
|
||||
acceptCard?: boolean;
|
||||
acceptEWallet?: boolean;
|
||||
twoFactorAuth?: boolean;
|
||||
sessionTimeout?: string;
|
||||
passwordExpiry?: string;
|
||||
loginAttempts?: string;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (user.role !== "admin") {
|
||||
throw new Error("Unauthorized: Admin access required");
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real implementation, save to ClinicSettings table
|
||||
// For now, just log and return success
|
||||
console.log("Updating admin settings:", Object.keys(data).join(", "));
|
||||
|
||||
revalidatePath("/admin/settings");
|
||||
return { success: true, message: "Admin settings saved successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating admin settings:", error);
|
||||
return { success: false, message: "Failed to save admin settings" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUserAccount() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (user.role === "admin") {
|
||||
return { success: false, message: "Cannot delete admin accounts" };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
return { success: true, message: "Account deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting account:", error);
|
||||
return { success: false, message: "Failed to delete account" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportUserData() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
try {
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
include: {
|
||||
appointmentsAsPatient: true,
|
||||
appointmentsAsDentist: true,
|
||||
payments: true,
|
||||
notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userData) {
|
||||
return { success: false, message: "User data not found" };
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
const dataExport = JSON.stringify(userData, null, 2);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Data exported successfully",
|
||||
data: dataExport,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error exporting data:", error);
|
||||
return { success: false, message: "Failed to export data" };
|
||||
}
|
||||
}
|
||||
110
lib/auth-session/auth-actions.ts
Normal file
110
lib/auth-session/auth-actions.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth-session/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Server Actions for Authentication
|
||||
*
|
||||
* Best practices:
|
||||
* - Use "use server" directive
|
||||
* - Return consistent response shapes
|
||||
* - Handle errors gracefully
|
||||
* - Use Better Auth's API methods
|
||||
*
|
||||
* Note: Prefer using authClient on the client side when possible
|
||||
* These are mainly for server-side flows or progressive enhancement
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
* @param email - User's email
|
||||
* @param password - User's password
|
||||
*/
|
||||
export async function signInWithEmail(email: string, password: string) {
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return { success: true as const };
|
||||
} catch (error) {
|
||||
console.error("[signInWithEmail] Error:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Sign in failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
* @param email - User's email
|
||||
* @param password - User's password
|
||||
* @param name - User's name
|
||||
*/
|
||||
export async function signUpWithEmail(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email, password, name },
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return { success: true as const };
|
||||
} catch (error) {
|
||||
console.error("[signUpWithEmail] Error:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : "Sign up failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out the current user
|
||||
*/
|
||||
export async function signOutAction() {
|
||||
try {
|
||||
await auth.api.signOut({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
redirect("/sign-in");
|
||||
} catch (error) {
|
||||
console.error("[signOutAction] Error:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Sign out failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session
|
||||
* Prefer using getSession() from auth-server.ts in Server Components
|
||||
*/
|
||||
export async function getCurrentSessionAction() {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: session,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[getCurrentSessionAction] Error:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Failed to get session",
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
136
lib/auth-session/auth-client.ts
Normal file
136
lib/auth-session/auth-client.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
import { stripeClient } from "@better-auth/stripe/client";
|
||||
|
||||
/**
|
||||
* Better Auth Client Configuration
|
||||
*
|
||||
* Best practices:
|
||||
* - Don't set baseURL (use relative paths for same-origin cookies)
|
||||
* - Always include credentials
|
||||
* - Handle errors gracefully
|
||||
* - Use plugins as needed
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
// Use relative paths for same-origin requests
|
||||
// baseURL is only needed if auth API is on different domain
|
||||
|
||||
fetchOptions: {
|
||||
credentials: "include", // Include cookies in all requests
|
||||
onError: async (context) => {
|
||||
const { response, error } = context;
|
||||
|
||||
// Rate limiting
|
||||
if (response?.status === 429) {
|
||||
const retryAfter = response.headers.get("X-Retry-After");
|
||||
toast.error(
|
||||
`Too many requests. Please try again in ${retryAfter} seconds.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (!response) {
|
||||
toast.error("Network error. Please check your connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Other errors
|
||||
console.error("Auth error:", error);
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
organizationClient(),
|
||||
stripeClient({
|
||||
subscription: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
/**
|
||||
* Export commonly used hooks and methods
|
||||
*/
|
||||
export const { useSession, signIn, signOut, signUp } = authClient;
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
* @param email - User's email address
|
||||
*/
|
||||
export const resendVerificationEmail = async (email: string) => {
|
||||
const res = await fetch("/api/auth/resend-verification", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to resend verification email.");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
/**
|
||||
* Sign in with email and password
|
||||
* @param email - User's email address
|
||||
* @param password - User's password
|
||||
*/
|
||||
export const signInWithEmail = async (email: string, password: string) => {
|
||||
const data = await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
// Handle the error
|
||||
if (ctx.error.status === 403) {
|
||||
throw new Error("Please verify your email address");
|
||||
}
|
||||
throw new Error(ctx.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in with Google using OAuth
|
||||
* This will redirect the user to Google's consent screen
|
||||
*/
|
||||
export const signInWithGoogle = async () => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in with Google using ID Token
|
||||
* Useful when you already have the Google ID Token from client-side
|
||||
* @param token - Google ID Token
|
||||
* @param accessToken - Google Access Token (optional)
|
||||
*/
|
||||
export const signInWithGoogleIdToken = async (
|
||||
token: string,
|
||||
accessToken?: string
|
||||
) => {
|
||||
const data = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
idToken: {
|
||||
token,
|
||||
accessToken,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request additional Google scopes (e.g., Google Drive, Gmail)
|
||||
* @param scopes - Array of Google API scopes to request
|
||||
*/
|
||||
export const requestAdditionalGoogleScopes = async (scopes: string[]) => {
|
||||
await authClient.linkSocial({
|
||||
provider: "google",
|
||||
scopes,
|
||||
});
|
||||
};
|
||||
141
lib/auth-session/auth-server.ts
Normal file
141
lib/auth-session/auth-server.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "./get-session";
|
||||
|
||||
/**
|
||||
* Server-side Authentication Helpers
|
||||
*
|
||||
* Best practices:
|
||||
* - All functions use getServerSession (cached)
|
||||
* - Redirects are handled gracefully
|
||||
* - Role checks include proper fallbacks
|
||||
* - Type-safe role checking
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the current session
|
||||
* Cached to avoid multiple lookups in the same request
|
||||
*/
|
||||
export const getSession = getServerSession;
|
||||
|
||||
/**
|
||||
* Require authentication in a Server Component or Server Action
|
||||
* Redirects to /sign-in if not authenticated
|
||||
* @returns The session object
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user
|
||||
* @returns The user object or null if not authenticated
|
||||
*/
|
||||
export async function getCurrentUser() {
|
||||
const session = await getSession();
|
||||
return session?.user ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
* @returns true if authenticated, false otherwise
|
||||
*/
|
||||
export async function isAuthenticated() {
|
||||
const session = await getSession();
|
||||
return !!session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's role
|
||||
* @returns The user's role or null
|
||||
*/
|
||||
export async function getUserRole() {
|
||||
const session = await getSession();
|
||||
return session?.user?.role ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin
|
||||
* @returns true if user is admin, false otherwise
|
||||
*/
|
||||
export async function isAdmin() {
|
||||
const session = await getSession();
|
||||
return session?.user?.role === "admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a dentist
|
||||
* @returns true if user is dentist, false otherwise
|
||||
*/
|
||||
export async function isDentist() {
|
||||
const session = await getSession();
|
||||
return session?.user?.role === "dentist";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user is a patient
|
||||
* @returns true if user is patient, false otherwise
|
||||
*/
|
||||
export async function isPatient() {
|
||||
const session = await getSession();
|
||||
return session?.user?.role === "patient";
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role
|
||||
* Redirects to appropriate page if not admin
|
||||
* @returns The session object
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await requireAuth();
|
||||
if (session.user?.role !== "admin") {
|
||||
const role = session.user?.role;
|
||||
redirect(role === "dentist" ? "/dentist" : role === "patient" ? "/patient" : "/");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require dentist role
|
||||
* Redirects to appropriate page if not dentist
|
||||
* @returns The session object
|
||||
*/
|
||||
export async function requireDentist() {
|
||||
const session = await requireAuth();
|
||||
if (session.user?.role !== "dentist") {
|
||||
const role = session.user?.role;
|
||||
redirect(role === "admin" ? "/admin" : role === "patient" ? "/patient" : "/");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require patient role
|
||||
* Redirects to appropriate page if not patient
|
||||
* @returns The session object
|
||||
*/
|
||||
export async function requirePatient() {
|
||||
const session = await requireAuth();
|
||||
if (session.user?.role !== "patient") {
|
||||
const role = session.user?.role;
|
||||
redirect(role === "admin" ? "/admin" : role === "dentist" ? "/dentist" : "/");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require staff role (admin or dentist)
|
||||
* Redirects to patient portal if neither
|
||||
* @returns The session object
|
||||
*/
|
||||
export async function requireStaff() {
|
||||
const session = await requireAuth();
|
||||
const role = session.user?.role;
|
||||
if (role !== "admin" && role !== "dentist") {
|
||||
redirect(role === "patient" ? "/patient" : "/");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
121
lib/auth-session/auth.ts
Normal file
121
lib/auth-session/auth.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { Resend } from "resend";
|
||||
import ForgotPasswordEmail from "@/components/emails/reset-password";
|
||||
import VerificationEmail from "@/components/emails/email-verification";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY!);
|
||||
|
||||
/**
|
||||
* Better Auth Configuration
|
||||
*
|
||||
* Best practices:
|
||||
* - Use environment variables for secrets and URLs
|
||||
* - Enable secure cookies in production
|
||||
* - Use rolling sessions for better UX
|
||||
* - Cache sessions to reduce database calls
|
||||
*/
|
||||
export const auth = betterAuth({
|
||||
// Core configuration
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
trustedOrigins: [
|
||||
process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
],
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
updateAge: 60 * 60 * 24, // Update session every 24 hours
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // Cache for 5 minutes
|
||||
},
|
||||
},
|
||||
|
||||
// Advanced security settings
|
||||
advanced: {
|
||||
useSecureCookies: process.env.NODE_ENV === "production",
|
||||
cookiePrefix: "better-auth",
|
||||
crossSubDomainCookies: {
|
||||
enabled: true, // Works across www and non-www
|
||||
},
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax", // CSRF protection while allowing normal navigation
|
||||
path: "/",
|
||||
// httpOnly is automatically set by Better Auth
|
||||
secure: process.env.NODE_ENV === "production", // HTTPS only in production
|
||||
},
|
||||
},
|
||||
// Database adapter
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "mongodb",
|
||||
}),
|
||||
|
||||
// Email & Password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
autoSignIn: false, // Don't auto sign-in until email verified
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await resend.emails.send({
|
||||
from: `${process.env.EMAIL_SENDER_NAME || "Dental U Care"} <${process.env.EMAIL_SENDER_ADDRESS || "send@dentalucare.tech"}>`,
|
||||
to: user.email,
|
||||
subject: "Reset your password",
|
||||
react: ForgotPasswordEmail({ username: user.name, resetUrl: url }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Email verification
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
await resend.emails.send({
|
||||
from: `${process.env.EMAIL_SENDER_NAME || "Dental U Care"} <${process.env.EMAIL_SENDER_ADDRESS || "send@dentalucare.tech"}>`,
|
||||
to: user.email,
|
||||
subject: "Verify your email",
|
||||
react: VerificationEmail({
|
||||
username: user.name,
|
||||
verificationUrl: url,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
// User model configuration
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
defaultValue: "patient",
|
||||
input: false, // Don't allow direct input
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Social authentication providers
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
prompt: "select_account", // Always show account selector
|
||||
},
|
||||
},
|
||||
|
||||
// Lifecycle hooks
|
||||
onAfterSignUp: async ({ user }: { user: { id: string; role?: string } }) => {
|
||||
// Ensure new users have a role
|
||||
if (!user.role) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { role: "patient" },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Plugins (nextCookies must be last)
|
||||
plugins: [nextCookies()],
|
||||
});
|
||||
43
lib/auth-session/get-session.ts
Normal file
43
lib/auth-session/get-session.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
import { auth } from "./auth";
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
|
||||
/**
|
||||
* Get the current session in a Server Component or Server Action
|
||||
*
|
||||
* Best practices:
|
||||
* - Cached to avoid multiple lookups in the same request
|
||||
* - Includes user role from database
|
||||
* - Returns null if not authenticated
|
||||
* - Use this in Server Components and Server Actions
|
||||
*
|
||||
* @returns The session object with user role or null if not authenticated
|
||||
*/
|
||||
export const getServerSession = cache(async () => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session || !session.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch role from database if not in session
|
||||
// Better Auth's session cache may not include custom fields
|
||||
if (!session.user.role) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (dbUser) {
|
||||
session.user.role = dbUser.role || "patient";
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("[getServerSession] Error:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
11
lib/server/server.ts
Normal file
11
lib/server/server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// This file can be used for server-side authentication utilities
|
||||
// Example usage:
|
||||
// import { auth } from "@/lib/auth-session/auth";
|
||||
// import { headers } from "next/headers";
|
||||
//
|
||||
// const someAuthenticatedAction = async () => {
|
||||
// "use server";
|
||||
// const session = await auth.api.getSession({
|
||||
// headers: await headers(),
|
||||
// });
|
||||
// };
|
||||
36
lib/types/doctor.ts
Normal file
36
lib/types/doctor.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface Doctor {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
github?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
}
|
||||
|
||||
export const doctors: Doctor[] = [
|
||||
{
|
||||
id: "doctor-1",
|
||||
name: "Kath Estrada",
|
||||
role: "Chief Dentist & Orthodontist",
|
||||
avatar: "/kath.jpg",
|
||||
},
|
||||
{
|
||||
id: "doctor-2",
|
||||
name: "Clyrelle Jade Cervantes",
|
||||
role: "Cosmetic Dentistry Specialist",
|
||||
avatar: "/cervs.jpg",
|
||||
},
|
||||
{
|
||||
id: "doctor-3",
|
||||
name: "Von Vryan Arguelles",
|
||||
role: "Oral Surgeon",
|
||||
avatar: "/von.jpg",
|
||||
},
|
||||
{
|
||||
id: "doctor-4",
|
||||
name: "Dexter Cabanag",
|
||||
role: "Periodontist",
|
||||
avatar: "/dexter.jpg",
|
||||
},
|
||||
];
|
||||
19
lib/types/prisma.ts
Normal file
19
lib/types/prisma.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma Client Configuration
|
||||
*
|
||||
* Important for MongoDB:
|
||||
* - No connection pooling needed (MongoDB handles it)
|
||||
* - Ensure DATABASE_URL uses mongodb:// or mongodb+srv:// protocol
|
||||
* - Disable Prisma Accelerate (not compatible with MongoDB)
|
||||
*/
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
243
lib/types/services-data.ts
Normal file
243
lib/types/services-data.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
duration: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
badge: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const serviceCategories: ServiceCategory[] = [
|
||||
{
|
||||
id: "basic",
|
||||
title: "Basic Services",
|
||||
badge: "Essential",
|
||||
services: [
|
||||
{
|
||||
id: "dental-consultation",
|
||||
name: "Dental Consultation / Checkup",
|
||||
price: "₱500 – ₱1,500",
|
||||
description: "Basic dental examination to check overall condition of teeth and gums",
|
||||
category: "Basic Services",
|
||||
duration: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "oral-prophylaxis",
|
||||
name: "Oral Prophylaxis (Cleaning)",
|
||||
price: "₱1,200 – ₱3,000",
|
||||
description: "Regular teeth cleaning, recommended every 6 months",
|
||||
category: "Basic Services",
|
||||
duration: 60,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "tooth-xray",
|
||||
name: "Tooth X-Ray",
|
||||
price: "₱700 – ₱2,500+",
|
||||
description: "Depends on type (periapical, panoramic, etc.)",
|
||||
category: "Basic Services",
|
||||
duration: 15,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "simple-extraction",
|
||||
name: "Simple Tooth Extraction",
|
||||
price: "₱1,500 – ₱5,000",
|
||||
description: "Basic tooth extraction procedure",
|
||||
category: "Basic Services",
|
||||
duration: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "deep-cleaning",
|
||||
name: "Deep Cleaning / Scaling and Root Planing",
|
||||
price: "₱3,000 – ₱10,000+",
|
||||
description: "For early signs of gum disease, deeper cleaning procedure",
|
||||
category: "Basic Services",
|
||||
duration: 90,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fillings",
|
||||
title: "Dental Fillings",
|
||||
badge: "Restorative",
|
||||
services: [
|
||||
{
|
||||
id: "amalgam-filling",
|
||||
name: "Amalgam Filling (Silver)",
|
||||
price: "₱800 – ₱2,500",
|
||||
description: "Traditional silver-colored filling material",
|
||||
category: "Dental Fillings",
|
||||
duration: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "composite-filling",
|
||||
name: "Composite Filling (Tooth-colored)",
|
||||
price: "₱1,500 – ₱4,500+",
|
||||
description: "Natural-looking tooth-colored filling",
|
||||
category: "Dental Fillings",
|
||||
duration: 45,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "ceramic-filling",
|
||||
name: "Ceramic/Gold Filling",
|
||||
price: "₱5,000 – ₱15,000+",
|
||||
description: "Premium filling materials for durability",
|
||||
category: "Dental Fillings",
|
||||
duration: 60,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
title: "Advanced Treatments",
|
||||
badge: "Popular",
|
||||
services: [
|
||||
{
|
||||
id: "surgical-extraction",
|
||||
name: "Surgical/Impacted Tooth Extraction",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Complex extraction for impacted teeth",
|
||||
category: "Advanced Treatments",
|
||||
duration: 60,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "root-canal",
|
||||
name: "Root Canal Treatment",
|
||||
price: "₱5,000 – ₱20,000+",
|
||||
description: "Treatment for infected tooth pulp, cleaned and sealed",
|
||||
category: "Advanced Treatments",
|
||||
duration: 90,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "basic-crown",
|
||||
name: "Dental Crowns (Basic - Metal or PFM)",
|
||||
price: "₱8,000 – ₱20,000+",
|
||||
description: "Cap for damaged tooth, metal or porcelain-fused-to-metal",
|
||||
category: "Advanced Treatments",
|
||||
duration: 120,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "premium-crown",
|
||||
name: "Dental Crowns (Premium - Zirconia, Emax)",
|
||||
price: "₱30,000 – ₱45,000+",
|
||||
description: "High-quality aesthetic crowns",
|
||||
category: "Advanced Treatments",
|
||||
duration: 120,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "teeth-whitening",
|
||||
name: "Teeth Whitening (Bleaching)",
|
||||
price: "₱9,000 – ₱30,000+",
|
||||
description: "Laser or in-clinic whitening procedure",
|
||||
category: "Advanced Treatments",
|
||||
duration: 60,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replacement",
|
||||
title: "Tooth Replacement",
|
||||
badge: "Restoration",
|
||||
services: [
|
||||
{
|
||||
id: "partial-denture",
|
||||
name: "Partial Denture",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Removable denture for missing teeth",
|
||||
category: "Tooth Replacement",
|
||||
duration: 180,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "full-denture",
|
||||
name: "Full Denture",
|
||||
price: "Contact for pricing",
|
||||
description: "Complete denture set, depends on number of teeth",
|
||||
category: "Tooth Replacement",
|
||||
duration: 240,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "dental-bridge",
|
||||
name: "Dental Bridges",
|
||||
price: "₱20,000 – ₱60,000+",
|
||||
description: "Replacement of missing teeth using adjacent teeth",
|
||||
category: "Tooth Replacement",
|
||||
duration: 180,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "dental-implant",
|
||||
name: "Dental Implants",
|
||||
price: "₱80,000 – ₱150,000+",
|
||||
description: "Permanent tooth replacement using titanium post + crown",
|
||||
category: "Tooth Replacement",
|
||||
duration: 300,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cosmetic",
|
||||
title: "Cosmetic & Orthodontics",
|
||||
badge: "Premium",
|
||||
services: [
|
||||
{
|
||||
id: "dental-veneers",
|
||||
name: "Dental Veneers",
|
||||
price: "₱12,000 – ₱35,000+ per tooth",
|
||||
description: "For aesthetic purposes - straight, white, beautiful teeth",
|
||||
category: "Cosmetic & Orthodontics",
|
||||
duration: 120,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "metal-braces",
|
||||
name: "Traditional Metal Braces",
|
||||
price: "₱35,000 – ₱80,000+",
|
||||
description: "Classic metal braces for teeth alignment",
|
||||
category: "Cosmetic & Orthodontics",
|
||||
duration: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "ceramic-braces",
|
||||
name: "Ceramic / Clear Braces",
|
||||
price: "₱100,000 – ₱200,000+",
|
||||
description: "Aesthetic clear or tooth-colored braces",
|
||||
category: "Cosmetic & Orthodontics",
|
||||
duration: 30,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flatten all services for easier access
|
||||
export const allServices = serviceCategories.flatMap(category =>
|
||||
category.services.map(service => ({
|
||||
...service,
|
||||
categoryTitle: category.title,
|
||||
badge: category.badge,
|
||||
}))
|
||||
);
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
122
lib/utils/appointment-helpers.ts
Normal file
122
lib/utils/appointment-helpers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { prisma } from "@/lib/types/prisma";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
type AppointmentSelect = {
|
||||
id: string;
|
||||
patientId: string;
|
||||
dentistId: string;
|
||||
serviceId: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
cancelReason: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type AppointmentWithRelations<TInclude extends Prisma.AppointmentInclude | undefined> =
|
||||
TInclude extends Prisma.AppointmentInclude
|
||||
? Prisma.AppointmentGetPayload<{ include: TInclude }>[]
|
||||
: AppointmentSelect[];
|
||||
|
||||
/**
|
||||
* Safely fetch appointments with relations, filtering out orphaned records
|
||||
* This prevents Prisma errors when appointments reference non-existent users
|
||||
*/
|
||||
export async function safeFindManyAppointments<
|
||||
TInclude extends Prisma.AppointmentInclude | undefined = undefined
|
||||
>(
|
||||
args: {
|
||||
where?: Prisma.AppointmentWhereInput;
|
||||
include?: TInclude;
|
||||
orderBy?: Prisma.AppointmentOrderByWithRelationInput | Prisma.AppointmentOrderByWithRelationInput[];
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
): Promise<AppointmentWithRelations<TInclude>> {
|
||||
try {
|
||||
// First, get all appointments without relations
|
||||
const appointments = await prisma.appointment.findMany({
|
||||
where: args.where,
|
||||
select: {
|
||||
id: true,
|
||||
patientId: true,
|
||||
dentistId: true,
|
||||
serviceId: true,
|
||||
date: true,
|
||||
timeSlot: true,
|
||||
status: true,
|
||||
notes: true,
|
||||
cancelReason: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: args.orderBy,
|
||||
take: args.take,
|
||||
skip: args.skip,
|
||||
});
|
||||
|
||||
// Get all unique patient and dentist IDs
|
||||
const patientIds = [...new Set(appointments.map((apt) => apt.patientId))];
|
||||
const dentistIds = [...new Set(appointments.map((apt) => apt.dentistId))];
|
||||
const serviceIds = [...new Set(appointments.map((apt) => apt.serviceId))];
|
||||
|
||||
// Verify that all referenced users and services exist
|
||||
const [patients, dentists, services] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { id: { in: patientIds } },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: { id: { in: dentistIds } },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.service.findMany({
|
||||
where: { id: { in: serviceIds } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const validPatientIds = new Set(patients.map((p) => p.id));
|
||||
const validDentistIds = new Set(dentists.map((d) => d.id));
|
||||
const validServiceIds = new Set(services.map((s) => s.id));
|
||||
|
||||
// Filter out appointments with invalid references
|
||||
const validAppointments = appointments.filter(
|
||||
(apt) =>
|
||||
validPatientIds.has(apt.patientId) &&
|
||||
validDentistIds.has(apt.dentistId) &&
|
||||
validServiceIds.has(apt.serviceId)
|
||||
);
|
||||
|
||||
// If no include needed, return early
|
||||
if (!args.include || validAppointments.length === 0) {
|
||||
return validAppointments as AppointmentWithRelations<TInclude>;
|
||||
}
|
||||
|
||||
// Now fetch the full appointments with relations for valid ones only
|
||||
const validAppointmentIds = validAppointments.map((apt) => apt.id);
|
||||
|
||||
try {
|
||||
const fullAppointments = await prisma.appointment.findMany({
|
||||
where: {
|
||||
id: { in: validAppointmentIds },
|
||||
},
|
||||
include: args.include,
|
||||
orderBy: args.orderBy,
|
||||
});
|
||||
|
||||
return fullAppointments as AppointmentWithRelations<TInclude>;
|
||||
} catch (error) {
|
||||
console.error("[safeFindManyAppointments] Error fetching relations:", error);
|
||||
// Return appointments without relations if relation fetch fails
|
||||
return validAppointments as AppointmentWithRelations<TInclude>;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[safeFindManyAppointments] Error:", error);
|
||||
// Return empty array on error instead of crashing
|
||||
return [] as unknown as AppointmentWithRelations<TInclude>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user