From 56ac9b142580ee2c7c0d934bd01d3b642c19db59 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sat, 16 May 2026 23:35:25 +0200 Subject: [PATCH] feat: Messaging-System mit Offline-First und WebSocket-Push (#58) ## Server - Messages-Tabelle (id, sender_id, receiver_id, body, sent_at, delivered_at) - MessageRepository: save/getUndelivered/getConversation/markDelivered (JOIN statt N+1) - POST /api/messages, GET /api/messages/{userId}: Nachrichten senden/abrufen - GET /api/users: User-Liste fuer authentifizierte User (ohne eigenen Account) - WebSocketManager: notifyNewMessage() + isOnline() - WebSocketRoutes: unzugestellte Nachrichten bei Reconnect pushen - LoginResponse: userId + username ergaenzt - Server-Dependency: kotlinx.serialization fuer shared ## App - MessageEntity + MessageDao (Room, Migration 3->4) - KrisenvorratDatabase v4, Migrations.MIGRATION_3_4 - MessageRepositoryImpl: Offline-First (isPending), drain bei WebSocket-Connect - WebSocketEvent.NewMessage -> MessageDto aus shared - WebSocketClientImpl: new_message-Event parsen - AUTH_USER_ID in SettingsKeys, SyncServiceImpl speichert userId bei Login - UserListScreen + UserListViewModel: User-Liste anzeigen - ChatScreen + ChatViewModel: WhatsApp-Style Chat (links/rechts, Zeitstempel) - Navigation: Screen.UserList, Screen.Chat, MESSAGES in Bottom-Nav - RepositoryModule: MessageRepository gebunden ## Tests - 234 Tests, 0 Fehler --- .github/skills/vps-deploy/SKILL.md | 44 ++- .../4.json | 314 ++++++++++++++++++ .../app/data/db/KrisenvorratDatabase.kt | 7 +- .../de/krisenvorrat/app/data/db/Migrations.kt | 19 ++ .../app/data/db/dao/MessageDao.kt | 33 ++ .../app/data/db/entity/MessageEntity.kt | 16 + .../data/repository/MessageRepositoryImpl.kt | 169 ++++++++++ .../krisenvorrat/app/data/sync/AuthModels.kt | 7 +- .../app/data/sync/SyncServiceImpl.kt | 2 + .../app/data/sync/WebSocketClient.kt | 2 + .../app/data/sync/WebSocketClientImpl.kt | 20 +- .../de/krisenvorrat/app/di/DatabaseModule.kt | 6 +- .../krisenvorrat/app/di/RepositoryModule.kt | 6 + .../app/domain/model/SettingsKeys.kt | 1 + .../domain/repository/MessageRepository.kt | 13 + .../app/ui/messaging/ChatScreen.kt | 181 ++++++++++ .../app/ui/messaging/ChatViewModel.kt | 60 ++++ .../app/ui/messaging/UserListScreen.kt | 87 +++++ .../app/ui/messaging/UserListViewModel.kt | 47 +++ .../app/ui/navigation/KrisenvorratNavGraph.kt | 18 +- .../krisenvorrat/app/ui/navigation/Screen.kt | 6 + .../app/ui/navigation/TopLevelDestination.kt | 8 + .../repository/MessageRepositoryImplTest.kt | 227 +++++++++++++ .../app/ui/messaging/ChatViewModelTest.kt | 104 ++++++ .../app/ui/messaging/UserListViewModelTest.kt | 104 ++++++ server/build.gradle.kts | 6 + .../krisenvorrat/server/db/DatabaseFactory.kt | 4 +- .../de/krisenvorrat/server/db/Tables.kt | 10 + .../krisenvorrat/server/model/AuthModels.kt | 7 +- .../de/krisenvorrat/server/plugins/Routing.kt | 10 +- .../server/repository/MessageRepository.kt | 102 ++++++ .../krisenvorrat/server/routes/AuthRoutes.kt | 20 +- .../server/routes/MessageRoutes.kt | 82 +++++ .../krisenvorrat/server/routes/UserRoutes.kt | 24 ++ .../server/routes/WebSocketRoutes.kt | 27 +- .../server/websocket/WebSocketManager.kt | 16 + .../main/resources/static/admin/index.html | 4 + .../krisenvorrat/shared/model/MessageDto.kt | 14 + .../shared/model/UserListItemDto.kt | 9 + 39 files changed, 1814 insertions(+), 22 deletions(-) create mode 100644 app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/4.json create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/MessageDao.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/entity/MessageEntity.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/repository/MessageRepositoryImpl.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/domain/repository/MessageRepository.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatViewModel.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListScreen.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListViewModel.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/ui/messaging/UserListViewModelTest.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/repository/MessageRepository.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/routes/MessageRoutes.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/routes/UserRoutes.kt create mode 100644 shared/src/main/kotlin/de/krisenvorrat/shared/model/MessageDto.kt create mode 100644 shared/src/main/kotlin/de/krisenvorrat/shared/model/UserListItemDto.kt diff --git a/.github/skills/vps-deploy/SKILL.md b/.github/skills/vps-deploy/SKILL.md index 82d3b37..f0313d8 100644 --- a/.github/skills/vps-deploy/SKILL.md +++ b/.github/skills/vps-deploy/SKILL.md @@ -112,24 +112,52 @@ services: ports: - '8080:8080' environment: - - KRISENVORRAT_API_KEY= + - KRISENVORRAT_JWT_SECRET= volumes: - ./data:/app/data ``` -**Hinweis:** Den echten API-Key NICHT in Skill-Dateien oder Git speichern. Er liegt nur in der `docker-compose.yml` auf dem VPS. +**Hinweis:** Das JWT-Secret NICHT in Skill-Dateien oder Git speichern. Es liegt nur in der `docker-compose.yml` auf dem VPS. + +--- + +## Authentifizierung + +Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr). + +### Admin-Zugang + +- **Admin-UI:** `http://195.246.231.210:8080/admin/` +- **Admin-User:** `admin` +- **Admin-Passwort:** Der User muss das Passwort selbst eingeben. Es ist NICHT gespeichert – bei Bedarf den User fragen. +- Beim ersten Start ohne `KRISENVORRAT_ADMIN_PASSWORD` ENV wird ein zufälliges Passwort generiert und in die Logs geschrieben. + +### Environment-Variablen + +| Variable | Pflicht | Beschreibung | +| ------------------------------- | ------- | --------------------------------------------------- | +| `KRISENVORRAT_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) | +| `KRISENVORRAT_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) | --- ## Server-Endpunkte -| Endpunkt | Auth | Beschreibung | -| ----------------------- | ------- | ------------------ | -| `GET /api/health` | nein | Health-Check → "OK"| -| `GET /api/inventory` | API-Key | Inventar abrufen | -| `PUT /api/inventory` | API-Key | Inventar hochladen | +| Endpunkt | Auth | Beschreibung | +| ------------------------------ | ----- | ------------------------------------- | +| `GET /api/health` | nein | Health-Check → "OK" | +| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) | +| `POST /api/auth/refresh` | nein | Access-Token erneuern | +| `GET /api/inventory` | JWT | Inventar des Users abrufen | +| `PUT /api/inventory` | JWT | Inventar des Users hochladen | +| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten | +| `GET /api/admin/users` | Admin | Alle User auflisten | +| `POST /api/admin/users` | Admin | Neuen User anlegen | +| `PUT /api/admin/users/{id}` | Admin | Passwort ändern | +| `DELETE /api/admin/users/{id}` | Admin | User löschen | +| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen | -API-Key wird als `Authorization: Bearer ` oder `X-API-Key: ` Header mitgeschickt. +JWT wird als `Authorization: Bearer ` Header mitgeschickt. --- diff --git a/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/4.json b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/4.json new file mode 100644 index 0000000..c4ea25e --- /dev/null +++ b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/4.json @@ -0,0 +1,314 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "1008ae2dd73e9444de995dcb89dcfa43", + "entities": [ + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category_id` INTEGER NOT NULL, `quantity` REAL NOT NULL, `unit` TEXT NOT NULL, `unit_price` REAL NOT NULL, `kcal_per_kg` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unitPrice", + "columnName": "unit_price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "kcalPerKg", + "columnName": "kcal_per_kg", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expiryDate", + "columnName": "expiry_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_items_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_items_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)" + } + ], + "foreignKeys": [ + { + "table": "categories", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_ops", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `operation` TEXT NOT NULL, `payload` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operation", + "columnName": "operation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sender_id` TEXT NOT NULL, `sender_username` TEXT NOT NULL, `receiver_id` TEXT NOT NULL, `body` TEXT NOT NULL, `sent_at` INTEGER NOT NULL, `is_pending` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "sender_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderUsername", + "columnName": "sender_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverId", + "columnName": "receiver_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sent_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPending", + "columnName": "is_pending", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1008ae2dd73e9444de995dcb89dcfa43')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt index 180a8a6..ce291b8 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt @@ -6,17 +6,19 @@ import androidx.room.TypeConverters import de.krisenvorrat.app.data.db.dao.CategoryDao import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.LocationDao +import de.krisenvorrat.app.data.db.dao.MessageDao import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.LocationEntity +import de.krisenvorrat.app.data.db.entity.MessageEntity import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity @Database( - entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class], - version = 3, + entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class], + version = 4, exportSchema = true ) @TypeConverters(LocalDateConverter::class) @@ -26,4 +28,5 @@ internal abstract class KrisenvorratDatabase : RoomDatabase() { abstract fun itemDao(): ItemDao abstract fun settingsDao(): SettingsDao abstract fun pendingSyncOpDao(): PendingSyncOpDao + abstract fun messageDao(): MessageDao } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt index 4527948..7974850 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt @@ -85,4 +85,23 @@ internal object Migrations { ) } } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `messages` ( + `id` TEXT NOT NULL, + `sender_id` TEXT NOT NULL, + `sender_username` TEXT NOT NULL, + `receiver_id` TEXT NOT NULL, + `body` TEXT NOT NULL, + `sent_at` INTEGER NOT NULL, + `is_pending` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } + } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/MessageDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/MessageDao.kt new file mode 100644 index 0000000..20e2569 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/MessageDao.kt @@ -0,0 +1,33 @@ +package de.krisenvorrat.app.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import de.krisenvorrat.app.data.db.entity.MessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface MessageDao { + + @Upsert + suspend fun upsert(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfNotExists(message: MessageEntity) + + @Query(""" + SELECT * FROM messages + WHERE (sender_id = :myId AND receiver_id = :otherId) + OR (sender_id = :otherId AND receiver_id = :myId) + ORDER BY sent_at ASC + """) + fun getConversation(myId: String, otherId: String): Flow> + + @Query("SELECT * FROM messages WHERE is_pending = 1 ORDER BY sent_at ASC") + suspend fun getPendingMessages(): List + + @Query("UPDATE messages SET is_pending = 0 WHERE id = :id") + suspend fun markDelivered(id: String) +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/entity/MessageEntity.kt b/app/src/main/java/de/krisenvorrat/app/data/db/entity/MessageEntity.kt new file mode 100644 index 0000000..e310e4b --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/entity/MessageEntity.kt @@ -0,0 +1,16 @@ +package de.krisenvorrat.app.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "messages") +internal data class MessageEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "sender_id") val senderId: String, + @ColumnInfo(name = "sender_username") val senderUsername: String, + @ColumnInfo(name = "receiver_id") val receiverId: String, + @ColumnInfo(name = "body") val body: String, + @ColumnInfo(name = "sent_at") val sentAt: Long, + @ColumnInfo(name = "is_pending") val isPending: Boolean +) diff --git a/app/src/main/java/de/krisenvorrat/app/data/repository/MessageRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/repository/MessageRepositoryImpl.kt new file mode 100644 index 0000000..e1f080e --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/repository/MessageRepositoryImpl.kt @@ -0,0 +1,169 @@ +package de.krisenvorrat.app.data.repository + +import de.krisenvorrat.app.data.db.dao.MessageDao +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent +import de.krisenvorrat.app.di.ApplicationScope +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.model.SyncError +import de.krisenvorrat.app.domain.repository.MessageRepository +import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.shared.model.UserListItemDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +private data class SendMessageRequest( + val id: String, + val receiverId: String, + val body: String, + val sentAt: Long +) + +@Singleton +internal class MessageRepositoryImpl @Inject constructor( + private val dao: MessageDao, + private val httpClient: HttpClient, + private val settingsRepository: SettingsRepository, + private val webSocketClient: WebSocketClient, + @ApplicationScope private val scope: CoroutineScope +) : MessageRepository { + + init { + scope.launch { + webSocketClient.events.collect { event -> + when (event) { + is WebSocketEvent.NewMessage -> { + val msg = event.message + dao.upsert( + MessageEntity( + id = msg.id, + senderId = msg.senderId, + senderUsername = msg.senderUsername, + receiverId = msg.receiverId, + body = msg.body, + sentAt = msg.sentAt, + isPending = false + ) + ) + } + is WebSocketEvent.Connected -> drainPendingMessages() + else -> Unit + } + } + } + } + + override fun getConversation(myId: String, otherId: String): Flow> = + dao.getConversation(myId, otherId) + + override suspend fun sendMessage(recipientId: String, body: String) { + val myId = settingsRepository.getValue(SettingsKeys.AUTH_USER_ID) ?: return + val myUsername = settingsRepository.getValue(SettingsKeys.AUTH_USERNAME) ?: "" + val localId = UUID.randomUUID().toString() + val sentAt = System.currentTimeMillis() + + dao.upsert( + MessageEntity( + id = localId, + senderId = myId, + senderUsername = myUsername, + receiverId = recipientId, + body = body, + sentAt = sentAt, + isPending = true + ) + ) + + val result = attemptSendToServer(localId, recipientId, body, sentAt) + if (result.isSuccess) { + dao.markDelivered(localId) + } + } + + override suspend fun fetchUsers(): Result> = + withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL) + ?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) + val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN) + ?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet")) + try { + val response = httpClient.get("${serverUrl.trimEnd('/')}/api/users") { + header("Authorization", "Bearer $token") + } + if (response.status == HttpStatusCode.OK) { + Result.success(response.body>()) + } else { + Result.failure(SyncError.ServerError(response.status.value, response.status.description)) + } + } catch (e: SocketTimeoutException) { + Result.failure(SyncError.Timeout(e)) + } catch (e: ConnectException) { + Result.failure(SyncError.ConnectionError(e)) + } catch (e: Exception) { + Result.failure(SyncError.Unknown(e)) + } + } + + override suspend fun getMyUserId(): String? = + settingsRepository.getValue(SettingsKeys.AUTH_USER_ID) + + override suspend fun drainPendingMessages() { + val pending = withContext(Dispatchers.IO) { dao.getPendingMessages() } + for (msg in pending) { + val result = attemptSendToServer(msg.id, msg.receiverId, msg.body, msg.sentAt) + if (result.isSuccess) { + withContext(Dispatchers.IO) { dao.markDelivered(msg.id) } + } + } + } + + private suspend fun attemptSendToServer( + id: String, + recipientId: String, + body: String, + sentAt: Long + ): Result = withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL) + ?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) + val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN) + ?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet")) + try { + val response = httpClient.post("${serverUrl.trimEnd('/')}/api/messages") { + header("Authorization", "Bearer $token") + contentType(ContentType.Application.Json) + setBody(SendMessageRequest(id = id, receiverId = recipientId, body = body, sentAt = sentAt)) + } + if (response.status == HttpStatusCode.Created || response.status == HttpStatusCode.OK) { + Result.success(Unit) + } else { + Result.failure(SyncError.ServerError(response.status.value, response.status.description)) + } + } catch (e: SocketTimeoutException) { + Result.failure(SyncError.Timeout(e)) + } catch (e: ConnectException) { + Result.failure(SyncError.ConnectionError(e)) + } catch (e: Exception) { + Result.failure(SyncError.Unknown(e)) + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt index 44527fc..8ed2f25 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt @@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable internal data class LoginRequest(val username: String, val password: String) @Serializable -internal data class LoginResponse(val accessToken: String, val refreshToken: String) +internal data class LoginResponse( + val accessToken: String, + val refreshToken: String, + val userId: String, + val username: String +) @Serializable internal data class RefreshRequest(val refreshToken: String) diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt index 9ca3b97..11c2cb3 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt @@ -83,6 +83,7 @@ internal class SyncServiceImpl @Inject constructor( settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken) settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken) settingsRepository.setValue(KEY_AUTH_USERNAME, username) + settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId) Result.success(Unit) } HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) @@ -212,5 +213,6 @@ internal class SyncServiceImpl @Inject constructor( val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME + val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt index 77a97b9..72dcd25 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt @@ -1,5 +1,6 @@ package de.krisenvorrat.app.data.sync +import de.krisenvorrat.shared.model.MessageDto import kotlinx.coroutines.flow.SharedFlow internal interface WebSocketClient { @@ -13,4 +14,5 @@ internal sealed interface WebSocketEvent { data object FullSyncRequired : WebSocketEvent data object Connected : WebSocketEvent data object Disconnected : WebSocketEvent + data class NewMessage(val message: MessageDto) : WebSocketEvent } diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt index 82613ee..b0b4c74 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt @@ -79,6 +79,18 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { when (event.type) { "inventoryUpdated" -> _events.emit(WebSocketEvent.InventoryUpdated(event.itemId ?: "")) "fullSyncRequired" -> _events.emit(WebSocketEvent.FullSyncRequired) + "new_message" -> { + val msg = de.krisenvorrat.shared.model.MessageDto( + id = event.id ?: return, + senderId = event.senderId ?: return, + senderUsername = event.senderUsername ?: return, + receiverId = event.receiverId ?: return, + body = event.body ?: return, + sentAt = event.sentAt ?: return, + deliveredAt = null + ) + _events.emit(WebSocketEvent.NewMessage(msg)) + } } } catch (_: Exception) { // ignore malformed events @@ -95,5 +107,11 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { @Serializable private data class WsServerEvent( val type: String, - val itemId: String? = null + val itemId: String? = null, + val id: String? = null, + val senderId: String? = null, + val senderUsername: String? = null, + val receiverId: String? = null, + val body: String? = null, + val sentAt: Long? = null ) diff --git a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt index 9d209a9..b53c85a 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt @@ -15,6 +15,7 @@ import de.krisenvorrat.app.data.db.Migrations import de.krisenvorrat.app.data.db.dao.CategoryDao import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.LocationDao +import de.krisenvorrat.app.data.db.dao.MessageDao import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.export.DatabaseTransaction @@ -29,7 +30,7 @@ internal object DatabaseModule { fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") .addCallback(DefaultDataCallback) - .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) + .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4) .build() private object DefaultDataCallback : RoomDatabase.Callback() { @@ -65,6 +66,9 @@ internal object DatabaseModule { @Provides fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao() + @Provides + fun provideMessageDao(db: KrisenvorratDatabase): MessageDao = db.messageDao() + @Provides @Singleton fun provideDatabaseTransaction(db: KrisenvorratDatabase): DatabaseTransaction = diff --git a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt index 091a5c4..33c3c61 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt @@ -8,11 +8,13 @@ import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl import de.krisenvorrat.app.data.repository.ItemRepositoryImpl import de.krisenvorrat.app.data.repository.LocationRepositoryImpl +import de.krisenvorrat.app.data.repository.MessageRepositoryImpl import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl import de.krisenvorrat.app.domain.repository.CategoryRepository import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.LocationRepository +import de.krisenvorrat.app.domain.repository.MessageRepository import de.krisenvorrat.app.domain.repository.SettingsRepository import javax.inject.Singleton @@ -39,4 +41,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindImportExportRepository(impl: ImportExportRepositoryImpl): ImportExportRepository + + @Binds + @Singleton + abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt index 116ec10..133f30c 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt @@ -8,6 +8,7 @@ internal object SettingsKeys { const val AUTH_ACCESS_TOKEN = "auth_access_token" const val AUTH_REFRESH_TOKEN = "auth_refresh_token" const val AUTH_USERNAME = "auth_username" + const val AUTH_USER_ID = "auth_user_id" const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" const val OPENAI_API_KEY = "openai_api_key" } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/MessageRepository.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/MessageRepository.kt new file mode 100644 index 0000000..2ca6802 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/MessageRepository.kt @@ -0,0 +1,13 @@ +package de.krisenvorrat.app.domain.repository + +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.shared.model.UserListItemDto +import kotlinx.coroutines.flow.Flow + +internal interface MessageRepository { + fun getConversation(myId: String, otherId: String): Flow> + suspend fun sendMessage(recipientId: String, body: String) + suspend fun fetchUsers(): Result> + suspend fun getMyUserId(): String? + suspend fun drainPendingMessages() +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt new file mode 100644 index 0000000..7e813e0 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt @@ -0,0 +1,181 @@ +package de.krisenvorrat.app.ui.messaging + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.krisenvorrat.app.data.db.entity.MessageEntity +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChatScreen( + onNavigateBack: () -> Unit, + viewModel: ChatViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(uiState.messages.size) { + if (uiState.messages.isNotEmpty()) { + listState.animateScrollToItem(uiState.messages.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(uiState.recipientUsername) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück" + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(uiState.messages, key = { it.id }) { message -> + MessageBubble( + message = message, + isMine = message.senderId == uiState.myUserId + ) + } + } + MessageInputBar( + text = uiState.inputText, + onTextChange = { viewModel.onInputChanged(it) }, + onSend = { viewModel.sendMessage() }, + isSending = uiState.isSending + ) + } + } +} + +@Composable +private fun MessageBubble(message: MessageEntity, isMine: Boolean) { + val horizontalAlignment = if (isMine) Alignment.End else Alignment.Start + val bubbleColor = if (isMine) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = horizontalAlignment + ) { + if (!isMine) { + Text( + text = message.senderUsername, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp) + ) + } + Surface( + shape = RoundedCornerShape( + topStart = if (isMine) 12.dp else 2.dp, + topEnd = if (isMine) 2.dp else 12.dp, + bottomStart = 12.dp, + bottomEnd = 12.dp + ), + color = bubbleColor, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text( + text = message.body, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = timeFormat.format(Date(message.sentAt)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End) + ) + } + } + } +} + +@Composable +private fun MessageInputBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + isSending: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Nachricht...") }, + maxLines = 4, + enabled = !isSending + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = onSend, + enabled = text.isNotBlank() && !isSending + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Senden" + ) + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatViewModel.kt new file mode 100644 index 0000000..91669e1 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatViewModel.kt @@ -0,0 +1,60 @@ +package de.krisenvorrat.app.ui.messaging + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.app.domain.repository.MessageRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal data class ChatUiState( + val messages: List = emptyList(), + val myUserId: String = "", + val recipientUsername: String = "", + val inputText: String = "", + val isSending: Boolean = false +) + +@HiltViewModel +internal class ChatViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val messageRepository: MessageRepository +) : ViewModel() { + + private val recipientId: String = savedStateHandle.get("recipientId") ?: "" + private val recipientUsername: String = savedStateHandle.get("recipientUsername") ?: "" + + private val _uiState = MutableStateFlow(ChatUiState(recipientUsername = recipientUsername)) + val uiState: StateFlow = _uiState + + init { + viewModelScope.launch { + val myId = messageRepository.getMyUserId() ?: "" + _uiState.update { it.copy(myUserId = myId) } + if (myId.isNotEmpty()) { + messageRepository.getConversation(myId, recipientId).collect { messages -> + _uiState.update { it.copy(messages = messages) } + } + } + } + } + + fun onInputChanged(text: String) { + _uiState.update { it.copy(inputText = text) } + } + + fun sendMessage() { + val text = _uiState.value.inputText.trim() + if (text.isEmpty()) return + viewModelScope.launch { + _uiState.update { it.copy(isSending = true, inputText = "") } + messageRepository.sendMessage(recipientId = recipientId, body = text) + _uiState.update { it.copy(isSending = false) } + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListScreen.kt new file mode 100644 index 0000000..47c6ed7 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListScreen.kt @@ -0,0 +1,87 @@ +package de.krisenvorrat.app.ui.messaging + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.krisenvorrat.shared.model.UserListItemDto + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun UserListScreen( + onUserClick: (id: String, username: String) -> Unit, + viewModel: UserListViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar(title = { Text("Nachrichten") }) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + uiState.isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + uiState.errorMessage != null -> Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = uiState.errorMessage ?: "Fehler", + style = MaterialTheme.typography.bodyMedium + ) + Button(onClick = { viewModel.retry() }) { Text("Erneut versuchen") } + } + uiState.users.isEmpty() -> Text( + text = "Keine Benutzer gefunden", + modifier = Modifier.align(Alignment.Center) + ) + else -> LazyColumn { + items(uiState.users) { user -> + UserListItem(user = user, onClick = { onUserClick(user.id, user.username) }) + } + } + } + } + } +} + +@Composable +private fun UserListItem(user: UserListItemDto, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(user.username) }, + leadingContent = { + Icon(imageVector = Icons.Outlined.Person, contentDescription = null) + }, + modifier = Modifier.clickable(onClick = onClick) + ) + HorizontalDivider() +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListViewModel.kt new file mode 100644 index 0000000..2203034 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/messaging/UserListViewModel.kt @@ -0,0 +1,47 @@ +package de.krisenvorrat.app.ui.messaging + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.krisenvorrat.app.domain.repository.MessageRepository +import de.krisenvorrat.shared.model.UserListItemDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal data class UserListUiState( + val users: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +@HiltViewModel +internal class UserListViewModel @Inject constructor( + private val messageRepository: MessageRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(UserListUiState(isLoading = true)) + val uiState: StateFlow = _uiState + + init { + loadUsers() + } + + private fun loadUsers() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + val result = messageRepository.fetchUsers() + _uiState.value = if (result.isSuccess) { + UserListUiState(users = result.getOrDefault(emptyList()), isLoading = false) + } else { + UserListUiState( + isLoading = false, + errorMessage = result.exceptionOrNull()?.message ?: "Fehler beim Laden" + ) + } + } + } + + fun retry() = loadUsers() +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt index 7461e25..be90828 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt @@ -5,12 +5,14 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import de.krisenvorrat.app.ui.category.CategoryListScreen import de.krisenvorrat.app.ui.camera.CameraScreen +import de.krisenvorrat.app.ui.category.CategoryListScreen import de.krisenvorrat.app.ui.dashboard.DashboardScreen import de.krisenvorrat.app.ui.item.ItemFormScreen import de.krisenvorrat.app.ui.item.ItemListScreen import de.krisenvorrat.app.ui.location.LocationListScreen +import de.krisenvorrat.app.ui.messaging.ChatScreen +import de.krisenvorrat.app.ui.messaging.UserListScreen import de.krisenvorrat.app.ui.settings.SettingsScreen import de.krisenvorrat.app.ui.warnings.WarningsScreen @@ -92,5 +94,19 @@ internal fun KrisenvorratNavGraph( composable { SettingsScreen() } + + composable { + UserListScreen( + onUserClick = { id, username -> + navController.navigate(Screen.Chat(recipientId = id, recipientUsername = username)) + } + ) + } + + composable { + ChatScreen( + onNavigateBack = { navController.popBackStack() } + ) + } } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt index e455f80..637c5a1 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt @@ -28,4 +28,10 @@ internal sealed interface Screen { @Serializable data object Settings : Screen + + @Serializable + data object UserList : Screen + + @Serializable + data class Chat(val recipientId: String, val recipientUsername: String) : Screen } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt index bd070fe..78d2642 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt @@ -1,6 +1,8 @@ package de.krisenvorrat.app.ui.navigation import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Warning @@ -34,6 +36,12 @@ internal enum class TopLevelDestination( unselectedIcon = Icons.Outlined.Warning, label = "Warnungen" ), + MESSAGES( + route = Screen.UserList, + selectedIcon = Icons.AutoMirrored.Filled.Message, + unselectedIcon = Icons.AutoMirrored.Outlined.Message, + label = "Nachrichten" + ), SETTINGS( route = Screen.Settings, selectedIcon = Icons.Filled.Settings, diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt new file mode 100644 index 0000000..f3a5062 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt @@ -0,0 +1,227 @@ +package de.krisenvorrat.app.data.repository + +import de.krisenvorrat.app.data.db.dao.MessageDao +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.shared.model.MessageDto +import de.krisenvorrat.shared.model.UserListItemDto +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.cancel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private class FakeMessageDao : MessageDao { + val upserted = mutableListOf() + val delivered = mutableListOf() + + override suspend fun upsert(message: MessageEntity) { upserted.add(message) } + override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) } + override fun getConversation(myId: String, otherId: String): Flow> = + MutableStateFlow(upserted.filter { + (it.senderId == myId && it.receiverId == otherId) || + (it.senderId == otherId && it.receiverId == myId) + }) + override suspend fun getPendingMessages(): List = + upserted.filter { it.isPending } + override suspend fun markDelivered(id: String) { + delivered.add(id) + val idx = upserted.indexOfFirst { it.id == id } + if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false) + } +} + +private class FakeMessageSettingsRepository : SettingsRepository { + private val store = mutableMapOf() + fun set(key: String, value: String) { store[key] = value } + + override suspend fun getValue(key: String): String? = store[key] + override suspend fun setValue(key: String, value: String) { store[key] = value } + override fun observeValue(key: String): Flow = MutableStateFlow(store[key]) + override fun getAll(): Flow> = + MutableStateFlow(emptyList()) +} + +private class FakeMessageWsClient : WebSocketClient { + val events2 = MutableSharedFlow(extraBufferCapacity = 16) + override val events: SharedFlow = events2.asSharedFlow() + override fun connect(serverUrl: String, accessToken: String) = Unit + override fun disconnect() = Unit +} + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageRepositoryImplTest { + + private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val testScope = TestScope() + + @After + fun tearDown() { + testScope.cancel() + } + + private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) { + install(ContentNegotiation) { json(jsonSerializer) } + } + + private fun buildRepository( + dao: FakeMessageDao, + httpClient: HttpClient, + settings: FakeMessageSettingsRepository, + wsClient: FakeMessageWsClient = FakeMessageWsClient() + ) = MessageRepositoryImpl( + dao = dao, + httpClient = httpClient, + settingsRepository = settings, + webSocketClient = wsClient, + scope = testScope + ) + + @Test + fun test_sendMessage_whenServerReachable_marksDelivered() = runTest { + // Given + val dao = FakeMessageDao() + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + set(SettingsKeys.AUTH_USER_ID, "user1") + set(SettingsKeys.AUTH_USERNAME, "Alice") + } + val engine = MockEngine { + respond( + content = """{"id":"x","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hi","sentAt":1}""", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val repo = buildRepository(dao, createClient(engine), settings) + + // When + repo.sendMessage("user2", "hi") + + // Then + assertTrue(dao.delivered.isNotEmpty()) + } + + @Test + fun test_sendMessage_whenServerUnreachable_remainsPending() = runTest { + // Given + val dao = FakeMessageDao() + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + set(SettingsKeys.AUTH_USER_ID, "user1") + set(SettingsKeys.AUTH_USERNAME, "Alice") + } + val engine = MockEngine { respondError(HttpStatusCode.ServiceUnavailable) } + val repo = buildRepository(dao, createClient(engine), settings) + + // When + repo.sendMessage("user2", "hi") + + // Then + assertTrue(dao.delivered.isEmpty()) + assertTrue(dao.upserted.any { it.isPending }) + } + + @Test + fun test_drainPendingMessages_sendsAllPending() = runTest { + // Given + val dao = FakeMessageDao() + dao.upserted.add( + MessageEntity("m1", "user1", "Alice", "user2", "hello", 1000L, isPending = true) + ) + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + } + val engine = MockEngine { + respond( + content = """{"id":"m1","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hello","sentAt":1000}""", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val repo = buildRepository(dao, createClient(engine), settings) + + // When + repo.drainPendingMessages() + + // Then + assertTrue(dao.delivered.contains("m1")) + } + + @Test + fun test_fetchUsers_returnsUserList() = runTest { + // Given + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + } + val users = listOf(UserListItemDto("u2", "Bob"), UserListItemDto("u3", "Carol")) + val engine = MockEngine { + respond( + content = jsonSerializer.encodeToString(ListSerializer(UserListItemDto.serializer()), users), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val repo = buildRepository(FakeMessageDao(), createClient(engine), settings) + + // When + val result = repo.fetchUsers() + + // Then + assertTrue(result.isSuccess) + assertEquals(2, result.getOrNull()?.size) + } + + @Test + fun test_newMessageEvent_upsertedToDatabase() = runTest { + // Given + val dao = FakeMessageDao() + val wsClient = FakeMessageWsClient() + buildRepository( + dao = dao, + httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }), + settings = FakeMessageSettingsRepository(), + wsClient = wsClient + ) + // Let init block start and subscribe before emitting + testScope.advanceUntilIdle() + + val msg = MessageDto("m1", "u2", "Bob", "u1", "hey", 1000L) + + // When + wsClient.events2.emit(WebSocketEvent.NewMessage(msg)) + testScope.advanceUntilIdle() + + // Then + assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending }) + } +} + diff --git a/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt new file mode 100644 index 0000000..2c4607e --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt @@ -0,0 +1,104 @@ +package de.krisenvorrat.app.ui.messaging + +import androidx.lifecycle.SavedStateHandle +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.app.domain.repository.MessageRepository +import de.krisenvorrat.shared.model.UserListItemDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeChatMessageRepository( + private val myUserId: String = "user1", + private val conversation: MutableStateFlow> = MutableStateFlow(emptyList()) +) : MessageRepository { + var sendMessageCalled = false + var lastSentBody: String? = null + + override fun getConversation(myId: String, otherId: String): Flow> = conversation + override suspend fun sendMessage(recipientId: String, body: String) { + sendMessageCalled = true + lastSentBody = body + } + override suspend fun fetchUsers(): Result> = Result.success(emptyList()) + override suspend fun getMyUserId(): String = myUserId + override suspend fun drainPendingMessages() = Unit +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel( + recipientId: String = "user2", + recipientUsername: String = "Bob", + repo: MessageRepository = FakeChatMessageRepository() + ): ChatViewModel { + val savedStateHandle = SavedStateHandle(mapOf("recipientId" to recipientId, "recipientUsername" to recipientUsername)) + return ChatViewModel(savedStateHandle = savedStateHandle, messageRepository = repo) + } + + @Test + fun test_uiState_showsCorrectRecipientUsername() = runTest(testDispatcher) { + // Given / When + val viewModel = createViewModel(recipientUsername = "Bob") + advanceUntilIdle() + + // Then + assertEquals("Bob", viewModel.uiState.value.recipientUsername) + } + + @Test + fun test_sendMessage_clearsInput() = runTest(testDispatcher) { + // Given + val viewModel = createViewModel() + advanceUntilIdle() + viewModel.onInputChanged("Hello") + + // When + viewModel.sendMessage() + advanceUntilIdle() + + // Then + assertEquals("", viewModel.uiState.value.inputText) + } + + @Test + fun test_sendMessage_whenEmptyBody_doesNotSend() = runTest(testDispatcher) { + // Given + val fakeRepo = FakeChatMessageRepository() + val viewModel = createViewModel(repo = fakeRepo) + advanceUntilIdle() + viewModel.onInputChanged(" ") + + // When + viewModel.sendMessage() + advanceUntilIdle() + + // Then + assertFalse(fakeRepo.sendMessageCalled) + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/messaging/UserListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/messaging/UserListViewModelTest.kt new file mode 100644 index 0000000..a9d6d31 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/messaging/UserListViewModelTest.kt @@ -0,0 +1,104 @@ +package de.krisenvorrat.app.ui.messaging + +import de.krisenvorrat.app.data.db.entity.MessageEntity +import de.krisenvorrat.app.domain.model.SyncError +import de.krisenvorrat.app.domain.repository.MessageRepository +import de.krisenvorrat.shared.model.UserListItemDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeUserListMessageRepository( + private val usersResult: Result> +) : MessageRepository { + var fetchCount = 0 + + override fun getConversation(myId: String, otherId: String): Flow> = + MutableStateFlow(emptyList()) + override suspend fun sendMessage(recipientId: String, body: String) = Unit + override suspend fun fetchUsers(): Result> { + fetchCount++ + return usersResult + } + override suspend fun getMyUserId(): String = "" + override suspend fun drainPendingMessages() = Unit +} + +@OptIn(ExperimentalCoroutinesApi::class) +class UserListViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun test_loadUsers_success_showsUsers() = runTest(testDispatcher) { + // Given + val users = listOf(UserListItemDto("u1", "Alice"), UserListItemDto("u2", "Bob")) + val repo = FakeUserListMessageRepository(Result.success(users)) + val viewModel = UserListViewModel(repo) + + // When + advanceUntilIdle() + + // Then + assertEquals(2, viewModel.uiState.value.users.size) + assertFalse(viewModel.uiState.value.isLoading) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun test_loadUsers_failure_showsError() = runTest(testDispatcher) { + // Given + val repo = FakeUserListMessageRepository( + Result.failure(SyncError.ConnectionError()) + ) + val viewModel = UserListViewModel(repo) + + // When + advanceUntilIdle() + + // Then + assertFalse(viewModel.uiState.value.isLoading) + assertNotNull(viewModel.uiState.value.errorMessage) + assertTrue(viewModel.uiState.value.users.isEmpty()) + } + + @Test + fun test_retry_reloadsUsers() = runTest(testDispatcher) { + // Given + val repo = FakeUserListMessageRepository(Result.success(emptyList())) + val viewModel = UserListViewModel(repo) + advanceUntilIdle() + val countBefore = repo.fetchCount + + // When + viewModel.retry() + advanceUntilIdle() + + // Then + assertEquals(countBefore + 1, repo.fetchCount) + } +} diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 5719f64..cc4c701 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -48,3 +48,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.serialization.json) } + +tasks { + named("distZip") { dependsOn("shadowJar") } + named("distTar") { dependsOn("shadowJar") } + named("startScripts") { dependsOn("shadowJar") } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt index d3c29a3..13e7383 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt @@ -20,8 +20,8 @@ internal object DatabaseFactory { ) { Database.connect(jdbcUrl, driver) transaction { - SchemaUtils.create(Users, Categories, Locations, Items, Settings) - SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings) + SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages) + SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages) } seedAdmin(adminPassword) } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt index 0528d5d..ac91255 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt @@ -55,3 +55,13 @@ internal object Settings : Table("settings") { override val primaryKey = PrimaryKey(id) } + +internal object Messages : Table("messages") { + val id = varchar("id", 36) + val senderId = varchar("sender_id", 36) + val receiverId = varchar("receiver_id", 36) + val body = text("body") + val sentAt = long("sent_at") + val deliveredAt = long("delivered_at").nullable() + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt b/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt index 8c7188c..21da59e 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt @@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable internal data class LoginRequest(val username: String, val password: String) @Serializable -internal data class LoginResponse(val accessToken: String, val refreshToken: String) +internal data class LoginResponse( + val accessToken: String, + val refreshToken: String, + val userId: String, + val username: String +) @Serializable internal data class RefreshRequest(val refreshToken: String) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt index 3a1e245..cda900a 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt @@ -1,10 +1,13 @@ package de.krisenvorrat.server.plugins import de.krisenvorrat.server.repository.InventoryRepository +import de.krisenvorrat.server.repository.MessageRepository import de.krisenvorrat.server.repository.UserRepository import de.krisenvorrat.server.routes.adminRoutes import de.krisenvorrat.server.routes.authRoutes import de.krisenvorrat.server.routes.inventoryRoutes +import de.krisenvorrat.server.routes.messageRoutes +import de.krisenvorrat.server.routes.userRoutes import de.krisenvorrat.server.routes.webSocketRoutes import de.krisenvorrat.server.security.JwtService import de.krisenvorrat.server.websocket.WebSocketManager @@ -20,6 +23,7 @@ import kotlin.time.Duration.Companion.seconds internal fun Application.configureRouting( inventoryRepository: InventoryRepository = InventoryRepository(), userRepository: UserRepository = UserRepository(), + messageRepository: MessageRepository = MessageRepository(), jwtService: JwtService = JwtService(environment.config), wsManager: WebSocketManager = WebSocketManager() ) { @@ -36,14 +40,16 @@ internal fun Application.configureRouting( // Public auth endpoints authRoutes(userRepository, jwtService) - // Protected inventory + admin endpoints + // Protected endpoints authenticate("auth-jwt") { inventoryRoutes(inventoryRepository, wsManager) adminRoutes(userRepository) + messageRoutes(messageRepository, userRepository, wsManager) + userRoutes(userRepository) } // WebSocket – auth via query param ?token= - webSocketRoutes(wsManager, jwtService) + webSocketRoutes(wsManager, jwtService, messageRepository) // Admin web UI (static) staticResources("/admin", "static/admin") diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/MessageRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/MessageRepository.kt new file mode 100644 index 0000000..393c244 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/MessageRepository.kt @@ -0,0 +1,102 @@ +package de.krisenvorrat.server.repository + +import de.krisenvorrat.server.db.Messages +import de.krisenvorrat.server.db.Users +import de.krisenvorrat.shared.model.MessageDto +import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.or +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update + +internal class MessageRepository { + + fun save( + id: String, + senderId: String, + senderUsername: String, + receiverId: String, + body: String, + sentAt: Long + ): MessageDto { + transaction { + Messages.insert { + it[Messages.id] = id + it[Messages.senderId] = senderId + it[Messages.receiverId] = receiverId + it[Messages.body] = body + it[Messages.sentAt] = sentAt + it[Messages.deliveredAt] = null + } + } + return MessageDto( + id = id, + senderId = senderId, + senderUsername = senderUsername, + receiverId = receiverId, + body = body, + sentAt = sentAt, + deliveredAt = null + ) + } + + fun getUndelivered(receiverId: String): List = transaction { + Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id) + .selectAll() + .where { (Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull() } + .map { row -> + MessageDto( + id = row[Messages.id], + senderId = row[Messages.senderId], + senderUsername = row.getOrNull(Users.username) ?: "", + receiverId = row[Messages.receiverId], + body = row[Messages.body], + sentAt = row[Messages.sentAt], + deliveredAt = row[Messages.deliveredAt] + ) + } + } + + fun markDelivered(messageId: String) { + transaction { + Messages.update({ Messages.id eq messageId }) { + it[deliveredAt] = System.currentTimeMillis() + } + } + } + + fun markAllDeliveredForReceiver(receiverId: String) { + transaction { + Messages.update({ + (Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull() + }) { + it[deliveredAt] = System.currentTimeMillis() + } + } + } + + fun getConversation(userId1: String, userId2: String): List = transaction { + Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id) + .selectAll() + .where { + ((Messages.senderId eq userId1) and (Messages.receiverId eq userId2)) or + ((Messages.senderId eq userId2) and (Messages.receiverId eq userId1)) + } + .orderBy(Messages.sentAt to SortOrder.ASC) + .map { row -> + MessageDto( + id = row[Messages.id], + senderId = row[Messages.senderId], + senderUsername = row.getOrNull(Users.username) ?: "", + receiverId = row[Messages.receiverId], + body = row[Messages.body], + sentAt = row[Messages.sentAt], + deliveredAt = row[Messages.deliveredAt] + ) + } + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt index c0bf55d..acab0e8 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt @@ -26,7 +26,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer } val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin) val refreshToken = jwtService.generateRefreshToken(user.id) - call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken)) + call.respond( + HttpStatusCode.OK, + LoginResponse( + accessToken = accessToken, + refreshToken = refreshToken, + userId = user.id, + username = user.username + ) + ) } post("/refresh") { @@ -49,7 +57,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer } val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin) val refreshToken = jwtService.generateRefreshToken(user.id) - call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken)) + call.respond( + HttpStatusCode.OK, + LoginResponse( + accessToken = accessToken, + refreshToken = refreshToken, + userId = user.id, + username = user.username + ) + ) } } } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/MessageRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/MessageRoutes.kt new file mode 100644 index 0000000..056f8cd --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/MessageRoutes.kt @@ -0,0 +1,82 @@ +package de.krisenvorrat.server.routes + +import de.krisenvorrat.server.model.ErrorResponse +import de.krisenvorrat.server.repository.MessageRepository +import de.krisenvorrat.server.repository.UserRepository +import de.krisenvorrat.server.security.UserPrincipal +import de.krisenvorrat.server.websocket.WebSocketManager +import io.ktor.http.* +import io.ktor.server.auth.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +internal data class SendMessageRequest( + val id: String? = null, + val receiverId: String, + val body: String, + val sentAt: Long +) + +internal fun Route.messageRoutes( + messageRepository: MessageRepository, + userRepository: UserRepository, + wsManager: WebSocketManager +) { + route("/api/messages") { + post { + val principal = call.principal() + ?: return@post call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + val request = call.receive() + if (request.body.isBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Body must not be empty") + ) + return@post + } + if (userRepository.findById(request.receiverId) == null) { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "Receiver not found") + ) + return@post + } + val msgId = request.id ?: UUID.randomUUID().toString() + val message = messageRepository.save( + id = msgId, + senderId = principal.userId, + senderUsername = principal.username, + receiverId = request.receiverId, + body = request.body, + sentAt = request.sentAt + ) + wsManager.notifyNewMessage(request.receiverId, message) + if (wsManager.isOnline(request.receiverId)) { + messageRepository.markDelivered(msgId) + } + call.respond(HttpStatusCode.Created, message) + } + + get("/{userId}") { + val myId = call.principal()?.userId + ?: return@get call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + val otherId = call.parameters["userId"] + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Missing userId") + ) + val messages = messageRepository.getConversation(myId, otherId) + call.respond(HttpStatusCode.OK, messages) + } + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/UserRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/UserRoutes.kt new file mode 100644 index 0000000..3f9aa9e --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/UserRoutes.kt @@ -0,0 +1,24 @@ +package de.krisenvorrat.server.routes + +import de.krisenvorrat.server.model.ErrorResponse +import de.krisenvorrat.server.repository.UserRepository +import de.krisenvorrat.server.security.UserPrincipal +import de.krisenvorrat.shared.model.UserListItemDto +import io.ktor.http.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +internal fun Route.userRoutes(userRepository: UserRepository) { + get("/api/users") { + val principal = call.principal() + ?: return@get call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + val users = userRepository.listAll() + .filter { it.id != principal.userId } + .map { UserListItemDto(id = it.id, username = it.username) } + call.respond(HttpStatusCode.OK, users) + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/WebSocketRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/WebSocketRoutes.kt index 0f96900..17e39a7 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/WebSocketRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/WebSocketRoutes.kt @@ -1,12 +1,21 @@ package de.krisenvorrat.server.routes +import de.krisenvorrat.server.repository.MessageRepository import de.krisenvorrat.server.security.JwtService import de.krisenvorrat.server.websocket.WebSocketManager import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.websocket.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put -internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtService) { +internal fun Route.webSocketRoutes( + wsManager: WebSocketManager, + jwtService: JwtService, + messageRepository: MessageRepository +) { webSocket("/ws/sync") { val token = call.request.queryParameters["token"] if (token == null) { @@ -21,6 +30,22 @@ internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtS return@webSocket } wsManager.addSession(userId, this) + val pending = messageRepository.getUndelivered(userId) + for (msg in pending) { + val payload = Json.encodeToString(buildJsonObject { + put("type", "new_message") + put("id", msg.id) + put("senderId", msg.senderId) + put("senderUsername", msg.senderUsername) + put("receiverId", msg.receiverId) + put("body", msg.body) + put("sentAt", msg.sentAt) + }) + send(Frame.Text(payload)) + } + if (pending.isNotEmpty()) { + messageRepository.markAllDeliveredForReceiver(userId) + } try { for (frame in incoming) { // Client frames are accepted but not processed (server push only) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/websocket/WebSocketManager.kt b/server/src/main/kotlin/de/krisenvorrat/server/websocket/WebSocketManager.kt index f858ccb..89b5fbf 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/websocket/WebSocketManager.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/websocket/WebSocketManager.kt @@ -1,5 +1,6 @@ package de.krisenvorrat.server.websocket +import de.krisenvorrat.shared.model.MessageDto import io.ktor.websocket.* import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.serialization.encodeToString @@ -21,6 +22,8 @@ internal class WebSocketManager { sessions[userId]?.remove(session) } + fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true + suspend fun notifyInventoryUpdated(userId: String, itemId: String) { val payload = buildJsonObject { put("type", "inventory_updated") @@ -38,6 +41,19 @@ internal class WebSocketManager { broadcast(userId, Json.encodeToString(payload)) } + suspend fun notifyNewMessage(receiverId: String, message: MessageDto) { + val payload = buildJsonObject { + put("type", "new_message") + put("id", message.id) + put("senderId", message.senderId) + put("senderUsername", message.senderUsername) + put("receiverId", message.receiverId) + put("body", message.body) + put("sentAt", message.sentAt) + } + broadcast(receiverId, Json.encodeToString(payload)) + } + private suspend fun broadcast(userId: String, message: String) { val userSessions = sessions[userId] ?: return val toRemove = mutableListOf() diff --git a/server/src/main/resources/static/admin/index.html b/server/src/main/resources/static/admin/index.html index 7dca145..8a77a09 100644 --- a/server/src/main/resources/static/admin/index.html +++ b/server/src/main/resources/static/admin/index.html @@ -32,6 +32,7 @@ .modal { background: #fff; border-radius: 8px; padding: 28px; width: 360px; } .modal h3 { margin-bottom: 16px; } .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; } + footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; } @@ -253,6 +254,8 @@ function logout() { accessToken = ''; sessionStorage.removeItem('accessToken'); + document.getElementById('login-username').value = ''; + document.getElementById('login-password').value = ''; document.getElementById('login-section').style.display = 'block'; document.getElementById('admin-section').style.display = 'none'; document.getElementById('logout-btn').style.display = 'none'; @@ -260,5 +263,6 @@ document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); }); + diff --git a/shared/src/main/kotlin/de/krisenvorrat/shared/model/MessageDto.kt b/shared/src/main/kotlin/de/krisenvorrat/shared/model/MessageDto.kt new file mode 100644 index 0000000..8e0fc5b --- /dev/null +++ b/shared/src/main/kotlin/de/krisenvorrat/shared/model/MessageDto.kt @@ -0,0 +1,14 @@ +package de.krisenvorrat.shared.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MessageDto( + val id: String, + val senderId: String, + val senderUsername: String, + val receiverId: String, + val body: String, + val sentAt: Long, + val deliveredAt: Long? = null +) diff --git a/shared/src/main/kotlin/de/krisenvorrat/shared/model/UserListItemDto.kt b/shared/src/main/kotlin/de/krisenvorrat/shared/model/UserListItemDto.kt new file mode 100644 index 0000000..e165fb7 --- /dev/null +++ b/shared/src/main/kotlin/de/krisenvorrat/shared/model/UserListItemDto.kt @@ -0,0 +1,9 @@ +package de.krisenvorrat.shared.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserListItemDto( + val id: String, + val username: String +)