diff --git a/.github/workflows/badge-assignment.yml b/.github/workflows/badge-assignment.yml deleted file mode 100644 index ff759626..00000000 --- a/.github/workflows/badge-assignment.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Daily Badge Assignment - -on: - schedule: - # Runs daily at midnight UTC (adjust timezone as needed) - - cron: "0 0 * * *" - - # Allow manual trigger - workflow_dispatch: - -jobs: - assign-badges: - runs-on: ubuntu-latest - - steps: - - name: Call Badge Assignment API - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ - ${{ secrets.APP_URL }}/api/badge-assignments - env: - CRON_SECRET: ${{ secrets.CRON_SECRET }} - APP_URL: ${{ secrets.APP_URL }} diff --git a/.github/workflows/ci-build-check.yml b/.github/workflows/ci-build-check.yml new file mode 100644 index 00000000..b07ea6d5 --- /dev/null +++ b/.github/workflows/ci-build-check.yml @@ -0,0 +1,119 @@ +name: CI - Build and Test + +on: + # Trigger on pull requests to main branch + pull_request: + branches: [ main, master ] + + # Trigger on pushes to main branch (merges) + push: + branches: [ main, master ] + + # Allow manual trigger + workflow_dispatch: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + continue-on-error: false + env: + CI: true + + - name: Build application + run: npm run build + continue-on-error: false + env: + CI: true + + - name: Check build output + run: | + if [ ! -d ".next" ]; then + echo "Build failed - .next directory not found" + exit 1 + fi + echo "Build successful - .next directory exists" + + - name: Upload build artifacts (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs-${{ matrix.node-version }} + path: | + .next/ + npm-debug.log* + yarn-debug.log* + yarn-error.log* + retention-days: 5 + + # Additional job for documentation build (if needed) + build-docs: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install main dependencies + run: npm ci + + - name: Install documentation dependencies + run: | + cd documentation + npm ci + + - name: Build documentation + run: | + cd documentation + npm run build + continue-on-error: false + + # Security and quality checks + security-audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run security audit + run: npm audit --audit-level=high + continue-on-error: true + + - name: Check for outdated packages + run: npm outdated || true diff --git a/.github/workflows/pr-quality-check.yml b/.github/workflows/pr-quality-check.yml new file mode 100644 index 00000000..fe475f59 --- /dev/null +++ b/.github/workflows/pr-quality-check.yml @@ -0,0 +1,32 @@ +name: PR Quality Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + quick-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build check + run: npm run build + env: + CI: true diff --git a/package-lock.json b/package-lock.json index 6c7461e6..1eb79eeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "dompurify": "^3.2.3", "dotenv": "^16.6.1", "formidable": "^3.5.2", - "framer-motion": "^11.15.0", + "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", "googleapis": "^152.0.0", "jotai": "^2.12.5", @@ -8296,6 +8296,8 @@ }, "node_modules/framer-motion": { "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", "license": "MIT", "dependencies": { "motion-dom": "^11.18.1", diff --git a/package.json b/package.json index 27e2cb1f..9ed63573 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "dompurify": "^3.2.3", "dotenv": "^16.6.1", "formidable": "^3.5.2", - "framer-motion": "^11.15.0", + "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", "googleapis": "^152.0.0", "jotai": "^2.12.5", diff --git a/src/app/api/meeting-cron/email/route.ts b/src/app/api/meeting-cron/email/route.ts new file mode 100644 index 00000000..bdceac69 --- /dev/null +++ b/src/app/api/meeting-cron/email/route.ts @@ -0,0 +1,362 @@ +import { NextRequest, NextResponse } from 'next/server'; +import nodemailer from 'nodemailer'; +import Meeting from '@/lib/models/meetingSchema'; +import User from '@/lib/models/userSchema'; +import connect from '@/lib/db'; + +// Create nodemailer transporter using Gmail SMTP +const createTransporter = () => { + return nodemailer.createTransport({ + service: 'gmail', + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + user: process.env.MEETING_NOTI_MAIL, + pass: process.env.MEETING_NOTI_PW, + }, + connectionTimeout: 60000, // 60 seconds + greetingTimeout: 30000, // 30 seconds + socketTimeout: 60000, // 60 seconds + }); +}; + +// Email template for meeting reminder +const createMeetingReminderEmail = ( + userFirstName: string, + userLastName: string, + otherUserFirstName: string, + otherUserLastName: string, + meetingTime: Date, + description: string, + meetingId: string +) => { + const formattedTime = meetingTime.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short' + }); + + // Always use custom meeting link format + const meetingLinkSection = ` +
+

Meeting Link:

+ https://code102.site/meeting/${meetingId} +
+ `; + + return { + subject: `🕐 Meeting Reminder: Your meeting starts soon!`, + html: ` + + + + + + Meeting Reminder + + +
+

⏰ Meeting Starting Soon!

+

Get ready for your skill swap session

+
+ +
+

+ Hi ${userFirstName} ${userLastName}, +

+ +

+ This is a friendly reminder that your meeting with ${otherUserFirstName} ${otherUserLastName} is starting soon! +

+ +
+

📅 Meeting Details

+

Time: ${formattedTime}

+

With: ${otherUserFirstName} ${otherUserLastName}

+

Description: ${description}

+
+ + ${meetingLinkSection} + +
+

💡 Quick Tips for a Great Meeting:

+ +
+ +
+

+ This is an automated reminder from SkillSwap Hub.
+ We hope you have a productive and enjoyable meeting! 🚀 +

+
+
+ +
+

+ © 2024 SkillSwap Hub. All rights reserved.
+ If you have any questions, please contact our support team. +

+
+ + + `, + text: ` +Meeting Reminder - Starting Soon! + +Hi ${userFirstName} ${userLastName}, + +Your meeting with ${otherUserFirstName} ${otherUserLastName} is starting soon! + +Meeting Details: +- Time: ${formattedTime} +- With: ${otherUserFirstName} ${otherUserLastName} +- Description: ${description} + +${`Meeting Link: https://code102.site/meeting/${meetingId}`} + +Quick Tips for a Great Meeting: +• Test your camera and microphone beforehand +• Find a quiet, well-lit space +• Have your materials ready +• Be punctual and respectful of each other's time + +This is an automated reminder from SkillSwap Hub. +We hope you have a productive and enjoyable meeting! + +© 2024 SkillSwap Hub. All rights reserved. + `.trim() + }; +}; + +// Main function to send meeting reminders +async function sendMeetingReminders() { + try { + console.log('🔍 Starting meeting reminder check...'); + + // Calculate time window: current time to current time + 10 minutes + const now = new Date(); + const tenMinutesFromNow = new Date(now.getTime() + 10 * 60 * 1000); + + console.log('⏰ Time window:', { + now: now.toISOString(), + tenMinutesFromNow: tenMinutesFromNow.toISOString() + }); + + // First, let's check all accepted meetings to debug + const allAcceptedMeetings = await Meeting.find({ + state: 'accepted' + }).populate('senderId receiverId'); + + console.log(`📊 Debug: Total accepted meetings found: ${allAcceptedMeetings.length}`); + + // Log details of each accepted meeting for debugging + allAcceptedMeetings.forEach((meeting, index) => { + const meetingTime = new Date(meeting.meetingTime); + const timeDiff = meetingTime.getTime() - now.getTime(); + const minutesDiff = Math.round(timeDiff / (1000 * 60)); + + console.log(`📋 Meeting ${index + 1}:`, { + id: meeting._id, + meetingTime: meetingTime.toISOString(), + timeDifferenceMinutes: minutesDiff, + isInWindow: minutesDiff >= 0 && minutesDiff <= 10 + }); + }); + + // Find accepted meetings within the next 10 minutes (including meetings starting now or in the past but not more than 10 minutes ago) + const upcomingMeetings = await Meeting.find({ + state: 'accepted', + meetingTime: { + $gte: now, // Meeting time is now or in the future + $lte: tenMinutesFromNow // Meeting time is within the next 10 minutes + } + }).populate('senderId receiverId'); + + console.log(`📅 Found ${upcomingMeetings.length} meetings starting within next 10 minutes`); + + if (upcomingMeetings.length === 0) { + return { + success: true, + message: 'No meetings found within the next 10 minutes', + processed: 0, + debug: { + totalAcceptedMeetings: allAcceptedMeetings.length, + timeWindow: { + now: now.toISOString(), + tenMinutesFromNow: tenMinutesFromNow.toISOString() + } + } + }; + } + + // Create transporter + const transporter = createTransporter(); + + // Verify transporter configuration + await transporter.verify(); + console.log('✅ Email transporter verified'); + + let emailsSent = 0; + let errors = []; + + // Process each meeting + for (const meeting of upcomingMeetings) { + try { + console.log(`📧 Processing meeting ${meeting._id}`); + + // Get populated user data + const sender = meeting.senderId as any; + const receiver = meeting.receiverId as any; + + if (!sender || !receiver) { + console.error(`❌ Missing user data for meeting ${meeting._id}`); + errors.push(`Missing user data for meeting ${meeting._id}`); + continue; + } + + console.log('👥 Meeting participants:', { + sender: `${sender.firstName} ${sender.lastName} (${sender.email})`, + receiver: `${receiver.firstName} ${receiver.lastName} (${receiver.email})` + }); + + // Create email content for sender + const senderEmail = createMeetingReminderEmail( + sender.firstName, + sender.lastName, + receiver.firstName, + receiver.lastName, + meeting.meetingTime, + meeting.description, + meeting._id.toString() + ); + + // Create email content for receiver + const receiverEmail = createMeetingReminderEmail( + receiver.firstName, + receiver.lastName, + sender.firstName, + sender.lastName, + meeting.meetingTime, + meeting.description, + meeting._id.toString() + ); + + // Send email to sender + try { + await transporter.sendMail({ + from: `"SkillSwap Hub" <${process.env.MEETING_NOTI_MAIL}>`, + to: sender.email, + subject: senderEmail.subject, + html: senderEmail.html, + text: senderEmail.text + }); + console.log(`✅ Email sent to sender: ${sender.email}`); + emailsSent++; + } catch (emailError: any) { + console.error(`❌ Failed to send email to sender ${sender.email}:`, emailError); + errors.push(`Failed to send email to sender ${sender.email}: ${emailError?.message || 'Unknown error'}`); + } + + // Send email to receiver + try { + await transporter.sendMail({ + from: `"SkillSwap Hub" <${process.env.MEETING_NOTI_MAIL}>`, + to: receiver.email, + subject: receiverEmail.subject, + html: receiverEmail.html, + text: receiverEmail.text + }); + console.log(`✅ Email sent to receiver: ${receiver.email}`); + emailsSent++; + } catch (emailError: any) { + console.error(`❌ Failed to send email to receiver ${receiver.email}:`, emailError); + errors.push(`Failed to send email to receiver ${receiver.email}: ${emailError?.message || 'Unknown error'}`); + } + + // Small delay between emails to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + + } catch (meetingError: any) { + console.error(`❌ Error processing meeting ${meeting._id}:`, meetingError); + errors.push(`Error processing meeting ${meeting._id}: ${meetingError?.message || 'Unknown error'}`); + } + } + + console.log(`🎉 Meeting reminder job completed. Emails sent: ${emailsSent}`); + + return { + success: true, + message: `Meeting reminders processed successfully`, + processed: upcomingMeetings.length, + emailsSent, + errors: errors.length > 0 ? errors : undefined + }; + + } catch (error) { + console.error('❌ Error in sendMeetingReminders:', error); + throw error; + } +} + +// GET endpoint for cron job +export async function GET(request: NextRequest) { + try { + console.log('🚀 Meeting cron job started at:', new Date().toISOString()); + + // Validate System API Key + const apiKey = request.headers.get('x-api-key'); + if (!apiKey || apiKey !== process.env.SYSTEM_API_KEY) { + console.error('❌ Unauthorized: Invalid or missing API key'); + return NextResponse.json({ + success: false, + message: 'Unauthorized: Invalid or missing API key', + timestamp: new Date().toISOString() + }, { status: 401 }); + } + + // Connect to database + await connect(); + + // Verify required environment variables + if (!process.env.MEETING_NOTI_MAIL || !process.env.MEETING_NOTI_PW) { + throw new Error('Missing required email configuration environment variables'); + } + + // Send meeting reminders + const result = await sendMeetingReminders(); + + console.log('✅ Meeting cron job completed successfully:', result); + + return NextResponse.json({ + success: true, + message: 'Meeting reminders cron job executed successfully', + timestamp: new Date().toISOString(), + result + }, { status: 200 }); + + } catch (error: any) { + console.error('❌ Meeting cron job failed:', error); + + return NextResponse.json({ + success: false, + message: 'Meeting reminders cron job failed', + error: error?.message || 'Unknown error', + timestamp: new Date().toISOString() + }, { status: 500 }); + } +} + +// Optional: Add a health check endpoint +export async function HEAD(request: NextRequest) { + return new NextResponse(null, { status: 200 }); +} diff --git a/src/app/api/meeting-notes/user/route.ts b/src/app/api/meeting-notes/user/route.ts new file mode 100644 index 00000000..92d26a91 --- /dev/null +++ b/src/app/api/meeting-notes/user/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connect from '@/lib/db'; +import MeetingNotes from '@/lib/models/meetingNotesSchema'; +import Meeting from '@/lib/models/meetingSchema'; + +export async function GET(req: NextRequest) { + await connect(); + try { + const url = new URL(req.url); + const userId = url.searchParams.get('userId'); + const otherUserId = url.searchParams.get('otherUserId'); + + if (!userId) { + return NextResponse.json({ message: 'Missing userId parameter' }, { status: 400 }); + } + + let query: any = { userId }; + + // If otherUserId is provided, only get notes for meetings with that specific user + if (otherUserId) { + // First find all meetings between these two users + const meetings = await Meeting.find({ + $or: [ + { senderId: userId, receiverId: otherUserId }, + { senderId: otherUserId, receiverId: userId } + ] + }).select('_id'); + + const meetingIds = meetings.map(m => m._id.toString()); + query.meetingId = { $in: meetingIds }; + } + + const notes = await MeetingNotes.find(query) + .sort({ lastModified: -1 }) + .lean(); + + // Process notes - some may have deleted meetings + const processedNotes = []; + + for (const note of notes) { + if (!note.content || note.content.trim().length === 0) { + continue; // Skip empty notes + } + + let meetingInfo = null; + + // Check if note has embedded meeting info (from deleted meetings) + if (note.meetingInfo) { + meetingInfo = note.meetingInfo; + } else { + // Try to populate from existing meeting + try { + const meeting = await Meeting.findById(note.meetingId).lean(); + if (meeting) { + meetingInfo = { + description: (meeting as any).description, + meetingTime: (meeting as any).meetingTime, + senderId: (meeting as any).senderId, + receiverId: (meeting as any).receiverId, + isDeleted: false + }; + } else { + // Meeting was deleted but no embedded info + meetingInfo = { + description: 'Removed Meeting', + meetingTime: note.createdAt, // Fallback to note creation date + senderId: 'unknown', + receiverId: 'unknown', + isDeleted: true + }; + } + } catch (err) { + console.error('Error fetching meeting for note:', err); + meetingInfo = { + description: 'Removed Meeting', + meetingTime: note.createdAt, + senderId: 'unknown', + receiverId: 'unknown', + isDeleted: true + }; + } + } + + processedNotes.push({ + _id: note._id, + meetingId: note.meetingId, + title: note.title, + content: note.content, + tags: note.tags, + wordCount: note.wordCount, + lastModified: note.lastModified, + createdAt: note.createdAt, + isPrivate: note.isPrivate, + meetingInfo + }); + } + + return NextResponse.json(processedNotes); + } catch (error: any) { + console.error('Error fetching user meeting notes:', error); + return NextResponse.json({ message: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/meeting/cancel/route.ts b/src/app/api/meeting/cancel/route.ts index 82df6438..123731b1 100644 --- a/src/app/api/meeting/cancel/route.ts +++ b/src/app/api/meeting/cancel/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import connect from "@/lib/db"; import meetingSchema from "@/lib/models/meetingSchema"; import cancelMeetingSchema from "@/lib/models/cancelMeetingSchema"; -import { cancelMeetingWithReason } from "@/services/meetingApiServices"; export async function POST(req: Request) { await connect(); @@ -35,10 +34,81 @@ export async function POST(req: Request) { ); } - // Use service function to handle cancellation with notification - const result = await cancelMeetingWithReason(meetingId, cancelledBy, reason.trim()); + // Check if meeting is too close to start time or currently in progress + const now = new Date(); + const meetingTime = new Date(meeting.meetingTime); + const tenMinutesBefore = new Date(meetingTime.getTime() - 10 * 60 * 1000); // 10 minutes before + const thirtyMinutesAfter = new Date(meetingTime.getTime() + 30 * 60 * 1000); // 30 minutes after - return NextResponse.json(result, { status: 200 }); + if (meeting.state === 'accepted' && now >= tenMinutesBefore && now <= thirtyMinutesAfter) { + const timeUntilMeeting = meetingTime.getTime() - now.getTime(); + const timeAfterMeeting = now.getTime() - meetingTime.getTime(); + + let message; + if (timeUntilMeeting > 0) { + const minutesUntil = Math.ceil(timeUntilMeeting / (1000 * 60)); + message = `Cannot cancel meeting. The meeting starts in ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}. Meetings cannot be cancelled within 10 minutes of the start time.`; + } else { + const minutesAfter = Math.floor(timeAfterMeeting / (1000 * 60)); + message = `Cannot cancel meeting. The meeting started ${minutesAfter} minute${minutesAfter === 1 ? '' : 's'} ago and may still be in progress. Meetings cannot be cancelled for up to 30 minutes after the start time.`; + } + + return NextResponse.json( + { message }, + { status: 400 } + ); + } + + // Update meeting state to cancelled + meeting.state = 'cancelled'; + const updatedMeeting = await meeting.save(); + + // Create cancellation record + const cancellation = new cancelMeetingSchema({ + meetingId: meetingId, + cancelledBy: cancelledBy, + reason: reason.trim(), + cancelledAt: new Date() + }); + + await cancellation.save(); + + // Send notification to the other user + try { + const otherUserId = meeting.senderId.toString() === cancelledBy ? meeting.receiverId : meeting.senderId; + + // Get canceller's name for notification + const cancellerResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/users/profile?id=${cancelledBy}`); + let cancellerName = 'Unknown User'; + + if (cancellerResponse.ok) { + const cancellerData = await cancellerResponse.json(); + if (cancellerData.success && cancellerData.user) { + cancellerName = `${cancellerData.user.firstName} ${cancellerData.user.lastName}`; + } + } + + // Send notification + const notificationResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/notification`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: otherUserId, + typeno: 5, // Meeting cancellation notification + description: `${cancellerName} has cancelled your meeting scheduled for ${new Date(meeting.meetingTime).toLocaleDateString()}. Reason: ${reason.trim()}`, + targetDestination: '/dashboard' + }) + }); + + if (!notificationResponse.ok) { + console.warn('Failed to send cancellation notification'); + } + } catch (notificationError) { + console.error('Error sending cancellation notification:', notificationError); + // Don't fail the cancellation if notification fails + } + + return NextResponse.json({ meeting: updatedMeeting }, { status: 200 }); } catch (error: any) { console.error('Error cancelling meeting:', error); diff --git a/src/app/api/meeting/route.ts b/src/app/api/meeting/route.ts index ca370831..a9f3e0ae 100644 --- a/src/app/api/meeting/route.ts +++ b/src/app/api/meeting/route.ts @@ -105,13 +105,23 @@ export async function GET(req: Request) { let query = {}; if (userId && otherUserId) { + // Fetch meetings between two specific users query = { $or: [ { senderId: userId, receiverId: otherUserId }, { senderId: otherUserId, receiverId: userId } ] }; + } else if (userId) { + // Fetch all meetings for a specific user (where they are either sender or receiver) + query = { + $or: [ + { senderId: userId }, + { receiverId: userId } + ] + }; } + // If no userId is provided, return empty array (don't return all meetings) const meetings = await meetingSchema.find(query); return NextResponse.json(meetings, { status: 200 }); @@ -171,34 +181,21 @@ export async function POST(req: Request) { export async function PATCH(req: Request) { await connect(); try { - const meetingData = await req.json(); - console.log('PATCH request received:', meetingData); - + const meetingData = await req.json(); const meeting = await meetingSchema.findById(meetingData._id); if (!meeting) { console.error('Meeting not found:', meetingData._id); return NextResponse.json({ message: "Meeting not found" }, { status: 404 }); } - - console.log('Found meeting:', meeting.state, meeting.acceptStatus); - - // ! Handle acceptance of pending meetings if (meeting.state === "pending" && meetingData.acceptStatus) { - console.log('Accepting meeting and creating Daily.co room...'); - - // First test the API connectivity const apiWorking = await testDailyAPI(); - try { const dailyRoomUrl = await createDailyRoom(meeting.meetingTime); meeting.state = "accepted"; meeting.meetingLink = dailyRoomUrl; - meeting.acceptStatus = true; - - console.log('Meeting accepted with Daily.co room:', dailyRoomUrl); - + meeting.acceptStatus = true; } catch (dailyError) { console.error('Daily.co integration failed, but continuing with meeting acceptance:', dailyError); diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index d9d948a4..ca71727e 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -28,15 +28,8 @@ export async function POST(req: Request) { // Check if content is a file link and skip encryption if it is const isFileLink = content.startsWith('File:'); - // Check if content is already encrypted (coming from socket) by checking if it's valid Base64 - // and doesn't contain readable text patterns - const isAlreadyEncrypted = !isFileLink && - content.match(/^[A-Za-z0-9+/=]+$/) && - content.length > 20 && // Encrypted content is typically longer - !content.match(/\s/); // Encrypted content doesn't contain spaces - - const encryptedContent: string = isFileLink ? content : - (isAlreadyEncrypted ? content : encryptMessage(content)); + // Now all content comes as plain text from client, so always encrypt (except files) + const encryptedContent: string = isFileLink ? content : encryptMessage(content); if (!chatRoomId || !senderId || !content) { return NextResponse.json( @@ -88,8 +81,8 @@ export async function POST(req: Request) { lastMessageContent = "File"; } } else { - // Decrypt the content first, then truncate to 18 chars - const decryptedContent = isAlreadyEncrypted ? decryptMessage(content) : content; + // Since we now always encrypt on server, decrypt the encrypted content + const decryptedContent = decryptMessage(encryptedContent); lastMessageContent = decryptedContent.length > 18 ? decryptedContent.substring(0, 18) + '...' : decryptedContent; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5ef893f3..d18d2d14 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { AuthProvider } from "@/lib/context/AuthContext"; import { SocketProvider } from "@/lib/context/SocketContext"; import { ToastProvider } from "@/lib/context/ToastContext"; +import { ReactNode } from "react"; import NotificationAlert from "@/components/notificationSystem/NotificationAlert"; import { SuspendedPopupProvider } from "@/components/ui/SuspendedPopup"; import "./globals.css"; @@ -25,7 +26,7 @@ export const metadata: Metadata = { export default function RootLayout({ children, }: { - children: React.ReactNode; + children: ReactNode; }) { return ( diff --git a/src/components/User/DashboardContent/MeetingContent.tsx b/src/components/User/DashboardContent/MeetingContent.tsx index 3470b2ce..ff6cc2d5 100644 --- a/src/components/User/DashboardContent/MeetingContent.tsx +++ b/src/components/User/DashboardContent/MeetingContent.tsx @@ -1,12 +1,23 @@ "use client"; import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { Search, Calendar, Filter } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import { Search, Calendar, Filter, FileText, ChevronDown, ChevronRight } from 'lucide-react'; import CancelMeetingModal from '@/components/meetingSystem/CancelMeetingModal'; import MeetingList from '@/components/meetingSystem/MeetingList'; +import SavedNotesList from '@/components/meetingSystem/SavedNotesList'; +import NotesViewModal from '@/components/meetingSystem/NotesViewModal'; import Meeting from '@/types/meeting'; -import { fetchAllUserMeetings, updateMeeting, filterMeetingsByType, checkMeetingNotesExist } from "@/services/meetingApiServices"; +import { + fetchAllUserMeetings, + updateMeeting, + filterMeetingsByType, + checkMeetingNotesExist, + cancelMeetingWithReason, + fetchAllUserMeetingNotes, + canCancelMeeting +} from "@/services/meetingApiServices"; +import { fetchUserProfile } from "@/services/chatApiServices"; +import { invalidateUsersCaches } from '@/services/sessionApiServices'; import { debouncedApiService } from '@/services/debouncedApiService'; import Alert from '@/components/ui/Alert'; import ConfirmationDialog from '@/components/ui/ConfirmationDialog'; @@ -31,9 +42,27 @@ interface CancellationAlert { meetingTime: string; } +interface MeetingNote { + _id: string; + meetingId: string; + title: string; + content: string; + tags: string[]; + wordCount: number; + lastModified: string; + createdAt: string; + isPrivate: boolean; + meetingInfo?: { + description: string; + meetingTime: string; + senderId: string; + receiverId: string; + isDeleted?: boolean; + }; +} + export default function MeetingContent() { const { user } = useAuth(); - const router = useRouter(); const [meetings, setMeetings] = useState([]); const [loading, setLoading] = useState(true); const [userProfiles, setUserProfiles] = useState({}); @@ -43,6 +72,14 @@ export default function MeetingContent() { const [showCancelledMeetings, setShowCancelledMeetings] = useState(false); const [meetingNotesStatus, setMeetingNotesStatus] = useState<{[meetingId: string]: boolean}>({}); const [checkingNotes, setCheckingNotes] = useState<{[meetingId: string]: boolean}>({}); + const [actionLoadingStates, setActionLoadingStates] = useState<{[meetingId: string]: string}>({}); + + // Saved notes states + const [savedNotes, setSavedNotes] = useState([]); + const [loadingSavedNotes, setLoadingSavedNotes] = useState(false); + const [showSavedNotes, setShowSavedNotes] = useState(false); + const [selectedNote, setSelectedNote] = useState(null); + const [showNotesModal, setShowNotesModal] = useState(false); // Search and filter states const [searchTerm, setSearchTerm] = useState(''); @@ -113,6 +150,21 @@ export default function MeetingContent() { setConfirmation(prev => ({ ...prev, isOpen: false })); }; + // Check if a meeting is currently happening (in non-cancellation period) + const isMeetingHappening = (meeting: Meeting): boolean => { + if (meeting.state !== 'accepted') return false; + + const now = new Date(); + const meetingTime = new Date(meeting.meetingTime); + const tenMinutesBefore = new Date(meetingTime.getTime() - 10 * 60 * 1000); // 10 minutes before + const thirtyMinutesAfter = new Date(meetingTime.getTime() + 30 * 60 * 1000); // 30 minutes after + + return now >= tenMinutesBefore && now <= thirtyMinutesAfter; + }; + + // Get meetings that are currently happening + const currentlyHappeningMeetings = meetings.filter(isMeetingHappening); + // Fetch all meetings for the authenticated user const fetchMeetingsData = useCallback(async () => { if (!user?._id) return; @@ -120,7 +172,13 @@ export default function MeetingContent() { try { setLoading(true); const data = await fetchAllUserMeetings(user._id); - setMeetings(data); + + // Additional safety filter to ensure we only show meetings involving the authenticated user + const userMeetings = data.filter(meeting => + meeting.senderId === user._id || meeting.receiverId === user._id + ); + + setMeetings(userMeetings); } catch (error) { console.error('Error fetching meetings:', error); showAlert('error', 'Failed to load meetings'); @@ -149,9 +207,7 @@ export default function MeetingContent() { const profileData = await debouncedApiService.makeRequest( cacheKey, async () => { - const res = await fetch(`/api/users/profile?id=${id}`); - const data = await res.json(); - return data.success ? data.user : null; + return await fetchUserProfile(id); }, 60000 // 1 minute cache for profiles ); @@ -208,22 +264,14 @@ export default function MeetingContent() { // Check notes for each meeting with caching for (const meeting of pastAndCompletedMeetings) { - const cacheKey = `notes-check-${meeting._id}-${user._id}`; - try { - const hasNotes = await debouncedApiService.makeRequest( - cacheKey, - async () => checkMeetingNotesExist(meeting._id, user._id), - 300000 // Cache for 5 minutes - ); - + const hasNotes = await checkMeetingNotesExist(meeting._id, user._id); notesStatus[meeting._id] = hasNotes; } catch (error) { console.error(`Error checking notes for meeting ${meeting._id}:`, error); notesStatus[meeting._id] = false; } finally { checking[meeting._id] = false; - setCheckingNotes(prev => ({...prev, [meeting._id]: false})); } } @@ -231,10 +279,135 @@ export default function MeetingContent() { setCheckingNotes(checking); }, [user?._id]); + // Fetch saved notes for all meetings + const fetchSavedNotes = useCallback(async () => { + if (!user?._id) return; + + try { + setLoadingSavedNotes(true); + const notes = await fetchAllUserMeetingNotes(user._id); + setSavedNotes(notes || []); + } catch (error) { + console.error('Error fetching saved notes:', error); + setSavedNotes([]); + } finally { + setLoadingSavedNotes(false); + } + }, [user?._id]); + + // Handle viewing notes + const handleViewNotes = (note: MeetingNote) => { + setSelectedNote(note); + setShowNotesModal(true); + }; + + // Handle downloading notes + const handleDownloadNotes = async (note: MeetingNote) => { + try { + // Create markdown content directly from the note data + const meetingTitle = note.meetingInfo?.description || note.title; + const meetingDate = note.meetingInfo?.meetingTime || note.createdAt; + + // Create a well-formatted markdown document + const formattedDate = new Date(meetingDate).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + const formattedTime = new Date(meetingDate).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + + // Clean up the content + let formattedContent = note.content + .replace(/^## (.*$)/gm, '## $1') + .replace(/^# (.*$)/gm, '# $1') + .replace(/^> (.*$)/gm, '> $1') + .replace(/^- (.*$)/gm, '- $1') + .trim(); + + const markdownDocument = `# Meeting Notes + +--- + +## Meeting Information + +- **Meeting:** ${note.title} +- **Date:** ${formattedDate} +- **Time:** ${formattedTime} +- **Meeting ID:** \`${note.meetingId}\`${note.meetingInfo?.isDeleted ? '\n- **Status:** ⚠️ Meeting Removed from System' : ''} + +--- + +## Content + +${formattedContent} + +--- + +## Meeting Details + +- **Word Count:** ${note.wordCount} +- **Tags:** ${note.tags?.join(', ') || 'None'} +- **Created:** ${new Date(note.createdAt).toLocaleDateString()} +- **Last Updated:** ${new Date(note.lastModified).toLocaleDateString()} +- **Privacy:** ${note.isPrivate ? 'Private' : 'Public'}${note.meetingInfo?.isDeleted ? '\n- **Note:** Original meeting has been removed from the system but notes are preserved' : ''} + +--- + +*Generated by SkillSwap Hub - Meeting Notes System* + `; + + // Create and trigger download + const blob = new Blob([markdownDocument], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const fileName = `meeting-notes-${note.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}-${new Date(meetingDate).toISOString().split('T')[0]}.md`; + + // Create download link + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = fileName; + + // Add to DOM, click, and remove + document.body.appendChild(a); + a.click(); + + // Cleanup + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + + showAlert('success', 'Notes downloaded successfully!'); + } catch (error: any) { + console.error('Error downloading notes:', error); + showAlert('error', 'Failed to download notes'); + } + }; + // Initial data fetch useEffect(() => { fetchMeetingsData(); - }, [fetchMeetingsData]); + fetchSavedNotes(); + }, [fetchMeetingsData, fetchSavedNotes]); + + // Update meeting statuses every minute to refresh currently happening meetings + useEffect(() => { + if (meetings.length === 0) return; + + const interval = setInterval(() => { + // Force a re-render to update currently happening meetings + // The isMeetingHappening function will recalculate based on current time + setMeetings(prevMeetings => [...prevMeetings]); + }, 60000); // Check every minute + + return () => clearInterval(interval); + }, [meetings.length]); // Fetch user profiles when meetings change useEffect(() => { @@ -264,6 +437,11 @@ export default function MeetingContent() { // Meeting action handler const handleMeetingAction = async (meetingId: string, action: 'accept' | 'reject' | 'cancel') => { + // Prevent multiple clicks by checking if action is already in progress + if (actionLoadingStates[meetingId]) { + return; + } + const actionText = action === 'accept' ? 'accept' : action === 'reject' ? 'decline' : 'cancel'; const confirmationTitle = action === 'accept' ? 'Accept Meeting' : action === 'reject' ? 'Decline Meeting' : 'Cancel Meeting'; @@ -275,6 +453,9 @@ export default function MeetingContent() { confirmationMessage, async () => { try { + // Set loading state for this specific meeting and action + setActionLoadingStates(prev => ({ ...prev, [meetingId]: action })); + const updatedMeeting = await updateMeeting(meetingId, action); if (updatedMeeting) { @@ -293,6 +474,13 @@ export default function MeetingContent() { } catch (error) { console.error(`Error ${action}ing meeting:`, error); showAlert('error', `Failed to ${actionText} meeting`); + } finally { + // Clear loading state + setActionLoadingStates(prev => { + const newState = { ...prev }; + delete newState[meetingId]; + return newState; + }); } }, confirmationType, @@ -307,41 +495,45 @@ export default function MeetingContent() { if (!user?._id) return; try { - const response = await fetch('/api/meeting/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - meetingId, - cancelledBy: user._id, - reason - }), - }); - - if (!response.ok) { - throw new Error(`Error cancelling meeting: ${response.status}`); - } + const meeting = await cancelMeetingWithReason(meetingId, user._id, reason); - const { meeting } = await response.json(); - - // Update meetings state - setMeetings(prevMeetings => - prevMeetings.map(m => m._id === meetingId ? meeting : m) - ); + if (meeting) { + setMeetings(prevMeetings => + prevMeetings.map(m => m._id === meetingId ? meeting : m) + ); - setShowCancelModal(false); - setMeetingToCancel(null); - showAlert('success', 'Meeting cancelled successfully'); - - // Refresh data - fetchMeetingsData(); - } catch (error) { + setShowCancelModal(false); + setMeetingToCancel(null); + showAlert('success', 'Meeting cancelled successfully'); + + // Refresh data + fetchMeetingsData(); + } else { + showAlert('error', 'Failed to cancel meeting'); + } + } catch (error: any) { console.error('Error cancelling meeting:', error); - showAlert('error', 'Failed to cancel meeting'); + const errorMessage = error.message || 'Failed to cancel meeting'; + showAlert('error', errorMessage); } }; // Show cancel modal const showCancelMeetingModal = (meetingId: string) => { + // Find the meeting to check if it can be cancelled + const meeting = meetings.find(m => m._id === meetingId); + if (!meeting) { + showAlert('error', 'Meeting not found'); + return; + } + + // Check if meeting can be cancelled + const { canCancel, reason } = canCancelMeeting(meeting); + if (!canCancel) { + showAlert('warning', reason!, 'Cannot Cancel Meeting'); + return; + } + setMeetingToCancel(meetingId); setShowCancelModal(true); }; @@ -415,7 +607,7 @@ export default function MeetingContent() { const cancelledMeetings = filteredMeetings.cancelledMeetings; // Check if there are any active meetings or requests - const hasActiveMeetingsOrRequests = pendingRequests.length > 0 || upcomingMeetings.length > 0; + const hasActiveMeetingsOrRequests = pendingRequests.length > 0 || upcomingMeetings.length > 0 || currentlyHappeningMeetings.length > 0; if (loading && meetings.length === 0) { return ( @@ -501,12 +693,21 @@ export default function MeetingContent() { Try adjusting your search or filter criteria

