Implement comprehensive fixes for remaining critical issues
This commit addresses the remaining high-priority issues identified in the
comprehensive codebase analysis, implementing proper notification systems,
error handling, input validation, and race condition fixes.
1. CRITICAL: Implement Stripe webhook notifications
- Created new NotificationService for centralized notification handling
- Supports both in-app notifications and email queuing
- Implemented all 4 missing webhook notifications:
* Subscription downgrade notifications
* Payment success receipts
* Payment failure alerts with recovery action
* Trial ending reminders with days remaining calculation
- Notifications stored in database and emitted via Socket.io
- File: src/backend/services/notificationService.js (NEW)
- Updated: src/backend/routes/webhooks/stripeWebhook.js
2. HIGH: Add comprehensive error handling to socket event handlers
- Wrapped all socket event handlers in try-catch blocks
- Emit error events back to clients when operations fail
- Prevents server crashes from unhandled socket errors
- Provides user feedback for failed socket operations
- File: src/backend/services/socketService.js
3. HIGH: Fix race condition in Chat component
- Added activeConversationRef to track current conversation
- Check conversation ID before updating messages after async load
- Clear messages immediately when switching conversations
- Prevents stale messages from appearing when rapidly switching
- File: src/frontend/components/Chat/Chat.jsx
4. HIGH: Add input validation to messaging service
- Validate userId and conversationId are valid strings
- Ensure message content is not empty and under 10K chars
- Validate contentType against allowed types
- Validate metadata structure
- Provides clear error messages for invalid input
- File: src/backend/services/messagingService.js
5. MEDIUM: Replace hardcoded URLs with environment variables
- Updated AuthContext to use VITE_API_URL env variable
- Maintains localhost fallback for development
- File: src/frontend/contexts/AuthContext.jsx
6. Documentation: Update .env.example
- Added FRONTEND_URL configuration
- Documented ALLOW_DEV_BYPASS security flag
- Added critical warnings for TURN server configuration
- Added Stripe configuration variables
- File: .env.example
These fixes significantly improve:
- User experience (notifications for all payment events)
- System reliability (proper error handling, race condition fixes)
- Security (input validation prevents malicious input)
- Maintainability (proper environment configuration)
This commit is contained in:
parent
13d926a9c5
commit
1dcb357313
7 changed files with 384 additions and 27 deletions
13
.env.example
13
.env.example
|
|
@ -4,6 +4,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aethex_passport
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Development Security (ONLY for development, DO NOT enable in production)
|
||||||
|
# Allows bypassing authentication - requires BOTH NODE_ENV=development AND ALLOW_DEV_BYPASS=true
|
||||||
|
ALLOW_DEV_BYPASS=true
|
||||||
|
|
||||||
# Blockchain Configuration (for .aethex domain verification)
|
# Blockchain Configuration (for .aethex domain verification)
|
||||||
RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY
|
RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY
|
||||||
|
|
@ -16,7 +21,13 @@ JWT_SECRET=your-secret-key-here
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
# TURN Server Configuration (for WebRTC NAT traversal)
|
# TURN Server Configuration (for WebRTC NAT traversal)
|
||||||
|
# CRITICAL: These MUST be set in production - no defaults allowed
|
||||||
TURN_SERVER_HOST=turn.example.com
|
TURN_SERVER_HOST=turn.example.com
|
||||||
TURN_SERVER_PORT=3478
|
TURN_SERVER_PORT=3478
|
||||||
TURN_SECRET=your-turn-secret-key
|
TURN_SECRET=your-turn-secret-key
|
||||||
TURN_TTL=86400
|
TURN_TTL=86400
|
||||||
|
|
||||||
|
# Stripe Configuration (for payments)
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
const db = require('../../database/db');
|
const db = require('../../database/db');
|
||||||
|
const notificationService = require('../../services/notificationService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stripe webhook endpoint
|
* Stripe webhook endpoint
|
||||||
|
|
@ -143,8 +144,8 @@ async function handleSubscriptionDeleted(subscription) {
|
||||||
|
|
||||||
console.log(`[Webhook] User ${userId} downgraded to free tier`);
|
console.log(`[Webhook] User ${userId} downgraded to free tier`);
|
||||||
|
|
||||||
// TODO: Send notification to user about downgrade
|
// Send notification to user about downgrade
|
||||||
// Could trigger email, in-app notification, etc.
|
await notificationService.notifySubscriptionDowngrade(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +178,13 @@ async function handlePaymentSucceeded(invoice) {
|
||||||
|
|
||||||
console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`);
|
console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`);
|
||||||
|
|
||||||
// TODO: Send receipt email to user
|
// Send receipt email to user
|
||||||
|
const amount = invoice.amount_paid / 100;
|
||||||
|
await notificationService.notifyPaymentSuccess(
|
||||||
|
subResult.rows[0].user_id,
|
||||||
|
amount,
|
||||||
|
invoice.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,8 +220,10 @@ async function handlePaymentFailed(invoice) {
|
||||||
|
|
||||||
console.log(`[Webhook] Failed payment logged for user ${userId}`);
|
console.log(`[Webhook] Failed payment logged for user ${userId}`);
|
||||||
|
|
||||||
// TODO: Send notification to user about failed payment
|
// Send notification to user about failed payment
|
||||||
// Include link to update payment method
|
const amount = invoice.amount_due / 100;
|
||||||
|
const reason = invoice.last_payment_error?.message || 'Unknown error';
|
||||||
|
await notificationService.notifyPaymentFailed(userId, amount, reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -232,9 +241,16 @@ async function handleTrialWillEnd(subscription) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subResult.rows.length > 0) {
|
if (subResult.rows.length > 0) {
|
||||||
// TODO: Send notification to user that trial is ending
|
const userId = subResult.rows[0].user_id;
|
||||||
// Remind them to add payment method
|
|
||||||
console.log(`[Webhook] Trial ending notification for user ${subResult.rows[0].user_id}`);
|
// Calculate days remaining in trial
|
||||||
|
const trialEnd = new Date(subscription.trial_end * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const daysRemaining = Math.ceil((trialEnd - now) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Send notification to user that trial is ending
|
||||||
|
await notificationService.notifyTrialEnding(userId, daysRemaining);
|
||||||
|
console.log(`[Webhook] Trial ending notification sent to user ${userId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,9 +263,37 @@ class MessagingService {
|
||||||
* Send a message
|
* Send a message
|
||||||
*/
|
*/
|
||||||
async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) {
|
async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) {
|
||||||
|
// Validate required parameters
|
||||||
|
if (!userId || typeof userId !== 'string') {
|
||||||
|
throw new Error('Invalid userId parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversationId || typeof conversationId !== 'string') {
|
||||||
|
throw new Error('Invalid conversationId parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||||
|
throw new Error('Message content cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.length > 10000) {
|
||||||
|
throw new Error('Message content exceeds maximum length of 10000 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate contentType
|
||||||
|
const validContentTypes = ['text', 'image', 'file', 'audio', 'video', 'code'];
|
||||||
|
if (!validContentTypes.includes(contentType)) {
|
||||||
|
throw new Error(`Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate metadata if provided
|
||||||
|
if (metadata !== null && typeof metadata !== 'object') {
|
||||||
|
throw new Error('metadata must be an object or null');
|
||||||
|
}
|
||||||
|
|
||||||
// Verify user is participant
|
// Verify user is participant
|
||||||
await this.verifyParticipant(conversationId, userId);
|
await this.verifyParticipant(conversationId, userId);
|
||||||
|
|
||||||
// In production, content should already be encrypted by client
|
// In production, content should already be encrypted by client
|
||||||
// For now, we'll just store it as-is
|
// For now, we'll just store it as-is
|
||||||
const contentEncrypted = content;
|
const contentEncrypted = content;
|
||||||
|
|
|
||||||
223
src/backend/services/notificationService.js
Normal file
223
src/backend/services/notificationService.js
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
/**
|
||||||
|
* Notification Service
|
||||||
|
* Handles sending notifications to users via multiple channels
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = require('../database/db');
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
/**
|
||||||
|
* Send notification to user
|
||||||
|
* @param {string} userId - User ID to notify
|
||||||
|
* @param {Object} notification - Notification details
|
||||||
|
* @param {string} notification.type - Type of notification (email, in-app, both)
|
||||||
|
* @param {string} notification.category - Category (payment, subscription, system, etc.)
|
||||||
|
* @param {string} notification.title - Notification title
|
||||||
|
* @param {string} notification.message - Notification message
|
||||||
|
* @param {Object} notification.metadata - Additional metadata
|
||||||
|
*/
|
||||||
|
async sendNotification(userId, notification) {
|
||||||
|
const { type = 'in-app', category, title, message, metadata = {} } = notification;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Store notification in database
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO notifications
|
||||||
|
(user_id, type, category, title, message, metadata, read, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, false, NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[userId, type, category, title, message, JSON.stringify(metadata)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedNotification = result.rows[0];
|
||||||
|
|
||||||
|
// If email notification requested, queue email
|
||||||
|
if (type === 'email' || type === 'both') {
|
||||||
|
await this.queueEmail(userId, notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit real-time notification via Socket.io if available
|
||||||
|
if (global.io) {
|
||||||
|
global.io.to(`user:${userId}`).emit('notification', {
|
||||||
|
id: savedNotification.id,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
createdAt: savedNotification.created_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedNotification;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send notification:', error);
|
||||||
|
// Don't throw - notifications should be best-effort
|
||||||
|
// Log error but don't fail the parent operation
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue email for sending
|
||||||
|
* In production, this would integrate with an email service like SendGrid, SES, etc.
|
||||||
|
*/
|
||||||
|
async queueEmail(userId, notification) {
|
||||||
|
const { title, message, metadata = {} } = notification;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user email
|
||||||
|
const userResult = await db.query(
|
||||||
|
`SELECT email, username FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
console.error(`User ${userId} not found for email notification`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
// Queue email in database (to be processed by email worker)
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO email_queue
|
||||||
|
(user_id, recipient_email, subject, body, template, metadata, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
user.email,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
metadata.template || 'default',
|
||||||
|
JSON.stringify({ ...metadata, username: user.username })
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Email queued for ${user.email}: ${title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to queue email:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send subscription downgrade notification
|
||||||
|
*/
|
||||||
|
async notifySubscriptionDowngrade(userId) {
|
||||||
|
return this.sendNotification(userId, {
|
||||||
|
type: 'both',
|
||||||
|
category: 'subscription',
|
||||||
|
title: 'Subscription Cancelled',
|
||||||
|
message: 'Your premium subscription has been cancelled and your account has been downgraded to the free tier. You still have access to premium features until the end of your billing period.',
|
||||||
|
metadata: {
|
||||||
|
template: 'subscription-downgrade',
|
||||||
|
action: {
|
||||||
|
label: 'Resubscribe',
|
||||||
|
url: '/premium'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment receipt
|
||||||
|
*/
|
||||||
|
async notifyPaymentSuccess(userId, amount, invoiceId) {
|
||||||
|
return this.sendNotification(userId, {
|
||||||
|
type: 'email',
|
||||||
|
category: 'payment',
|
||||||
|
title: 'Payment Receipt',
|
||||||
|
message: `Your payment of $${amount.toFixed(2)} has been processed successfully. Thank you for your continued subscription!`,
|
||||||
|
metadata: {
|
||||||
|
template: 'payment-receipt',
|
||||||
|
amount,
|
||||||
|
invoiceId,
|
||||||
|
action: {
|
||||||
|
label: 'View Invoice',
|
||||||
|
url: `/billing/invoices/${invoiceId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment failure notification
|
||||||
|
*/
|
||||||
|
async notifyPaymentFailed(userId, amount, reason = 'Unknown') {
|
||||||
|
return this.sendNotification(userId, {
|
||||||
|
type: 'both',
|
||||||
|
category: 'payment',
|
||||||
|
title: 'Payment Failed',
|
||||||
|
message: `We were unable to process your payment of $${amount.toFixed(2)}. Please update your payment method to continue your subscription.`,
|
||||||
|
metadata: {
|
||||||
|
template: 'payment-failed',
|
||||||
|
amount,
|
||||||
|
reason,
|
||||||
|
action: {
|
||||||
|
label: 'Update Payment Method',
|
||||||
|
url: '/settings/billing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send trial ending notification
|
||||||
|
*/
|
||||||
|
async notifyTrialEnding(userId, daysRemaining = 3) {
|
||||||
|
return this.sendNotification(userId, {
|
||||||
|
type: 'both',
|
||||||
|
category: 'subscription',
|
||||||
|
title: 'Trial Ending Soon',
|
||||||
|
message: `Your premium trial will end in ${daysRemaining} days. Add a payment method to continue enjoying premium features without interruption.`,
|
||||||
|
metadata: {
|
||||||
|
template: 'trial-ending',
|
||||||
|
daysRemaining,
|
||||||
|
action: {
|
||||||
|
label: 'Add Payment Method',
|
||||||
|
url: '/settings/billing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notifications for user
|
||||||
|
*/
|
||||||
|
async getUnreadNotifications(userId, limit = 50) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT * FROM notifications
|
||||||
|
WHERE user_id = $1 AND read = false
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
[userId, limit]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
async markAsRead(notificationId, userId) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE notifications
|
||||||
|
SET read = true, read_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2`,
|
||||||
|
[notificationId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read for user
|
||||||
|
*/
|
||||||
|
async markAllAsRead(userId) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE notifications
|
||||||
|
SET read = true, read_at = NOW()
|
||||||
|
WHERE user_id = $1 AND read = false`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new NotificationService();
|
||||||
|
|
@ -86,21 +86,88 @@ class SocketService {
|
||||||
// Join all conversations user is part of
|
// Join all conversations user is part of
|
||||||
this.joinUserConversations(socket, userId);
|
this.joinUserConversations(socket, userId);
|
||||||
|
|
||||||
// Handle events
|
// Handle events with error handling
|
||||||
socket.on('join_conversation', (data) => this.handleJoinConversation(socket, data));
|
socket.on('join_conversation', async (data) => {
|
||||||
socket.on('leave_conversation', (data) => this.handleLeaveConversation(socket, data));
|
try {
|
||||||
socket.on('typing_start', (data) => this.handleTypingStart(socket, data));
|
await this.handleJoinConversation(socket, data);
|
||||||
socket.on('typing_stop', (data) => this.handleTypingStop(socket, data));
|
} catch (error) {
|
||||||
socket.on('call_signal', (data) => this.handleCallSignal(socket, data));
|
console.error('Error handling join_conversation:', error);
|
||||||
|
socket.emit('error', { event: 'join_conversation', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave_conversation', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleLeaveConversation(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling leave_conversation:', error);
|
||||||
|
socket.emit('error', { event: 'leave_conversation', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('typing_start', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleTypingStart(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling typing_start:', error);
|
||||||
|
socket.emit('error', { event: 'typing_start', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('typing_stop', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleTypingStop(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling typing_stop:', error);
|
||||||
|
socket.emit('error', { event: 'typing_stop', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call_signal', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleCallSignal(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling call_signal:', error);
|
||||||
|
socket.emit('error', { event: 'call_signal', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Call signaling events
|
// Call signaling events
|
||||||
socket.on('call:offer', (data) => this.handleCallOffer(socket, data));
|
socket.on('call:offer', async (data) => {
|
||||||
socket.on('call:answer', (data) => this.handleCallAnswer(socket, data));
|
try {
|
||||||
socket.on('call:ice-candidate', (data) => this.handleIceCandidate(socket, data));
|
await this.handleCallOffer(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling call:offer:', error);
|
||||||
|
socket.emit('call:error', { type: 'offer', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call:answer', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleCallAnswer(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling call:answer:', error);
|
||||||
|
socket.emit('call:error', { type: 'answer', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call:ice-candidate', async (data) => {
|
||||||
|
try {
|
||||||
|
await this.handleIceCandidate(socket, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling call:ice-candidate:', error);
|
||||||
|
socket.emit('call:error', { type: 'ice-candidate', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Disconnect handler
|
// Disconnect handler
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', async () => {
|
||||||
this.handleDisconnect(socket, userId);
|
try {
|
||||||
|
await this.handleDisconnect(socket, userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling disconnect:', error);
|
||||||
|
// Don't emit error as socket is disconnecting
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ export default function Chat() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [typingUsers, setTypingUsers] = useState(new Set());
|
const [typingUsers, setTypingUsers] = useState(new Set());
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const typingTimeoutRef = useRef(null);
|
const typingTimeoutRef = useRef(null);
|
||||||
|
const activeConversationRef = useRef(null);
|
||||||
|
|
||||||
// Load conversations on mount
|
// Load conversations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -101,7 +102,9 @@ export default function Chat() {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
// Only update messages if this conversation is still active
|
||||||
|
// This prevents race conditions when rapidly switching conversations
|
||||||
|
if (data.success && activeConversationRef.current === conversationId) {
|
||||||
setMessages(data.messages);
|
setMessages(data.messages);
|
||||||
|
|
||||||
// Mark as read
|
// Mark as read
|
||||||
|
|
@ -116,8 +119,16 @@ export default function Chat() {
|
||||||
|
|
||||||
// Select conversation
|
// Select conversation
|
||||||
const selectConversation = async (conversation) => {
|
const selectConversation = async (conversation) => {
|
||||||
|
const conversationId = conversation.id;
|
||||||
|
|
||||||
|
// Update state and ref immediately to prevent race conditions
|
||||||
setActiveConversation(conversation);
|
setActiveConversation(conversation);
|
||||||
await loadMessages(conversation.id);
|
activeConversationRef.current = conversationId;
|
||||||
|
|
||||||
|
// Clear current messages while loading
|
||||||
|
setMessages([]);
|
||||||
|
|
||||||
|
await loadMessages(conversationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle new message
|
// Handle new message
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ export function AuthProvider({ children }) {
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
// Mock login - in production, call actual API
|
// Mock login - in production, call actual API
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3000/api/auth/login', {
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||||
|
const response = await fetch(`${apiUrl}/api/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password })
|
body: JSON.stringify({ email, password })
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue