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 = `
+
+ `;
+
+ 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:
+
+ 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.
+ 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 */}
+
+
+
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"
+ >
+
+
+ Saved Meeting Notes ({savedNotes.length})
+
+ {showSavedNotes ? (
+
+ ) : (
+
+ )}
+
+ {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({
+ setShowTips(true)}
+ title="Camera & Microphone Tips"
+ >
+
+
setShowNotes(!showNotes)}
/>
+
+ {/* Media Device Tips Modal */}
+ setShowTips(false)}
+ />
);
}
@@ -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
+
+
+
+
+
+ {
+ setJoinError(null);
+ setIsJoining(true);
+ window.location.reload();
+ }}
+ className="w-full bg-blue-600 hover:bg-blue-700"
+ >
+ Try Again
+
+
+
+ Return to Dashboard
+
+
+
+
+ );
+ }
+
+ 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 */}
+
+
+ Got it!
+
+ window.location.reload()}
+ className="flex-1"
+ >
+ Refresh Page
+
+
+
+
+
+ );
+}
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({
<>
onMeetingAction(meeting._id, 'accept')}
- className="px-3 py-1.5 bg-green-500 text-white rounded-md hover:bg-green-600 text-sm font-medium transition-colors"
+ disabled={!!actionLoadingStates[meeting._id]}
+ className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
+ actionLoadingStates[meeting._id] === 'accept'
+ ? 'bg-green-400 text-white cursor-not-allowed'
+ : actionLoadingStates[meeting._id]
+ ? 'bg-gray-300 text-gray-500 cursor-not-allowed'
+ : 'bg-green-500 text-white hover:bg-green-600'
+ }`}
>
- Accept
+ {actionLoadingStates[meeting._id] === 'accept' ? 'Accepting...' : 'Accept'}
onMeetingAction(meeting._id, 'reject')}
- className="px-3 py-1.5 bg-red-500 text-white rounded-md hover:bg-red-600 text-sm font-medium transition-colors"
+ disabled={!!actionLoadingStates[meeting._id]}
+ className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
+ actionLoadingStates[meeting._id] === 'reject'
+ ? 'bg-red-400 text-white cursor-not-allowed'
+ : actionLoadingStates[meeting._id]
+ ? 'bg-gray-300 text-gray-500 cursor-not-allowed'
+ : 'bg-red-500 text-white hover:bg-red-600'
+ }`}
>
- Decline
+ {actionLoadingStates[meeting._id] === 'reject' ? 'Declining...' : 'Decline'}
>
)}
- {/* 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 && (
- Join Meeting
+ {type === 'happening' ? '🔴 Join Now' : 'Join Meeting'}
)}
{/* Cancel button */}
- {canCancel && (
+ {basicCanCancel && (
onCancelMeeting(meeting._id)}
- className="px-3 py-1.5 bg-red-500 text-white rounded-md hover:bg-red-600 text-sm font-medium transition-colors"
+ onClick={handleCancelClick}
+ disabled={!timingAllowsCancel}
+ className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
+ timingAllowsCancel
+ ? 'bg-red-500 text-white hover:bg-red-600'
+ : 'bg-gray-300 text-gray-500 cursor-not-allowed'
+ }`}
+ title={!timingAllowsCancel ? cancelRestrictionReason : 'Cancel meeting'}
>
Cancel
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
-
-
- Schedule Meeting
-
+ {showCreateMeetingButton && (
+
+
+ Schedule Meeting
+
+ )}
+ {!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}
+
+
+
+ onDownload(note)}
+ className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
+ title="Download notes"
+ >
+
+
+
+
+
+
+
+
+ {/* 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 */}
+
+
+ Close
+
+ onDownload(note)}
+ className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
+ >
+
+ Download
+
+
+
+
+ );
+}
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}
+
+
+ onViewNotes(note)}
+ className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
+ title="View notes"
+ >
+
+
+ onDownloadNotes(note)}
+ className="p-1 text-gray-400 hover:text-green-600 transition-colors"
+ title="Download notes"
+ >
+
+
+
+
+
+ {/* 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
-
-
-
- New
-
-
-
- {/* 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