diff --git a/Backend/config/database.js b/Backend/config/database.js new file mode 100644 index 0000000..a852edb --- /dev/null +++ b/Backend/config/database.js @@ -0,0 +1,25 @@ +const boolFromEnv = (value, fallback = false) => { + if (value === undefined || value === '') return fallback + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()) +} + +const numberFromEnv = (value, fallback) => { + if (value === undefined || value === '') return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback +} + +const config = { + uri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017', + dbName: process.env.MONGO_DB || 'chatapp', + options: { + user: process.env.MONGO_USER || undefined, + pass: process.env.MONGO_PASSWORD || undefined, + socketTimeoutMS: numberFromEnv(process.env.MONGO_SOCKET_TIMEOUT, 45000), + connectTimeoutMS: numberFromEnv(process.env.MONGO_CONNECT_TIMEOUT, 30000), + replicaSet: process.env.MONGO_REPLICA_SET || undefined, + tls: boolFromEnv(process.env.MONGO_TLS, false), + }, +} + +module.exports = config diff --git a/Backend/config/index.js b/Backend/config/index.js new file mode 100644 index 0000000..1c45829 --- /dev/null +++ b/Backend/config/index.js @@ -0,0 +1,29 @@ +const fs = require('fs') +const path = require('path') + +loadLocalEnv() + +module.exports = { + port: process.env.PORT || 4000, + clientOrigin: process.env.CLIENT_ORIGIN || '*', +} + +function loadLocalEnv() { + const envPath = path.join(__dirname, '..', '.env') + if (!fs.existsSync(envPath)) { + return + } + + const content = fs.readFileSync(envPath, 'utf-8') + for (const line of content.split(/\r?\n/)) { + if (!line || line.trim().startsWith('#')) continue + + const [key, ...rest] = line.split('=') + if (!key) continue + + const value = rest.join('=').trim() + if (key && value && process.env[key] === undefined) { + process.env[key] = value + } + } +} diff --git a/Backend/lib/datastore.js b/Backend/lib/datastore.js new file mode 100644 index 0000000..31f4f55 --- /dev/null +++ b/Backend/lib/datastore.js @@ -0,0 +1,34 @@ +const path = require('path') +const JsonStore = require('./stores/jsonStore') + +async function createDataStore() { + const requestedStore = process.env.DATA_STORE + const forceJson = requestedStore === 'json' + const strictMongo = requestedStore === 'mongo' + + if (forceJson) { + const filePath = path.join(__dirname, '..', 'data', 'db.json') + console.log(`Using JSON fallback store at ${filePath}`) + return new JsonStore(filePath) + } + + try { + const createMongoStore = require('./stores/mongoStore') + return await createMongoStore() + } catch (error) { + if (strictMongo) { + throw error + } + + console.warn( + 'MongoDB store unavailable, falling back to JSON store.', + error.message, + ) + } + + const filePath = path.join(__dirname, '..', 'data', 'db.json') + console.log(`Using JSON fallback store at ${filePath}`) + return new JsonStore(filePath) +} + +module.exports = createDataStore diff --git a/Backend/lib/stores/jsonStore.js b/Backend/lib/stores/jsonStore.js new file mode 100644 index 0000000..f21cf83 --- /dev/null +++ b/Backend/lib/stores/jsonStore.js @@ -0,0 +1,150 @@ +const fs = require('fs') +const path = require('path') + +class JsonStore { + constructor(filePath) { + this.filePath = filePath + this.state = { users: [], messages: [] } + this._ensureStore() + } + + _ensureStore() { + const dir = path.dirname(this.filePath) + fs.mkdirSync(dir, { recursive: true }) + if (!fs.existsSync(this.filePath)) { + this._persist() + return + } + try { + const contents = fs.readFileSync(this.filePath, 'utf-8') + this.state = JSON.parse(contents) + } catch (error) { + console.warn('JSON store damaged, recreating', error) + this._persist() + } + } + + _persist() { + fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8') + } + + async upsertUser({ id, username, room }) { + const trimmed = username?.trim() + if (!trimmed) { + throw new Error('Username is required') + } + const now = new Date().toISOString() + const index = this.state.users.findIndex((user) => user.id === id) + if (index >= 0) { + const updated = { + ...this.state.users[index], + username: trimmed, + room, + isOnline: true, + lastActiveAt: now, + } + this.state.users[index] = updated + this._persist() + return updated + } + const user = { + id, + username: trimmed, + room, + isOnline: true, + joinedAt: now, + lastActiveAt: now, + } + this.state.users.push(user) + this._persist() + return user + } + + async setUserOnline(userId, room) { + const user = this.state.users.find((entry) => entry.id === userId) + if (!user) { + return null + } + Object.assign(user, { + isOnline: true, + room, + lastActiveAt: new Date().toISOString(), + }) + this._persist() + return user + } + + async setUserOffline(userId) { + const user = this.state.users.find((entry) => entry.id === userId) + if (!user) { + return null + } + Object.assign(user, { + isOnline: false, + lastActiveAt: new Date().toISOString(), + }) + this._persist() + return user + } + + async updateUsername(userId, username) { + const user = this.state.users.find((entry) => entry.id === userId) + if (!user) return null + user.username = username.trim() + user.lastActiveAt = new Date().toISOString() + this._persist() + return user + } + + async removeUser(userId) { + const index = this.state.users.findIndex((entry) => entry.id === userId) + if (index === -1) return null + const [removed] = this.state.users.splice(index, 1) + this._persist() + return removed + } + + async getUser(userId) { + return this.state.users.find((entry) => entry.id === userId) || null + } + + async getParticipants(room) { + return this.state.users + .filter((entry) => entry.room === room) + .map(({ id, username, isOnline, joinedAt, lastActiveAt }) => ({ + id, + username, + isOnline, + joinedAt, + lastActiveAt, + })) + .sort((a, b) => { + if (a.isOnline === b.isOnline) { + return new Date(b.lastActiveAt) - new Date(a.lastActiveAt) + } + return a.isOnline ? -1 : 1 + }) + } + + async addMessage({ id, room, userId, username, text }) { + const message = { + id, + room, + userId, + username, + text, + createdAt: new Date().toISOString(), + } + this.state.messages.push(message) + this._persist() + return message + } + + async getMessages(room, limit = 100) { + return this.state.messages + .filter((message) => message.room === room) + .slice(-limit) + } +} + +module.exports = JsonStore diff --git a/Backend/lib/stores/mongoStore.js b/Backend/lib/stores/mongoStore.js new file mode 100644 index 0000000..8584df5 --- /dev/null +++ b/Backend/lib/stores/mongoStore.js @@ -0,0 +1,217 @@ +const config = require('../../config/database') + + +let MongoClient +try { + ;({ MongoClient } = require('mongodb')) +} catch (error) { + MongoClient = null +} + + +const collectionNames = { + users: 'users', + messages: 'messages', +} + +function normalizeUser(doc) { + if (!doc) return null + const { + _id, + username, + room, + isOnline, + joinedAt, + lastActiveAt, + } = doc + return { + id: _id, + username, + room, + isOnline, + joinedAt, + lastActiveAt, + } +} + +function normalizeMessage(doc) { + if (!doc) return null + const { + _id, + room, + userId, + username, + text, + createdAt, + } = doc + return { + id: _id, + room, + userId, + username, + text, + createdAt, + } +} + +function extractDocument(result) { + if (!result) return null + return Object.prototype.hasOwnProperty.call(result, 'value') + ? result.value + : result +} + +async function createMongoStore() { + // Guard: fail fast with an actionable message if the driver is missing. + if (!MongoClient) { + const message = + 'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.' + const error = new Error(message) + error.code = 'MODULE_NOT_INSTALLED' + throw error + } + + const client = new MongoClient(config.uri, { + ...config.options, + appName: 'chat-app', + }) + + await client.connect() + const db = client.db(config.dbName) + + const users = db.collection(collectionNames.users) + const messages = db.collection(collectionNames.messages) + + await Promise.all([ + users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), + messages.createIndex({ room: 1, createdAt: 1 }), + ]) + + console.log( + `Connected to MongoDB at ${ + config.uri + }/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`, + ) + + return { + async upsertUser({ id, username, room }) { + const now = new Date() + const result = await users.findOneAndUpdate( + { _id: id }, + { + $set: { + username: username.trim(), + room, + isOnline: true, + lastActiveAt: now, + updatedAt: now, + }, + $setOnInsert: { + joinedAt: now, + }, + }, + { + upsert: true, + returnDocument: 'after', + }, + ) + const document = extractDocument(result) + if (document) { + return normalizeUser(document) + } + + + const inserted = await users.findOne({ _id: id }) + return normalizeUser(inserted) + }, + + async setUserOnline(userId, room) { + const result = await users.findOneAndUpdate( + { _id: userId }, + { + $set: { + isOnline: true, + room, + lastActiveAt: new Date(), + }, + }, + { returnDocument: 'after' }, + ) + return normalizeUser(extractDocument(result)) + }, + + async setUserOffline(userId) { + const result = await users.findOneAndUpdate( + { _id: userId }, + { + $set: { + isOnline: false, + lastActiveAt: new Date(), + }, + }, + { returnDocument: 'after' }, + ) + return normalizeUser(extractDocument(result)) + }, + + async updateUsername(userId, username) { + const result = await users.findOneAndUpdate( + { _id: userId }, + { + $set: { + username: username.trim(), + lastActiveAt: new Date(), + }, + }, + { returnDocument: 'after' }, + ) + return normalizeUser(extractDocument(result)) + }, + + async removeUser(userId) { + const result = await users.findOneAndDelete({ _id: userId }) + return normalizeUser(extractDocument(result)) + }, + + async getUser(userId) { + const doc = await users.findOne({ _id: userId }) + return normalizeUser(doc) + }, + + async getParticipants(room) { + const docs = await users + .find({ room }) + .sort({ isOnline: -1, lastActiveAt: -1 }) + .toArray() + return docs.map(normalizeUser) + }, + + async addMessage({ id, room, userId, username, text }) { + const message = { + _id: id, + room, + userId, + username, + text, + createdAt: new Date(), + } + await messages.insertOne(message) + return normalizeMessage(message) + }, + + async getMessages(room, limit = 100) { + const docs = await messages + .find({ room }) + .sort({ createdAt: 1 }) + .limit(limit) + .toArray() + return docs.map(normalizeMessage) + }, + + async disconnect() { + await client.close() + }, + } +} + +module.exports = createMongoStore