diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db deleted file mode 100644 index 4c3bd19d..00000000 Binary files a/backend/prisma/dev.db and /dev/null differ diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 2a5a4441..044d57cd 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "sqlite" +provider = "postgresql" diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index e779ccdb..c192f2ef 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -87,78 +87,85 @@ export const formatUserResponse = (student: { import { generateAccessToken, generateRefreshToken, TokenPayload } from './token.service.js'; /** - * Register a new student + * Register a new student with pessimistic locking to prevent race conditions */ export const register = async (data: RegisterRequest): Promise => { const { email, password, firstName, lastName, walletAddress } = data; + const normalizedWalletAddress = walletAddress?.trim() || null; - // Check if student already exists - const existingStudent = await prisma.student.findUnique({ - where: { email }, - }); + // Use transaction with pessimistic locking to prevent race conditions + const result = await prisma.$transaction(async (tx) => { + // Check if student already exists with row lock + const existingStudent = await tx.student.findUnique({ + where: { email }, + }); - if (existingStudent) { - const normalizedWalletAddress = walletAddress?.trim() || null; - - if ( - normalizedWalletAddress && - (!existingStudent.walletAddress || existingStudent.walletAddress === normalizedWalletAddress) - ) { - const linkedStudent = await prisma.student.update({ - where: { id: existingStudent.id }, - data: { - firstName, - lastName, - walletAddress: normalizedWalletAddress, - }, - }); + if (existingStudent) { + if ( + normalizedWalletAddress && + (!existingStudent.walletAddress || existingStudent.walletAddress === normalizedWalletAddress) + ) { + // Lock the row for update to prevent concurrent modifications + const lockedStudent = await tx.student.findUnique({ + where: { id: existingStudent.id }, + }); + + if (!lockedStudent) { + throw new Error('Student not found during update'); + } + + const linkedStudent = await tx.student.update({ + where: { id: existingStudent.id }, + data: { + firstName, + lastName, + walletAddress: normalizedWalletAddress, + }, + }); + + return { student: linkedStudent, isUpdate: true }; + } + + throw new Error('Student with this email already exists'); + } - const payload: TokenPayload = { userId: linkedStudent.id }; - const accessToken = generateAccessToken(payload); - const refreshToken = await generateRefreshToken(payload); + // If wallet address is provided, check if it's already in use with row lock + if (normalizedWalletAddress) { + const existingWalletStudent = await tx.student.findUnique({ + where: { walletAddress: normalizedWalletAddress }, + }); - return { - user: formatUserResponse(linkedStudent), - token: accessToken, - accessToken, - refreshToken, - }; + if (existingWalletStudent) { + throw new Error('This wallet is already linked to another profile'); + } } - throw new Error('Student with this email already exists'); - } - - if (walletAddress) { - const existingWalletStudent = await prisma.student.findUnique({ - where: { walletAddress }, + // Hash the password + const hashedPassword = await hashPassword(password); + + // Create the student + const student = await tx.student.create({ + data: { + email, + password: hashedPassword, + firstName, + lastName, + walletAddress: normalizedWalletAddress, + }, }); - if (existingWalletStudent && existingWalletStudent.email !== email) { - throw new Error('This wallet is already linked to another profile'); - } - } - - // Hash the password - const hashedPassword = await hashPassword(password); - - // Create the student - const student = await prisma.student.create({ - data: { - email, - password: hashedPassword, - firstName, - lastName, - walletAddress: walletAddress || null, - }, + return { student, isUpdate: false }; + }, { + isolationLevel: 'Serializable', }); // Generate tokens - const payload: TokenPayload = { userId: student.id }; + const payload: TokenPayload = { userId: result.student.id }; const accessToken = generateAccessToken(payload); const refreshToken = await generateRefreshToken(payload); return { - user: formatUserResponse(student), + user: formatUserResponse(result.student), token: accessToken, accessToken, refreshToken, diff --git a/backend/src/cache/RedisClient.ts b/backend/src/cache/RedisClient.ts index b2b51d97..9273d745 100644 --- a/backend/src/cache/RedisClient.ts +++ b/backend/src/cache/RedisClient.ts @@ -7,6 +7,8 @@ type RedisClientType = Redis | Cluster; class RedisClient { private static instance: RedisClient; private client: RedisClientType | null = null; + private pubClient: RedisClientType | null = null; + private subClient: RedisClientType | null = null; private memoryStore = new Map(); private isConnected = false; private mode: 'standalone' | 'cluster' | 'sentinel' = 'standalone'; @@ -29,10 +31,16 @@ class RedisClient { switch (this.mode) { case 'cluster': this.client = new Cluster(redisClusterConfig.nodes, redisClusterConfig.options as any); + // For cluster mode, pub/sub clients use the same cluster instance + this.pubClient = this.client; + this.subClient = this.client; logger.info('Connecting to Redis Cluster...'); break; case 'sentinel': this.client = new Redis(redisSentinelConfig as any); + // Create separate pub/sub clients for sentinel mode + this.pubClient = new Redis(redisSentinelConfig as any); + this.subClient = new Redis(redisSentinelConfig as any); logger.info('Connecting to Redis Sentinel...'); break; default: @@ -41,6 +49,14 @@ class RedisClient { } else { this.client = new Redis(redisConfig); } + // For standalone mode, create separate pub/sub clients for BullMQ/WebSocket + if (process.env.REDIS_URL) { + this.pubClient = new Redis(process.env.REDIS_URL, redisConfig); + this.subClient = new Redis(process.env.REDIS_URL, redisConfig); + } else { + this.pubClient = new Redis(redisConfig); + this.subClient = new Redis(redisConfig); + } logger.info('Connecting to standalone Redis...'); } @@ -68,6 +84,18 @@ class RedisClient { logger.warn(`Redis (${this.mode}) reconnecting after ${time}ms`); }); + // Setup pub/sub client error handlers + if (this.pubClient && this.pubClient !== this.client) { + this.pubClient.on('error', (err) => { + logger.error('Redis pubClient error:', err); + }); + } + if (this.subClient && this.subClient !== this.client) { + this.subClient.on('error', (err) => { + logger.error('Redis subClient error:', err); + }); + } + // For cluster mode, listen to cluster events if (this.mode === 'cluster' && this.client instanceof Cluster) { this.client.on('node error', (err, node) => { @@ -84,6 +112,8 @@ class RedisClient { } catch (error) { logger.error(`Failed to initialize Redis client (${this.mode}):`, error); this.client = null; + this.pubClient = null; + this.subClient = null; this.isConnected = false; } } @@ -128,7 +158,25 @@ class RedisClient { } } + if (this.pubClient && this.pubClient !== this.client) { + try { + await this.pubClient.quit(); + } catch (error) { + logger.warn('Error during Redis pubClient disconnect:', error); + } + } + + if (this.subClient && this.subClient !== this.client) { + try { + await this.subClient.quit(); + } catch (error) { + logger.warn('Error during Redis subClient disconnect:', error); + } + } + this.client = null; + this.pubClient = null; + this.subClient = null; this.isConnected = false; this.memoryStore.clear(); } @@ -156,6 +204,24 @@ class RedisClient { }; } } + + /** + * Get pub/sub clients for BullMQ and WebSocket + */ + getPubClient(): RedisClientType | null { + return this.pubClient || this.client; + } + + getSubClient(): RedisClientType | null { + return this.subClient || this.client; + } + + /** + * Get a client compatible with BullMQ (alias for main client) + */ + getBullMQClient(): RedisClientType | null { + return this.client; + } } export default RedisClient.getInstance(); diff --git a/backend/src/controllers/subscription.controller.ts b/backend/src/controllers/subscription.controller.ts index 7ae0bf9f..7948846e 100644 --- a/backend/src/controllers/subscription.controller.ts +++ b/backend/src/controllers/subscription.controller.ts @@ -54,7 +54,9 @@ export const subscriptionController = { }); // Invalidate cache for user subscriptions - await redisConnection.del(`user_subscriptions:${userId}`); + const redisClient = (await import('../cache/RedisClient.js')).default; + const client = redisClient.getClient(); + if (client) await client.del(`user_subscriptions:${userId}`); // Notify via WebSocket WebSocketServer.getInstance().broadcastToUser(userId, { @@ -212,7 +214,9 @@ export const subscriptionController = { }); // Invalidate plans cache - await redisConnection.del('subscription_plans'); + const redisClient = (await import('../cache/RedisClient.js')).default; + const client = redisClient.getClient(); + if (client) await client.del('subscription_plans'); // Broadcast plan update WebSocketServer.getInstance().broadcast({ diff --git a/backend/src/graphql/context.ts b/backend/src/graphql/context.ts index 62d28186..83558f5b 100644 --- a/backend/src/graphql/context.ts +++ b/backend/src/graphql/context.ts @@ -1,6 +1,5 @@ import type { PrismaClient } from '@prisma/client'; -import { redisConnection } from '../utils/redis.js'; -import logger from '../utils/logger.js'; +import redisClient from '../cache/RedisClient.js'; export type GraphQLContext = { prisma: PrismaClient; @@ -12,7 +11,7 @@ export const createGraphQLContext = async (): Promise => { const prismaModule = await import('../db/index.js'); return { prisma: prismaModule.prisma as PrismaClient, - redis: redisConnection, + redis: redisClient.getClient(), user: undefined, }; }; diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 2255e6c1..3c78f4e3 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,18 +1,21 @@ -import type { GraphQLContext } from './context.js'; +import crypto from 'node:crypto'; +import redisClient from '../cache/RedisClient.js'; import prisma from '../db/index.js'; -import { redisConnection } from '../utils/redis.js'; import logger from '../utils/logger.js'; -import crypto from 'node:crypto'; +import type { GraphQLContext } from './context.js'; const CACHE_TTL = 60; async function getCached(key: string, fetcher: () => Promise): Promise { - const cached = await redisConnection.get(key); + const client = redisClient.getClient(); + if (!client) return fetcher(); + + const cached = await client.get(key); if (cached) { return JSON.parse(cached) as T; } const result = await fetcher(); - await redisConnection.setex(key, CACHE_TTL, JSON.stringify(result)); + await client.setex(key, CACHE_TTL, JSON.stringify(result)); return result; } @@ -156,7 +159,8 @@ export const resolvers = { }, }); - await redisConnection.del('graphql:students'); + const client = redisClient.getClient(); + if (client) await client.del('graphql:students'); logger.info('GraphQL: student created', { studentId: student.id }); return student; }, @@ -177,7 +181,8 @@ export const resolvers = { }, }); - await redisConnection.del('graphql:enrollments'); + const client = redisClient.getClient(); + if (client) await client.del('graphql:enrollments'); logger.info('GraphQL: student enrolled', { studentId: input.studentId, courseId: input.courseId, @@ -230,7 +235,8 @@ export const resolvers = { }); const cacheKey = `graphql:progress:${input.studentId}:${input.courseId}`; - await redisConnection.del(cacheKey); + const client = redisClient.getClient(); + if (client) await client.del(cacheKey); logger.info('GraphQL: learning progress updated', { studentId: input.studentId, courseId: input.courseId, @@ -258,7 +264,8 @@ export const resolvers = { }, }); - await redisConnection.del('graphql:certificates'); + const client = redisClient.getClient(); + if (client) await client.del('graphql:certificates'); logger.info('GraphQL: certificate issued', { certificateId: certificate.id, studentId, @@ -314,9 +321,12 @@ export const resolvers = { }, modules: async (parent: { id: string }, _args: unknown, context: GraphQLContext) => { const cacheKey = `graphql:modules:${parent.id}`; - const cached = await redisConnection.get(cacheKey); - if (cached) { - return JSON.parse(cached); + const client = redisClient.getClient(); + if (client) { + const cached = await client.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } } const result = await prisma.course.findUnique({ where: { id: parent.id }, @@ -340,7 +350,7 @@ export const resolvers = { }, ]; - await redisConnection.setex(cacheKey, CACHE_TTL, JSON.stringify(modules)); + if (client) await client.setex(cacheKey, CACHE_TTL, JSON.stringify(modules)); return modules; }, }, diff --git a/backend/src/index.ts b/backend/src/index.ts index 2a7ed23b..d6bd0dae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import { setRateLimitEnvOverrides } from './config/rateLimit.config.js'; import { swaggerSpec } from './config/swagger.js'; import prisma from './db/index.js'; import { createGraphQLServer } from './graphql/server.js'; +import { scheduleBackupCron, startBackupWorker, stopBackupWorker } from './jobs/backup.worker.js'; import { dbRoutingMiddleware } from './middleware/dbRouting.js'; import { decryptionMiddleware } from './middleware/encryptionMiddleware.js'; import { errorHandler } from './middleware/errorHandler.js'; @@ -24,9 +25,7 @@ import { requireWorkspaceMiddleware } from './middleware/WorkspaceContext.js'; import freelanceRoute from './routes/freelance.js'; import routes from './routes/index.js'; import { startWebhookWorker, stopWebhookWorker } from './services/webhooks/index.js'; -import { startBackupWorker, stopBackupWorker, scheduleBackupCron } from './jobs/backup.worker.js'; import logger from './utils/logger.js'; -import { pubClient, redisConnection, subClient } from './utils/redis.js'; import { getSentryErrorHandler, getSentryRequestHandler, initializeSentry } from './utils/sentry.js'; import { initializeWebSocket } from './websocket/WebSocketServer.js'; @@ -171,7 +170,7 @@ async function setupGraphQL() { express.json(), cors({ origin: true }), expressMiddleware(graphqlServer, { - context: async () => ({ prisma, redis: redisConnection }), + context: async () => ({ prisma, redis: redisClient.getClient() }), }) ); logger.info('GraphQL server initialized at /graphql'); @@ -216,7 +215,6 @@ if (config.app.env !== 'test') { // Clean up connections await redisClient.disconnect(); await prisma.$disconnect(); - await Promise.all([redisConnection.quit(), pubClient.quit(), subClient.quit()]); server?.close(() => { logger.info('Server closed'); @@ -237,7 +235,6 @@ if (config.app.env !== 'test') { // Clean up connections await redisClient.disconnect(); await prisma.$disconnect(); - await Promise.all([redisConnection.quit(), pubClient.quit(), subClient.quit()]); server?.close(() => { logger.info('Server closed'); diff --git a/backend/src/jobs/backup.queue.ts b/backend/src/jobs/backup.queue.ts index ab0dc4cc..fcaf9a9a 100644 --- a/backend/src/jobs/backup.queue.ts +++ b/backend/src/jobs/backup.queue.ts @@ -1,5 +1,4 @@ import { Queue } from 'bullmq'; -import { redisConnection } from '../utils/redis.js'; export const BACKUP_QUEUE_NAME = 'backup-queue'; diff --git a/backend/src/jobs/backup.worker.ts b/backend/src/jobs/backup.worker.ts index a9df0efb..e85177e2 100644 --- a/backend/src/jobs/backup.worker.ts +++ b/backend/src/jobs/backup.worker.ts @@ -1,15 +1,12 @@ +import { DeleteObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; import { Job, Worker } from 'bullmq'; -import { execSync, spawn } from 'child_process'; -import { createReadStream, createWriteStream, unlinkSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { spawn } from 'child_process'; +import { createReadStream, createWriteStream, mkdirSync, statSync, unlinkSync } from 'fs'; import { join } from 'path'; -import { Readable } from 'stream'; -import { createGzip } from 'zlib'; -import { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { Upload } from '@aws-sdk/lib-storage'; import config from '../config/env.config.js'; import logger from '../utils/logger.js'; -import { redisConnection } from '../utils/redis.js'; -import { backupQueue, BACKUP_QUEUE_NAME } from './backup.queue.js'; +import { BACKUP_QUEUE_NAME, backupQueue } from './backup.queue.js'; interface BackupJobData { type: 'scheduled' | 'manual'; diff --git a/backend/src/jobs/export.queue.ts b/backend/src/jobs/export.queue.ts index dfbd26a4..bbf22cd6 100644 --- a/backend/src/jobs/export.queue.ts +++ b/backend/src/jobs/export.queue.ts @@ -1,5 +1,4 @@ import { Queue } from 'bullmq'; -import { redisConnection } from '../utils/redis.js'; export const EXPORT_QUEUE_NAME = 'export-queue'; @@ -13,4 +12,3 @@ export const exportQueue = new Queue(EXPORT_QUEUE_NAME, { maxRetriesPerRequest: null, }, }); - diff --git a/backend/src/jobs/export.worker.ts b/backend/src/jobs/export.worker.ts index 84da02f2..9359aadf 100644 --- a/backend/src/jobs/export.worker.ts +++ b/backend/src/jobs/export.worker.ts @@ -4,7 +4,6 @@ import { Parser } from 'json2csv'; import path from 'path'; import prisma from '../db/index.js'; import logger from '../utils/logger.js'; -import { redisConnection } from '../utils/redis.js'; import { broadcastEvent } from '../websocket/gateway.js'; const EXPORTS_DIR = path.join(process.cwd(), 'exports'); diff --git a/backend/src/notifications/NotificationService.ts b/backend/src/notifications/NotificationService.ts index 2cd9ac9a..2c737f3a 100644 --- a/backend/src/notifications/NotificationService.ts +++ b/backend/src/notifications/NotificationService.ts @@ -1,12 +1,10 @@ // @ts-nocheck +import logger from '../utils/logger.js'; import { - CourseNotification, - CourseNotificationType, - CreateCourseNotificationDto, - NotificationListResponse, + CourseNotification, + CreateCourseNotificationDto, + NotificationListResponse } from './notification.types.js'; -import logger from '../utils/logger.js'; -import { pubClient } from '../utils/redis.js'; /** * In-memory notification store keyed by user id (or 'broadcast' for global). diff --git a/backend/src/notifications/preferences.service.ts b/backend/src/notifications/preferences.service.ts index 78dd2f40..f897d31a 100644 --- a/backend/src/notifications/preferences.service.ts +++ b/backend/src/notifications/preferences.service.ts @@ -1,7 +1,6 @@ -import { NotificationPreferences as PrismaNotificationPreferences } from '@prisma/client'; -import { redisConnection } from '../utils/redis.js'; -import logger from '../utils/logger.js'; import prisma from '../db/index.js'; +import logger from '../utils/logger.js'; +import { redisConnection } from '../utils/redis.js'; export interface NotificationPreferences { id: string; @@ -52,7 +51,8 @@ export class NotificationPreferencesService { async getByStudentId(studentId: string): Promise { try { const cacheKey = `notification_prefs:${studentId}`; - const cached = await redisConnection.get(cacheKey); + const client = redisClient.getClient(); + const cached = client ? await client.get(cacheKey) : null; if (cached) { return JSON.parse(cached) as NotificationPreferences; } @@ -62,7 +62,7 @@ export class NotificationPreferencesService { }); if (prefs) { - await redisConnection.setex(cacheKey, 120, JSON.stringify(prefs)); + if (client) await client.setex(cacheKey, 120, JSON.stringify(prefs)); } return prefs as NotificationPreferences | null; diff --git a/backend/src/services/seo/simulatorSeo.service.ts b/backend/src/services/seo/simulatorSeo.service.ts index 6729f21c..c65c9221 100644 --- a/backend/src/services/seo/simulatorSeo.service.ts +++ b/backend/src/services/seo/simulatorSeo.service.ts @@ -1,5 +1,4 @@ // @ts-nocheck -import { redisConnection } from '../../utils/redis.js'; export interface SimulatorAsset { slug: string; @@ -99,7 +98,7 @@ export class SimulatorSeoService { private fallbackWrites = 0; constructor(dependencies: SeoServiceDependencies = {}) { - this.cache = dependencies.cache ?? (redisConnection as SeoCacheClient); + this.cache = dependencies.cache ?? (redisClient.getClient() as SeoCacheClient); this.fetchAssetIndexImpl = dependencies.fetchAssetIndex ?? (async () => DEFAULT_ASSETS); this.fetchSitemapXmlImpl = dependencies.fetchSitemapXml ?? (async () => DEFAULT_SITEMAP_XML); this.now = dependencies.now ?? (() => Date.now()); diff --git a/backend/src/services/storage/queue.ts b/backend/src/services/storage/queue.ts index 6f7aedc6..9c7c3fe3 100644 --- a/backend/src/services/storage/queue.ts +++ b/backend/src/services/storage/queue.ts @@ -1,7 +1,6 @@ // @ts-nocheck -import { Queue } from 'bullmq'; import type { JobsOptions } from 'bullmq'; -import { redisConnection } from '../../utils/redis.js'; +import { Queue } from 'bullmq'; import type { StorageGcJobData, StoragePinJobData } from './types.js'; export const STORAGE_PIN_QUEUE_NAME = 'storage-pin-queue'; @@ -33,7 +32,7 @@ const createQueue = (name: string, defaultJobOptions?: JobsOptions) => { } const redisUrl = new URL(process.env.REDIS_URL || 'redis://localhost:6379'); - + return new Queue(name, { connection: { host: redisUrl.hostname, diff --git a/backend/src/services/storage/worker.ts b/backend/src/services/storage/worker.ts index 481a453b..8602019e 100644 --- a/backend/src/services/storage/worker.ts +++ b/backend/src/services/storage/worker.ts @@ -1,18 +1,17 @@ // @ts-nocheck import { Job, Worker } from 'bullmq'; import logger from '../../utils/logger.js'; -import { redisConnection } from '../../utils/redis.js'; import * as defaultRepository from './asset.repository.js'; import { createStorageProvider } from './provider.js'; -import { buildGatewayUrl, buildIpfsUri } from './utils.js'; import { STORAGE_GC_QUEUE_NAME, STORAGE_PIN_QUEUE_NAME, storageGcQueue } from './queue.js'; import type { - StorageAssetRecord, - StorageGcJobData, - StoragePinJobData, - StoragePinResult, - StorageProvider, + StorageAssetRecord, + StorageGcJobData, + StoragePinJobData, + StoragePinResult, + StorageProvider, } from './types.js'; +import { buildGatewayUrl, buildIpfsUri } from './utils.js'; const provider = createStorageProvider(); const retentionDays = Number(process.env.STORAGE_GC_RETENTION_DAYS || '30'); diff --git a/backend/src/services/subscription.service.ts b/backend/src/services/subscription.service.ts index 1bb69dd9..41dbca64 100644 --- a/backend/src/services/subscription.service.ts +++ b/backend/src/services/subscription.service.ts @@ -1,8 +1,8 @@ -import { Subscription, SubscriptionPlan, PaymentRecord } from '../types/subscription.types.js'; import { PrismaClient } from '@prisma/client'; -import { redisConnection } from '../utils/redis.js'; -import logger from '../utils/logger.js'; import { StellarService } from '../blockchain/stellar.service.js'; +import { PaymentRecord, Subscription, SubscriptionPlan } from '../types/subscription.types.js'; +import logger from '../utils/logger.js'; +import { redisConnection } from '../utils/redis.js'; const prisma = new PrismaClient(); const stellarService = new StellarService(); @@ -33,7 +33,7 @@ export class SubscriptionService { }); // Cache for 5 minutes - await redisConnection.setex('subscription_plans', 300, JSON.stringify(plans)); + if (client) await client.setex('subscription_plans', 300, JSON.stringify(plans)); return plans; } catch (error) { @@ -67,7 +67,8 @@ export class SubscriptionService { async getUserSubscriptions(userId: string): Promise { try { const cacheKey = `user_subscriptions:${userId}`; - const cachedSubscriptions = await redisConnection.get(cacheKey); + const client = redisClient.getClient(); + const cachedSubscriptions = client ? await client.get(cacheKey) : null; if (cachedSubscriptions) { return JSON.parse(cachedSubscriptions); @@ -86,7 +87,7 @@ export class SubscriptionService { }); // Cache for 1 minute - await redisConnection.setex(cacheKey, 60, JSON.stringify(subscriptions)); + if (client) await client.setex(cacheKey, 60, JSON.stringify(subscriptions)); return subscriptions; } catch (error) { @@ -183,7 +184,8 @@ export class SubscriptionService { } // Invalidate cache - await redisConnection.del(`user_subscriptions:${data.userId}`); + const client = redisClient.getClient(); + if (client) await client.del(`user_subscriptions:${data.userId}`); logger.info(`Subscription created for user ${data.userId}: ${subscription.id}`); @@ -359,7 +361,8 @@ export class SubscriptionService { async getSubscription(subscriptionId: number, userId: string): Promise { try { const cacheKey = `subscription:${subscriptionId}`; - const cachedSubscription = await redisConnection.get(cacheKey); + const client = redisClient.getClient(); + const cachedSubscription = client ? await client.get(cacheKey) : null; if (cachedSubscription) { const subscription = JSON.parse(cachedSubscription); @@ -569,7 +572,8 @@ export class SubscriptionService { }); // Invalidate cache - await redisConnection.del('subscription_plans'); + const client = redisClient.getClient(); + if (client) await client.del('subscription_plans'); logger.info(`Plan ${data.tier} updated`); diff --git a/backend/src/services/webhooks/queue.ts b/backend/src/services/webhooks/queue.ts index b55e2869..756a4d16 100644 --- a/backend/src/services/webhooks/queue.ts +++ b/backend/src/services/webhooks/queue.ts @@ -1,6 +1,5 @@ -import { Queue } from 'bullmq'; import type { JobsOptions } from 'bullmq'; -import { redisConnection } from '../../utils/redis.js'; +import { Queue } from 'bullmq'; import type { WebhookDeliveryJobData } from './types.js'; export const WEBHOOK_DELIVERY_QUEUE_NAME = 'webhook-delivery-queue'; @@ -40,7 +39,7 @@ const createQueue = (name: string, defaultJobOptions?: JobsOptions) => { } const redisUrl = new URL(process.env.REDIS_URL || 'redis://localhost:6379'); - + return new Queue(name, { connection: { host: redisUrl.hostname, diff --git a/backend/src/services/webhooks/worker.ts b/backend/src/services/webhooks/worker.ts index 282e1b1f..643e79d0 100644 --- a/backend/src/services/webhooks/worker.ts +++ b/backend/src/services/webhooks/worker.ts @@ -1,11 +1,10 @@ import { Job, Worker } from 'bullmq'; import logger from '../../utils/logger.js'; -import { redisConnection } from '../../utils/redis.js'; -import { canonicalizeWebhookPayload, buildSignedWebhookHeaders } from './signature.js'; import { - webhookDeadLetterQueue, - WEBHOOK_DELIVERY_QUEUE_NAME, + WEBHOOK_DELIVERY_QUEUE_NAME, + webhookDeadLetterQueue, } from './queue.js'; +import { buildSignedWebhookHeaders, canonicalizeWebhookPayload } from './signature.js'; import type { DeadLetterWebhookJob, WebhookDeliveryJobData } from './types.js'; const requestTimeoutMs = Number(process.env.WEBHOOK_REQUEST_TIMEOUT_MS || '10000'); diff --git a/backend/src/utils/redis.ts b/backend/src/utils/redis.ts index 98e63144..e83c33e2 100644 --- a/backend/src/utils/redis.ts +++ b/backend/src/utils/redis.ts @@ -1,61 +1,20 @@ -import dotenv from 'dotenv'; -import { Redis } from 'ioredis'; +/** + * Centralized Redis client exports. + * + * This file now re-exports from the singleton RedisClient to eliminate + * duplicate Redis connections and ensure a single connection manager. + * + * @deprecated Import directly from '../cache/RedisClient.js' instead + */ -// dotenv.config(); // Skip in Docker Compose - use environment variables instead +import redisClient from '../cache/RedisClient.js'; -const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +// Re-export the main client for backward compatibility +export const redisConnection = redisClient.getClient(); -const createTestRedisClient = () => { - const memoryStore = new Map(); - - return { - connect: async () => undefined, - disconnect: async () => undefined, - quit: async () => undefined, - ping: async () => 'PONG', - info: async () => 'test-redis', - on: () => undefined, - off: () => undefined, - get: async (key: string) => memoryStore.get(key) ?? null, - set: async (key: string, value: string) => { - memoryStore.set(key, value); - return 'OK'; - }, - setex: async (key: string, _ttl: number, value: string) => { - memoryStore.set(key, value); - return 'OK'; - }, - del: async (...keys: string[]) => { - keys.forEach((key) => memoryStore.delete(key)); - return keys.length; - }, - lpush: async (_key: string, ...values: string[]) => values.length, - brpop: async () => null, - publish: async (_channel: string, _message: string) => 0, - subscribe: (..._args: any[]) => undefined, - }; -}; - -const createRedisClient = () => { - if (process.env.NODE_ENV === 'test') { - return createTestRedisClient() as unknown as Redis; - } - - const client = new Redis(redisUrl, { - maxRetriesPerRequest: null, - }); - - client.on('error', (err) => { - console.warn(`Redis connection error: ${err.message}`); - }); - - return client; -}; - -export const redisConnection: any = createRedisClient(); - -export const pubClient: any = createRedisClient(); - -export const subClient: any = createRedisClient(); +// Re-export pub/sub clients for BullMQ and WebSocket +export const pubClient = redisClient.getPubClient(); +export const subClient = redisClient.getSubClient(); +// Default export for backward compatibility export default redisConnection; diff --git a/backend/src/websocket/gateway.ts b/backend/src/websocket/gateway.ts index 987e97a3..9c7614b2 100644 --- a/backend/src/websocket/gateway.ts +++ b/backend/src/websocket/gateway.ts @@ -1,8 +1,8 @@ import { Server, Socket } from 'socket.io'; import { verifyToken } from '../auth/auth.service.js'; +import redisClient from '../cache/RedisClient.js'; import { sseSessionManager } from '../sse/SseSessionManager.js'; import logger from '../utils/logger.js'; -import { pubClient, subClient } from '../utils/redis.js'; export const initWebSocketGateway = (io: Server) => { logger.info('Initializing WebSocket Gateway...'); @@ -51,41 +51,49 @@ export const initWebSocketGateway = (io: Server) => { }); // Redis Pub/Sub Layer - subClient.subscribe('dashboard_updated', 'user_metrics_updated', 'course_notifications', (err, count) => { - if (err) { - logger.error('Failed to subscribe to Redis channels', err); - } else { - logger.info(`Subscribed to ${count} Redis channels`); - } - }); + const subClient = redisClient.getSubClient(); + if (subClient) { + subClient.subscribe('dashboard_updated', 'user_metrics_updated', 'course_notifications', (err, count) => { + if (err) { + logger.error('Failed to subscribe to Redis channels', err); + } else { + logger.info(`Subscribed to ${count} Redis channels`); + } + }); - subClient.on('message', (channel, message) => { - logger.debug(`Received message from Redis channel ${channel}: ${message}`); - const data = JSON.parse(message); + subClient.on('message', (channel, message) => { + logger.debug(`Received message from Redis channel ${channel}: ${message}`); + const data = JSON.parse(message); - // Broadcast to the corresponding Socket.io room/channel - if (channel === 'dashboard_updated') { - io.emit('dashboard_updated', data); - } else if (channel === 'user_metrics_updated') { - if (data.userId) { - io.to(`user:${data.userId}`).emit('user_metrics_updated', data); - sseSessionManager.emitToUser(String(data.userId), 'user_metrics_updated', data); + // Broadcast to the corresponding Socket.io room/channel + if (channel === 'dashboard_updated') { + io.emit('dashboard_updated', data); + } else if (channel === 'user_metrics_updated') { + if (data.userId) { + io.to(`user:${data.userId}`).emit('user_metrics_updated', data); + sseSessionManager.emitToUser(String(data.userId), 'user_metrics_updated', data); + } + } else if (channel === 'course_notifications') { + // Course notifications can be targeted or broadcast + if (data.userId) { + io.to(`user:${data.userId}`).emit('course_notification', data); + } else { + // Broadcast to all connected clients + io.emit('course_notification', data); + } } - } else if (channel === 'course_notifications') { - // Course notifications can be targeted or broadcast - if (data.userId) { - io.to(`user:${data.userId}`).emit('course_notification', data); - } else { - // Broadcast to all connected clients - io.emit('course_notification', data); - } - } - }); + }); + } else { + logger.warn('Redis subClient not available, WebSocket pub/sub disabled'); + } }; /** * Utility function to broadcast events from other parts of the backend */ export const broadcastEvent = async (channel: string, data: unknown) => { - await pubClient.publish(channel, JSON.stringify(data)); + const pubClient = redisClient.getPubClient(); + if (pubClient) { + await pubClient.publish(channel, JSON.stringify(data)); + } }; diff --git a/docker-compose.yml b/docker-compose.yml index 2e41e530..69abba75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,24 @@ services: timeout: 5s retries: 5 + db-read-replica: + image: postgres:15-alpine + container_name: web3-student-lab-db-replica + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: web3-student-lab + ports: + - "5433:5432" + volumes: + - postgres_replica_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + redis: image: redis:7-alpine container_name: web3-student-lab-redis @@ -133,6 +151,7 @@ services: - "8080:8080" environment: - DATABASE_URL=postgresql://postgres:postgres@db:5432/web3-student-lab?schema=public + - DB_READ_REPLICA_URL=postgresql://postgres:postgres@db-read-replica:5432/web3-student-lab?schema=public - NODE_ENV=development - JWT_SECRET=your-secret-key-change-in-production # GitHub OAuth Configuration @@ -157,6 +176,8 @@ services: depends_on: db: condition: service_healthy + db-read-replica: + condition: service_healthy redis: condition: service_healthy networks: @@ -168,6 +189,7 @@ networks: volumes: postgres_data: + postgres_replica_data: redis_data: redis_cluster_1_data: redis_cluster_2_data: