Dental Care

This commit is contained in:
Iliyan Angelov
2025-11-16 14:29:51 +02:00
commit 39077550ef
194 changed files with 43197 additions and 0 deletions

View 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" };
}
}

View 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" };
}
}

View 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,
};
}
}

View 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,
});
};

View 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
View 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()],
});

View 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
View 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
View 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
View 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
View 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
View 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));
}

View 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>;
}
}