Initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
docker-compose.yml text eol=lf
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
Generated
-72
@@ -1,72 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ChangeListManager">
|
|
||||||
<list default="true" id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
|
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
||||||
</component>
|
|
||||||
<component name="CopilotPersistence">
|
|
||||||
<persistenceIdMap>
|
|
||||||
<entry key="_//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App" value="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
|
|
||||||
</persistenceIdMap>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectColorInfo"><![CDATA[{
|
|
||||||
"associatedIndex": 3
|
|
||||||
}]]></component>
|
|
||||||
<component name="ProjectId" id="3Aw7oEkK1nQjM1LVTrOnDRFvCjP" />
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
|
||||||
"keyToString": {
|
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
|
||||||
"javascript.preferred.runtime.type.id": "node",
|
|
||||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-20.04/home/francescod/dev/Chat-App",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
}
|
|
||||||
}]]></component>
|
|
||||||
<component name="RecentsManager">
|
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
|
||||||
<recent name="\\wsl.localhost\Ubuntu-20.04\home\francescod\dev\Chat-App\Backend" />
|
|
||||||
</key>
|
|
||||||
</component>
|
|
||||||
<component name="SharedIndexes">
|
|
||||||
<attachedChunks>
|
|
||||||
<set>
|
|
||||||
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
|
|
||||||
</set>
|
|
||||||
</attachedChunks>
|
|
||||||
</component>
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="Default task">
|
|
||||||
<changelist id="183cc890-1dbc-48d5-8436-4d83b3671444" name="Changes" comment="" />
|
|
||||||
<created>1751619297246</created>
|
|
||||||
<option name="number" value="Default" />
|
|
||||||
<option name="presentableId" value="Default" />
|
|
||||||
<updated>1751619297246</updated>
|
|
||||||
<workItem from="1773491953643" duration="2831000" />
|
|
||||||
</task>
|
|
||||||
<servers />
|
|
||||||
</component>
|
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
<component name="github-copilot-workspace">
|
|
||||||
<instructionFileLocations>
|
|
||||||
<option value=".github/instructions" />
|
|
||||||
</instructionFileLocations>
|
|
||||||
<promptFileLocations>
|
|
||||||
<option value=".github/prompts" />
|
|
||||||
</promptFileLocations>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
|
# Docker lädt ein fertiges Image mit Linux Alpine und Node.js Version 20.
|
||||||
|
# Alpine ist eine sehr kleine Linux-Distribution — kleineres Image, schnellere Downloads.
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Docker erstellt Verzeichnis /app falls noch nicht vorhanden.
|
||||||
|
# Alle folgenden Befehle laufen in diesem Verzeichnis.
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Zuerst nur package.json kopieren — noch nicht den ganzen Code.
|
||||||
|
# Warum: Docker cached jeden Schritt. Wenn sich nur der Code ändert aber nicht die
|
||||||
|
# Dependencies, muss npm ci nicht nochmal laufen — Build wird schneller (Layer Caching).
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# npm ci installiert exakt die Versionen aus package-lock.json — reproduzierbarer Build.
|
||||||
|
# --omit=dev: Keine Entwicklungs-Dependencies in Produktion — kleineres und sichereres Image.
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Jetzt erst den restlichen Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# NODE_ENV=production aktiviert Optimierungen in Express (z.B. bessere Performance, weniger Logs).
|
||||||
|
# PORT=4000 definiert auf welchem Port der Server lauscht.
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=4000
|
ENV PORT=4000
|
||||||
|
|
||||||
|
# Dokumentiert welchen Port der Container verwendet — öffnet ihn aber nicht wirklich.
|
||||||
|
# Das passiert in docker-compose.yml.
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# Startbefehl wenn der Container läuft.
|
||||||
|
# Array-Form statt String damit Node.js direkt als Prozess gestartet wird — kein Shell-Prozess dazwischen.
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const typingByRoom = new Map()
|
||||||
|
|
||||||
|
// io.use: Middleware die bei jeder eingehenden WebSocket-Verbindung läuft.
|
||||||
|
// Prüft ob room und userId im Handshake vorhanden sind und ob der User in der DB existiert.
|
||||||
|
// Wer vorher nicht POST /join aufgerufen hat, hat keine gültige userId und wird abgelehnt.
|
||||||
io.use(async (socket, next) => {
|
io.use(async (socket, next) => {
|
||||||
try {
|
try {
|
||||||
const auth = socket.handshake.auth || {}
|
const auth = socket.handshake.auth || {}
|
||||||
@@ -25,6 +30,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
|||||||
return next(new Error('User not authorized for this room'))
|
return next(new Error('User not authorized for this room'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// room und userId in socket.data speichern — so sind sie in registerConnection verfügbar.
|
||||||
socket.data.room = room
|
socket.data.room = room
|
||||||
socket.data.userId = userId
|
socket.data.userId = userId
|
||||||
next()
|
next()
|
||||||
@@ -34,7 +40,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
registerConnection(io, socket, database, onError).catch((error) => {
|
registerConnection(io, socket, database, onError, typingByRoom).catch((error) => {
|
||||||
onError('Socket connection setup failed', error)
|
onError('Socket connection setup failed', error)
|
||||||
socket.disconnect(true)
|
socket.disconnect(true)
|
||||||
})
|
})
|
||||||
@@ -43,8 +49,10 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) {
|
|||||||
return io
|
return io
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerConnection(io, socket, database, onError) {
|
async function registerConnection(io, socket, database, onError, typingByRoom) {
|
||||||
const { room, userId } = socket.data
|
const { room, userId } = socket.data
|
||||||
|
|
||||||
|
// socket.join: Fügt den Socket einem Room-Kanal hinzu — io.to(room).emit() erreicht nur diese Sockets.
|
||||||
socket.join(room)
|
socket.join(room)
|
||||||
|
|
||||||
const user = await database.setUserOnline(userId, room)
|
const user = await database.setUserOnline(userId, room)
|
||||||
@@ -53,10 +61,25 @@ async function registerConnection(io, socket, database, onError) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
socket.data.username = user.username
|
||||||
|
|
||||||
socket.emit('connection:ready', { userId, room })
|
socket.emit('connection:ready', { userId, room })
|
||||||
socket.emit('history:init', await database.getMessages(room))
|
|
||||||
|
// Teilnehmerliste an alle im Room senden — User sieht sofort wer online ist.
|
||||||
await broadcastParticipants(io, database, room)
|
await broadcastParticipants(io, database, room)
|
||||||
|
|
||||||
|
// typingByRoom: Map pro Room — speichert wer gerade tippt { userId → { userId, username } }.
|
||||||
|
socket.on('typing:start', () => {
|
||||||
|
if (!typingByRoom.has(room)) typingByRoom.set(room, new Map())
|
||||||
|
typingByRoom.get(room).set(userId, { userId, username: socket.data.username })
|
||||||
|
io.to(room).emit('typing:update', [...typingByRoom.get(room).values()])
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('typing:stop', () => {
|
||||||
|
typingByRoom.get(room)?.delete(userId)
|
||||||
|
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||||
|
})
|
||||||
|
|
||||||
socket.on('message:send', async (payload) => {
|
socket.on('message:send', async (payload) => {
|
||||||
try {
|
try {
|
||||||
const text =
|
const text =
|
||||||
@@ -65,11 +88,16 @@ async function registerConnection(io, socket, database, onError) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nochmal prüfen ob der User noch im richtigen Room ist — könnte sich seit dem Verbinden geändert haben.
|
||||||
const activeUser = await database.getUser(userId)
|
const activeUser = await database.getUser(userId)
|
||||||
if (!activeUser || activeUser.room !== room) {
|
if (!activeUser || activeUser.room !== room) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tipp-Indikator sofort entfernen wenn eine Nachricht gesendet wird.
|
||||||
|
typingByRoom.get(room)?.delete(userId)
|
||||||
|
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||||
|
|
||||||
const message = await database.addMessage({
|
const message = await database.addMessage({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
room,
|
room,
|
||||||
@@ -77,6 +105,7 @@ async function registerConnection(io, socket, database, onError) {
|
|||||||
username: activeUser.username,
|
username: activeUser.username,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
})
|
})
|
||||||
|
// Nachricht an alle im Room senden — inkl. Sender selbst.
|
||||||
io.to(room).emit('message:new', message)
|
io.to(room).emit('message:new', message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError('Failed to handle socket message', error)
|
onError('Failed to handle socket message', error)
|
||||||
@@ -84,6 +113,9 @@ async function registerConnection(io, socket, database, onError) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
typingByRoom.get(room)?.delete(userId)
|
||||||
|
io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])])
|
||||||
|
// User als offline markieren und Teilnehmerliste aktualisieren.
|
||||||
database
|
database
|
||||||
.setUserOffline(userId)
|
.setUserOffline(userId)
|
||||||
.then(() => broadcastParticipants(io, database, room))
|
.then(() => broadcastParticipants(io, database, room))
|
||||||
@@ -100,6 +132,8 @@ async function broadcastParticipants(io, database, room) {
|
|||||||
io.to(room).emit('participants:update', participants)
|
io.to(room).emit('participants:update', participants)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liest einen Parameter zuerst aus der primären Quelle (auth), dann aus dem Fallback (query).
|
||||||
|
// Schützt gegen leere Strings — nur echte Werte werden akzeptiert.
|
||||||
function readParam(primary, fallback) {
|
function readParam(primary, fallback) {
|
||||||
if (typeof primary === 'string' && primary.trim()) return primary
|
if (typeof primary === 'string' && primary.trim()) return primary
|
||||||
if (typeof fallback === 'string' && fallback.trim()) return fallback
|
if (typeof fallback === 'string' && fallback.trim()) return fallback
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
// JsonStore: Dateibasierter Fallback-Store wenn kein MongoDB vorhanden ist.
|
||||||
|
// Alle Daten liegen als JSON-Datei auf der Festplatte — kein externer Datenbankserver nötig.
|
||||||
|
// Nachteil gegenüber MongoDB: Kein Concurrency-Schutz bei mehreren gleichzeitigen Schreiboperationen.
|
||||||
class JsonStore {
|
class JsonStore {
|
||||||
constructor(filePath) {
|
constructor(filePath) {
|
||||||
this.filePath = filePath
|
this.filePath = filePath
|
||||||
this.state = { users: [], messages: [] }
|
this.state = { users: [], messages: [] } // In-Memory-State — alle Operationen laufen darauf.
|
||||||
this._ensureStore()
|
this._ensureStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stellt sicher dass die JSON-Datei und das Verzeichnis existieren.
|
||||||
|
// Wenn die Datei beschädigt ist (kein gültiges JSON), wird sie neu erstellt.
|
||||||
_ensureStore() {
|
_ensureStore() {
|
||||||
const dir = path.dirname(this.filePath)
|
const dir = path.dirname(this.filePath)
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true }) // Verzeichnis anlegen falls nicht vorhanden.
|
||||||
if (!fs.existsSync(this.filePath)) {
|
if (!fs.existsSync(this.filePath)) {
|
||||||
this._persist()
|
this._persist()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const contents = fs.readFileSync(this.filePath, 'utf-8')
|
const contents = fs.readFileSync(this.filePath, 'utf-8')
|
||||||
this.state = JSON.parse(contents)
|
this.state = JSON.parse(contents) // Datei in den In-Memory-State laden.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Datei vorhanden aber nicht lesbar oder kein gültiges JSON — neu erstellen.
|
||||||
console.warn('JSON store damaged, recreating', error)
|
console.warn('JSON store damaged, recreating', error)
|
||||||
this._persist()
|
this._persist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schreibt den aktuellen In-Memory-State in die JSON-Datei.
|
||||||
|
// null, 2 formatiert das JSON mit Einrückung — besser lesbar beim Debugging.
|
||||||
_persist() {
|
_persist() {
|
||||||
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
|
fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert: User aktualisieren wenn vorhanden, sonst neu anlegen.
|
||||||
|
// Spread-Operator {...this.state.users[index], ...} übernimmt alle bestehenden Felder
|
||||||
|
// und überschreibt nur die geänderten — joinedAt bleibt beim Update erhalten.
|
||||||
async upsertUser({ id, username, room }) {
|
async upsertUser({ id, username, room }) {
|
||||||
const trimmed = username?.trim()
|
const trimmed = username?.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -60,6 +71,7 @@ class JsonStore {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Object.assign mutiert das gefundene Objekt direkt im Array — kein Index-Lookup nötig.
|
||||||
async setUserOnline(userId, room) {
|
async setUserOnline(userId, room) {
|
||||||
const user = this.state.users.find((entry) => entry.id === userId)
|
const user = this.state.users.find((entry) => entry.id === userId)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -74,6 +86,7 @@ class JsonStore {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
|
||||||
async setUserOffline(userId) {
|
async setUserOffline(userId) {
|
||||||
const user = this.state.users.find((entry) => entry.id === userId)
|
const user = this.state.users.find((entry) => entry.id === userId)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -96,6 +109,8 @@ class JsonStore {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splice(index, 1) entfernt genau ein Element an der gefundenen Position.
|
||||||
|
// Destructuring [removed] holt das gelöschte Element aus dem zurückgegebenen Array.
|
||||||
async removeUser(userId) {
|
async removeUser(userId) {
|
||||||
const index = this.state.users.findIndex((entry) => entry.id === userId)
|
const index = this.state.users.findIndex((entry) => entry.id === userId)
|
||||||
if (index === -1) return null
|
if (index === -1) return null
|
||||||
@@ -108,6 +123,8 @@ class JsonStore {
|
|||||||
return this.state.users.find((entry) => entry.id === userId) || null
|
return this.state.users.find((entry) => entry.id === userId) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .map() gibt nur die öffentlichen Felder zurück — interne Felder werden nicht nach außen gegeben.
|
||||||
|
// Sortierung: Online-User zuerst, dann nach letzter Aktivität absteigend.
|
||||||
async getParticipants(room) {
|
async getParticipants(room) {
|
||||||
return this.state.users
|
return this.state.users
|
||||||
.filter((entry) => entry.room === room)
|
.filter((entry) => entry.room === room)
|
||||||
@@ -126,6 +143,7 @@ class JsonStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut.
|
||||||
async addMessage({ id, room, userId, username, text }) {
|
async addMessage({ id, room, userId, username, text }) {
|
||||||
const message = {
|
const message = {
|
||||||
id,
|
id,
|
||||||
@@ -140,11 +158,25 @@ class JsonStore {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slice(-limit) gibt die letzten N Nachrichten zurück — neueste am Ende, älteste zuerst.
|
||||||
async getMessages(room, limit = 100) {
|
async getMessages(room, limit = 100) {
|
||||||
return this.state.messages
|
return this.state.messages
|
||||||
.filter((message) => message.room === room)
|
.filter((message) => message.room === room)
|
||||||
.slice(-limit)
|
.slice(-limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roomMap: Objekt das pro Room die Teilnehmerzahl zählt — schneller als mehrere filter()-Aufrufe.
|
||||||
|
// localeCompare: Alphabetische Sortierung mit Sprachunterstützung (Umlaute etc.).
|
||||||
|
async getRooms() {
|
||||||
|
const roomMap = {}
|
||||||
|
for (const user of this.state.users) {
|
||||||
|
if (!roomMap[user.room]) {
|
||||||
|
roomMap[user.room] = { name: user.room, participantCount: 0 }
|
||||||
|
}
|
||||||
|
if (user.isOnline) roomMap[user.room].participantCount++
|
||||||
|
}
|
||||||
|
return Object.values(roomMap).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = JsonStore
|
module.exports = JsonStore
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const config = require('../../config/database')
|
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
|
let MongoClient
|
||||||
try {
|
try {
|
||||||
;({ MongoClient } = require('mongodb'))
|
;({ MongoClient } = require('mongodb'))
|
||||||
@@ -8,12 +9,14 @@ try {
|
|||||||
MongoClient = null
|
MongoClient = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden.
|
||||||
const collectionNames = {
|
const collectionNames = {
|
||||||
users: 'users',
|
users: 'users',
|
||||||
messages: 'messages',
|
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) {
|
function normalizeUser(doc) {
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
const {
|
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) {
|
function normalizeMessage(doc) {
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
const {
|
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) {
|
function extractDocument(result) {
|
||||||
if (!result) return null
|
if (!result) return null
|
||||||
return Object.prototype.hasOwnProperty.call(result, 'value')
|
return Object.prototype.hasOwnProperty.call(result, 'value')
|
||||||
@@ -61,8 +68,9 @@ function extractDocument(result) {
|
|||||||
: result
|
: result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden.
|
||||||
async function createMongoStore() {
|
async function createMongoStore() {
|
||||||
// Guard: fail fast with an actionable message if the driver is missing.
|
|
||||||
if (!MongoClient) {
|
if (!MongoClient) {
|
||||||
const message =
|
const message =
|
||||||
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
|
'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.'
|
||||||
@@ -73,18 +81,20 @@ async function createMongoStore() {
|
|||||||
|
|
||||||
const client = new MongoClient(config.uri, {
|
const client = new MongoClient(config.uri, {
|
||||||
...config.options,
|
...config.options,
|
||||||
appName: 'chat-app',
|
appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist.
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.connect()
|
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 users = db.collection(collectionNames.users)
|
||||||
const messages = db.collection(collectionNames.messages)
|
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([
|
await Promise.all([
|
||||||
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }),
|
users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants()
|
||||||
messages.createIndex({ room: 1, createdAt: 1 }),
|
messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages()
|
||||||
])
|
])
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -93,7 +103,12 @@ async function createMongoStore() {
|
|||||||
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
|
}/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern).
|
||||||
return {
|
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 }) {
|
async upsertUser({ id, username, room }) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const result = await users.findOneAndUpdate(
|
const result = await users.findOneAndUpdate(
|
||||||
@@ -112,7 +127,7 @@ async function createMongoStore() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
upsert: true,
|
upsert: true,
|
||||||
returnDocument: 'after',
|
returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte.
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const document = extractDocument(result)
|
const document = extractDocument(result)
|
||||||
@@ -120,11 +135,12 @@ async function createMongoStore() {
|
|||||||
return normalizeUser(document)
|
return normalizeUser(document)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück.
|
||||||
const inserted = await users.findOne({ _id: id })
|
const inserted = await users.findOne({ _id: id })
|
||||||
return normalizeUser(inserted)
|
return normalizeUser(inserted)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt.
|
||||||
async setUserOnline(userId, room) {
|
async setUserOnline(userId, room) {
|
||||||
const result = await users.findOneAndUpdate(
|
const result = await users.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
@@ -140,6 +156,7 @@ async function createMongoStore() {
|
|||||||
return normalizeUser(extractDocument(result))
|
return normalizeUser(extractDocument(result))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war.
|
||||||
async setUserOffline(userId) {
|
async setUserOffline(userId) {
|
||||||
const result = await users.findOneAndUpdate(
|
const result = await users.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
@@ -154,6 +171,7 @@ async function createMongoStore() {
|
|||||||
return normalizeUser(extractDocument(result))
|
return normalizeUser(extractDocument(result))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB.
|
||||||
async updateUsername(userId, username) {
|
async updateUsername(userId, username) {
|
||||||
const result = await users.findOneAndUpdate(
|
const result = await users.findOneAndUpdate(
|
||||||
{ _id: userId },
|
{ _id: userId },
|
||||||
@@ -168,6 +186,7 @@ async function createMongoStore() {
|
|||||||
return normalizeUser(extractDocument(result))
|
return normalizeUser(extractDocument(result))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig.
|
||||||
async removeUser(userId) {
|
async removeUser(userId) {
|
||||||
const result = await users.findOneAndDelete({ _id: userId })
|
const result = await users.findOneAndDelete({ _id: userId })
|
||||||
return normalizeUser(extractDocument(result))
|
return normalizeUser(extractDocument(result))
|
||||||
@@ -178,14 +197,19 @@ async function createMongoStore() {
|
|||||||
return normalizeUser(doc)
|
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) {
|
async getParticipants(room) {
|
||||||
const docs = await users
|
const docs = await users
|
||||||
.find({ room })
|
.find({ room })
|
||||||
.sort({ isOnline: -1, lastActiveAt: -1 })
|
.sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend
|
||||||
.toArray()
|
.toArray()
|
||||||
return docs.map(normalizeUser)
|
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 }) {
|
async addMessage({ id, room, userId, username, text }) {
|
||||||
const message = {
|
const message = {
|
||||||
_id: id,
|
_id: id,
|
||||||
@@ -196,9 +220,11 @@ async function createMongoStore() {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
}
|
}
|
||||||
await messages.insertOne(message)
|
await messages.insertOne(message)
|
||||||
|
// Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query.
|
||||||
return normalizeMessage(message)
|
return normalizeMessage(message)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird.
|
||||||
async getMessages(room, limit = 100) {
|
async getMessages(room, limit = 100) {
|
||||||
const docs = await messages
|
const docs = await messages
|
||||||
.find({ room })
|
.find({ room })
|
||||||
@@ -208,6 +234,27 @@ async function createMongoStore() {
|
|||||||
return docs.map(normalizeMessage)
|
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() {
|
async disconnect() {
|
||||||
await client.close()
|
await client.close()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const asyncHandler = require('../utils/asyncHandler')
|
|||||||
function createRoomsRouter({ database, io }) {
|
function createRoomsRouter({ database, io }) {
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const rooms = await database.getRooms()
|
||||||
|
res.json({ rooms })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/:room/join',
|
'/:room/join',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
Run the full stack with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:5173`
|
||||||
|
- Backend: `http://localhost:4000`
|
||||||
|
- MongoDB: `mongodb://localhost:27017/chatapp`
|
||||||
|
|
||||||
|
The Compose file starts all required services together:
|
||||||
|
|
||||||
|
- `frontend` as a Vite container
|
||||||
|
- `backend` as a Node/Express container
|
||||||
|
- `mongo` with a persistent `mongo-data` volume
|
||||||
|
|
||||||
|
Stop everything with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove containers and database volume with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development Without Docker
|
||||||
|
|
||||||
|
1. Copy the backend environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp Backend/.env.example Backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start MongoDB locally or with Docker.
|
||||||
|
|
||||||
|
3. Start the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Backend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend tries MongoDB at `mongodb://127.0.0.1:27017/chatapp` first and falls back to `Backend/data/db.json` only if MongoDB is unavailable. Use `DATA_STORE=mongo` to fail fast on MongoDB errors, or `DATA_STORE=json` to force the JSON store.
|
||||||
+93
-54
@@ -1,59 +1,98 @@
|
|||||||
services:
|
|
||||||
frontend:
|
|
||||||
image: francesco448/chat-app-frontend:latest
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
VITE_API_URL: ""
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
networks:
|
|
||||||
- chat-network
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: francesco448/chat-app-backend:latest
|
|
||||||
build:
|
|
||||||
context: ./Backend
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
- PORT=4000
|
|
||||||
- CLIENT_ORIGIN=http://localhost
|
|
||||||
- DATA_STORE=mongo
|
|
||||||
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-chatpass}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
|
|
||||||
- MONGO_DB=${MONGO_DB:-chatapp}
|
|
||||||
networks:
|
|
||||||
- chat-network
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:7
|
|
||||||
container_name: chat-app-mongo
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:-chatpass}
|
|
||||||
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
|
|
||||||
volumes:
|
|
||||||
- mongo-data:/data/db
|
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
networks:
|
|
||||||
- chat-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
chat-network:
|
chat-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
# Fertiges Frontend-Image von Docker Hub.
|
||||||
|
image: francesco448/chat-app-frontend:latest
|
||||||
|
container_name: chat-app-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Frontend startet erst, nachdem der Backend-Container gestartet wurde.
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
# Nur das Frontend wird nach außen veröffentlicht.
|
||||||
|
# Standardmäßig TrueNAS-Port 9004 -> Nginx-Port 80.
|
||||||
|
# FRONTEND_PORT kann optional über .env geändert werden.
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-9004}:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- chat-network
|
||||||
|
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: francesco448/chat-app-backend:latest
|
||||||
|
container_name: chat-app-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PORT=4000
|
||||||
|
- NODE_ENV=production
|
||||||
|
- CLIENT_ORIGIN=${CLIENT_ORIGIN:?CLIENT_ORIGIN muss gesetzt werden}
|
||||||
|
- DATA_STORE=mongo
|
||||||
|
- MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss gesetzt werden}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin
|
||||||
|
- MONGO_DB=${MONGO_DB:-chatapp}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- chat-network
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
# Feste Major-Version verwenden statt "latest".
|
||||||
|
image: mongo:7
|
||||||
|
container_name: chat-app-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Benutzer kann über .env überschrieben werden.
|
||||||
|
- MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin}
|
||||||
|
|
||||||
|
# Für Production kein unsicheres Default-Passwort wie "chatpass".
|
||||||
|
# Der Stack startet nicht, solange das Passwort in .env fehlt.
|
||||||
|
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss in .env gesetzt werden}
|
||||||
|
|
||||||
|
# Datenbankname kann über .env überschrieben werden.
|
||||||
|
- MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp}
|
||||||
|
|
||||||
|
# Persistenz direkt auf deinem TrueNAS-Dataset.
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
- /mnt/PoolHDD/AppData/Chat-App/mongodb:/data/db
|
||||||
driver: local
|
|
||||||
|
# Kein Port 27017 nach außen.
|
||||||
|
# MongoDB ist ausschließlich im internen Docker-Netzwerk erreichbar.
|
||||||
|
networks:
|
||||||
|
- chat-network
|
||||||
|
|
||||||
|
# Authentifizierter MongoDB-Healthcheck.
|
||||||
|
# $$ verhindert, dass Docker Compose die Variablen bereits auf dem Host ersetzt.
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- >-
|
||||||
|
mongosh --quiet
|
||||||
|
--host 127.0.0.1
|
||||||
|
--username "$$MONGO_INITDB_ROOT_USERNAME"
|
||||||
|
--password "$$MONGO_INITDB_ROOT_PASSWORD"
|
||||||
|
--authenticationDatabase admin
|
||||||
|
--eval "quit(db.adminCommand('ping').ok ? 0 : 2)"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
+25
-1
@@ -1,23 +1,47 @@
|
|||||||
|
# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile.
|
||||||
|
# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules.
|
||||||
|
# Kleineres und sichereres Image als wenn alles in einem Stage wäre.
|
||||||
|
|
||||||
|
# Stage 1: Builder — zuständig für das Bauen der React-App.
|
||||||
|
# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann.
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier.
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Zuerst nur package.json kopieren für optimales Layer Caching.
|
||||||
|
# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change.
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Code kopieren — nach npm ci damit der Cache optimal genutzt wird.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben.
|
||||||
|
# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar.
|
||||||
|
# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt.
|
||||||
|
# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter.
|
||||||
ARG VITE_API_URL=""
|
ARG VITE_API_URL=""
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle.
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien.
|
||||||
|
# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen.
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js.
|
||||||
|
# --from=builder verweist auf Stage 1 per Name.
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration.
|
||||||
|
# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback.
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Port 80 dokumentieren — Standard HTTP-Port.
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen
|
||||||
|
# und Docker würde denken der Container ist fertig und ihn stoppen.
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+28
-5
@@ -1,37 +1,60 @@
|
|||||||
server {
|
server {
|
||||||
|
# Nginx lauscht auf Port 80 (Standard HTTP-Port).
|
||||||
|
# Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy).
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|
||||||
# Serve the static React build
|
# Statische React-Dateien ausliefern (HTML, CSS, JS).
|
||||||
|
# Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert.
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
# Fallback to index.html for client-side routing
|
|
||||||
|
# try_files: Nginx sucht zuerst nach einer echten Datei ($uri),
|
||||||
|
# dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück.
|
||||||
|
# Warum nötig: React ist eine Single Page Application (SPA) — alle Routen
|
||||||
|
# existieren nur im Browser, nicht als echte Dateien auf dem Server.
|
||||||
|
# Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern.
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy REST API requests to the backend
|
# Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet.
|
||||||
|
# Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80.
|
||||||
|
# Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen.
|
||||||
|
# Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx.
|
||||||
location /rooms {
|
location /rooms {
|
||||||
proxy_pass http://backend:4000;
|
proxy_pass http://backend:4000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
# Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt.
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
# X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender.
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail).
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy the health check endpoint to the backend
|
# Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet.
|
||||||
|
# Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml).
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://backend:4000;
|
proxy_pass http://backend:4000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy Socket.IO — requires WebSocket upgrade headers
|
# WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header.
|
||||||
|
# HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage
|
||||||
|
# und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt.
|
||||||
|
# Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln
|
||||||
|
# und der WebSocket-Handshake würde fehlschlagen.
|
||||||
location /socket.io/ {
|
location /socket.io/ {
|
||||||
proxy_pass http://backend:4000;
|
proxy_pass http://backend:4000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
# Siehe Zeile 72 in App.jsx
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached —
|
||||||
|
# eine dauerhafte Verbindung darf nicht gecacht werden.
|
||||||
|
# da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,65 @@ button:disabled {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rooms-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-list__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-list__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chip {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chip:hover {
|
||||||
|
border-color: rgba(94, 234, 212, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chip--active {
|
||||||
|
border-color: rgba(94, 234, 212, 0.8);
|
||||||
|
color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chip__count {
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
padding: 0.4rem 1.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Centralised error style classes */
|
/* Centralised error style classes */
|
||||||
|
|
||||||
/* Inline text error (e.g. below a field) */
|
/* Inline text error (e.g. below a field) */
|
||||||
|
|||||||
+164
-16
@@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages'
|
|||||||
import { validateJoinForm } from './utils/validators'
|
import { validateJoinForm } from './utils/validators'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
|
// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen.
|
||||||
|
// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist.
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json).
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Jede State-Variable ist einzeln deklariert statt in einem Objekt.
|
||||||
|
// So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung.
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [room, setRoom] = useState('general')
|
const [room, setRoom] = useState('general') // Standardroom beim ersten Laden
|
||||||
const [session, setSession] = useState(null)
|
const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room }
|
||||||
const [status, setStatus] = useState('idle')
|
const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [participants, setParticipants] = useState([])
|
const [participants, setParticipants] = useState([])
|
||||||
const [messageInput, setMessageInput] = useState('')
|
const [messageInput, setMessageInput] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen
|
||||||
|
const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl
|
||||||
|
|
||||||
const socketRef = useRef(null)
|
// useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer.
|
||||||
const messagesEndRef = useRef(null)
|
const socketRef = useRef(null) // Aktive Socket.IO-Verbindung
|
||||||
|
const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll
|
||||||
|
const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce
|
||||||
|
|
||||||
|
// Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig.
|
||||||
const connectionLabel = t(`status.${status}`, { defaultValue: status })
|
const connectionLabel = t(`status.${status}`, { defaultValue: status })
|
||||||
const isConnected = status === 'connected'
|
const isConnected = status === 'connected'
|
||||||
|
|
||||||
|
// Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus.
|
||||||
|
// Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das.
|
||||||
if (!session?.id || !session?.room) {
|
if (!session?.id || !session?.room) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak).
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
let reconnectTimer
|
let reconnectTimer
|
||||||
@@ -38,6 +53,11 @@ function App() {
|
|||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (isCancelled) return
|
if (isCancelled) return
|
||||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
|
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting')
|
||||||
|
|
||||||
|
// auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert
|
||||||
|
// und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat,
|
||||||
|
// hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt.
|
||||||
|
// transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback.
|
||||||
const socket = io(API_URL, {
|
const socket = io(API_URL, {
|
||||||
auth: {
|
auth: {
|
||||||
room: session.room,
|
room: session.room,
|
||||||
@@ -46,6 +66,8 @@ function App() {
|
|||||||
reconnectionAttempts: 5,
|
reconnectionAttempts: 5,
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können.
|
||||||
socketRef.current = socket
|
socketRef.current = socket
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
@@ -53,16 +75,15 @@ function App() {
|
|||||||
setStatus('connected')
|
setStatus('connected')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene).
|
||||||
|
// Reconnect-Events kommen vom Manager, nicht vom Socket selbst.
|
||||||
socket.io.on('reconnect_attempt', () => {
|
socket.io.on('reconnect_attempt', () => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) setStatus('reconnecting')
|
||||||
setStatus('reconnecting')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden.
|
||||||
socket.io.on('reconnect_failed', () => {
|
socket.io.on('reconnect_failed', () => {
|
||||||
if (!isCancelled) {
|
if (!isCancelled) setStatus('error')
|
||||||
setStatus('error')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('connect_error', (socketError) => {
|
socket.on('connect_error', (socketError) => {
|
||||||
@@ -73,6 +94,9 @@ function App() {
|
|||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
if (isCancelled) return
|
if (isCancelled) return
|
||||||
|
setTypingUsers([])
|
||||||
|
// 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect.
|
||||||
|
// Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen.
|
||||||
if (reason !== 'io client disconnect' && retryCount < 5) {
|
if (reason !== 'io client disconnect' && retryCount < 5) {
|
||||||
retryCount += 1
|
retryCount += 1
|
||||||
setStatus('reconnecting')
|
setStatus('reconnecting')
|
||||||
@@ -81,25 +105,36 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Server schickt beim Verbinden die letzten N Nachrichten des Rooms.
|
||||||
socket.on('history:init', (payload) => {
|
socket.on('history:init', (payload) => {
|
||||||
setMessages(Array.isArray(payload) ? payload : [])
|
setMessages(Array.isArray(payload) ? payload : [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich.
|
||||||
|
// prev.push() würde dieselbe Referenz behalten → kein Re-Render.
|
||||||
socket.on('message:new', (payload) => {
|
socket.on('message:new', (payload) => {
|
||||||
if (payload) {
|
if (payload) {
|
||||||
setMessages((prev) => [...prev, payload])
|
setMessages((prev) => [...prev, payload])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wird bei jedem Join/Leave eines Users im Room gesendet.
|
||||||
socket.on('participants:update', (payload) => {
|
socket.on('participants:update', (payload) => {
|
||||||
setParticipants(Array.isArray(payload) ? payload : [])
|
setParticipants(Array.isArray(payload) ? payload : [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade".
|
||||||
|
socket.on('typing:update', (payload) => {
|
||||||
|
if (!Array.isArray(payload)) return
|
||||||
|
setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username))
|
||||||
|
})
|
||||||
|
|
||||||
socket.on('connection:ready', () => {
|
socket.on('connection:ready', () => {
|
||||||
setError('')
|
setError('')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird.
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
if (!socketRef.current?.connected && !isCancelled) {
|
if (!socketRef.current?.connected && !isCancelled) {
|
||||||
setStatus('connecting')
|
setStatus('connecting')
|
||||||
@@ -108,22 +143,55 @@ function App() {
|
|||||||
|
|
||||||
connect()
|
connect()
|
||||||
|
|
||||||
|
// Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet.
|
||||||
|
// Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak.
|
||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
isCancelled = true
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
|
clearTimeout(typingTimerRef.current)
|
||||||
socketRef.current?.disconnect()
|
socketRef.current?.disconnect()
|
||||||
socketRef.current = null
|
socketRef.current = null
|
||||||
|
setTypingUsers([])
|
||||||
}
|
}
|
||||||
}, [session?.id, session?.room])
|
}, [session?.id, session?.room])
|
||||||
|
|
||||||
|
// Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
// Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite.
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRooms()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchRooms = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/rooms`)
|
||||||
|
if (res.ok) {
|
||||||
|
const { rooms: list } = await res.json()
|
||||||
|
setRooms(list)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const participantCount = participants.length
|
const participantCount = participants.length
|
||||||
|
|
||||||
|
// useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render.
|
||||||
|
const typingLabel = useMemo(() => {
|
||||||
|
if (typingUsers.length === 0) return ''
|
||||||
|
if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…`
|
||||||
|
if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…`
|
||||||
|
return `${typingUsers.length} Personen schreiben…`
|
||||||
|
}, [typingUsers])
|
||||||
|
|
||||||
const handleJoin = async (event) => {
|
const handleJoin = async (event) => {
|
||||||
|
// Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
// Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request.
|
||||||
const validationError = validateJoinForm(username, room)
|
const validationError = validateJoinForm(username, room)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setError(validationError)
|
setError(validationError)
|
||||||
@@ -133,12 +201,14 @@ function App() {
|
|||||||
setError('')
|
setError('')
|
||||||
setStatus(session ? 'reconnecting' : 'connecting')
|
setStatus(session ? 'reconnecting' : 'connecting')
|
||||||
try {
|
try {
|
||||||
|
// encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20.
|
||||||
|
// Ohne Kodierung wäre die URL ungültig und der Request würde scheitern.
|
||||||
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
|
const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
userId: session?.id,
|
userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen.
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -150,10 +220,19 @@ function App() {
|
|||||||
if (!data?.user) {
|
if (!data?.user) {
|
||||||
throw new Error(ERROR_MESSAGES.api.noUserReturned)
|
throw new Error(ERROR_MESSAGES.api.noUserReturned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf.
|
||||||
setSession(data.user)
|
setSession(data.user)
|
||||||
setUsername(data.user.username)
|
setUsername(data.user.username)
|
||||||
setRoom(data.user.room)
|
setRoom(data.user.room)
|
||||||
|
|
||||||
|
// Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer.
|
||||||
|
// Der WebSocket überschreibt den Snapshot später mit history:init.
|
||||||
await fetchRoomSnapshots(data.user.room)
|
await fetchRoomSnapshots(data.user.room)
|
||||||
|
fetchRooms()
|
||||||
|
if (socketRef.current?.connected) {
|
||||||
|
setStatus('connected')
|
||||||
|
}
|
||||||
} catch (joinError) {
|
} catch (joinError) {
|
||||||
console.error(joinError)
|
console.error(joinError)
|
||||||
setError(joinError.message)
|
setError(joinError.message)
|
||||||
@@ -161,6 +240,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2.
|
||||||
const fetchRoomSnapshots = async (targetRoom) => {
|
const fetchRoomSnapshots = async (targetRoom) => {
|
||||||
const encodedRoom = encodeURIComponent(targetRoom)
|
const encodedRoom = encodeURIComponent(targetRoom)
|
||||||
try {
|
try {
|
||||||
@@ -177,20 +257,38 @@ function App() {
|
|||||||
setParticipants(list)
|
setParticipants(list)
|
||||||
}
|
}
|
||||||
} catch (snapshotError) {
|
} catch (snapshotError) {
|
||||||
|
// Nicht kritisch — WebSocket liefert history:init und participants:update nach.
|
||||||
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
|
console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause.
|
||||||
|
// clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt.
|
||||||
|
const handleMessageInputChange = (event) => {
|
||||||
|
setMessageInput(event.target.value)
|
||||||
|
const socket = socketRef.current
|
||||||
|
if (!socket?.connected) return
|
||||||
|
socket.emit('typing:start')
|
||||||
|
clearTimeout(typingTimerRef.current)
|
||||||
|
typingTimerRef.current = setTimeout(() => {
|
||||||
|
socket.emit('typing:stop')
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachricht per WebSocket senden — nicht per HTTP.
|
||||||
|
// Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room.
|
||||||
|
// Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle.
|
||||||
const handleSendMessage = (event) => {
|
const handleSendMessage = (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!session || !messageInput.trim()) {
|
if (!session || !messageInput.trim()) return
|
||||||
return
|
|
||||||
}
|
|
||||||
const socket = socketRef.current
|
const socket = socketRef.current
|
||||||
if (!socket || !socket.connected) {
|
if (!socket || !socket.connected) {
|
||||||
setError(ERROR_MESSAGES.connection.noActiveConnection)
|
setError(ERROR_MESSAGES.connection.noActiveConnection)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Tipp-Indikator sofort beenden — nicht auf die 1500ms warten.
|
||||||
|
clearTimeout(typingTimerRef.current)
|
||||||
|
socket.emit('typing:stop')
|
||||||
try {
|
try {
|
||||||
socket.emit('message:send', {
|
socket.emit('message:send', {
|
||||||
text: messageInput.trim(),
|
text: messageInput.trim(),
|
||||||
@@ -203,8 +301,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht.
|
||||||
|
// setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt.
|
||||||
const handleLeave = async () => {
|
const handleLeave = async () => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
clearTimeout(typingTimerRef.current)
|
||||||
|
socketRef.current?.emit('typing:stop')
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
|
await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -212,15 +314,19 @@ function App() {
|
|||||||
body: JSON.stringify({ userId: session.id }),
|
body: JSON.stringify({ userId: session.id }),
|
||||||
})
|
})
|
||||||
} catch (leaveError) {
|
} catch (leaveError) {
|
||||||
|
// HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt.
|
||||||
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
|
console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError)
|
||||||
} finally {
|
} finally {
|
||||||
setSession(null)
|
setSession(null)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setParticipants([])
|
setParticipants([])
|
||||||
|
setTypingUsers([])
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
|
fetchRooms()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt.
|
||||||
const connectionHint = useMemo(() => {
|
const connectionHint = useMemo(() => {
|
||||||
if (!session) return t('hint.noSession')
|
if (!session) return t('hint.noSession')
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -239,6 +345,7 @@ function App() {
|
|||||||
<h1>{t('app.headline')}</h1>
|
<h1>{t('app.headline')}</h1>
|
||||||
<p>{connectionHint}</p>
|
<p>{connectionHint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
|
||||||
<div className={`status-badge status-${status}`}>
|
<div className={`status-badge status-${status}`}>
|
||||||
<span className="status-dot" aria-hidden="true"></span>
|
<span className="status-dot" aria-hidden="true"></span>
|
||||||
{connectionLabel}
|
{connectionLabel}
|
||||||
@@ -249,6 +356,7 @@ function App() {
|
|||||||
<form className="session-form" onSubmit={handleJoin}>
|
<form className="session-form" onSubmit={handleJoin}>
|
||||||
<div className="field-group">
|
<div className="field-group">
|
||||||
<label htmlFor="username">{t('form.username')}</label>
|
<label htmlFor="username">{t('form.username')}</label>
|
||||||
|
{/* Controlled Input: value kommt aus State, onChange aktualisiert State. */}
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
value={username}
|
value={username}
|
||||||
@@ -263,12 +371,15 @@ function App() {
|
|||||||
value={room}
|
value={room}
|
||||||
placeholder={t('form.roomPlaceholder')}
|
placeholder={t('form.roomPlaceholder')}
|
||||||
onChange={(event) => setRoom(event.target.value.toLowerCase())}
|
onChange={(event) => setRoom(event.target.value.toLowerCase())}
|
||||||
|
// toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general").
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
|
{/* Button-Text wechselt: "Verbinden" ohne Session, "Aktualisieren" wenn bereits eingeloggt. */}
|
||||||
{session ? t('form.update') : t('form.connect')}
|
{session ? t('form.update') : t('form.connect')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */}
|
||||||
{session ? (
|
{session ? (
|
||||||
<button type="button" className="ghost" onClick={handleLeave}>
|
<button type="button" className="ghost" onClick={handleLeave}>
|
||||||
{t('form.leave')}
|
{t('form.leave')}
|
||||||
@@ -277,6 +388,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{error ? <p className="form-error">{t(error)}</p> : null}
|
{error ? <p className="form-error">{t(error)}</p> : null}
|
||||||
|
{rooms.length > 0 && (
|
||||||
|
<div className="rooms-list">
|
||||||
|
<p className="rooms-list__label">{t('rooms.title')}</p>
|
||||||
|
<div className="rooms-list__chips">
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.name}
|
||||||
|
// key ist Pflicht bei React-Listen — React nutzt es beim Re-Render um Elemente zu identifizieren.
|
||||||
|
type="button"
|
||||||
|
className={`room-chip ${session?.room === r.name ? 'room-chip--active' : ''}`}
|
||||||
|
onClick={() => setRoom(r.name)}
|
||||||
|
>
|
||||||
|
#{r.name}
|
||||||
|
<span className="room-chip__count">{r.participantCount}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main className="chat-grid">
|
<main className="chat-grid">
|
||||||
@@ -284,6 +414,7 @@ function App() {
|
|||||||
<header className="chat-panel__header">
|
<header className="chat-panel__header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{t('chat.title')}</h2>
|
<h2>{t('chat.title')}</h2>
|
||||||
|
{/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}
|
||||||
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
|
<p>{t('chat.liveFeed', { room: session?.room ?? room })}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
|
<span className="muted">{t('chat.messageCount', { count: messages.length })}</span>
|
||||||
@@ -296,6 +427,7 @@ function App() {
|
|||||||
<article
|
<article
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`message ${message.userId === session?.id ? 'me' : ''}`}
|
className={`message ${message.userId === session?.id ? 'me' : ''}`}
|
||||||
|
// CSS-Klasse 'me': Eigene Nachrichten werden rechts ausgerichtet (Chat-Bubble-Stil).
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<strong>{message.username}</strong>
|
<strong>{message.username}</strong>
|
||||||
@@ -305,23 +437,31 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */}
|
||||||
|
{typingLabel && (
|
||||||
|
<p className="typing-indicator">{typingLabel}</p>
|
||||||
|
)}
|
||||||
<form className="composer" onSubmit={handleSendMessage}>
|
<form className="composer" onSubmit={handleSendMessage}>
|
||||||
<input
|
<input
|
||||||
value={messageInput}
|
value={messageInput}
|
||||||
onChange={(event) => setMessageInput(event.target.value)}
|
onChange={handleMessageInputChange}
|
||||||
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
|
placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')}
|
||||||
disabled={!session || !isConnected}
|
disabled={!session || !isConnected}
|
||||||
|
// Deaktiviert wenn kein Session oder WebSocket nicht verbunden — kein Senden ohne Verbindung.
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!session || !messageInput.trim() || !isConnected}
|
disabled={!session || !messageInput.trim() || !isConnected}
|
||||||
|
// Aktiv nur wenn: Session vorhanden + Text eingegeben + WebSocket verbunden.
|
||||||
>
|
>
|
||||||
{t('chat.send')}
|
{t('chat.send')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="participants-panel">
|
<aside className="participants-panel">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('participants.title')}</h2>
|
<h2>{t('participants.title')}</h2>
|
||||||
@@ -333,6 +473,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
participants.map((participant) => (
|
participants.map((participant) => (
|
||||||
<li key={participant.id}>
|
<li key={participant.id}>
|
||||||
|
{/* Presence-Punkt: CSS-Klasse 'online' oder 'away' je nach isOnline-Status. */}
|
||||||
<span
|
<span
|
||||||
className={`presence ${participant.isOnline ? 'online' : 'away'}`}
|
className={`presence ${participant.isOnline ? 'online' : 'away'}`}
|
||||||
aria-label={participant.isOnline ? t('participants.online') : 'offline'}
|
aria-label={participant.isOnline ? t('participants.online') : 'offline'}
|
||||||
@@ -355,6 +496,10 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausserhalb der Komponente — wird nicht bei jedem Render neu erstellt.
|
||||||
|
|
||||||
|
// Intl.DateTimeFormat: Eingebaute Browser-API, kein extra Paket nötig.
|
||||||
|
// 'de-DE' formatiert im 24-Stunden-Format (13:45).
|
||||||
function formatTime(timestamp) {
|
function formatTime(timestamp) {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
@@ -363,6 +508,9 @@ function formatTime(timestamp) {
|
|||||||
}).format(new Date(timestamp))
|
}).format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intl.RelativeTimeFormat: Gibt "vor 5 Minuten", "gestern" etc. aus.
|
||||||
|
// numeric: 'auto' → "gestern" statt "vor 1 Tag".
|
||||||
|
// Berechnung: Minuten → Stunden → Tage (von klein nach gross).
|
||||||
function formatRelativeTime(timestamp) {
|
function formatRelativeTime(timestamp) {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' })
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
"placeholderDisabled": "Verbinde dich, um zu schreiben",
|
"placeholderDisabled": "Verbinde dich, um zu schreiben",
|
||||||
"send": "Senden"
|
"send": "Senden"
|
||||||
},
|
},
|
||||||
|
"rooms": {
|
||||||
|
"title": "Aktive Räume"
|
||||||
|
},
|
||||||
"participants": {
|
"participants": {
|
||||||
"title": "Aktive Teilnehmer",
|
"title": "Aktive Teilnehmer",
|
||||||
"empty": "Niemand ist aktiv",
|
"empty": "Niemand ist aktiv",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
title: "Testprotokoll – Blackbox Test"
|
||||||
|
author: "Chat App"
|
||||||
|
date: "19.03.2026"
|
||||||
|
geometry: margin=2cm
|
||||||
|
fontsize: 11pt
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektinformationen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Projekt** | Chat App |
|
||||||
|
| **Datum** | 19.03.2026 |
|
||||||
|
| **Tester** | _______________ |
|
||||||
|
| **Version** | 1.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testumgebung
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Browser** | Chrome / Firefox |
|
||||||
|
| **URL** | http://localhost |
|
||||||
|
| **Methode** | Manueller Blackbox Test |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beschreibung der Systemteile
|
||||||
|
|
||||||
|
| Komponente | Technologie | Aufgabe |
|
||||||
|
|---|---|---|
|
||||||
|
| **Frontend** | React, Vite, Socket.IO Client, Nginx | Benutzeroberfläche für den Chat. Stellt Verbindung zum Backend über WebSocket her und zeigt Nachrichten, Teilnehmer und Verbindungsstatus an. |
|
||||||
|
| **Backend** | Node.js, Express, Socket.IO | REST-API und WebSocket-Server. Verwaltet Benutzer-Sessions, Räume und leitet Nachrichten in Echtzeit weiter. |
|
||||||
|
| **Datenbank** | MongoDB | Persistente Speicherung von Nachrichten und Benutzerdaten. Alternativ steht ein JSON-Fallback-Store zur Verfügung. |
|
||||||
|
| **Infrastruktur** | Docker, Docker Compose, Nginx | Containerisierung aller Dienste. Nginx fungiert als Reverse Proxy für das Frontend. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorschläge zur Ausfallsicherheit
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
|
||||||
|
- Automatischer Reconnect bei WebSocket-Verbindungsabbruch ist bereits implementiert (5 Versuche)
|
||||||
|
- Statische Dateien über CDN ausliefern für höhere Verfügbarkeit
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
|
||||||
|
- Mehrere Instanzen hinter einem Load Balancer betreiben (z. B. Nginx upstream)
|
||||||
|
- Bei mehreren Instanzen Socket.IO mit Redis Adapter koppeln, damit alle Instanzen dieselben Räume/Events teilen
|
||||||
|
- Health-Check-Endpunkt `/health` ist bereits vorhanden und kann von einem Orchestrator (z. B. Kubernetes) genutzt werden
|
||||||
|
|
||||||
|
**Datenbank**
|
||||||
|
|
||||||
|
- MongoDB Replica Set einrichten: primärer Node schreibt, sekundäre Nodes lesen → bei Ausfall des primären Nodes übernimmt automatisch ein sekundärer
|
||||||
|
- Regelmäßige automatische Backups des Volumes einrichten
|
||||||
|
- JSON-Fallback-Store dient als Notlösung bei MongoDB-Ausfall (bereits implementiert)
|
||||||
|
|
||||||
|
**Infrastruktur**
|
||||||
|
|
||||||
|
- `restart: unless-stopped` ist in allen Docker-Diensten bereits konfiguriert → automatischer Neustart bei Absturz
|
||||||
|
- Für Produktion: Docker Swarm oder Kubernetes für automatisches Failover und Skalierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testfälle
|
||||||
|
|
||||||
|
| Nr. | Beschreibung | Eingabe | Erwartetes Ergebnis | Tatsächliches Ergebnis | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| T01 | Beitreten mit gültigem Benutzernamen und Raum | Username: `Max`, Room: `general` | Benutzer tritt dem Raum bei, Chatverlauf wird angezeigt | | [ ] |
|
||||||
|
| T02 | Beitreten ohne Benutzernamen | Username: *(leer)*, Room: `general` | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
|
||||||
|
| T03 | Beitreten ohne Raumnamen | Username: `Max`, Room: *(leer)* | Fehlermeldung wird angezeigt, kein Beitritt | | [ ] |
|
||||||
|
| T04 | Nachricht senden | Nachricht: `Hallo!` → Senden | Nachricht erscheint im Chat | | [ ] |
|
||||||
|
| T05 | Leere Nachricht senden | Nachricht: *(leer)* → Senden | Nachricht wird nicht gesendet | | [ ] |
|
||||||
|
| T06 | Zwei Benutzer im selben Raum | Tab 1: `Max` / `general`, Tab 2: `Anna` / `general` | Beide sehen Nachrichten des jeweils anderen in Echtzeit | | [ ] |
|
||||||
|
| T07 | Benutzer in verschiedenen Räumen | Tab 1: `Max` / `general`, Tab 2: `Anna` / `privat` | Nachrichten sind voneinander getrennt | | [ ] |
|
||||||
|
| T08 | Teilnehmerliste aktualisiert sich | Tab 2 schließen | Tab 1 zeigt Benutzer als offline/entfernt | | [ ] |
|
||||||
|
| T09 | Chatverlauf nach Neuverbindung | Seite neu laden, wieder einloggen | Vorherige Nachrichten werden geladen | | [ ] |
|
||||||
|
| T10 | Sonderzeichen in Nachricht | Nachricht: `<script>alert(1)</script>` | Text wird als plain text angezeigt, kein Script ausgeführt | | [ ] |
|
||||||
|
| T11 | Langer Text | Nachricht mit 500 Zeichen | Nachricht vollständig angezeigt, kein Layout-Fehler | | [ ] |
|
||||||
|
| T12 | Backend nicht erreichbar | Backend stoppen, App öffnen | Fehlermeldung oder Reconnect-Versuch wird angezeigt | | [ ] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legende
|
||||||
|
|
||||||
|
| Symbol | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| [OK] | Bestanden |
|
||||||
|
| [X] | Fehlgeschlagen |
|
||||||
|
| [ ] | Nicht getestet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
| Gesamt | Bestanden | Fehlgeschlagen | Nicht getestet |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 12 | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bemerkungen
|
||||||
|
|
||||||
|
\vspace{1cm}
|
||||||
|
|
||||||
|
_______________________________________________
|
||||||
|
|
||||||
|
\vspace{1cm}
|
||||||
|
|
||||||
|
_______________________________________________
|
||||||
Reference in New Issue
Block a user