Initial commit

This commit is contained in:
Francesco Lorenzo D'Amico
2026-08-10 15:18:25 +02:00
parent 8fdb425402
commit a5d116358e
15 changed files with 700 additions and 165 deletions
+57 -10
View File
@@ -1,6 +1,7 @@
const config = require('../../config/database')
// Sicheres Laden des MongoDB-Treibers — wenn das Paket nicht installiert ist,
// wird der Fehler erst in createMongoStore() mit einer klaren Meldung geworfen.
let MongoClient
try {
;({ MongoClient } = require('mongodb'))
@@ -8,12 +9,14 @@ try {
MongoClient = null
}
// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden.
const collectionNames = {
users: 'users',
messages: 'messages',
}
// MongoDB speichert den Primärschlüssel intern als "_id".
// Diese Funktion übersetzt das in "id" damit der Rest der App nichts von MongoDB-Interna wissen muss.
function normalizeUser(doc) {
if (!doc) return null
const {
@@ -34,6 +37,8 @@ function normalizeUser(doc) {
}
}
// Gleiche Logik wie normalizeUser — username wird redundant in der Nachricht gespeichert
// damit beim Laden keine extra DB-Abfrage für den Usernamen nötig ist.
function normalizeMessage(doc) {
if (!doc) return null
const {
@@ -54,6 +59,8 @@ function normalizeMessage(doc) {
}
}
// Kompatibilitätsfunktion für ältere MongoDB-Treiber (< 4.0).
// findOneAndUpdate gibt dort das Ergebnis in einem "value"-Wrapper zurück — ab 4.0 direkt.
function extractDocument(result) {
if (!result) return null
return Object.prototype.hasOwnProperty.call(result, 'value')
@@ -61,8 +68,9 @@ function extractDocument(result) {
: result
}
// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
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/.'
@@ -73,18 +81,20 @@ async function createMongoStore() {
const client = new MongoClient(config.uri, {
...config.options,
appName: 'chat-app',
appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
})
await client.connect()
const db = client.db(config.dbName)
const db = client.db(config.dbName) // Entspricht "USE chatapp" in SQL.
const users = db.collection(collectionNames.users)
const messages = db.collection(collectionNames.messages)
// Indizes werden einmalig beim Start angelegt — ohne Index durchsucht MongoDB jede Abfrage
// alle Dokumente (Full Collection Scan). Promise.all legt beide parallel an.
await Promise.all([
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }),
messages.createIndex({ room: 1, createdAt: 1 }),
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants()
messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
])
console.log(
@@ -93,7 +103,12 @@ async function createMongoStore() {
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
)
// Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern).
return {
// Upsert = Update + Insert in einem Schritt (atomare Operation).
// Wenn User existiert: Felder aktualisieren. Wenn nicht: neu anlegen.
// $setOnInsert stellt sicher dass joinedAt nur beim ersten Anlegen gesetzt wird, nicht bei Updates.
async upsertUser({ id, username, room }) {
const now = new Date()
const result = await users.findOneAndUpdate(
@@ -112,7 +127,7 @@ async function createMongoStore() {
},
{
upsert: true,
returnDocument: 'after',
returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
},
)
const document = extractDocument(result)
@@ -120,11 +135,12 @@ async function createMongoStore() {
return normalizeUser(document)
}
// Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück.
const inserted = await users.findOne({ _id: id })
return normalizeUser(inserted)
},
// $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt.
async setUserOnline(userId, room) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -140,6 +156,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
async setUserOffline(userId) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -154,6 +171,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
// trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB.
async updateUsername(userId, username) {
const result = await users.findOneAndUpdate(
{ _id: userId },
@@ -168,6 +186,7 @@ async function createMongoStore() {
return normalizeUser(extractDocument(result))
},
// findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig.
async removeUser(userId) {
const result = await users.findOneAndDelete({ _id: userId })
return normalizeUser(extractDocument(result))
@@ -178,14 +197,19 @@ async function createMongoStore() {
return normalizeUser(doc)
},
// Online-User zuerst, dann nach letzter Aktivität sortiert.
// toArray() lädt alle Cursor-Ergebnisse in den Speicher — bei sehr großen Datenmengen wäre ein Cursor besser.
async getParticipants(room) {
const docs = await users
.find({ room })
.sort({ isOnline: -1, lastActiveAt: -1 })
.sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend
.toArray()
return docs.map(normalizeUser)
},
// _id wird von außen als UUID übergeben statt von MongoDB generiert —
// so ist die ID bereits bekannt bevor die DB antwortet (z.B. für den WebSocket-Broadcast).
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
async addMessage({ id, room, userId, username, text }) {
const message = {
_id: id,
@@ -196,9 +220,11 @@ async function createMongoStore() {
createdAt: new Date(),
}
await messages.insertOne(message)
// Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query.
return normalizeMessage(message)
},
// limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird.
async getMessages(room, limit = 100) {
const docs = await messages
.find({ room })
@@ -208,6 +234,27 @@ async function createMongoStore() {
return docs.map(normalizeMessage)
},
// Aggregation Pipeline: MongoDB berechnet das Ergebnis serverseitig — effizienter als
// alle Daten laden und in JavaScript auswerten.
// $group: Gruppiert User nach Room (wie GROUP BY in SQL).
// $cond: Zählt nur Online-User pro Room.
// $project: Blendet _id aus und benennt es in "name" um.
async getRooms() {
const result = await users.aggregate([
{
$group: {
_id: '$room',
participantCount: { $sum: { $cond: ['$isOnline', 1, 0] } },
},
},
{ $project: { _id: 0, name: '$_id', participantCount: 1 } },
{ $sort: { name: 1 } },
]).toArray()
return result
},
// Verbindung sauber trennen beim Herunterfahren der App — ohne close() bleiben
// offene Verbindungen auf dem MongoDB-Server hängen.
async disconnect() {
await client.close()
},