+ ) : (pendingRequests.length === 0 && upcomingMeetings.length === 0 && pastMeetings.length === 0 && cancelledMeetings.length === 0) ? ( +
+ +

No meetings scheduled

+

+ Connect with other users and schedule meetings through skill matches in the chat system +

+
) : ( {}} // No create meeting in this view - it shows all meetings onMeetingAction={handleMeetingAction} onCancelMeeting={showCancelMeetingModal} onAlert={showAlert} onTogglePastMeetings={() => setShowPastMeetings(!showPastMeetings)} onToggleCancelledMeetings={() => setShowCancelledMeetings(!showCancelledMeetings)} + showCreateMeetingButton={false} // Hide create meeting button in dashboard view /> )} + {/* Saved Meeting Notes - Collapsible */} +
+
+ + {showSavedNotes && ( +
+ +
+ )} +
+
+ {/* Modals */} {showCancelModal && meetingToCancel && ( + + {/* Notes View Modal */} + {showNotesModal && selectedNote && ( + { + setShowNotesModal(false); + setSelectedNote(null); + }} + onDownload={handleDownloadNotes} + /> + )} ); } diff --git a/src/components/homepage/Testimonial.tsx b/src/components/homepage/Testimonial.tsx index d6c11104..02e17ccf 100644 --- a/src/components/homepage/Testimonial.tsx +++ b/src/components/homepage/Testimonial.tsx @@ -254,7 +254,8 @@ const SuccessStories: FC = () => { {/* CSS for animations and scrollbar hiding */} - + ` + }} /> ); }; diff --git a/src/components/meetingSystem/DailyMeeting.tsx b/src/components/meetingSystem/DailyMeeting.tsx index 4dcc51c0..c74ab422 100644 --- a/src/components/meetingSystem/DailyMeeting.tsx +++ b/src/components/meetingSystem/DailyMeeting.tsx @@ -26,9 +26,11 @@ import { Users, Maximize, Minimize, - StickyNote + StickyNote, + HelpCircle } from 'lucide-react'; import { MeetingNotesSidebar } from './MeetingNotesSidebar'; +import { MediaDeviceTips } from './MediaDeviceTips'; interface DailyMeetingProps { roomUrl: string; @@ -94,17 +96,32 @@ function DailyMeetingInner({ // UI State const [isFullscreen, setIsFullscreen] = useState(false); const [showNotes, setShowNotes] = useState(false); + const [showTips, setShowTips] = useState(false); const videoContainerRef = useRef(null); - // Meeting control functions + // Meeting control functions with enhanced error handling const toggleAudio = useCallback(async () => { if (!daily) return; - await daily.setLocalAudio(!localParticipant?.audio); + + try { + await daily.setLocalAudio(!localParticipant?.audio); + } catch (error) { + console.error('Audio toggle failed:', error); + // Show user-friendly error message + alert('Unable to access microphone. It might be in use by another application.'); + } }, [daily, localParticipant?.audio]); const toggleVideo = useCallback(async () => { if (!daily) return; - await daily.setLocalVideo(!localParticipant?.video); + + try { + await daily.setLocalVideo(!localParticipant?.video); + } catch (error) { + console.error('Video toggle failed:', error); + // Show user-friendly error message + alert('Unable to access camera. It might be in use by another browser tab or application.'); + } }, [daily, localParticipant?.video]); const toggleScreenShare = useCallback(async () => { @@ -133,6 +150,36 @@ function DailyMeetingInner({ } }, [daily, onLeave]); + // Function to join meeting with fallback options + const joinMeetingWithFallback = useCallback(async () => { + if (!daily) return; + + try { + // First try with camera and microphone + await daily.setLocalVideo(true); + await daily.setLocalAudio(true); + } catch (error) { + console.warn('Could not enable camera/microphone, trying audio-only mode:', error); + try { + // Fallback to audio-only if camera fails + await daily.setLocalVideo(false); + await daily.setLocalAudio(true); + } catch (audioError) { + console.warn('Audio also failed, joining in listen-only mode:', audioError); + // Last resort: join without any media + await daily.setLocalVideo(false); + await daily.setLocalAudio(false); + } + } + }, [daily]); + + // Call this when meeting state changes to joined + useEffect(() => { + if (meetingState === 'joined-meeting') { + joinMeetingWithFallback(); + } + }, [meetingState, joinMeetingWithFallback]); + // Toggle fullscreen const toggleFullscreen = useCallback(() => { if (!videoContainerRef.current) return; @@ -247,6 +294,14 @@ function DailyMeetingInner({
+
); } @@ -443,32 +504,146 @@ export default function DailyMeeting({ meetingDescription }: DailyMeetingProps) { const [callObject, setCallObject] = useState(null); + const [joinError, setJoinError] = useState(null); + const [isJoining, setIsJoining] = useState(true); useEffect(() => { + let isMounted = true; + import('@daily-co/daily-js').then((DailyIframe) => { - const call = DailyIframe.default.createCallObject(); + if (!isMounted) return; + + const call = DailyIframe.default.createCallObject({ + // Enhanced configuration for better device handling + videoSource: 'camera', + audioSource: 'microphone', + // Try to gracefully handle device conflicts + startVideoOff: false, + startAudioOff: false, + }); + + // Listen for join events + call.on('joined-meeting', () => { + if (isMounted) { + setIsJoining(false); + setJoinError(null); + } + }); + + call.on('error', (error: any) => { + if (isMounted) { + console.error('Daily meeting error:', error); + setIsJoining(false); + + // Handle specific error types + if (error.type === 'cam-in-use' || error.errorMsg?.includes('camera')) { + setJoinError('Camera is already in use by another tab or application. Please close other video calls and try again.'); + } else if (error.type === 'mic-in-use' || error.errorMsg?.includes('microphone')) { + setJoinError('Microphone is already in use by another tab or application. Please close other audio calls and try again.'); + } else if (error.errorMsg?.includes('Permission denied')) { + setJoinError('Camera and microphone access denied. Please allow permissions and refresh the page.'); + } else { + setJoinError('Failed to join meeting. Please try again or check your connection.'); + } + } + }); + setCallObject(call); - // Join the meeting + // Join the meeting with enhanced error handling call.join({ url: roomUrl, userName: userName || 'Anonymous User', }).catch((error: any) => { - console.error('Failed to join meeting:', error); + if (isMounted) { + console.error('Failed to join meeting:', error); + setIsJoining(false); + + // Parse error messages for better user feedback + const errorMessage = error.message || error.toString(); + if (errorMessage.includes('camera') || errorMessage.includes('video')) { + setJoinError('Camera access failed. Another browser tab might be using your camera. Please close other video calls and try again.'); + } else if (errorMessage.includes('microphone') || errorMessage.includes('audio')) { + setJoinError('Microphone access failed. Another application might be using your microphone.'); + } else if (errorMessage.includes('permission')) { + setJoinError('Please allow camera and microphone permissions to join the meeting.'); + } else { + setJoinError('Unable to connect to the meeting. Please check your internet connection and try again.'); + } + } }); return () => { - call.destroy(); + if (call && !call.isDestroyed()) { + call.destroy(); + } }; }); + + return () => { + isMounted = false; + }; }, [roomUrl, userName]); - if (!callObject) { + // Show error state with helpful message and retry option + if (joinError) { + return ( +
+
+
+
+ +
+

Unable to Join Meeting

+

{joinError}

+ +
+

💡 Quick Solutions:

+
    +
  • • Close other browser tabs using your camera/microphone
  • +
  • • Close video calling apps (Zoom, Teams, Skype, etc.)
  • +
  • • Refresh this page and allow camera permissions
  • +
  • • Try using a different browser or incognito mode
  • +
+
+
+ +
+ + + +
+
+
+ ); + } + + if (!callObject || isJoining) { return (
-

Initializing meeting...

+

+ {!callObject ? 'Initializing meeting...' : 'Connecting to meeting...'} +

+

+ Make sure no other tabs are using your camera or microphone +

); diff --git a/src/components/meetingSystem/MediaDeviceTips.tsx b/src/components/meetingSystem/MediaDeviceTips.tsx new file mode 100644 index 00000000..5b9a1674 --- /dev/null +++ b/src/components/meetingSystem/MediaDeviceTips.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle, X, Camera, Mic, Monitor, Smartphone } from 'lucide-react'; + +interface MediaDeviceTipsProps { + isVisible: boolean; + onClose: () => void; +} + +export function MediaDeviceTips({ isVisible, onClose }: MediaDeviceTipsProps) { + if (!isVisible) return null; + + return ( +
+
+
+ {/* Header */} +
+
+ +

Camera & Microphone Tips

+
+ +
+ + {/* Content */} +
+
+

⚠️ Multiple Browser Limitation

+

+ Only one browser tab can access your camera and microphone at the same time. + This is a security feature built into web browsers. +

+
+ +
+

If you can't access camera/microphone:

+ +
+
+ +
+

Close Other Browser Tabs

+

Close any other tabs that might be using your camera (video calls, other meetings)

+
+
+ +
+ +
+

Close Video Applications

+

Close apps like Zoom, Teams, Skype, or any camera software

+
+
+ +
+ +
+

Check Browser Permissions

+

Make sure you've allowed camera and microphone access for this website

+
+
+ +
+ +
+

Try Different Browser

+

If issues persist, try opening the meeting in a different browser or incognito mode

+
+
+
+
+ +
+

💡 Pro Tips:

+
    +
  • • Use one browser for video calls only
  • +
  • • Keep other video apps closed during meetings
  • +
  • • Refresh the page if you encounter permission issues
  • +
  • • Use headphones to prevent echo
  • +
+
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/meetingSystem/MediaDeviceWarning.tsx b/src/components/meetingSystem/MediaDeviceWarning.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/meetingSystem/MeetingItem.tsx b/src/components/meetingSystem/MeetingItem.tsx index db793a9f..3af9ed09 100644 --- a/src/components/meetingSystem/MeetingItem.tsx +++ b/src/components/meetingSystem/MeetingItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Calendar, Clock, Download, Video, XCircle } from 'lucide-react'; import Meeting from '@/types/meeting'; -import { getMeetingStatus, downloadMeetingNotesFile } from '@/services/meetingApiServices'; +import { getMeetingStatus, downloadMeetingNotesFile, canCancelMeeting } from '@/services/meetingApiServices'; import OptimizedAvatar from '@/components/ui/OptimizedAvatar'; interface UserProfile { @@ -12,11 +12,12 @@ interface UserProfile { interface MeetingItemProps { meeting: Meeting; - type: 'pending' | 'upcoming' | 'past' | 'cancelled'; + type: 'pending' | 'upcoming' | 'past' | 'cancelled' | 'happening'; userId: string; userProfiles: { [userId: string]: UserProfile }; meetingNotesStatus: { [meetingId: string]: boolean }; checkingNotes: { [meetingId: string]: boolean }; + actionLoadingStates: { [meetingId: string]: string }; onMeetingAction: (meetingId: string, action: 'accept' | 'reject' | 'cancel') => void; onCancelMeeting: (meetingId: string) => void; onAlert: (type: 'success' | 'error' | 'warning' | 'info', message: string) => void; @@ -29,6 +30,7 @@ export default function MeetingItem({ userProfiles, meetingNotesStatus, checkingNotes, + actionLoadingStates, onMeetingAction, onCancelMeeting, onAlert @@ -40,10 +42,23 @@ export default function MeetingItem({ : otherUserProfile?.firstName || 'User'; const isPendingReceiver = type === 'pending' && meeting.receiverId === userId; - const canCancel = type === 'upcoming' && (meeting.senderId === userId || meeting.state === 'accepted'); + const basicCanCancel = type === 'upcoming' && (meeting.senderId === userId || meeting.state === 'accepted'); + + // Check timing restrictions for cancellation + const { canCancel: timingAllowsCancel, reason: cancelRestrictionReason } = canCancelMeeting(meeting); + const canCancel = basicCanCancel && timingAllowsCancel; const status = getMeetingStatus(meeting, userId); + // Handle cancel button click with timing validation + const handleCancelClick = () => { + if (!timingAllowsCancel && cancelRestrictionReason) { + onAlert('warning', cancelRestrictionReason); + return; + } + onCancelMeeting(meeting._id); + }; + // Format date and time utilities const formatDate = (date: string | Date) => { return new Date(date).toLocaleDateString('en-US', { @@ -87,7 +102,19 @@ export default function MeetingItem({ }; return ( -
+
+ {/* Live indicator for happening meetings */} + {type === 'happening' && ( +
+
+ 🔴 Meeting in Progress +
+ )} + {/* Header */}
@@ -174,35 +201,59 @@ export default function MeetingItem({ <> )} - {/* Join meeting button - only for upcoming meetings */} - {type === 'upcoming' && meeting.state === 'accepted' && meeting.meetingLink && ( + {/* Join meeting button - for upcoming and happening meetings */} + {(type === 'upcoming' || type === 'happening') && meeting.state === 'accepted' && meeting.meetingLink && ( )} {/* Cancel button */} - {canCancel && ( + {basicCanCancel && ( diff --git a/src/components/meetingSystem/MeetingList.tsx b/src/components/meetingSystem/MeetingList.tsx index 1473e409..aab6260f 100644 --- a/src/components/meetingSystem/MeetingList.tsx +++ b/src/components/meetingSystem/MeetingList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AlertCircle, Calendar, CheckCircle, ChevronDown, ChevronRight, Clock, Plus, XCircle } from 'lucide-react'; +import { AlertCircle, Calendar, CheckCircle, ChevronDown, ChevronRight, Clock, Plus, XCircle, Radio } from 'lucide-react'; import Meeting from '@/types/meeting'; import MeetingItem from './MeetingItem'; @@ -14,6 +14,7 @@ interface MeetingListProps { upcomingMeetings: Meeting[]; pastMeetings: Meeting[]; cancelledMeetings: Meeting[]; + currentlyHappeningMeetings: Meeting[]; hasActiveMeetingsOrRequests: boolean; showPastMeetings: boolean; showCancelledMeetings: boolean; @@ -21,12 +22,14 @@ interface MeetingListProps { userProfiles: { [userId: string]: UserProfile }; meetingNotesStatus: { [meetingId: string]: boolean }; checkingNotes: { [meetingId: string]: boolean }; + actionLoadingStates: { [meetingId: string]: string }; onScheduleMeeting: () => void; onMeetingAction: (meetingId: string, action: 'accept' | 'reject' | 'cancel') => void; onCancelMeeting: (meetingId: string) => void; onAlert: (type: 'success' | 'error' | 'warning' | 'info', message: string) => void; onTogglePastMeetings: () => void; onToggleCancelledMeetings: () => void; + showCreateMeetingButton?: boolean; // Optional prop to control create meeting button visibility } export default function MeetingList({ @@ -34,6 +37,7 @@ export default function MeetingList({ upcomingMeetings, pastMeetings, cancelledMeetings, + currentlyHappeningMeetings, hasActiveMeetingsOrRequests, showPastMeetings, showCancelledMeetings, @@ -41,27 +45,36 @@ export default function MeetingList({ userProfiles, meetingNotesStatus, checkingNotes, + actionLoadingStates, onScheduleMeeting, onMeetingAction, onCancelMeeting, onAlert, onTogglePastMeetings, - onToggleCancelledMeetings + onToggleCancelledMeetings, + showCreateMeetingButton = true // Default to true for backward compatibility }: MeetingListProps) { - const totalMeetings = pendingRequests.length + upcomingMeetings.length + pastMeetings.length + cancelledMeetings.length; + const totalMeetings = pendingRequests.length + upcomingMeetings.length + pastMeetings.length + cancelledMeetings.length + currentlyHappeningMeetings.length; if (totalMeetings === 0) { return (

No meetings scheduled

- + {showCreateMeetingButton && ( + + )} + {!showCreateMeetingButton && ( +

+ Meetings can be scheduled through skill matches in the chat system +

+ )}
); } @@ -85,6 +98,7 @@ export default function MeetingList({ userProfiles={userProfiles} meetingNotesStatus={meetingNotesStatus} checkingNotes={checkingNotes} + actionLoadingStates={actionLoadingStates} onMeetingAction={onMeetingAction} onCancelMeeting={onCancelMeeting} onAlert={onAlert} @@ -94,6 +108,40 @@ export default function MeetingList({
)} + {/* Currently Happening Meetings */} + {currentlyHappeningMeetings.length > 0 && ( +
+

+ + Currently Happening ({currentlyHappeningMeetings.length}) +

+
+ {currentlyHappeningMeetings.map((meeting) => ( +
+ {/* Animated highlight border */} +
+
+
+
+ +
+
+ ))} +
+
+ )} + {/* Upcoming Meetings */} {upcomingMeetings.length > 0 && (
@@ -111,6 +159,7 @@ export default function MeetingList({ userProfiles={userProfiles} meetingNotesStatus={meetingNotesStatus} checkingNotes={checkingNotes} + actionLoadingStates={actionLoadingStates} onMeetingAction={onMeetingAction} onCancelMeeting={onCancelMeeting} onAlert={onAlert} @@ -158,6 +207,7 @@ export default function MeetingList({ userProfiles={userProfiles} meetingNotesStatus={meetingNotesStatus} checkingNotes={checkingNotes} + actionLoadingStates={actionLoadingStates} onMeetingAction={onMeetingAction} onCancelMeeting={onCancelMeeting} onAlert={onAlert} @@ -196,6 +246,7 @@ export default function MeetingList({ userProfiles={userProfiles} meetingNotesStatus={meetingNotesStatus} checkingNotes={checkingNotes} + actionLoadingStates={actionLoadingStates} onMeetingAction={onMeetingAction} onCancelMeeting={onCancelMeeting} onAlert={onAlert} diff --git a/src/components/meetingSystem/MeetingTips.tsx b/src/components/meetingSystem/MeetingTips.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/meetingSystem/NotesViewModal.tsx b/src/components/meetingSystem/NotesViewModal.tsx new file mode 100644 index 00000000..2a493617 --- /dev/null +++ b/src/components/meetingSystem/NotesViewModal.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { X, Calendar, Clock, Tag, Download, FileText } from 'lucide-react'; + +interface MeetingNote { + _id: string; + meetingId: string; + title: string; + content: string; + tags: string[]; + wordCount: number; + lastModified: string; + createdAt: string; + isPrivate: boolean; + meetingInfo?: { + description: string; + meetingTime: string; + senderId: string; + receiverId: string; + isDeleted?: boolean; + }; +} + +interface NotesViewModalProps { + note: MeetingNote; + onClose: () => void; + onDownload: (note: MeetingNote) => void; +} + +export default function NotesViewModal({ + note, + onClose, + onDownload +}: NotesViewModalProps) { + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatContent = (content: string) => { + return content.split('\n').map((line, index) => ( +

+ {line || '\u00A0'} {/* Non-breaking space for empty lines */} +

+ )); + }; + + return ( +
+
+ {/* Header */} +
+
+ +

+ {note.title} +

+
+
+ + +
+
+ + {/* Content */} +
+ {/* Meeting Info */} + {note.meetingInfo && ( +
+

Meeting Details

+
+
+ + + {note.meetingInfo.description} + {note.meetingInfo.isDeleted && ' (Removed Meeting)'} + +
+
+ + {formatDate(note.meetingInfo.meetingTime)} +
+ {note.meetingInfo.isDeleted && ( +
+ * This meeting has been removed from the system but your notes are preserved. +
+ )} +
+
+ )} + + {/* Tags */} + {note.tags && note.tags.length > 0 && ( +
+
+ + Tags +
+
+ {note.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + + {/* Notes Content */} +
+

Notes Content

+
+ {note.content ? ( +
') }} /> + ) : ( +

No content available

+ )} +
+
+ + {/* Footer Info */} +
+
+
+ {note.wordCount} words + {note.isPrivate && ( + Private Notes + )} +
+
+ Created: {formatDate(note.createdAt)} + Modified: {formatDate(note.lastModified)} +
+
+
+
+ + {/* Footer Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/meetingSystem/SavedNotesList.tsx b/src/components/meetingSystem/SavedNotesList.tsx new file mode 100644 index 00000000..5e05d2d7 --- /dev/null +++ b/src/components/meetingSystem/SavedNotesList.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { FileText, Calendar, Clock, Tag, Eye, Download } from 'lucide-react'; + +interface MeetingNote { + _id: string; + meetingId: string; + title: string; + content: string; + tags: string[]; + wordCount: number; + lastModified: string; + createdAt: string; + isPrivate: boolean; + meetingInfo?: { + description: string; + meetingTime: string; + senderId: string; + receiverId: string; + isDeleted?: boolean; + }; +} + +interface SavedNotesListProps { + notes: MeetingNote[]; + loading: boolean; + onViewNotes: (note: MeetingNote) => void; + onDownloadNotes: (note: MeetingNote) => void; +} + +export default function SavedNotesList({ + notes, + loading, + onViewNotes, + onDownloadNotes +}: SavedNotesListProps) { + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + if (loading) { + return ( +
+
+

Loading saved notes...

+
+ ); + } + + if (notes.length === 0) { + return ( +
+ +

No saved meeting notes found

+

+ Notes will appear here after you save them from past meetings +

+
+ ); + } + + return ( +
+ {notes.map((note) => ( +
+ {/* Note Header */} +
+

+ {note.title} +

+
+ + +
+
+ + {/* Meeting Info */} + {note.meetingInfo && ( +
+
+ + + Meeting: {note.meetingInfo.description} + {note.meetingInfo.isDeleted && ' (Removed)'} + +
+
+ + {formatDate(note.meetingInfo.meetingTime)} +
+
+ )} + + {/* Note Summary - No content preview */} +

+ Meeting notes saved - Click to view or download +

+ + {/* Tags */} + {note.tags && note.tags.length > 0 && ( +
+ +
+ {note.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {note.tags.length > 3 && ( + + +{note.tags.length - 3} more + + )} +
+
+ )} + + {/* Footer Info */} +
+
+ {note.wordCount} words + {note.isPrivate && ( + Private + )} +
+ Modified {formatDate(note.lastModified)} +
+
+ ))} +
+ ); +} diff --git a/src/components/meetingSystem/new b/src/components/meetingSystem/new new file mode 100644 index 00000000..e69de29b diff --git a/src/components/messageSystem/MeetingBox.tsx b/src/components/messageSystem/MeetingBox.tsx index 2b7b7636..bf57c842 100644 --- a/src/components/messageSystem/MeetingBox.tsx +++ b/src/components/messageSystem/MeetingBox.tsx @@ -1,8 +1,11 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { Calendar, Plus } from 'lucide-react'; +import { Calendar, Plus, FileText, ChevronDown, ChevronRight } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import CreateMeetingModal from '@/components/meetingSystem/CreateMeetingModal'; import CancelMeetingModal from '@/components/meetingSystem/CancelMeetingModal'; import MeetingList from '@/components/meetingSystem/MeetingList'; +import SavedNotesList from '@/components/meetingSystem/SavedNotesList'; +import NotesViewModal from '@/components/meetingSystem/NotesViewModal'; import Meeting from '@/types/meeting'; import { fetchMeetings, @@ -12,8 +15,11 @@ import { fetchMeetingCancellation, acknowledgeMeetingCancellation, checkMeetingNotesExist, + fetchAllUserMeetingNotes, + downloadMeetingNotesFile, filterMeetingsByType, - checkMeetingLimit + checkMeetingLimit, + canCancelMeeting } from "@/services/meetingApiServices"; import { fetchChatRoom, fetchUserProfile } from "@/services/chatApiServices"; import { invalidateUsersCaches } from '@/services/sessionApiServices'; @@ -40,6 +46,25 @@ interface CancellationAlert { meetingTime: string; } +interface MeetingNote { + _id: string; + meetingId: string; + title: string; + content: string; + tags: string[]; + wordCount: number; + lastModified: string; + createdAt: string; + isPrivate: boolean; + meetingInfo?: { + description: string; + meetingTime: string; + senderId: string; + receiverId: string; + isDeleted?: boolean; + }; +} + interface MeetingBoxProps { chatRoomId: string; userId: string; @@ -50,6 +75,80 @@ interface MeetingBoxProps { export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdate }: MeetingBoxProps) { const [meetings, setMeetings] = useState([]); const [loading, setLoading] = useState(true); + + // Animation variants + const containerVariants = { + hidden: { + opacity: 0, + y: 20, + scale: 0.95 + }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { + duration: 0.4, + ease: "easeOut", + staggerChildren: 0.1 + } + }, + exit: { + opacity: 0, + y: -20, + scale: 0.95, + transition: { + duration: 0.3, + ease: "easeIn" + } + } + }; + + const itemVariants = { + hidden: { + opacity: 0, + x: -20 + }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.3, + ease: "easeOut" + } + } + }; + + const headerVariants = { + hidden: { + opacity: 0, + y: -10 + }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: "easeOut", + delay: 0.1 + } + } + }; + + const buttonHover = { + scale: 1.05, + transition: { + duration: 0.2, + ease: "easeInOut" + } + }; + + const buttonTap = { + scale: 0.95, + transition: { + duration: 0.1 + } + }; const [userProfiles, setUserProfiles] = useState({}); const [showCreateModal, setShowCreateModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false); @@ -59,6 +158,14 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat const [showCancelledMeetings, setShowCancelledMeetings] = useState(false); const [meetingNotesStatus, setMeetingNotesStatus] = useState<{[meetingId: string]: boolean}>({}); const [checkingNotes, setCheckingNotes] = useState<{[meetingId: string]: boolean}>({}); + const [actionLoadingStates, setActionLoadingStates] = useState<{[meetingId: string]: string}>({}); + + // Saved notes states + const [savedNotes, setSavedNotes] = useState([]); + const [loadingSavedNotes, setLoadingSavedNotes] = useState(false); + const [showSavedNotes, setShowSavedNotes] = useState(false); + const [selectedNote, setSelectedNote] = useState(null); + const [showNotesModal, setShowNotesModal] = useState(false); // Alert and confirmation states const [alert, setAlert] = useState<{ @@ -87,7 +194,7 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat onConfirm: () => {} }); - // Helper functions for alerts and confirmations + // Helper functions for alerts const showAlert = (type: 'success' | 'error' | 'warning' | 'info', message: string, title?: string) => { setAlert({ isOpen: true, @@ -123,9 +230,37 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat setConfirmation(prev => ({ ...prev, isOpen: false })); }; + // Check if a meeting is currently happening (in non-cancellation period) + const isMeetingHappening = (meeting: Meeting): boolean => { + if (meeting.state !== 'accepted') return false; + + const now = new Date(); + const meetingTime = new Date(meeting.meetingTime); + const tenMinutesBefore = new Date(meetingTime.getTime() - 10 * 60 * 1000); // 10 minutes before + const thirtyMinutesAfter = new Date(meetingTime.getTime() + 30 * 60 * 1000); // 30 minutes after + + return now >= tenMinutesBefore && now <= thirtyMinutesAfter; + }; + + // Get meetings that are currently happening + const currentlyHappeningMeetings = meetings.filter(isMeetingHappening); + + // Update meeting statuses every minute to refresh currently happening meetings + useEffect(() => { + if (meetings.length === 0) return; + + const interval = setInterval(() => { + // Force a re-render to update currently happening meetings + // The isMeetingHappening function will recalculate based on current time + setMeetings(prevMeetings => [...prevMeetings]); + }, 60000); // Check every minute + + return () => clearInterval(interval); + }, [meetings.length]); + // Use API service to filter meetings const filteredMeetings = filterMeetingsByType(meetings, userId); - const hasActiveMeetingsOrRequests = filteredMeetings.pendingRequests.length > 0 || filteredMeetings.upcomingMeetings.length > 0; + const hasActiveMeetingsOrRequests = filteredMeetings.pendingRequests.length > 0 || filteredMeetings.upcomingMeetings.length > 0 || currentlyHappeningMeetings.length > 0; // Track if meetings have been loaded to prevent duplicate onMeetingUpdate calls const hasFetchedMeetings = useRef(false); @@ -285,6 +420,124 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat setCheckingNotes(checking); }, [userId]); + // Fetch saved notes when otherUserId is available + const fetchSavedNotes = useCallback(async () => { + if (!otherUserId) return; + + try { + setLoadingSavedNotes(true); + const notes = await fetchAllUserMeetingNotes(userId, otherUserId); + setSavedNotes(notes || []); + } catch (error) { + console.error('Error fetching saved notes:', error); + setSavedNotes([]); + } finally { + setLoadingSavedNotes(false); + } + }, [userId, otherUserId]); + + // Fetch saved notes when otherUserId changes + useEffect(() => { + if (otherUserId) { + fetchSavedNotes(); + } + }, [otherUserId, fetchSavedNotes]); + + // Handle viewing notes + const handleViewNotes = (note: MeetingNote) => { + setSelectedNote(note); + setShowNotesModal(true); + }; + + // Handle downloading notes + const handleDownloadNotes = async (note: MeetingNote) => { + try { + // Create markdown content directly from the note data + const meetingTitle = note.meetingInfo?.description || note.title; + const meetingDate = note.meetingInfo?.meetingTime || note.createdAt; + + // Create a well-formatted markdown document + const formattedDate = new Date(meetingDate).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + const formattedTime = new Date(meetingDate).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }); + + // Clean up the content + let formattedContent = note.content + .replace(/^## (.*$)/gm, '## $1') + .replace(/^# (.*$)/gm, '# $1') + .replace(/^> (.*$)/gm, '> $1') + .replace(/^- (.*$)/gm, '- $1') + .trim(); + + const markdownDocument = `# Meeting Notes + +--- + +## Meeting Information + +- **Meeting:** ${note.title} +- **Date:** ${formattedDate} +- **Time:** ${formattedTime} +- **Meeting ID:** \`${note.meetingId}\`${note.meetingInfo?.isDeleted ? '\n- **Status:** ⚠️ Meeting Removed from System' : ''} + +--- + +## Content + +${formattedContent} + +--- + +## Meeting Details + +- **Word Count:** ${note.wordCount} +- **Tags:** ${note.tags?.join(', ') || 'None'} +- **Created:** ${new Date(note.createdAt).toLocaleDateString()} +- **Last Updated:** ${new Date(note.lastModified).toLocaleDateString()} +- **Privacy:** ${note.isPrivate ? 'Private' : 'Public'}${note.meetingInfo?.isDeleted ? '\n- **Note:** Original meeting has been removed from the system but notes are preserved' : ''} + +--- + +*Generated by SkillSwap Hub - Meeting Notes System* + `; + + // Create and trigger download + const blob = new Blob([markdownDocument], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const fileName = `meeting-notes-${note.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}-${new Date(meetingDate).toISOString().split('T')[0]}.md`; + + // Create download link + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = fileName; + + // Add to DOM, click, and remove + document.body.appendChild(a); + a.click(); + + // Cleanup + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + + showAlert('success', 'Notes downloaded successfully!'); + } catch (error: any) { + console.error('Error downloading notes:', error); + showAlert('error', 'Failed to download notes'); + } + }; + // Check for notes when meetings change useEffect(() => { if (meetings.length === 0) { @@ -302,6 +555,11 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat // Meeting action handler const handleMeetingAction = async (meetingId: string, action: 'accept' | 'reject' | 'cancel') => { + // Prevent multiple clicks by checking if action is already in progress + if (actionLoadingStates[meetingId]) { + return; + } + const actionText = action === 'accept' ? 'accept' : action === 'reject' ? 'decline' : 'cancel'; const confirmationTitle = action === 'accept' ? 'Accept Meeting' : action === 'reject' ? 'Decline Meeting' : 'Cancel Meeting'; @@ -313,6 +571,9 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat confirmationMessage, async () => { try { + // Set loading state for this specific meeting and action + setActionLoadingStates(prev => ({ ...prev, [meetingId]: action })); + const updatedMeeting = await updateMeeting(meetingId, action); if (updatedMeeting) { @@ -336,6 +597,13 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat } catch (error) { console.error(`Error ${action}ing meeting:`, error); showAlert('error', `Failed to ${actionText} meeting`); + } finally { + // Clear loading state + setActionLoadingStates(prev => { + const newState = { ...prev }; + delete newState[meetingId]; + return newState; + }); } }, confirmationType, @@ -406,94 +674,330 @@ export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdat setMeetingToCancel(null); showAlert('success', 'Meeting cancelled successfully'); + // Invalidate cache for both users + if (otherUserId) { + invalidateUsersCaches(userId, otherUserId); + } + if (onMeetingUpdateRef.current) { onMeetingUpdateRef.current(); } } else { showAlert('error', 'Failed to cancel meeting'); } - } catch (error) { + } catch (error: any) { console.error('Error cancelling meeting:', error); - showAlert('error', 'Failed to cancel meeting'); + const errorMessage = error.message || 'Failed to cancel meeting'; + showAlert('error', errorMessage); } }; // Show cancel modal const showCancelMeetingModal = (meetingId: string) => { + // Find the meeting to check if it can be cancelled + const meeting = meetings.find(m => m._id === meetingId); + if (!meeting) { + showAlert('error', 'Meeting not found'); + return; + } + + // Check if meeting can be cancelled + const { canCancel, reason } = canCancelMeeting(meeting); + if (!canCancel) { + showAlert('warning', reason!, 'Cannot Cancel Meeting'); + return; + } + setMeetingToCancel(meetingId); setShowCancelModal(true); }; if (loading && meetings.length === 0) { return ( -
-
-

Loading meetings...

-
+ + {/* Background pulse effect */} + + +
+ + + Loading meetings... + +
+
); } return ( -
- {/* Header */} -
-
- -

Meetings

-
- -
- - {/* Meetings List */} -
- setShowPastMeetings(!showPastMeetings)} - onToggleCancelledMeetings={() => setShowCancelledMeetings(!showCancelledMeetings)} + <> + {/* Animated Background Elements */} + + {/* Floating circles */} + -
- - {/* Modals */} - {showCreateModal && ( - setShowCreateModal(false)} - onCreate={handleCreateMeeting} - receiverName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'this user'} + - )} - - {showCancelModal && meetingToCancel && ( - { - setShowCancelModal(false); - setMeetingToCancel(null); + - )} + + + + {/* Header */} + + + + + +

Meetings

+
+ + + + + New + +
+ + {/* Meetings List */} + + setShowPastMeetings(!showPastMeetings)} + onToggleCancelledMeetings={() => setShowCancelledMeetings(!showCancelledMeetings)} + /> + + + {/* Saved Meeting Notes - Collapsible */} + + + setShowSavedNotes(!showSavedNotes)} + className="w-full bg-gray-50 hover:bg-gray-100 px-4 py-3 flex items-center justify-between text-sm font-medium text-gray-700 transition-colors" + whileHover={{ backgroundColor: "rgb(243 244 246)" }} + whileTap={{ scale: 0.98 }} + > +
+ + + + Saved Meeting Notes ({savedNotes.length}) +
+ + {showSavedNotes ? ( + + ) : ( + + )} + +
+ + {showSavedNotes && ( + + + + )} + +
+
+
+ + {/* Modals */} + + {showCreateModal && ( + + setShowCreateModal(false)} + onCreate={handleCreateMeeting} + receiverName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'this user'} + /> + + )} + + {showCancelModal && meetingToCancel && ( + + { + setShowCancelModal(false); + setMeetingToCancel(null); + }} + onCancel={handleCancelMeeting} + userName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'User'} + /> + + )} + {/* Alert Component */} -
+ + {/* Notes View Modal */} + + {showNotesModal && selectedNote && ( + + { + setShowNotesModal(false); + setSelectedNote(null); + }} + onDownload={handleDownloadNotes} + /> + + )} + + ); } \ No newline at end of file diff --git a/src/components/messageSystem/MessageBox.tsx b/src/components/messageSystem/MessageBox.tsx index 56cc1b08..ec69c77b 100644 --- a/src/components/messageSystem/MessageBox.tsx +++ b/src/components/messageSystem/MessageBox.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState, useRef, useCallback } from "react"; +import { motion, AnimatePresence } from 'framer-motion'; import { useSocket } from '@/lib/context/SocketContext'; import { IMessage } from "@/types/chat"; import { CornerUpLeft } from "lucide-react"; @@ -42,11 +43,20 @@ function DateBadge({ date }: { date: Date }) { }).format(date); return ( -
-
+ + {formattedDate} -
-
+
+ ); } @@ -55,20 +65,43 @@ function DateBadge({ date }: { date: Date }) { */ function SkillMatchInfoMessage({ participantName }: { participantName?: string }) { return ( -
-
-
-
+ + + + New Skill Match! 🎉 -
-

+ + This chat was created because you and {participantName || 'your chat partner'} were matched based on your skills! You can now discuss your skill exchange and schedule a skill sharing session. -

-
-
+ + + ); } @@ -226,15 +259,14 @@ export default function MessageBox({ useEffect(() => { if (!newMessage || newMessage.chatRoomId !== chatRoomId) return; - // Decrypt the message content before adding to state - const decryptedMessage = { + // New messages from socket are plain text (not encrypted yet) + // No need to decrypt since they come directly from client before server encryption + const messageToAdd = { ...newMessage, - content: newMessage.content.startsWith('File:') - ? newMessage.content - : decryptMessage(newMessage.content) + content: newMessage.content // Keep as-is, it's plain text from socket }; - setMessages((prev) => [...prev, decryptedMessage]); + setMessages((prev) => [...prev, messageToAdd]); }, [newMessage, chatRoomId, userId, socket]); // Add/update typing event listeners diff --git a/src/components/messageSystem/MessageInput.tsx b/src/components/messageSystem/MessageInput.tsx index 4dba1d75..86e61a70 100644 --- a/src/components/messageSystem/MessageInput.tsx +++ b/src/components/messageSystem/MessageInput.tsx @@ -6,7 +6,6 @@ import { Paperclip, X, CornerUpLeft } from "lucide-react"; import { IMessage } from "@/types/chat"; import { sendMessage as sendMessageService, fetchUserProfile } from "@/services/chatApiServices"; -import { encryptMessage } from "@/lib/messageEncryption/encryption"; interface MessageInputProps { chatRoomId: string; @@ -109,22 +108,21 @@ export default function MessageInput({ if (response.ok) { setFileUrl(result?.url || null); - // Determine content and whether to encrypt + // Determine content - no client-side encryption const messageContent = `File:${file?.name}:${result?.url || ""}`; - const isFileLink = messageContent.startsWith('File:'); - const encryptedContent = isFileLink ? messageContent : encryptMessage(messageContent); + const finalContent = messageContent; // Send as plain text, server will encrypt // Send message with file URL const newMsg = { chatRoomId, senderId, receiverId: chatParticipants.find((id) => id !== senderId), - content: encryptedContent, // Now sending encrypted content via socket + content: finalContent, // Send plain text, server handles encryption sentAt: Date.now(), replyFor: replyingTo?._id || null, }; - // Send via socket immediately (now encrypted) + // Send via socket immediately (plain text) socketSendMessage(newMsg); // Reset UI immediately @@ -182,21 +180,20 @@ export default function MessageInput({ setLoading(true); - // Determine content and whether to encrypt + // Determine content - no client-side encryption const messageContent = fileUrl ? `File:${file?.name}:${fileUrl}` : message.trim(); - const isFileLink = messageContent.startsWith('File:'); - const encryptedContent = isFileLink ? messageContent : encryptMessage(messageContent); + const finalContent = messageContent; // Send as plain text, server will encrypt const newMsg = { chatRoomId, senderId, receiverId: chatParticipants.find((id) => id !== senderId), - content: encryptedContent, // Now sending encrypted content via socket + content: finalContent, // Send plain text, server handles encryption sentAt: Date.now(), replyFor: replyingTo?._id || null, }; - // Send via socket immediately (now encrypted) + // Send via socket immediately (plain text) socketSendMessage(newMsg); // Reset UI immediately after socket send diff --git a/src/components/messageSystem/SessionBox.tsx b/src/components/messageSystem/SessionBox.tsx index 9f09a954..408a6612 100644 --- a/src/components/messageSystem/SessionBox.tsx +++ b/src/components/messageSystem/SessionBox.tsx @@ -423,7 +423,7 @@ export default function SessionBox({ chatRoomId, userId, otherUserId, otherUser: // Memoized error component const ErrorComponent = useMemo(() => (
-

Failed to load user information

+

Failed to load user

), []); diff --git a/src/components/sessionSystem/CreateSessionModal.tsx b/src/components/sessionSystem/CreateSessionModal.tsx index e0c6acf7..6fa77c6f 100644 --- a/src/components/sessionSystem/CreateSessionModal.tsx +++ b/src/components/sessionSystem/CreateSessionModal.tsx @@ -296,7 +296,7 @@ export default function CreateSessionModal({
-

What You Want to Learn

+

What You Want to Recive

diff --git a/src/lib/messageEncryption/encryption.ts b/src/lib/messageEncryption/encryption.ts index a85a9107..bd482936 100644 --- a/src/lib/messageEncryption/encryption.ts +++ b/src/lib/messageEncryption/encryption.ts @@ -2,8 +2,8 @@ import CryptoJS from "crypto-js"; /** * Secret key used for encryption/decryption. - * SECURITY NOTE: Ideally, this should be stored in environment variables, - * with a strong randomly generated value in production. + * SECURITY NOTE: Only used on server-side for better security. + * Client-side no longer encrypts messages. */ const secretKey: string = process.env.M_KEY || "secretKey"; diff --git a/src/lib/models/meetingNotesSchema.ts b/src/lib/models/meetingNotesSchema.ts index c46c4dde..9f1e890e 100644 --- a/src/lib/models/meetingNotesSchema.ts +++ b/src/lib/models/meetingNotesSchema.ts @@ -46,6 +46,17 @@ const meetingNotesSchema = new mongoose.Schema({ autoSaveCount: { type: Number, default: 0 + }, + // Embedded meeting info for when meetings are deleted + meetingInfo: { + description: String, + meetingTime: Date, + senderId: String, + receiverId: String, + isDeleted: { + type: Boolean, + default: false + } } }, { timestamps: true diff --git a/src/lib/models/meetingSchema.ts b/src/lib/models/meetingSchema.ts index 3e430ea3..4f0aa7de 100644 --- a/src/lib/models/meetingSchema.ts +++ b/src/lib/models/meetingSchema.ts @@ -49,4 +49,7 @@ const meetingSchema: Schema = new Schema({ } }); +// Create TTL index to automatically delete meetings 2 weeks after their scheduled time +meetingSchema.index({ meetingTime: 1 }, { expireAfterSeconds: 14 * 24 * 60 * 60 }); // 14 days in seconds + export default mongoose.models.Meeting || mongoose.model('Meeting', meetingSchema); \ No newline at end of file diff --git a/src/services/meetingApiServices.ts b/src/services/meetingApiServices.ts index 4286ca3d..f0ec5abb 100644 --- a/src/services/meetingApiServices.ts +++ b/src/services/meetingApiServices.ts @@ -300,7 +300,9 @@ export async function cancelMeetingWithReason( reason: string ): Promise { try { - const response = await fetch('/api/meeting/cancel', { + // Use relative URL for client-side requests + const url = '/api/meeting/cancel'; + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -311,42 +313,23 @@ export async function cancelMeetingWithReason( }); if (!response.ok) { - throw new Error(`Error cancelling meeting: ${response.status}`); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Error cancelling meeting: ${response.status}`); } const { meeting } = await response.json(); - // Send notification to the other user + // Invalidate cache for both users if (meeting && meeting.senderId && meeting.receiverId) { const senderId = meeting.senderId._id || meeting.senderId; const receiverId = meeting.receiverId._id || meeting.receiverId; - - try { - const otherUserId = senderId === cancelledBy ? receiverId : senderId; - const cancellerName = await getUserName(cancelledBy); - const description = `Meeting cancelled by ${cancellerName}${reason ? `: ${reason}` : ''}`; - - await sendMeetingNotification( - otherUserId, - 10, // MEETING_CANCELLED - description, - `/dashboard?tab=meetings` - ); - - console.log('Meeting cancellation notification sent successfully'); - } catch (notificationError) { - console.error('Failed to send meeting cancellation notification:', notificationError); - // Continue even if notification fails - } - - // Invalidate cache for both users invalidateUsersCaches(senderId, receiverId); } return meeting; } catch (error) { console.error('Error cancelling meeting:', error); - return null; + throw error; // Re-throw the error so the UI can handle it properly } } @@ -450,6 +433,32 @@ export async function fetchMeetingNotes(meetingId: string, userId: string) { } } +/** + ** Fetch all meeting notes for a user + * + * @param userId - ID of the current user + * @param otherUserId - Optional ID of the other user to filter notes for meetings with specific user + * @returns Promise that resolves to array of meeting notes data + */ +export async function fetchAllUserMeetingNotes(userId: string, otherUserId?: string) { + const cacheKey = `user-notes-${userId}${otherUserId ? `-${otherUserId}` : ''}`; + + return debouncedApiService.makeRequest( + cacheKey, + async () => { + const url = `/api/meeting-notes/user?userId=${userId}${otherUserId ? `&otherUserId=${otherUserId}` : ''}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Error fetching user meeting notes: ${response.status}`); + } + + return await response.json(); + }, + 60000 // 1 minute cache + ); +} + /** ** Filter meetings by type and user * @@ -559,8 +568,8 @@ export async function downloadMeetingNotesFile( try { const data = await fetchMeetingNotes(meetingId, userId); - if (!data) { - return false; + if (!data || !data.content || data.content.trim().length === 0) { + throw new Error('No notes content found for this meeting'); } // Create a well-formatted markdown document @@ -612,26 +621,87 @@ ${formattedContent} - **Word Count:** ${wordCount} - **Tags:** ${data.tags?.join(', ') || 'None'} - **Created:** ${new Date(data.createdAt || meetingDate).toLocaleDateString()} -- **Last Updated:** ${data.updatedAt ? new Date(data.updatedAt).toLocaleDateString() : 'N/A'} +- **Last Updated:** ${data.lastModified ? new Date(data.lastModified).toLocaleDateString() : 'N/A'} --- *Generated by SkillSwap Hub - Meeting Notes System* `; + // Create and trigger download const blob = new Blob([markdownDocument], { type: 'text/markdown;charset=utf-8' }); + + // Check if browser supports download + if (typeof window === 'undefined') { + throw new Error('Download not supported in server environment'); + } + const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `meeting-notes-${meetingId}-${new Date(meetingDate).toISOString().split('T')[0]}.md`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + const fileName = `meeting-notes-${meetingId}-${new Date(meetingDate).toISOString().split('T')[0]}.md`; + // Try modern download approach first + if ((navigator as any).msSaveBlob) { + // IE 10+ + (navigator as any).msSaveBlob(blob, fileName); + } else { + // Modern browsers + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = fileName; + + // Add to DOM, click, and remove + document.body.appendChild(a); + a.click(); + + // Cleanup + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + } + + console.log('Download triggered successfully for:', fileName); return true; } catch (error) { console.error('Error downloading notes:', error); - return false; + throw error; // Re-throw to let the caller handle the error properly } +} + +/** + ** Check if a meeting can be cancelled based on timing rules + * + * @param meeting - Meeting object to check + * @returns Object with canCancel boolean and reason message if cannot cancel + */ +export function canCancelMeeting(meeting: Meeting): { canCancel: boolean; reason?: string } { + // Only accepted meetings have timing restrictions + if (meeting.state !== 'accepted') { + return { canCancel: true }; + } + + const now = new Date(); + const meetingTime = new Date(meeting.meetingTime); + const tenMinutesBefore = new Date(meetingTime.getTime() - 10 * 60 * 1000); // 10 minutes before + const thirtyMinutesAfter = new Date(meetingTime.getTime() + 30 * 60 * 1000); // 30 minutes after + + // Check if meeting is too close to start time or currently in progress + if (now >= tenMinutesBefore && now <= thirtyMinutesAfter) { + const timeUntilMeeting = meetingTime.getTime() - now.getTime(); + const timeAfterMeeting = now.getTime() - meetingTime.getTime(); + + let reason; + if (timeUntilMeeting > 0) { + const minutesUntil = Math.ceil(timeUntilMeeting / (1000 * 60)); + reason = `Cannot cancel meeting. The meeting starts in ${minutesUntil} minute${minutesUntil === 1 ? '' : 's'}. Meetings cannot be cancelled within 10 minutes of the start time.`; + } else { + const minutesAfter = Math.floor(timeAfterMeeting / (1000 * 60)); + reason = `Cannot cancel meeting. The meeting started ${minutesAfter} minute${minutesAfter === 1 ? '' : 's'} ago and may still be in progress. Meetings cannot be cancelled for up to 30 minutes after the start time.`; + } + + return { canCancel: false, reason }; + } + + return { canCancel: true }; } \ No newline at end of file