265 lines
8.1 KiB
JavaScript
265 lines
8.1 KiB
JavaScript
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'))
|
|
} catch (error) {
|
|
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 {
|
|
_id,
|
|
username,
|
|
room,
|
|
isOnline,
|
|
joinedAt,
|
|
lastActiveAt,
|
|
} = doc
|
|
return {
|
|
id: _id,
|
|
username,
|
|
room,
|
|
isOnline,
|
|
joinedAt,
|
|
lastActiveAt,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
_id,
|
|
room,
|
|
userId,
|
|
username,
|
|
text,
|
|
createdAt,
|
|
} = doc
|
|
return {
|
|
id: _id,
|
|
room,
|
|
userId,
|
|
username,
|
|
text,
|
|
createdAt,
|
|
}
|
|
}
|
|
|
|
// 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')
|
|
? result.value
|
|
: result
|
|
}
|
|
|
|
// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
|
|
async function createMongoStore() {
|
|
|
|
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', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
|
|
})
|
|
|
|
await client.connect()
|
|
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 }), // Für getParticipants()
|
|
messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
|
|
])
|
|
|
|
console.log(
|
|
`Connected to MongoDB at ${
|
|
config.uri
|
|
}/${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(
|
|
{ _id: id },
|
|
{
|
|
$set: {
|
|
username: username.trim(),
|
|
room,
|
|
isOnline: true,
|
|
lastActiveAt: now,
|
|
updatedAt: now,
|
|
},
|
|
$setOnInsert: {
|
|
joinedAt: now,
|
|
},
|
|
},
|
|
{
|
|
upsert: true,
|
|
returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
|
|
},
|
|
)
|
|
const document = extractDocument(result)
|
|
if (document) {
|
|
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 },
|
|
{
|
|
$set: {
|
|
isOnline: true,
|
|
room,
|
|
lastActiveAt: new Date(),
|
|
},
|
|
},
|
|
{ returnDocument: 'after' },
|
|
)
|
|
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 },
|
|
{
|
|
$set: {
|
|
isOnline: false,
|
|
lastActiveAt: new Date(),
|
|
},
|
|
},
|
|
{ returnDocument: 'after' },
|
|
)
|
|
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 },
|
|
{
|
|
$set: {
|
|
username: username.trim(),
|
|
lastActiveAt: new Date(),
|
|
},
|
|
},
|
|
{ returnDocument: 'after' },
|
|
)
|
|
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))
|
|
},
|
|
|
|
async getUser(userId) {
|
|
const doc = await users.findOne({ _id: userId })
|
|
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 }) // -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,
|
|
room,
|
|
userId,
|
|
username,
|
|
text,
|
|
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 })
|
|
.sort({ createdAt: 1 })
|
|
.limit(limit)
|
|
.toArray()
|
|
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()
|
|
},
|
|
}
|
|
}
|
|
|
|
module.exports = createMongoStore
|