diff --git a/packages/recovery-system/backend/scripts/seed-counselors.ts b/packages/recovery-system/backend/scripts/seed-counselors.ts new file mode 100644 index 0000000..bebcaaf --- /dev/null +++ b/packages/recovery-system/backend/scripts/seed-counselors.ts @@ -0,0 +1,174 @@ +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import User from '../src/models/User'; +import Counselor from '../src/models/Counselor'; + +dotenv.config(); + +const MONGO_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/relife'; + +async function seed() { + try { + console.log('🌱 Starting seed...'); + + // Connect to MongoDB + await mongoose.connect(MONGO_URI); + console.log('āœ… Connected to MongoDB'); + + // Clear existing data (optional - comment out if you want to keep data) + // await User.deleteMany({}); + // await Counselor.deleteMany({}); + // console.log('šŸ—‘ļø Cleared existing data'); + + // Create test users for counselors + const testUsers = [ + { + name: 'Dr. Sarah Johnson', + email: 'sarah@example.com', + image: 'https://api.example.com/avatar1.jpg' + }, + { + name: 'Dr. Michael Chen', + email: 'michael@example.com', + image: 'https://api.example.com/avatar2.jpg' + }, + { + name: 'Dr. Emma Wilson', + email: 'emma@example.com', + image: 'https://api.example.com/avatar3.jpg' + } + ]; + + const createdUsers = []; + for (const userData of testUsers) { + const existingUser = await User.findOne({ email: userData.email }); + if (!existingUser) { + const user = await User.create(userData); + createdUsers.push(user); + console.log(`āœ… Created user: ${user.name}`); + } else { + createdUsers.push(existingUser); + console.log(`ā„¹ļø User already exists: ${existingUser.name}`); + } + } + + // Generate test slots (next 7 days, 4 slots per day) + function generateSlots() { + const slots = []; + const now = new Date(); + + for (let day = 0; day < 7; day++) { + const date = new Date(now); + date.setDate(date.getDate() + day); + + // Morning slot + const morning = new Date(date); + morning.setHours(9, 0, 0, 0); + const morningEnd = new Date(morning); + morningEnd.setHours(10, 0, 0, 0); + slots.push({ start: morning, end: morningEnd, isBooked: false }); + + // Mid-morning slot + const midMorning = new Date(date); + midMorning.setHours(11, 0, 0, 0); + const midMorningEnd = new Date(midMorning); + midMorningEnd.setHours(12, 0, 0, 0); + slots.push({ start: midMorning, end: midMorningEnd, isBooked: false }); + + // Afternoon slot + const afternoon = new Date(date); + afternoon.setHours(14, 0, 0, 0); + const afternoonEnd = new Date(afternoon); + afternoonEnd.setHours(15, 0, 0, 0); + slots.push({ start: afternoon, end: afternoonEnd, isBooked: false }); + + // Late afternoon slot + const lateAfternoon = new Date(date); + lateAfternoon.setHours(16, 0, 0, 0); + const lateAfternoonEnd = new Date(lateAfternoon); + lateAfternoonEnd.setHours(17, 0, 0, 0); + slots.push({ start: lateAfternoon, end: lateAfternoonEnd, isBooked: false }); + } + + return slots; + } + + // Create counselor profiles + const counselorData = [ + { + userId: createdUsers[0]._id, + credentials: { + degree: 'Master of Science in Counseling', + license: 'LPC-001001', + licenseState: 'California', + yearsOfExperience: 5, + certifications: ['Addiction Specialist', 'SAMHSA Certified'] + }, + specializations: ['addiction', 'substance_abuse', 'relapse_prevention'], + bio: 'Dr. Sarah Johnson is a certified addiction recovery counselor with 5 years of experience helping clients overcome substance abuse and addiction. She specializes in motivational interviewing and cognitive behavioral therapy. Sarah is passionate about helping others on their recovery journey.', + isVerified: true, + hourlyRate: 50, + ratingCount: 12, + availableSlots: generateSlots() + }, + { + userId: createdUsers[1]._id, + credentials: { + degree: 'PhD in Clinical Psychology', + license: 'MFT-001002', + licenseState: 'New York', + yearsOfExperience: 8, + certifications: ['Trauma-Informed Care', 'EMDR Certified'] + }, + specializations: ['anxiety', 'depression', 'trauma', 'ptsd'], + bio: 'Dr. Michael Chen is a licensed clinical psychologist with 8 years of experience in treating anxiety, depression, and trauma-related disorders. He has helped over 500 clients achieve significant improvements in their mental health through evidence-based therapeutic approaches and compassionate care.', + isVerified: true, + hourlyRate: 60, + ratingCount: 25, + availableSlots: generateSlots() + }, + { + userId: createdUsers[2]._id, + credentials: { + degree: 'Master of Marriage and Family Therapy', + license: 'LMFT-001003', + licenseState: 'Texas', + yearsOfExperience: 10, + certifications: ['Family Systems Therapy', 'Child Psychology'] + }, + specializations: ['family_therapy', 'group_therapy', 'teen_counseling', 'stress_management'], + bio: 'Dr. Emma Wilson is a licensed marriage and family therapist with over 10 years of clinical experience. She specializes in working with families, teens, and individuals experiencing life transitions. Emma is dedicated to helping clients build stronger relationships and develop healthy coping strategies.', + isVerified: true, + hourlyRate: 75, + ratingCount: 40, + availableSlots: generateSlots() + } + ]; + + for (const counselor of counselorData) { + const existingCounselor = await Counselor.findOne({ userId: counselor.userId }); + if (!existingCounselor) { + await Counselor.create(counselor); + console.log(`āœ… Created counselor: ${counselor.specializations.join(', ')} - ${counselor.availableSlots.length} slots`); + } else { + console.log(`ā„¹ļø Counselor already exists for user: ${counselor.userId}`); + } + } + + console.log('\nšŸŽ‰ Seed completed successfully!'); + console.log('\nšŸ“‹ Summary:'); + console.log(` - Users created: ${createdUsers.length}`); + console.log(` - Counselors with slots added`); + console.log('\nšŸš€ You can now test the API endpoints!'); + + } catch (error) { + console.error('āŒ Seed failed:', error); + process.exit(1); + } finally { + await mongoose.connection.close(); + console.log('\nāœ… MongoDB connection closed'); + process.exit(0); + } +} + +seed(); diff --git a/packages/recovery-system/backend/src/app.ts b/packages/recovery-system/backend/src/app.ts index 9eeb9f2..a27a9d0 100644 --- a/packages/recovery-system/backend/src/app.ts +++ b/packages/recovery-system/backend/src/app.ts @@ -7,8 +7,9 @@ import progressRoutes from "./routes/index"; import journalRoutes from "./routes/journal.routes"; import communityRoutes from "./routes/community.routes"; import counselorRoutes from "./routes/counselor.routes"; +import bookingRoutes from "./routes/booking.routes"; import { isAuth } from "./middleware/isAuth"; -import { getProfile, updateProfile, getProfileDetails } from "./controllers/auth.controller"; +import { getProfile, updateProfile, getProfileDetails, signUp } from "./controllers/auth.controller"; import chatRoutes from "./routes/chat.routes"; import gamesRoutes from "./routes/games.routes"; //import analyzer from 'express-api-timer'; @@ -40,6 +41,7 @@ app.use(helmet()); app.get("/api/auth/me", isAuth, getProfile); app.put("/api/auth/profile", isAuth, updateProfile); app.get("/api/auth/profile/details", isAuth, getProfileDetails); +app.post("/api/auth/register", signUp); // Auth routes (BetterAuth catch-all) @@ -61,6 +63,9 @@ app.use('/api/community', communityRoutes); // Counselor Routes app.use('/api/counselor', counselorRoutes); +// Booking Routes +app.use('/api', bookingRoutes); + // Chat Routes app.use('/api/chat', chatRoutes); diff --git a/packages/recovery-system/backend/src/controllers/Counselor.controller.ts b/packages/recovery-system/backend/src/controllers/Counselor.controller.ts index 022a2bd..86e7437 100644 --- a/packages/recovery-system/backend/src/controllers/Counselor.controller.ts +++ b/packages/recovery-system/backend/src/controllers/Counselor.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import Counselor from '../models/Counselor'; +// import User from '../models/User'; import { ValidationCredentials, ValidateSpecialization, validationBio } from '../utils/counselorValidation'; // POST /api/counselors @@ -107,4 +108,74 @@ export const getCounselorProfile = async (req: Request, res: Response) => { } catch (error) { return res.status(500).json({ error: 'Failed to get counselor profile' }); } +}; + +/** + * GET /api/counselors + * Get all counselors with optional filters + */ +export const getAllCounselors = async (req: Request, res: Response) => { + try { + const { specialty, specializations, isVerified } = req.query; + + const filters: any = { isActive: true }; + + // Support both 'specialty' and 'specializations' query params + const specParam = (specialty || specializations) as string; + if (specParam) { + filters.specializations = { $in: [specParam.toLowerCase()] }; + } + + if (isVerified !== undefined) { + filters.isVerified = isVerified === 'true'; + } + + const counselors = await Counselor.find(filters) + .select('-credentials.license -availability') + .populate('userId', 'name email') + .sort({ rating: -1 }); + + return res.status(200).json({ + message: 'Counselors retrieved successfully', + count: counselors.length, + counselors, + }); + } catch (error: any) { + console.error('Error fetching counselors:', error.message || error); + return res.status(500).json({ + error: 'Failed to fetch counselors', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }); + } +}; + +/** + * GET /api/counselors/:id + * Get a single counselor by ID + */ +export const getCounselorById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Validate ObjectId format + if (!id.match(/^[0-9a-fA-F]{24}$/)) { + return res.status(400).json({ error: 'Invalid counselor ID format' }); + } + + const counselor = await Counselor.findById(id) + .select('-credentials.license') + .populate('userId', 'name email'); + + if (!counselor) { + return res.status(404).json({ error: 'Counselor not found' }); + } + + return res.status(200).json({ + message: 'Counselor retrieved successfully', + counselor, + }); + } catch (error) { + console.error('Error fetching counselor:', error); + return res.status(500).json({ error: 'Failed to fetch counselor' }); + } }; \ No newline at end of file diff --git a/packages/recovery-system/backend/src/controllers/auth.controller.ts b/packages/recovery-system/backend/src/controllers/auth.controller.ts index 342dfea..a227af7 100644 --- a/packages/recovery-system/backend/src/controllers/auth.controller.ts +++ b/packages/recovery-system/backend/src/controllers/auth.controller.ts @@ -24,7 +24,7 @@ export const getProfile = async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }); } - res.json({ user: fullUser }); + return res.json({ user: fullUser }); } catch (error) { return res.status(500).json({ error: 'Failed to get profile' }); } @@ -74,7 +74,7 @@ export const updateProfile = async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }); } - res.json({ user: updatedUser, message: 'Profile updated successfully' }); + return res.json({ user: updatedUser, message: 'Profile updated successfully' }); } catch (error) { return res.status(500).json({ error: 'Failed to update profile' }); } @@ -100,8 +100,67 @@ export const getProfileDetails = async (req: Request, res: Response) => { return res.status(404).json({ error: 'User profile not found' }); } - res.json({ profile: userProfile }); + return res.json({ profile: userProfile }); } catch (error) { return res.status(500).json({ error: 'Failed to get profile details' }); } +}; + +/** + * Custom sign-up endpoint for testing + * @route POST /api/auth/register + * Creates a new user with email/password + */ +export const signUp = async (req: Request, res: Response) => { + try { + const { name, email, password, role } = req.body; + + // Validate inputs + if (!name || !email || !password) { + return res.status(400).json({ error: 'Name, email, and password are required' }); + } + + // Check if user already exists + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res.status(400).json({ error: 'Email already registered' }); + } + + // Create new user + const newUser = new User({ + name, + email, + role: role || 'user', + emailVerified: false, + accountStatus: 'active' + }); + + // Note: In production, use proper password hashing (bcrypt, argon2, etc.) + // For now, storing plaintext (for testing only) + (newUser as any).password = password; + + await newUser.save(); + + // Generate a simple JWT token for testing + const token = Buffer.from(JSON.stringify({ + id: newUser._id, + email: newUser.email, + name: newUser.name, + role: newUser.role + })).toString('base64'); + + return res.status(201).json({ + token, + user: { + _id: newUser._id, + name: newUser.name, + email: newUser.email, + role: newUser.role + }, + message: 'User registered successfully' + }); + } catch (error: any) { + console.error('Sign up error:', error); + return res.status(500).json({ error: 'Failed to register user' }); + } }; \ No newline at end of file diff --git a/packages/recovery-system/backend/src/controllers/booking.controller.ts b/packages/recovery-system/backend/src/controllers/booking.controller.ts new file mode 100644 index 0000000..665f2e6 --- /dev/null +++ b/packages/recovery-system/backend/src/controllers/booking.controller.ts @@ -0,0 +1,282 @@ +import { Request, Response } from 'express'; +import { BookingService } from '../services/booking.service'; +import { CounselorService } from '../services/counselor.service'; + +/** + * POST /api/bookings + * Create a new booking + */ +export const createBooking = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const { counselorId, slotStart, slotEnd, notes } = req.body; + let { fee } = req.body; + + // Validate required fields + if (!counselorId || !slotStart || !slotEnd) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['counselorId', 'slotStart', 'slotEnd'], + }); + } + + // Parse dates + const start = new Date(slotStart); + const end = new Date(slotEnd); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return res.status(400).json({ error: 'Invalid date format' }); + } + + if (start >= end) { + return res.status(400).json({ error: 'Slot start time must be before end time' }); + } + + // Auto-calculate fee from counselor's hourly rate if not provided + if (fee === undefined) { + const counselor = await CounselorService.getCounselorById(counselorId); + if (!counselor) { + return res.status(404).json({ error: 'Counselor not found' }); + } + // Calculate fee based on hours (hourly rate * hours) + const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); + fee = counselor.hourlyRate * hours; + } + + // Validate fee if provided + if (typeof fee !== 'number' || fee < 0) { + return res.status(400).json({ error: 'Fee must be a non-negative number' }); + } + + // Create booking + const booking = await BookingService.createBooking({ + userId, + counselorId, + slotStart: start, + slotEnd: end, + fee, + notes, + }); + + return res.status(201).json({ + message: 'Booking created successfully', + booking, + }); + } catch (error: any) { + console.error('Error creating booking:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.status === 404) { + return res.status(404).json({ error: error.message }); + } + if (error.status === 409) { + return res.status(409).json({ error: error.message }); + } + + return res.status(500).json({ error: 'Failed to create booking' }); + } +}; + +/** + * GET /api/bookings + * Get all bookings for the logged-in user + */ +export const getUserBookings = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const bookings = await BookingService.getUserBookings(userId); + + return res.status(200).json({ + message: 'Bookings retrieved successfully', + count: bookings.length, + bookings, + }); + } catch (error: any) { + console.error('Error fetching bookings:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + + return res.status(500).json({ error: 'Failed to fetch bookings' }); + } +}; + +/** + * GET /api/bookings/:id + * Get a single booking + */ +export const getBookingById = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const { id } = req.params; + + const booking = await BookingService.getBookingById(id, userId); + + return res.status(200).json({ + message: 'Booking retrieved successfully', + booking, + }); + } catch (error: any) { + console.error('Error fetching booking:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.status === 403) { + return res.status(403).json({ error: error.message }); + } + if (error.status === 404) { + return res.status(404).json({ error: error.message }); + } + + return res.status(500).json({ error: 'Failed to fetch booking' }); + } +}; + +/** + * DELETE /api/bookings/:id + * Cancel a booking + */ +export const cancelBooking = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const { id } = req.params; + const { cancellationReason } = req.body; + + const booking = await BookingService.cancelBooking(id, userId, cancellationReason); + + return res.status(200).json({ + message: 'Booking cancelled successfully', + booking, + }); + } catch (error: any) { + console.error('Error cancelling booking:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.status === 403) { + return res.status(403).json({ error: error.message }); + } + if (error.status === 404) { + return res.status(404).json({ error: error.message }); + } + if (error.status === 409) { + return res.status(409).json({ error: error.message }); + } + + console.error('Unhandled error:', error); + return res.status(500).json({ error: 'Failed to cancel booking' }); + } +}; + +/** + * GET /api/counselors/:counselorId/available-slots + * Get available slots for a counselor + */ +export const getCounselorAvailableSlots = async (req: Request, res: Response) => { + try { + const { counselorId } = req.params; + + const slots = await CounselorService.getAvailableSlots(counselorId); + + return res.status(200).json({ + message: 'Available slots retrieved successfully', + count: slots.length, + slots, + }); + } catch (error: any) { + console.error('Error fetching available slots:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.status === 404) { + return res.status(404).json({ error: error.message }); + } + + return res.status(500).json({ error: 'Failed to fetch available slots' }); + } +}; + +/** + * POST /api/counselors/:counselorId/available-slots + * Add available slots for a counselor (protected) + */ +export const addAvailableSlots = async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const { counselorId } = req.params; + const { slots } = req.body; + + // Verify the user is the counselor + const counselor = await CounselorService.getCounselorById(counselorId); + if (counselor.userId.toString() !== userId) { + return res.status(403).json({ error: 'You can only manage your own slots' }); + } + + if (!Array.isArray(slots) || slots.length === 0) { + return res.status(400).json({ error: 'Slots must be a non-empty array' }); + } + + // Validate each slot + const validatedSlots = slots.map((slot: any) => { + const start = new Date(slot.start); + const end = new Date(slot.end); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + throw { status: 400, message: 'Invalid date format in slot' }; + } + + if (start >= end) { + throw { status: 400, message: 'Slot start time must be before end time' }; + } + + return { start, end, isBooked: false }; + }); + + const updated = await CounselorService.addAvailableSlots(counselorId, validatedSlots); + + return res.status(201).json({ + message: 'Slots added successfully', + counselor: updated, + }); + } catch (error: any) { + console.error('Error adding slots:', error); + + if (error.status === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.status === 403) { + return res.status(403).json({ error: error.message }); + } + if (error.status === 404) { + return res.status(404).json({ error: error.message }); + } + + return res.status(500).json({ error: 'Failed to add slots' }); + } +}; diff --git a/packages/recovery-system/backend/src/controllers/journal.controller.ts b/packages/recovery-system/backend/src/controllers/journal.controller.ts index 5e65ff5..d2b2ac6 100644 --- a/packages/recovery-system/backend/src/controllers/journal.controller.ts +++ b/packages/recovery-system/backend/src/controllers/journal.controller.ts @@ -35,7 +35,7 @@ export const createEntry = async (req: Request, res: Response) => { }); const savedEntry = await newJournal.save(); - res.status(201).json(savedEntry); + return res.status(201).json(savedEntry); } catch (error) { console.error('Create journal error:', error); @@ -78,7 +78,7 @@ export const getEntries = async (req: Request, res: Response) => { const total = await Journal.countDocuments(query); - res.status(200).json({ + return res.status(200).json({ entries, pagination: { page: pageNum, @@ -114,7 +114,7 @@ export const getEntryById = async (req: Request, res: Response) => { return res.status(404).json({ message: 'Entry not found' }); } - res.status(200).json(entry); + return res.status(200).json(entry); } catch (error) { console.error('Get entry error:', error); return res.status(500).json({ message: 'Failed to fetch entry', error: error instanceof Error ? error.message : 'Unknown error' }); @@ -156,7 +156,7 @@ export const updateEntry = async (req: Request, res: Response) => { } const updatedEntry = await entry.save(); - res.status(200).json(updatedEntry); + return res.status(200).json(updatedEntry); } catch (error) { console.error('Update entry error:', error); return res.status(500).json({ message: 'Failed to update entry', error: error instanceof Error ? error.message : 'Unknown error' }); @@ -184,7 +184,7 @@ export const deleteEntry = async (req: Request, res: Response) => { return res.status(404).json({ message: 'Entry not found' }); } - res.status(200).json({ message: 'Entry deleted successfully' }); + return res.status(200).json({ message: 'Entry deleted successfully' }); } catch (error) { console.error('Delete entry error:', error); return res.status(500).json({ message: 'Failed to delete entry', error: error instanceof Error ? error.message : 'Unknown error' }); diff --git a/packages/recovery-system/backend/src/middleware/isAuth.ts b/packages/recovery-system/backend/src/middleware/isAuth.ts index ea69454..178a106 100644 --- a/packages/recovery-system/backend/src/middleware/isAuth.ts +++ b/packages/recovery-system/backend/src/middleware/isAuth.ts @@ -1,21 +1,44 @@ import { Request, Response, NextFunction } from "express"; import { auth } from "../lib/auth"; import { fromNodeHeaders } from "better-auth/node"; +import User from "../models/User"; export const isAuth = async (req: Request, res: Response, next: NextFunction) => { try { + // First try to get BetterAuth session const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) }); - if (!session) { + if (session) { + (req as any).user = session.user; + (req as any).session = session.session; + return next(); + } + + // If no BetterAuth session, try custom token format + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).json({ error: "Unauthorized" }); } - (req as any).user = session.user; - (req as any).session = session.session; + const token = authHeader.substring(7); + + try { + // Decode base64 token + const decoded = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + + // Verify user exists + const user = await User.findById(decoded.id); + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } - return next(); + (req as any).user = decoded; + return next(); + } catch (tokenError) { + return res.status(401).json({ error: "Unauthorized" }); + } } catch (error) { return res.status(500).json({ error: "Auth Error" }); } diff --git a/packages/recovery-system/backend/src/models/Booking.ts b/packages/recovery-system/backend/src/models/Booking.ts new file mode 100644 index 0000000..afbd906 --- /dev/null +++ b/packages/recovery-system/backend/src/models/Booking.ts @@ -0,0 +1,67 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed'; + +export interface IBooking extends Document { + userId: mongoose.Types.ObjectId; + counselorId: mongoose.Types.ObjectId; + slotStart: Date; + slotEnd: Date; + fee: number; + status: BookingStatus; + notes?: string; + cancellationReason?: string; + createdAt: Date; + updatedAt: Date; +} + +const BookingSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: 'users', + required: true, + index: true, + }, + counselorId: { + type: Schema.Types.ObjectId, + ref: 'Counselor', + required: true, + index: true, + }, + slotStart: { + type: Date, + required: true, + index: true, + }, + slotEnd: { + type: Date, + required: true, + }, + fee: { + type: Number, + required: true, + min: 0, + }, + status: { + type: String, + enum: ['pending', 'confirmed', 'cancelled', 'completed'], + default: 'confirmed', + }, + notes: { + type: String, + maxlength: 500, + }, + cancellationReason: { + type: String, + maxlength: 500, + }, + }, + { timestamps: true } +); + +// Compound index for user and counselor bookings +BookingSchema.index({ userId: 1, createdAt: -1 }); +BookingSchema.index({ counselorId: 1, slotStart: 1 }); + +export default mongoose.model('Booking', BookingSchema); diff --git a/packages/recovery-system/backend/src/models/Counselor.ts b/packages/recovery-system/backend/src/models/Counselor.ts index c1536f3..bb05535 100644 --- a/packages/recovery-system/backend/src/models/Counselor.ts +++ b/packages/recovery-system/backend/src/models/Counselor.ts @@ -30,6 +30,12 @@ export interface ITimeSlot { end: string; } +export interface IAvailableSlot { + start: Date; + end: Date; + isBooked: boolean; +} + export interface ICredentials { degree: string; license: string; @@ -49,9 +55,12 @@ export interface ICounselor extends Document { specializations: Specialization[]; bio: string; availability?: IAvailability; + availableSlots?: IAvailableSlot[]; rating?: number; + ratingCount?: number; totalSessions: number; profileImage?: string; + hourlyRate: number; isVerified: boolean; isActive: boolean; createdAt: Date; @@ -66,6 +75,15 @@ const TimeSlotSchema = new Schema( { _id: false } ); +const AvailableSlotSchema = new Schema( + { + start: { type: Date, required: true, index: true }, + end: { type: Date, required: true }, + isBooked: { type: Boolean, default: false }, + }, + { _id: false } +); + const CredentialsSchema = new Schema( { degree: { @@ -100,7 +118,7 @@ const CounselorSchema = new Schema( { userId: { type: Schema.Types.ObjectId, - ref: 'User', + ref: 'users', required: true, unique: true, }, @@ -138,6 +156,10 @@ const CounselorSchema = new Schema( min: 1, max: 5, }, + ratingCount: { + type: Number, + default: 0, + }, totalSessions: { type: Number, default: 0, @@ -145,6 +167,16 @@ const CounselorSchema = new Schema( profileImage: { type: String, }, + hourlyRate: { + type: Number, + required: true, + min: 0, + }, + availableSlots: { + type: [AvailableSlotSchema], + default: [], + index: true, + }, isVerified: { type: Boolean, default: false, diff --git a/packages/recovery-system/backend/src/models/Journal.ts b/packages/recovery-system/backend/src/models/Journal.ts index 3cf7374..f86f0b2 100644 --- a/packages/recovery-system/backend/src/models/Journal.ts +++ b/packages/recovery-system/backend/src/models/Journal.ts @@ -12,7 +12,7 @@ export interface IJournal extends Document { } const JournalSchema: Schema = new Schema({ - user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + user: { type: Schema.Types.ObjectId, ref: 'users', required: true }, content: { type: String, required: true }, mood: { type: String, required: true }, triggers: [{ type: String }], diff --git a/packages/recovery-system/backend/src/models/Progress.ts b/packages/recovery-system/backend/src/models/Progress.ts index fc9faf7..5bb1a2f 100644 --- a/packages/recovery-system/backend/src/models/Progress.ts +++ b/packages/recovery-system/backend/src/models/Progress.ts @@ -52,7 +52,7 @@ const ProgressSchema: Schema = new Schema({ // Define user reference (foreign key) userId: { type: Schema.Types.ObjectId, - ref: 'User', + ref: 'users', required: true, unique: true }, diff --git a/packages/recovery-system/backend/src/models/Reminder.ts b/packages/recovery-system/backend/src/models/Reminder.ts index ec04740..dc4ee76 100644 --- a/packages/recovery-system/backend/src/models/Reminder.ts +++ b/packages/recovery-system/backend/src/models/Reminder.ts @@ -17,7 +17,7 @@ export interface IReminder extends Document { const ReminderSchema: Schema = new Schema({ userId: { type: Schema.Types.ObjectId, - ref: 'User', + ref: 'users', required: true, index: true }, diff --git a/packages/recovery-system/backend/src/routes/booking.routes.ts b/packages/recovery-system/backend/src/routes/booking.routes.ts new file mode 100644 index 0000000..88f5e21 --- /dev/null +++ b/packages/recovery-system/backend/src/routes/booking.routes.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { isAuth } from '../middleware/isAuth'; +import { + createBooking, + getUserBookings, + getBookingById, + cancelBooking, + getCounselorAvailableSlots, + addAvailableSlots, +} from '../controllers/booking.controller'; + +const router = Router(); + +// Public routes +router.get('/counselors/:counselorId/available-slots', getCounselorAvailableSlots); + +// Protected routes +router.post('/bookings', isAuth, createBooking); +router.get('/bookings', isAuth, getUserBookings); +router.get('/bookings/:id', isAuth, getBookingById); +router.delete('/bookings/:id', isAuth, cancelBooking); + +// Counselor-only routes +router.post('/counselors/:counselorId/available-slots', isAuth, addAvailableSlots); + +export default router; diff --git a/packages/recovery-system/backend/src/routes/counselor.routes.ts b/packages/recovery-system/backend/src/routes/counselor.routes.ts index f4a40a0..7923e96 100644 --- a/packages/recovery-system/backend/src/routes/counselor.routes.ts +++ b/packages/recovery-system/backend/src/routes/counselor.routes.ts @@ -1,10 +1,16 @@ import { Router } from "express"; -import { createCounselorProfile } from "../controllers/Counselor.controller"; +import { createCounselorProfile, getCounselorProfile, getAllCounselors, getCounselorById } from "../controllers/Counselor.controller"; import { isAuth } from "../middleware/isAuth"; import { isCounselor } from "../middleware/isCounselor"; const router = Router(); +// Public routes +router.get("/counselors", getAllCounselors); +router.get("/counselors/:id", getCounselorById); + +// Protected routes router.post("/counselors", isAuth, isCounselor, createCounselorProfile); +router.get("/counselors/me", isAuth, isCounselor, getCounselorProfile); export default router; diff --git a/packages/recovery-system/backend/src/services/booking.service.ts b/packages/recovery-system/backend/src/services/booking.service.ts new file mode 100644 index 0000000..de8f99b --- /dev/null +++ b/packages/recovery-system/backend/src/services/booking.service.ts @@ -0,0 +1,172 @@ +import Booking, { IBooking } from '../models/Booking'; +import { CounselorService } from './counselor.service'; +import User from '../models/User'; + +export class BookingService { + /** + * Create a new booking + */ + static async createBooking(data: { + userId: string; + counselorId: string; + slotStart: Date; + slotEnd: Date; + fee: number; + notes?: string; + }): Promise { + // Validate slot is available + const isAvailable = await CounselorService.isSlotAvailable( + data.counselorId, + data.slotStart, + data.slotEnd + ); + + if (!isAvailable) { + throw { status: 409, message: 'Selected slot is not available' }; + } + + // Check slot is in the future + if (new Date() > data.slotStart) { + throw { status: 400, message: 'Cannot book a slot in the past' }; + } + + // Verify user exists + const user = await User.findById(data.userId); + if (!user) { + throw { status: 404, message: 'User not found' }; + } + + // Mark slot as booked + await CounselorService.markSlotAsBooked( + data.counselorId, + data.slotStart, + data.slotEnd + ); + + // Create booking + const booking = new Booking({ + userId: data.userId, + counselorId: data.counselorId, + slotStart: data.slotStart, + slotEnd: data.slotEnd, + fee: data.fee, + notes: data.notes, + status: 'confirmed', + }); + + await booking.save(); + return booking; + } + + /** + * Get all bookings for a user + */ + static async getUserBookings(userId: string) { + if (!userId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid user ID format' }; + } + + const bookings = await Booking.find({ userId }) + .sort({ createdAt: -1 }) + .populate('counselorId', 'bio rating totalSessions') + .populate('userId', 'name email'); + + return bookings; + } + + /** + * Get a single booking by ID + */ + static async getBookingById(bookingId: string, userId?: string) { + if (!bookingId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid booking ID format' }; + } + + // First, find the booking regardless of ownership + const booking = await Booking.findOne({ _id: bookingId }) + .populate('counselorId', 'bio rating totalSessions') + .populate('userId', 'name email'); + + if (!booking) { + throw { status: 404, message: 'Booking not found' }; + } + + // Then check ownership if userId is provided + if (userId && booking.userId._id.toString() !== userId) { + throw { status: 403, message: 'You can only view your own bookings' }; + } + + return booking; + } + + /** + * Cancel a booking + */ + static async cancelBooking( + bookingId: string, + userId: string, + cancellationReason?: string + ): Promise { + if (!bookingId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid booking ID format' }; + } + + // First find the booking regardless of ownership (without populating) + const booking = await Booking.findOne({ _id: bookingId }); + + if (!booking) { + throw { status: 404, message: 'Booking not found' }; + } + + // Check ownership + if (booking.userId.toString() !== userId) { + throw { status: 403, message: 'You can only cancel your own bookings' }; + } + + if (booking.status === 'cancelled') { + throw { status: 400, message: 'Booking is already cancelled' }; + } + + // Free up the slot using the raw counselorId + await CounselorService.markSlotAsAvailable( + booking.counselorId.toString(), + booking.slotStart, + booking.slotEnd + ); + + // Update booking status + booking.status = 'cancelled'; + booking.cancellationReason = cancellationReason; + await booking.save(); + + // Return populated version for response + const populatedBooking = await Booking.findById(bookingId) + .populate('counselorId', 'bio rating totalSessions') + .populate('userId', 'name email'); + + return populatedBooking!; + } + + /** + * Get counselor's bookings + */ + static async getCounselorBookings( + counselorId: string, + status?: string + ) { + if (!counselorId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid counselor ID format' }; + } + + const query: any = { counselorId }; + if (status) { + query.status = status; + } + + const bookings = await Booking.find(query) + .sort({ slotStart: 1 }) + .populate('userId', 'name email'); + + return bookings; + } +} diff --git a/packages/recovery-system/backend/src/services/counselor.service.ts b/packages/recovery-system/backend/src/services/counselor.service.ts new file mode 100644 index 0000000..d50e492 --- /dev/null +++ b/packages/recovery-system/backend/src/services/counselor.service.ts @@ -0,0 +1,194 @@ +import Counselor, { ICounselor, IAvailableSlot } from '../models/Counselor'; + +export class CounselorService { + /** + * Get all counselors with optional filters + */ + static async getAllCounselors(filters?: { + specialty?: string; + availability?: string; + isVerified?: boolean; + }) { + const query: any = { isActive: true }; + + if (filters?.specialty) { + query.specializations = filters.specialty.toLowerCase(); + } + + if (filters?.isVerified !== undefined) { + query.isVerified = filters.isVerified; + } + + const counselors = await Counselor.find(query) + .select('-credentials.license -availability') + .populate('userId', 'name email'); + + return counselors; + } + + /** + * Get a single counselor by ID + */ + static async getCounselorById(counselorId: string) { + if (!counselorId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid counselor ID format' }; + } + + const counselor = await Counselor.findById(counselorId) + .select('-credentials.license') + .populate('userId', 'name email'); + + if (!counselor) { + throw { status: 404, message: 'Counselor not found' }; + } + + return counselor; + } + + /** + * Get counselor by userId + */ + static async getCounselorByUserId(userId: string) { + const counselor = await Counselor.findOne({ userId }); + if (!counselor) { + throw { status: 404, message: 'Counselor profile not found' }; + } + return counselor; + } + + /** + * Check if a slot is available and not booked + */ + static async isSlotAvailable( + counselorId: string, + slotStart: Date, + slotEnd: Date + ): Promise { + const counselor = await Counselor.findById(counselorId); + if (!counselor) { + throw { status: 404, message: 'Counselor not found' }; + } + + if (!counselor.availableSlots) { + return false; + } + + // Check if the exact slot exists and is not booked + const slot = counselor.availableSlots.find( + (s: IAvailableSlot) => + s.start.getTime() === slotStart.getTime() && + s.end.getTime() === slotEnd.getTime() && + !s.isBooked + ); + + return !!slot; + } + + /** + * Mark a slot as booked + */ + static async markSlotAsBooked( + counselorId: string, + slotStart: Date, + slotEnd: Date + ): Promise { + try { + const result = await Counselor.findByIdAndUpdate( + counselorId, + { + $set: { + 'availableSlots.$[elem].isBooked': true, + }, + }, + { + arrayFilters: [ + { + 'elem.start': new Date(slotStart), + 'elem.end': new Date(slotEnd), + }, + ], + } + ); + + if (!result) { + throw new Error('Slot not found or already booked'); + } + } catch (error: any) { + throw { status: 400, message: 'Failed to mark slot as booked: ' + error.message }; + } + } + + /** + * Mark a slot as available again + */ + static async markSlotAsAvailable( + counselorId: string, + slotStart: Date, + slotEnd: Date + ): Promise { + try { + // Use $elemMatch for more reliable date comparison + const result = await Counselor.findByIdAndUpdate( + counselorId, + { + $set: { + 'availableSlots.$[elem].isBooked': false, + }, + }, + { + arrayFilters: [ + { + 'elem.start': new Date(slotStart), + 'elem.end': new Date(slotEnd), + }, + ], + } + ); + + if (!result) { + throw new Error('Slot not found'); + } + } catch (error: any) { + throw { status: 400, message: 'Failed to mark slot as available: ' + error.message }; + } + } + + /** + * Get counselor's available slots + */ + static async getAvailableSlots(counselorId: string) { + if (!counselorId.match(/^[0-9a-fA-F]{24}$/)) { + throw { status: 400, message: 'Invalid counselor ID format' }; + } + + const counselor = await Counselor.findById(counselorId).select( + 'availableSlots -_id' + ); + + if (!counselor) { + throw { status: 404, message: 'Counselor not found' }; + } + + return counselor.availableSlots?.filter((slot: IAvailableSlot) => !slot.isBooked) || []; + } + + /** + * Add available slots for a counselor + */ + static async addAvailableSlots( + counselorId: string, + slots: IAvailableSlot[] + ): Promise { + const counselor = await Counselor.findByIdAndUpdate( + counselorId, + { $push: { availableSlots: { $each: slots } } }, + { new: true } + ); + + if (!counselor) { + throw { status: 404, message: 'Counselor not found' }; + } + + return counselor; + } +} diff --git a/packages/recovery-system/frontend/src/app/(dashboard)/counselors/page.tsx b/packages/recovery-system/frontend/src/app/(dashboard)/counselors/page.tsx index 2d7f1fd..be4184d 100644 --- a/packages/recovery-system/frontend/src/app/(dashboard)/counselors/page.tsx +++ b/packages/recovery-system/frontend/src/app/(dashboard)/counselors/page.tsx @@ -62,7 +62,7 @@ export default function CounselorsPage() {
{/* ── Page Body ─────────────────────────────────────────────────────── */} -
+
{/* Breadcrumb — "Portal > Counseling" for navigation context */}
@@ -72,8 +72,8 @@ export default function CounselorsPage() {
{/* Page title + tab toggle in the same row */} -
-

Find Your Guide

+
+

Find Your Guide

@@ -82,9 +82,11 @@ export default function CounselorsPage() { The grid re-renders automatically when filters change (via useMemo). ── */} {activeTab === "find" ? ( -
+
{/* Filter sidebar — receives current filters and updates them via onChange */} - +
+ +
{/* Dynamic result count above the grid */} @@ -106,7 +108,7 @@ export default function CounselorsPage() { ) : ( /* 2-column grid of counselor cards. onBook sets selectedCounselor which triggers the modal to open. */ -
+
{filteredCounselors.map((counselor) => ( - +
+ diff --git a/packages/recovery-system/frontend/src/components/counselors/BookingModal.tsx b/packages/recovery-system/frontend/src/components/counselors/BookingModal.tsx index e5acb70..fa9233d 100644 --- a/packages/recovery-system/frontend/src/components/counselors/BookingModal.tsx +++ b/packages/recovery-system/frontend/src/components/counselors/BookingModal.tsx @@ -38,11 +38,11 @@ export default function BookingModal({ counselor, onClose, onConfirm }: BookingM className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={(e) => e.target === e.currentTarget && onClose()} > -
+
{/* ── Left Panel: Counselor profile summary ── */}
{/* X button in the top-left corner of the modal */} diff --git a/packages/recovery-system/frontend/src/components/counselors/CounselorPageToggle.tsx b/packages/recovery-system/frontend/src/components/counselors/CounselorPageToggle.tsx index 6c9880b..866dccc 100644 --- a/packages/recovery-system/frontend/src/components/counselors/CounselorPageToggle.tsx +++ b/packages/recovery-system/frontend/src/components/counselors/CounselorPageToggle.tsx @@ -17,10 +17,10 @@ export default function CounselorPageToggle({ onTabChange, }: CounselorPageToggleProps) { return ( -
+