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
11
.env.example
11
.env.example
|
|
@ -4,6 +4,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aethex_passport
|
|||
# Server Configuration
|
||||
PORT=3000
|
||||
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)
|
||||
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_MAX_REQUESTS=100
|
||||
# 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_PORT=3478
|
||||
TURN_SECRET=your-turn-secret-key
|
||||
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 stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
const db = require('../../database/db');
|
||||
const notificationService = require('../../services/notificationService');
|
||||
|
||||
/**
|
||||
* Stripe webhook endpoint
|
||||
|
|
@ -143,8 +144,8 @@ async function handleSubscriptionDeleted(subscription) {
|
|||
|
||||
console.log(`[Webhook] User ${userId} downgraded to free tier`);
|
||||
|
||||
// TODO: Send notification to user about downgrade
|
||||
// Could trigger email, in-app notification, etc.
|
||||
// Send notification to user about downgrade
|
||||
await notificationService.notifySubscriptionDowngrade(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +178,13 @@ async function handlePaymentSucceeded(invoice) {
|
|||
|
||||
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}`);
|
||||
|
||||
// TODO: Send notification to user about failed payment
|
||||
// Include link to update payment method
|
||||
// Send notification to user about failed payment
|
||||
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) {
|
||||
// TODO: Send notification to user that trial is ending
|
||||
// Remind them to add payment method
|
||||
console.log(`[Webhook] Trial ending notification for user ${subResult.rows[0].user_id}`);
|
||||
const userId = 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,6 +263,34 @@ class MessagingService {
|
|||
* Send a message
|
||||
*/
|
||||
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
|
||||
await this.verifyParticipant(conversationId, userId);
|
||||
|
||||
|
|
|
|||
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
|
||||
this.joinUserConversations(socket, userId);
|
||||
|
||||
// Handle events
|
||||
socket.on('join_conversation', (data) => this.handleJoinConversation(socket, data));
|
||||
socket.on('leave_conversation', (data) => this.handleLeaveConversation(socket, data));
|
||||
socket.on('typing_start', (data) => this.handleTypingStart(socket, data));
|
||||
socket.on('typing_stop', (data) => this.handleTypingStop(socket, data));
|
||||
socket.on('call_signal', (data) => this.handleCallSignal(socket, data));
|
||||
// Handle events with error handling
|
||||
socket.on('join_conversation', async (data) => {
|
||||
try {
|
||||
await this.handleJoinConversation(socket, data);
|
||||
} catch (error) {
|
||||
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
|
||||
socket.on('call:offer', (data) => this.handleCallOffer(socket, data));
|
||||
socket.on('call:answer', (data) => this.handleCallAnswer(socket, data));
|
||||
socket.on('call:ice-candidate', (data) => this.handleIceCandidate(socket, data));
|
||||
socket.on('call:offer', async (data) => {
|
||||
try {
|
||||
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
|
||||
socket.on('disconnect', () => {
|
||||
this.handleDisconnect(socket, userId);
|
||||
socket.on('disconnect', async () => {
|
||||
try {
|
||||
await this.handleDisconnect(socket, userId);
|
||||
} catch (error) {
|
||||
console.error('Error handling disconnect:', error);
|
||||
// Don't emit error as socket is disconnecting
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export default function Chat() {
|
|||
const [error, setError] = useState(null);
|
||||
|
||||
const typingTimeoutRef = useRef(null);
|
||||
const activeConversationRef = useRef(null);
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -101,7 +102,9 @@ export default function Chat() {
|
|||
|
||||
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);
|
||||
|
||||
// Mark as read
|
||||
|
|
@ -116,8 +119,16 @@ export default function Chat() {
|
|||
|
||||
// Select conversation
|
||||
const selectConversation = async (conversation) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
// Update state and ref immediately to prevent race conditions
|
||||
setActiveConversation(conversation);
|
||||
await loadMessages(conversation.id);
|
||||
activeConversationRef.current = conversationId;
|
||||
|
||||
// Clear current messages while loading
|
||||
setMessages([]);
|
||||
|
||||
await loadMessages(conversationId);
|
||||
};
|
||||
|
||||
// Handle new message
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export function AuthProvider({ children }) {
|
|||
const login = async (email, password) => {
|
||||
// Mock login - in production, call actual API
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
|
|
|
|||
Loading…
Reference in a new issue