Merge pull request #2 from AeThex-Corporation/claude/find-fix-bug-mkkscfv6hiao6uvi-LBUkV
Claude/find fix bug mkkscfv6hiao6uvi lb uk v
This commit is contained in:
commit
fa1d0fcc70
11 changed files with 442 additions and 40 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_...
|
||||||
|
|
@ -7,21 +7,28 @@ const jwt = require('jsonwebtoken');
|
||||||
function authenticateUser(req, res, next) {
|
function authenticateUser(req, res, next) {
|
||||||
try {
|
try {
|
||||||
// In development mode, allow requests without auth for testing
|
// In development mode, allow requests without auth for testing
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// SECURITY: This bypass only works if BOTH conditions are met:
|
||||||
|
// 1. NODE_ENV is 'development'
|
||||||
|
// 2. ALLOW_DEV_BYPASS is explicitly set to 'true'
|
||||||
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
const allowDevBypass = process.env.ALLOW_DEV_BYPASS === 'true';
|
||||||
|
|
||||||
|
if (isDevelopment && allowDevBypass) {
|
||||||
// Check for token, but if not present, use demo user
|
// Check for token, but if not present, use demo user
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
// Use demo user for development
|
// Use demo user for development
|
||||||
|
console.warn('⚠️ Development mode: Using demo user without authentication');
|
||||||
req.user = {
|
req.user = {
|
||||||
id: 'demo-user-123',
|
id: 'demo-user-123',
|
||||||
email: 'demo@aethex.dev'
|
email: 'demo@aethex.dev'
|
||||||
};
|
};
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
// Try to verify, but don't fail if invalid
|
// Try to verify, but don't fail if invalid
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
@ -31,15 +38,16 @@ function authenticateUser(req, res, next) {
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Use demo user if token invalid
|
// Use demo user if token invalid
|
||||||
|
console.warn('⚠️ Development mode: Invalid token, using demo user');
|
||||||
req.user = {
|
req.user = {
|
||||||
id: 'demo-user-123',
|
id: 'demo-user-123',
|
||||||
email: 'demo@aethex.dev'
|
email: 'demo@aethex.dev'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: Strict auth required
|
// Production: Strict auth required
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ class CallService {
|
||||||
[conversationId]
|
[conversationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (conversationResult.rows.length === 0) {
|
||||||
|
throw new Error(`Conversation ${conversationId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
const conversation = conversationResult.rows[0];
|
const conversation = conversationResult.rows[0];
|
||||||
|
|
||||||
// Determine if group call
|
// Determine if group call
|
||||||
|
|
@ -249,9 +253,12 @@ class CallService {
|
||||||
async generateTURNCredentials(userId) {
|
async generateTURNCredentials(userId) {
|
||||||
const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL
|
const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL
|
||||||
const username = `${timestamp}:${userId}`;
|
const username = `${timestamp}:${userId}`;
|
||||||
|
|
||||||
// Generate credential using HMAC
|
// Generate credential using HMAC
|
||||||
const turnSecret = process.env.TURN_SECRET || 'default-secret-change-me';
|
const turnSecret = process.env.TURN_SECRET;
|
||||||
|
if (!turnSecret) {
|
||||||
|
throw new Error('TURN_SECRET environment variable must be set for TURN server authentication');
|
||||||
|
}
|
||||||
const hmac = crypto.createHmac('sha1', turnSecret);
|
const hmac = crypto.createHmac('sha1', turnSecret);
|
||||||
hmac.update(username);
|
hmac.update(username);
|
||||||
const credential = hmac.digest('base64');
|
const credential = hmac.digest('base64');
|
||||||
|
|
@ -265,8 +272,12 @@ class CallService {
|
||||||
[userId, username, credential, timestamp]
|
[userId, username, credential, timestamp]
|
||||||
);
|
);
|
||||||
|
|
||||||
const turnHost = process.env.TURN_SERVER_HOST || 'turn.aethex.app';
|
const turnHost = process.env.TURN_SERVER_HOST;
|
||||||
const turnPort = process.env.TURN_SERVER_PORT || '3478';
|
const turnPort = process.env.TURN_SERVER_PORT;
|
||||||
|
|
||||||
|
if (!turnHost || !turnPort) {
|
||||||
|
throw new Error('TURN_SERVER_HOST and TURN_SERVER_PORT environment variables must be set');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
urls: [
|
urls: [
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ class GameForgeIntegrationService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (integrationResult.rows.length === 0) {
|
||||||
|
throw new Error('Failed to create GameForge integration');
|
||||||
|
}
|
||||||
|
|
||||||
const integration = integrationResult.rows[0];
|
const integration = integrationResult.rows[0];
|
||||||
|
|
||||||
// Create channels
|
// Create channels
|
||||||
|
|
@ -88,13 +92,17 @@ class GameForgeIntegrationService {
|
||||||
const description = this.getChannelDescription(channelName);
|
const description = this.getChannelDescription(channelName);
|
||||||
|
|
||||||
const conversationResult = await db.query(
|
const conversationResult = await db.query(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
(type, title, description, created_by, gameforge_project_id)
|
(type, title, description, created_by, gameforge_project_id)
|
||||||
VALUES ('group', $1, $2, $3, $4)
|
VALUES ('group', $1, $2, $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[title, description, ownerId, projectId]
|
[title, description, ownerId, projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (conversationResult.rows.length === 0) {
|
||||||
|
throw new Error('Failed to create conversation for GameForge channel');
|
||||||
|
}
|
||||||
|
|
||||||
const conversation = conversationResult.rows[0];
|
const conversation = conversationResult.rows[0];
|
||||||
|
|
||||||
// Add team members based on permissions
|
// Add team members based on permissions
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -287,8 +315,13 @@ class MessagingService {
|
||||||
`SELECT username, verified_domain, avatar_url FROM users WHERE id = $1`,
|
`SELECT username, verified_domain, avatar_url FROM users WHERE id = $1`,
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
throw new Error(`User ${userId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
const user = userResult.rows[0];
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
conversationId: message.conversation_id,
|
conversationId: message.conversation_id,
|
||||||
|
|
|
||||||
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 })
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ export async function encryptMessage(message, recipientPublicKeys) {
|
||||||
|
|
||||||
// Encrypt AES key for each recipient with their RSA public key
|
// Encrypt AES key for each recipient with their RSA public key
|
||||||
const encryptedKeys = {};
|
const encryptedKeys = {};
|
||||||
|
const failedRecipients = [];
|
||||||
|
|
||||||
for (const recipientKeyB64 of recipientPublicKeys) {
|
for (const recipientKeyB64 of recipientPublicKeys) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -201,10 +202,22 @@ export async function encryptMessage(message, recipientPublicKeys) {
|
||||||
|
|
||||||
encryptedKeys[recipientKeyB64] = arrayBufferToBase64(encryptedKey);
|
encryptedKeys[recipientKeyB64] = arrayBufferToBase64(encryptedKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to encrypt for recipient:', error);
|
failedRecipients.push({
|
||||||
|
publicKey: recipientKeyB64.substring(0, 20) + '...', // Truncate for error message
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throw error if any encryptions failed to prevent partial message delivery
|
||||||
|
if (failedRecipients.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to encrypt message for ${failedRecipients.length} recipient(s): ${
|
||||||
|
failedRecipients.map(r => r.error).join(', ')
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ciphertext: arrayBufferToBase64(encryptedMessage),
|
ciphertext: arrayBufferToBase64(encryptedMessage),
|
||||||
iv: arrayBufferToBase64(iv),
|
iv: arrayBufferToBase64(iv),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue