Upload via GUI (7592 Dateien)

This commit is contained in:
2026-10-16 19:07:55 +00:00
parent 7f397d596c
commit 893bef356c
34 changed files with 11604 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
*.jks
*.keystore
keystore.properties
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.DeskHub">
<meta-data
android:name="de.deskhub.staff.creator"
android:value="M_Viper" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,23 @@
package de.deskhub.staff
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import de.deskhub.staff.data.TicketRepository
import de.deskhub.staff.data.security.SecureSessionStore
import de.deskhub.staff.ui.DeskHubApp
import de.deskhub.staff.ui.DeskHubViewModel
import de.deskhub.staff.ui.DeskHubViewModelFactory
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repository = TicketRepository(SecureSessionStore(applicationContext))
val factory = DeskHubViewModelFactory(repository)
setContent {
val viewModel: DeskHubViewModel = viewModel(factory = factory)
DeskHubApp(viewModel)
}
}
}
@@ -0,0 +1,901 @@
package de.deskhub.staff.data
import android.os.Build
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.remote.DeskHubUrl
import de.deskhub.staff.data.remote.dto.AuditLogEntryDto
import de.deskhub.staff.data.remote.dto.BanEntryDto
import de.deskhub.staff.data.remote.dto.BusinessHolidayDto
import de.deskhub.staff.data.remote.dto.BusinessHourDto
import de.deskhub.staff.data.remote.dto.ChatContactDto
import de.deskhub.staff.data.remote.dto.ChatMessageDto
import de.deskhub.staff.data.remote.dto.KeyLabelDto
import de.deskhub.staff.data.remote.dto.LiveChatAttachmentDto
import de.deskhub.staff.data.remote.dto.LiveChatListItemDto
import de.deskhub.staff.data.remote.dto.LiveChatMessageDto
import de.deskhub.staff.data.remote.dto.TeamMemberDto
import de.deskhub.staff.data.remote.dto.TicketRuleActionDto
import de.deskhub.staff.data.remote.dto.TicketRuleConditionDto
import de.deskhub.staff.data.remote.dto.TicketRuleDto
import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest
import de.deskhub.staff.data.remote.dto.CannedResponseDto
import de.deskhub.staff.data.remote.dto.CreateTicketRequest
import de.deskhub.staff.data.remote.dto.DepartmentDto
import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest
import de.deskhub.staff.data.remote.dto.EmailAddressDto
import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest
import de.deskhub.staff.data.remote.dto.EmailTemplateDto
import de.deskhub.staff.data.remote.dto.HelpTopicDto
import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest
import de.deskhub.staff.data.remote.dto.OrganizationDto
import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest
import de.deskhub.staff.data.remote.dto.PriorityFullDto
import de.deskhub.staff.data.remote.dto.PrioritySaveRequest
import de.deskhub.staff.data.remote.dto.RoleDto
import de.deskhub.staff.data.remote.dto.RoleSaveRequest
import de.deskhub.staff.data.remote.dto.StaffAdminDto
import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest
import de.deskhub.staff.data.remote.dto.StaffExtraDeptDto
import de.deskhub.staff.data.remote.dto.TaskCreateRequest
import de.deskhub.staff.data.remote.dto.TaskDto
import de.deskhub.staff.data.remote.dto.TeamDto
import de.deskhub.staff.data.remote.dto.TeamSaveRequest
import de.deskhub.staff.data.remote.dto.CustomerDetailDto
import de.deskhub.staff.data.remote.dto.CustomerDto
import de.deskhub.staff.data.remote.dto.DepartmentStatDto
import de.deskhub.staff.data.remote.dto.EscalationRuleDto
import de.deskhub.staff.data.remote.dto.EscalationSaveRequest
import de.deskhub.staff.data.remote.dto.KbArticleDto
import de.deskhub.staff.data.remote.dto.KbCategoryDto
import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest
import de.deskhub.staff.data.remote.dto.LoginRequest
import de.deskhub.staff.data.remote.dto.MessageRequest
import de.deskhub.staff.data.remote.dto.NamedOptionDto
import de.deskhub.staff.data.remote.dto.PriorityDto
import de.deskhub.staff.data.remote.dto.StaffDto
import de.deskhub.staff.data.remote.dto.StatusOptionDto
import de.deskhub.staff.data.remote.dto.TicketDetailDto
import de.deskhub.staff.data.remote.dto.TicketSummaryDto
import de.deskhub.staff.data.remote.dto.TimeEntryDto
import de.deskhub.staff.data.remote.dto.TimeEntryTotalDto
import de.deskhub.staff.data.security.SecureSessionStore
import de.deskhub.staff.data.security.StoredSession
import de.deskhub.staff.domain.model.ActivityUpdate
import de.deskhub.staff.domain.model.AuditLogEntry
import de.deskhub.staff.domain.model.AuditLogPage
import de.deskhub.staff.domain.model.BanEntry
import de.deskhub.staff.domain.model.BusinessHoliday
import de.deskhub.staff.domain.model.BusinessHour
import de.deskhub.staff.domain.model.BusinessHoursData
import de.deskhub.staff.domain.model.AvailabilityData
import de.deskhub.staff.domain.model.ChatContact
import de.deskhub.staff.domain.model.ChatConversation
import de.deskhub.staff.domain.model.ChatMessage
import de.deskhub.staff.domain.model.KeyLabel
import de.deskhub.staff.domain.model.LiveChatAttachment
import de.deskhub.staff.domain.model.LiveChatDetail
import de.deskhub.staff.domain.model.LiveChatMessage
import de.deskhub.staff.domain.model.LiveChatSummary
import de.deskhub.staff.domain.model.TeamMember
import de.deskhub.staff.domain.model.TicketRule
import de.deskhub.staff.domain.model.TicketRuleAction
import de.deskhub.staff.domain.model.TicketRuleCondition
import de.deskhub.staff.domain.model.TicketRulesData
import de.deskhub.staff.domain.model.CannedResponse
import de.deskhub.staff.domain.model.Customer
import de.deskhub.staff.domain.model.CustomerDetail
import de.deskhub.staff.domain.model.CustomerProfile
import de.deskhub.staff.domain.model.DashboardStats
import de.deskhub.staff.domain.model.Department
import de.deskhub.staff.domain.model.DepartmentStat
import de.deskhub.staff.domain.model.DepartmentData
import de.deskhub.staff.domain.model.DepartmentEmailOption
import de.deskhub.staff.domain.model.HealthIssue
import de.deskhub.staff.domain.model.DepartmentInput
import de.deskhub.staff.domain.model.DepartmentMember
import de.deskhub.staff.domain.model.EmailAddress
import de.deskhub.staff.domain.model.EmailAddressInput
import de.deskhub.staff.domain.model.EmailAddressData
import de.deskhub.staff.domain.model.EmailTemplate
import de.deskhub.staff.domain.model.EscalationData
import de.deskhub.staff.domain.model.EscalationRule
import de.deskhub.staff.domain.model.HelpTopic
import de.deskhub.staff.domain.model.HelpTopicInput
import de.deskhub.staff.domain.model.HelpTopicData
import de.deskhub.staff.domain.model.KbArticle
import de.deskhub.staff.domain.model.KbCategory
import de.deskhub.staff.domain.model.KbData
import de.deskhub.staff.domain.model.Metadata
import de.deskhub.staff.domain.model.NamedOption
import de.deskhub.staff.domain.model.Organization
import de.deskhub.staff.domain.model.Priority
import de.deskhub.staff.domain.model.PriorityFull
import de.deskhub.staff.domain.model.Role
import de.deskhub.staff.domain.model.RoleData
import de.deskhub.staff.domain.model.SpamActionResult
import de.deskhub.staff.domain.model.Staff
import de.deskhub.staff.domain.model.StaffAdmin
import de.deskhub.staff.domain.model.StaffAdminData
import de.deskhub.staff.domain.model.StaffAdminInput
import de.deskhub.staff.domain.model.StaffExtraDept
import de.deskhub.staff.domain.model.StaffTask
import de.deskhub.staff.domain.model.StatusOption
import de.deskhub.staff.domain.model.Team
import de.deskhub.staff.domain.model.TeamData
import de.deskhub.staff.domain.model.ThreadAttachment
import de.deskhub.staff.domain.model.ThreadComment
import de.deskhub.staff.domain.model.TicketDetail
import de.deskhub.staff.domain.model.TicketFilters
import de.deskhub.staff.domain.model.TicketPage
import de.deskhub.staff.domain.model.TicketSummary
import de.deskhub.staff.domain.model.TicketThread
import de.deskhub.staff.domain.model.TimeEntriesReport
import de.deskhub.staff.domain.model.TimeEntry
import de.deskhub.staff.domain.model.TimeEntryTotal
sealed interface LoginResult {
data object TotpRequired : LoginResult
data class Success(val staff: Staff) : LoginResult
}
class TicketRepository(
private val sessionStore: SecureSessionStore,
) {
private var session: StoredSession? = null
private var api: DeskHubApi? = null
fun restoreSession(): StoredSession? {
val stored = sessionStore.load() ?: return null
configure(stored)
return stored
}
/** Zuletzt verwendete Server-Adresse, falls (noch) keine aktive Sitzung besteht – erspart erneutes Eintippen. */
fun lastServerUrl(): String? = sessionStore.loadLastServerUrl()
suspend fun login(
serverUrl: String,
email: String,
password: String,
totpCode: String?,
): LoginResult {
val normalizedUrl = DeskHubUrl.normalize(serverUrl)
sessionStore.saveLastServerUrl(normalizedUrl)
val loginApi = DeskHubApi(normalizedUrl) { null }
val response = loginApi.login(
LoginRequest(
email = email.trim(),
password = password,
totpCode = totpCode?.takeIf { it.isNotBlank() },
deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(),
),
)
if (response.requiresTotp) return LoginResult.TotpRequired
val token = requireNotNull(response.token) { "Die Anmeldung lieferte kein Token." }
val expiresAt = requireNotNull(response.expiresAt) { "Das Token hat kein Ablaufdatum." }
val staffDto = requireNotNull(response.staff) { "Die Anmeldung lieferte kein Mitarbeiterprofil." }
val stored = StoredSession(normalizedUrl, token, expiresAt, staffDto.name)
sessionStore.save(stored)
configure(stored)
return LoginResult.Success(staffDto.toDomain())
}
suspend fun validateSession(): Staff = requireApi().me().staff.toDomain()
suspend fun metadata(): Metadata {
val response = requireApi().metadata()
return Metadata(
staff = response.staff.toDomain(),
departments = response.departments.map(NamedOptionDto::toDomain),
priorities = response.priorities.map(PriorityDto::toDomain),
agents = response.agents.map(NamedOptionDto::toDomain),
statuses = response.statuses.map(StatusOptionDto::toDomain),
tags = response.tags.map(NamedOptionDto::toDomain),
liveChatEnabled = response.liveChatEnabled,
)
}
suspend fun tickets(filters: TicketFilters, page: Int = 1): TicketPage {
val query = buildMap {
put("page", page.toString())
put("perPage", "30")
put("status", filters.statuses.joinToString(","))
if (filters.query.isNotBlank()) put("q", filters.query.trim())
if (filters.assigned.isNotBlank()) put("assigned", filters.assigned)
filters.departmentId?.let { put("departmentId", it.toString()) }
filters.priorityId?.let { put("priorityId", it.toString()) }
filters.tagId?.let { put("tagId", it.toString()) }
if (filters.sla.isNotBlank()) put("sla", filters.sla)
}
val response = requireApi().tickets(query)
return TicketPage(
items = response.items.map(TicketSummaryDto::toDomain),
page = response.page,
total = response.total,
hasMore = response.hasMore,
)
}
suspend fun ticket(number: String): TicketDetail =
requireApi().ticket(number).ticket.toDomain()
suspend fun updateTicket(
number: String,
status: String?,
assignedStaffId: Int?,
updateAssignment: Boolean,
): TicketSummary {
val request = linkedMapOf<String, Any?>("number" to number)
status?.let { request["status"] = it }
if (updateAssignment) request["assignedStaffId"] = assignedStaffId
return requireApi().updateTicket(request).ticket.toDomain()
}
suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult {
val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark")
return SpamActionResult(
ticket = response.ticket.toDomain(),
moved = response.moved,
failed = response.failed,
)
}
suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) {
requireApi().addMessage(MessageRequest(number, type, message, setStatus))
}
suspend fun cannedResponses(): List<CannedResponse> =
requireApi().cannedResponses().items.map(CannedResponseDto::toDomain)
suspend fun dashboard(): DashboardStats {
val response = requireApi().dashboard()
return DashboardStats(
myOpen = response.myOpen,
overdue = response.overdue,
unassigned = response.unassigned,
unanswered = response.unanswered,
resolvedToday = response.resolvedToday,
slaPercent = response.slaPercent,
recent = response.recent.map(TicketSummaryDto::toDomain),
departments = response.departments?.map(DepartmentStatDto::toDomain),
)
}
suspend fun customers(query: String): List<Customer> =
requireApi().customers(query).items.map(CustomerDto::toDomain)
suspend fun customerProfile(userId: Int?, email: String?): CustomerProfile {
val response = requireApi().customer(userId, email)
return CustomerProfile(
customer = response.customer.toDomain(),
tickets = response.tickets.map(TicketSummaryDto::toDomain),
)
}
suspend fun createTicket(
requesterName: String,
requesterEmail: String,
subject: String,
message: String,
departmentId: Int,
priorityId: Int,
assignedStaffId: Int?,
notifyCustomer: Boolean,
): TicketSummary {
val request = CreateTicketRequest(
requesterName = requesterName,
requesterEmail = requesterEmail,
subject = subject,
message = message,
departmentId = departmentId,
priorityId = priorityId,
assignedStaffId = assignedStaffId,
notifyCustomer = notifyCustomer,
)
return requireApi().createTicket(request).ticket.toDomain()
}
suspend fun activitySince(since: String): ActivityUpdate {
val response = requireApi().activity(since)
return ActivityUpdate(response.newReplies, response.newUnassigned, response.serverTime, response.health.map { HealthIssue(it.level, it.message) })
}
suspend fun logout() {
requireApi().logout()
clearSession()
}
// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────
suspend fun kbData(): KbData {
val response = requireApi().kb()
return KbData(
categories = response.categories.map(KbCategoryDto::toDomain),
articles = response.articles.map(KbArticleDto::toDomain),
)
}
suspend fun kbSaveArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean): Int =
requireApi().kbSaveArticle(KbSaveArticleRequest(id = id, title = title, categoryId = categoryId, body = body, isPublished = isPublished)).id
suspend fun kbDeleteArticle(id: Int) = requireApi().kbDeleteArticle(id)
suspend fun kbCreateCategory(name: String): Int = requireApi().kbCreateCategory(name).id
suspend fun kbDeleteCategory(id: Int) = requireApi().kbDeleteCategory(id)
// ─── Admin: Zeiterfassung ────────────────────────────────────────────────
suspend fun timeEntries(staffId: Int?, from: String, to: String): TimeEntriesReport {
val query = buildMap {
put("from", from)
put("to", to)
staffId?.let { put("staffId", it.toString()) }
}
val response = requireApi().timeEntries(query)
return TimeEntriesReport(
staffOptions = response.staffOptions.map(NamedOptionDto::toDomain),
entries = response.entries.map(TimeEntryDto::toDomain),
totalsByStaff = response.totalsByStaff.map(TimeEntryTotalDto::toDomain),
totalMinutes = response.totalMinutes,
)
}
// ─── Admin: Eskalationsregeln ────────────────────────────────────────────
suspend fun escalationData(): EscalationData {
val response = requireApi().escalationRules()
return EscalationData(
priorities = response.priorities.map(NamedOptionDto::toDomain),
staffOptions = response.staffOptions.map(NamedOptionDto::toDomain),
departments = response.departments.map(NamedOptionDto::toDomain),
rules = response.rules.map(EscalationRuleDto::toDomain),
)
}
suspend fun escalationSave(
id: Int?,
name: String,
isActive: Boolean,
triggerType: String,
triggerHours: Int,
conditionStatus: String?,
conditionPriorityId: Int?,
conditionDeptId: Int?,
actionSetPriorityId: Int?,
actionAssignStaffId: Int?,
actionSetStatus: String?,
actionNotifyAdmin: Boolean,
actionNotifyStaff: Boolean,
sortOrder: Int,
): Int = requireApi().escalationSave(
EscalationSaveRequest(
id = id,
name = name,
isActive = isActive,
triggerType = triggerType,
triggerHours = triggerHours,
conditionStatus = conditionStatus,
conditionPriorityId = conditionPriorityId,
conditionDeptId = conditionDeptId,
actionSetPriorityId = actionSetPriorityId,
actionAssignStaffId = actionAssignStaffId,
actionSetStatus = actionSetStatus,
actionNotifyAdmin = actionNotifyAdmin,
actionNotifyStaff = actionNotifyStaff,
sortOrder = sortOrder,
),
).id
suspend fun escalationDelete(id: Int) = requireApi().escalationDelete(id)
suspend fun escalationToggle(id: Int) = requireApi().escalationToggle(id)
// ─── Admin: Sperrliste ────────────────────────────────────────────────────
suspend fun banlist(): List<BanEntry> = requireApi().banlist().items.map(BanEntryDto::toDomain)
suspend fun banCreate(matchType: String, value: String, reason: String?) =
requireApi().banCreate(matchType, value, reason?.ifBlank { null })
suspend fun banBulkAdd(values: String, reason: String?): Pair<Int, Int> {
val response = requireApi().banBulkAdd(values, reason?.ifBlank { null })
return response.added to response.skipped
}
suspend fun banDelete(id: Int) = requireApi().banDelete(id)
// ─── Admin: Abteilungen ───────────────────────────────────────────────────
suspend fun departmentData(): DepartmentData {
val r = requireApi().departments()
return DepartmentData(
departments = r.items.map(DepartmentDto::toDomain),
emailOptions = r.emailOptions.map { DepartmentEmailOption(it.email, it.departmentName) },
staffOptions = r.staffOptions.map(NamedOptionDto::toDomain),
slaPlans = r.slaPlans.map(NamedOptionDto::toDomain),
)
}
suspend fun departmentSave(id: Int?, input: DepartmentInput): Int =
requireApi().departmentSave(
DepartmentSaveRequest(
id = id, name = input.name, email = input.email, isDefault = input.isDefault, signature = input.signature,
isActive = input.isActive, managerId = input.managerId, slaPlanId = input.slaPlanId, notes = input.notes,
),
).id
suspend fun departmentDelete(id: Int) = requireApi().departmentDelete(id)
// ─── Admin: Prioritäten ───────────────────────────────────────────────────
suspend fun report(config: de.deskhub.staff.data.remote.dto.ReportConfigDto?, savedId: Int?): de.deskhub.staff.data.remote.dto.ReportResponseDto {
val query = buildMap<String, String> {
if (savedId != null) {
put("saved", savedId.toString())
} else if (config != null) {
put("dimension", config.dimension)
put("metrics", config.metrics.joinToString(","))
if (config.from.isNotBlank()) put("from", config.from)
if (config.to.isNotBlank()) put("to", config.to)
put("sort", config.sort)
if (config.desc) put("desc", "1")
}
}
return requireApi().report(query)
}
suspend fun reportSave(name: String, shared: Boolean, config: de.deskhub.staff.data.remote.dto.ReportConfigDto): Int =
requireApi().reportSave(de.deskhub.staff.data.remote.dto.ReportSaveRequest(name = name, isShared = shared, config = config)).id
suspend fun reportDelete(id: Int) = requireApi().reportDelete(id)
suspend fun savedViews(): List<de.deskhub.staff.domain.model.SavedView> = requireApi().savedViews().items.map { dto ->
val f = dto.filters
de.deskhub.staff.domain.model.SavedView(
id = dto.id, name = dto.name, isShared = dto.isShared, mine = dto.mine, ownerName = dto.ownerName,
filters = TicketFilters(
query = f["q"].orEmpty(),
statuses = f["status"].orEmpty().split(',').filter { it.isNotBlank() }.toSet().let { s ->
if (s.containsAll(setOf("open", "pending", "answered", "on_hold", "resolved", "closed"))) emptySet() else s
},
assigned = f["assigned"].orEmpty(),
departmentId = f["departmentId"]?.toIntOrNull(),
priorityId = f["priorityId"]?.toIntOrNull(),
sla = f["sla"].orEmpty(),
),
)
}
suspend fun savedViewSave(name: String, shared: Boolean, filters: TicketFilters): Int {
val map = buildMap {
if (filters.query.isNotBlank()) put("q", filters.query)
put("status", (if (filters.statuses.isEmpty()) setOf("open", "pending", "answered", "on_hold", "resolved", "closed") else filters.statuses).joinToString(","))
if (filters.assigned.isNotBlank()) put("assigned", filters.assigned)
filters.departmentId?.let { put("departmentId", it.toString()) }
filters.priorityId?.let { put("priorityId", it.toString()) }
if (filters.sla.isNotBlank()) put("sla", filters.sla)
}
return requireApi().savedViewSave(de.deskhub.staff.data.remote.dto.SavedViewSaveRequest(name = name, isShared = shared, filters = map)).id
}
suspend fun savedViewDelete(id: Int) = requireApi().savedViewDelete(id)
suspend fun slaPlans(): List<de.deskhub.staff.domain.model.SlaPlan> =
requireApi().slaPlans().items.map { de.deskhub.staff.domain.model.SlaPlan(it.id, it.name, it.responseHours, it.resolutionHours, it.isActive, it.notes, it.departmentCount, it.helpTopicCount) }
suspend fun slaPlanSave(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String): Int =
requireApi().slaPlanSave(de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest(id = id, name = name, responseHours = responseHours, resolutionHours = resolutionHours, isActive = isActive, notes = notes)).id
suspend fun slaPlanDelete(id: Int) = requireApi().slaPlanDelete(id)
suspend fun prioritiesAdmin(): List<PriorityFull> = requireApi().prioritiesAdmin().items.map(PriorityFullDto::toDomain)
suspend fun prioritySave(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int): Int =
requireApi().prioritySave(
PrioritySaveRequest(id = id, name = name, color = color, responseHours = responseHours, resolutionHours = resolutionHours, sortOrder = sortOrder),
).id
suspend fun priorityDelete(id: Int) = requireApi().priorityDelete(id)
// ─── Admin: Hilfe-Themen ──────────────────────────────────────────────────
suspend fun helpTopics(): HelpTopicData {
val r = requireApi().helpTopics()
return HelpTopicData(
departments = r.departments.map(NamedOptionDto::toDomain),
priorities = r.priorities.map(NamedOptionDto::toDomain),
slaPlans = r.slaPlans.map(NamedOptionDto::toDomain),
topics = r.items.map(HelpTopicDto::toDomain),
)
}
suspend fun helpTopicSave(id: Int?, input: HelpTopicInput): Int =
requireApi().helpTopicSave(
HelpTopicSaveRequest(
id = id, name = input.name, departmentId = input.departmentId, priorityId = input.priorityId, isActive = input.isActive,
isPublic = input.isPublic, parentId = input.parentId, slaPlanId = input.slaPlanId, notes = input.notes, sortOrder = input.sortOrder,
),
).id
suspend fun helpTopicDelete(id: Int) = requireApi().helpTopicDelete(id)
// ─── Admin: Organisationen ────────────────────────────────────────────────
suspend fun organizations(): List<Organization> = requireApi().organizations().items.map(OrganizationDto::toDomain)
suspend fun organizationSave(id: Int?, name: String, notes: String?): Int =
requireApi().organizationSave(OrganizationSaveRequest(id = id, name = name, notes = notes)).id
suspend fun organizationDelete(id: Int) = requireApi().organizationDelete(id)
// ─── Admin: Teams ─────────────────────────────────────────────────────────
suspend fun teams(): TeamData {
val r = requireApi().teams()
return TeamData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), teams = r.items.map(TeamDto::toDomain))
}
suspend fun teamSave(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List<Int>): Int =
requireApi().teamSave(TeamSaveRequest(id = id, name = name, leadId = leadId, notes = notes, memberIds = memberIds)).id
suspend fun teamToggle(id: Int) = requireApi().teamToggle(id)
suspend fun teamDelete(id: Int) = requireApi().teamDelete(id)
// ─── Admin: Rollen ────────────────────────────────────────────────────────
suspend fun roles(): RoleData {
val r = requireApi().roles()
return RoleData(
permissionKeys = r.permissionKeys,
defaultAgentPermissions = r.defaultAgentPermissions,
roles = r.items.map(RoleDto::toDomain),
)
}
suspend fun roleSave(id: Int?, name: String, permissions: Map<String, Boolean>): Int =
requireApi().roleSave(RoleSaveRequest(id = id, name = name, permissions = permissions)).id
suspend fun roleDelete(id: Int) = requireApi().roleDelete(id)
// ─── Admin: Mitarbeiter ───────────────────────────────────────────────────
suspend fun staffAdminList(): StaffAdminData {
val r = requireApi().staffAdminList()
return StaffAdminData(
departments = r.departments.map(NamedOptionDto::toDomain),
roles = r.roles.map(NamedOptionDto::toDomain),
staff = r.items.map(StaffAdminDto::toDomain),
teams = r.teams.map(NamedOptionDto::toDomain),
canManageTeams = r.canManageTeams,
)
}
suspend fun staffAdminSave(id: Int?, input: StaffAdminInput): Int = requireApi().staffAdminSave(
StaffAdminSaveRequest(
id = id, name = input.name, email = input.email, role = input.role, roleId = input.roleId,
departmentId = input.departmentId, isActive = input.isActive, password = input.password, signature = input.signature,
extraDepartments = input.extraDepartments.map { StaffExtraDeptDto(it.departmentId, it.notify, it.roleId) },
teamIds = input.teamIds,
),
).id
suspend fun staffAdminDelete(id: Int) = requireApi().staffAdminDelete(id)
// ─── Admin: Geschäftszeiten ───────────────────────────────────────────────
suspend fun businessHours(): BusinessHoursData {
val r = requireApi().businessHours()
return BusinessHoursData(
enabled = r.enabled,
hours = r.hours.map(BusinessHourDto::toDomain),
holidays = r.holidays.map(BusinessHolidayDto::toDomain),
)
}
suspend fun businessHoursSave(enabled: Boolean, hours: List<BusinessHour>) =
requireApi().businessHoursSave(enabled, hours.map { BusinessHourDto(it.weekday, it.isOpen, it.openTime, it.closeTime) })
suspend fun businessHolidayAdd(date: String, name: String): Int = requireApi().businessHolidayAdd(date, name).id
suspend fun businessHolidayDelete(id: Int) = requireApi().businessHolidayDelete(id)
// ─── Admin: E-Mail-Adressen ───────────────────────────────────────────────
suspend fun emailAddresses(): EmailAddressData {
val r = requireApi().emailAddresses()
return EmailAddressData(
departments = r.departments.map(NamedOptionDto::toDomain),
priorities = r.priorities.map(NamedOptionDto::toDomain),
helpTopics = r.helpTopics.map(NamedOptionDto::toDomain),
addresses = r.items.map(EmailAddressDto::toDomain),
)
}
suspend fun emailAddressSave(id: Int?, input: EmailAddressInput): Int =
requireApi().emailAddressSave(
EmailAddressSaveRequest(
id = id, email = input.email, label = input.label, departmentId = input.departmentId, priorityId = input.priorityId, isDefault = input.isDefault,
helpTopicId = input.helpTopicId, isActive = input.isActive, notes = input.notes,
smtpEnabled = input.smtpEnabled, smtpHost = input.smtpHost, smtpPort = input.smtpPort, smtpEncryption = input.smtpEncryption,
smtpUsername = input.smtpUsername, smtpPassword = input.smtpPassword,
imapEnabled = input.imapEnabled, imapHost = input.imapHost, imapPort = input.imapPort, imapEncryption = input.imapEncryption,
imapUsername = input.imapUsername, imapPassword = input.imapPassword, imapFolder = input.imapFolder, imapAfterFetch = input.imapAfterFetch,
imapArchiveFolder = input.imapArchiveFolder, imapSpamFolder = input.imapSpamFolder,
),
).id
/** action: "sendTest" (Test-Mail an die eigene Adresse) oder "testImap" (Postfach-Verbindung). Liefert die Servermeldung. */
suspend fun emailAddressAction(action: String, id: Int): String = requireApi().emailAddressAction(action, id).message
suspend fun emailAddressDelete(id: Int) = requireApi().emailAddressDelete(id)
// ─── Admin: E-Mail-Vorlagen ───────────────────────────────────────────────
suspend fun emailTemplates(): List<EmailTemplate> = requireApi().emailTemplates().items.map(EmailTemplateDto::toDomain)
suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) = requireApi().emailTemplateSave(key, subject, bodyText)
suspend fun emailTemplateReset(key: String) = requireApi().emailTemplateReset(key)
// ─── Admin: Admin-Protokoll ───────────────────────────────────────────────
suspend fun auditLog(page: Int): AuditLogPage {
val r = requireApi().auditLog(page)
return AuditLogPage(items = r.items.map(AuditLogEntryDto::toDomain), page = r.page, hasMore = r.hasMore)
}
// ─── Aufgaben ─────────────────────────────────────────────────────────────
suspend fun tasks(filter: String): de.deskhub.staff.domain.model.TaskData {
val r = requireApi().tasks(filter)
return de.deskhub.staff.domain.model.TaskData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), tasks = r.items.map(TaskDto::toDomain))
}
suspend fun taskCreate(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?): Int =
requireApi().taskCreate(
TaskCreateRequest(title = title, description = description, assignedToId = assignedToId, dueAt = dueAt, ticketNumber = ticketNumber),
).id
suspend fun taskUpdateStatus(id: Int, status: String) = requireApi().taskUpdateStatus(id, status)
suspend fun taskDelete(id: Int) = requireApi().taskDelete(id)
// ─── Interner Chat ────────────────────────────────────────────────────────
suspend fun chatContacts(): List<ChatContact> = requireApi().chatContacts().contacts.map(ChatContactDto::toDomain)
suspend fun chatConversation(withId: Int): ChatConversation {
val r = requireApi().chatConversation(withId)
return ChatConversation(with = r.with.toDomain(), messages = r.messages.map(ChatMessageDto::toDomain))
}
suspend fun chatSend(toId: Int, message: String): Int = requireApi().chatSend(toId, message).id
// ─── Live-Chat (Kunden-Widget) ────────────────────────────────────────────
suspend fun liveChatList(): List<LiveChatSummary> =
requireApi().liveChatList().items.map(LiveChatListItemDto::toDomain)
suspend fun liveChatMessages(number: String): LiveChatDetail {
val r = requireApi().liveChatMessages(number, 0)
return LiveChatDetail(
number = number,
status = r.status,
requesterName = r.requesterName,
requesterEmail = r.requesterEmail,
departmentName = r.departmentName,
lockedByOtherName = r.lockedByOtherName,
customerTyping = r.customerTyping,
messages = r.messages.map(LiveChatMessageDto::toDomain),
)
}
suspend fun liveChatSend(number: String, message: String) { requireApi().liveChatSend(number, message) }
suspend fun liveChatTyping(number: String) { requireApi().liveChatTyping(number) }
suspend fun liveChatEnd(number: String) { requireApi().liveChatEnd(number) }
// ─── Verfügbarkeit (Status) ────────────────────────────────────────────────
suspend fun availability(): AvailabilityData {
val r = requireApi().availability()
return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain))
}
suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) }
// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────
suspend fun ticketRules(): TicketRulesData {
val r = requireApi().ticketRules()
return TicketRulesData(
fieldOptions = r.fieldOptions.map(KeyLabelDto::toDomain),
operatorOptions = r.operatorOptions.map(KeyLabelDto::toDomain),
actionOptions = r.actionOptions.map(KeyLabelDto::toDomain),
departments = r.departments.map(NamedOptionDto::toDomain),
priorities = r.priorities.map(NamedOptionDto::toDomain),
helpTopics = r.helpTopics.map(NamedOptionDto::toDomain),
staffOptions = r.staffOptions.map(NamedOptionDto::toDomain),
cannedResponses = r.cannedResponses.map(NamedOptionDto::toDomain),
rules = r.rules.map(TicketRuleDto::toDomain),
autoCloseEnabled = r.autoCloseEnabled,
autoCloseDays = r.autoCloseDays,
)
}
suspend fun saveTicketRule(
id: Int?,
name: String,
matchType: String,
isActive: Boolean,
sortOrder: Int,
conditions: List<TicketRuleCondition>,
actions: List<TicketRuleAction>,
): Int {
val request = TicketRuleSaveRequest(
id = id,
name = name,
matchType = matchType,
isActive = isActive,
sortOrder = sortOrder,
conditions = conditions.map { TicketRuleConditionDto(it.field, it.operator, it.value) },
actions = actions.map { TicketRuleActionDto(it.type, it.value, it.valueLabel) },
)
return requireApi().saveTicketRule(request).id
}
suspend fun toggleTicketRule(id: Int) { requireApi().toggleTicketRule(id) }
suspend fun deleteTicketRule(id: Int) { requireApi().deleteTicketRule(id) }
suspend fun saveAutoClose(enabled: Boolean, days: Int) { requireApi().saveAutoClose(enabled, days) }
// ─── Bild-Upload ──────────────────────────────────────────────────────────
suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): String =
requireApi().uploadImage(bytes, filename, mimeType).url
fun clearSession() {
sessionStore.clear()
session = null
api = null
}
private fun configure(stored: StoredSession) {
session = stored
api = DeskHubApi(stored.serverUrl) { session?.token }
}
private fun requireApi(): DeskHubApi =
checkNotNull(api) { "Keine aktive deskhub-Sitzung." }
}
private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions)
private fun NamedOptionDto.toDomain() = NamedOption(id, name)
private fun PriorityDto.toDomain() = Priority(id, name, color)
private fun StatusOptionDto.toDomain() = StatusOption(value, label, color)
private fun CannedResponseDto.toDomain() = CannedResponse(id, title, body, departmentId)
private fun DepartmentStatDto.toDomain() = DepartmentStat(name, open, overdue)
private fun CustomerDto.toDomain() = Customer(id, name, email, organizationName, ticketCount, isGuest)
private fun CustomerDetailDto.toDomain() = CustomerDetail(id, name, email, organizationName, isGuest, createdAt)
private fun BanEntryDto.toDomain() = BanEntry(id, matchType, value, reason, addedByName, createdAt)
private fun DepartmentDto.toDomain() = Department(
id, name, email, isDefault, signature, isActive, managerId, managerName, slaPlanId, notes,
members.map { DepartmentMember(it.id, it.name, it.isPrimary) }, updatedAt,
)
private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseHours, resolutionHours, sortOrder)
private fun HelpTopicDto.toDomain() = HelpTopic(id, name, departmentId, departmentName, priorityId, priorityName, priorityColor, isActive, sortOrder, isPublic, parentId, slaPlanId, path.ifBlank { name }, notes, updatedAt)
private fun OrganizationDto.toDomain() = Organization(id, name, notes, memberCount)
private fun TeamDto.toDomain() = Team(id, name, leadId, leadName, notes, isActive, memberCount, memberIds)
private fun RoleDto.toDomain() = Role(id, name, permissions, staffCount)
private fun StaffAdminDto.toDomain() = StaffAdmin(
id, name, email, role, roleId, departmentId, departmentName, isActive, signature,
extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) },
teamIds, createdAt,
)
private fun BusinessHourDto.toDomain() = BusinessHour(weekday, isOpen, openTime, closeTime)
private fun BusinessHolidayDto.toDomain() = BusinessHoliday(id, date, name)
private fun EmailAddressDto.toDomain() = EmailAddress(
id, email, label, departmentId, departmentName, priorityId, priorityName, isDefault,
helpTopicId, isActive, notes, smtpEnabled, smtpHost, smtpPort, smtpEncryption, smtpUsername, smtpHasPassword,
imapEnabled, imapHost, imapPort, imapEncryption, imapUsername, imapFolder, imapAfterFetch, imapArchiveFolder, imapSpamFolder, imapHasPassword, imapLastRunAt,
)
private fun EmailTemplateDto.toDomain() = EmailTemplate(key, label, placeholders, subject, body, isCustomized)
private fun AuditLogEntryDto.toDomain() = AuditLogEntry(id, staffName, action, createdAt)
private fun TaskDto.toDomain() = StaffTask(id, title, description, status, assignedToId, assignedName, creatorName, dueAt, ticketNumber, ticketSubject)
private fun ChatContactDto.toDomain() = ChatContact(id, name, unreadCount, lastMessageAt)
private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, message, createdAt)
private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt)
private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size)
private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain))
private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt)
private fun KeyLabelDto.toDomain() = KeyLabel(value, label)
private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value)
private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel)
private fun TicketRuleDto.toDomain() = TicketRule(
id, name, matchType, isActive, sortOrder,
conditions.map(TicketRuleConditionDto::toDomain),
actions.map(TicketRuleActionDto::toDomain),
)
private fun KbCategoryDto.toDomain() = KbCategory(id, name)
private fun KbArticleDto.toDomain() = KbArticle(id, categoryId, categoryName, title, body, isPublished, views)
private fun TimeEntryDto.toDomain() = TimeEntry(id, staffName, ticketNumber, subject, minutes, note, loggedAt)
private fun TimeEntryTotalDto.toDomain() = TimeEntryTotal(staffName, minutes)
private fun EscalationRuleDto.toDomain() = EscalationRule(
id = id,
name = name,
isActive = isActive,
triggerType = triggerType,
triggerHours = triggerHours,
conditionStatus = conditionStatus,
conditionPriorityId = conditionPriorityId,
conditionPriorityName = conditionPriorityName,
conditionDeptId = conditionDeptId,
conditionDeptName = conditionDeptName,
actionSetPriorityId = actionSetPriorityId,
actionSetPriorityName = actionSetPriorityName,
actionAssignStaffId = actionAssignStaffId,
actionAssignStaffName = actionAssignStaffName,
actionSetStatus = actionSetStatus,
actionNotifyAdmin = actionNotifyAdmin,
actionNotifyStaff = actionNotifyStaff,
sortOrder = sortOrder,
appliedCount = appliedCount,
)
private fun TicketSummaryDto.toDomain() = TicketSummary(
id = id,
number = number,
subject = subject,
requesterName = requesterName,
requesterEmail = requesterEmail,
status = status,
priority = priority.toDomain(),
department = department.toDomain(),
assignedStaff = assignedStaff?.toDomain(),
isOverdue = isOverdue,
dueLabel = dueLabel,
isSpam = isSpam,
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun TicketDetailDto.toDomain() = TicketDetail(
summary = TicketSummary(
id = id,
number = number,
subject = subject,
requesterName = requesterName,
requesterEmail = requesterEmail,
status = status,
priority = priority.toDomain(),
department = department.toDomain(),
assignedStaff = assignedStaff?.toDomain(),
isOverdue = isOverdue,
isSpam = isSpam,
createdAt = createdAt,
updatedAt = updatedAt,
),
source = source,
firstResponseDueAt = firstResponseDueAt,
resolutionDueAt = resolutionDueAt,
lockedByOtherName = lockedByOtherName,
threads = threads.map { thread ->
TicketThread(
id = thread.id,
authorType = thread.authorType,
authorName = thread.authorName,
message = thread.message,
isInternalNote = thread.isInternalNote,
createdAt = thread.createdAt,
attachments = thread.attachments.map { ThreadAttachment(it.id, it.name, it.size) },
comments = thread.comments.map {
ThreadComment(it.id, it.message, it.staff.toDomain(), it.createdAt)
},
)
},
permissions = permissions,
)
@@ -0,0 +1,14 @@
package de.deskhub.staff.data.remote
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Merkt sich, ob der Server gerade erreichbar ist (wird von jedem API-Aufruf aktualisiert). */
object ConnectionMonitor {
private val mutableOffline = MutableStateFlow(false)
val offline: StateFlow<Boolean> = mutableOffline
fun set(isOffline: Boolean) {
mutableOffline.value = isOffline
}
}
@@ -0,0 +1,594 @@
package de.deskhub.staff.data.remote
import com.google.gson.GsonBuilder
import de.deskhub.staff.data.remote.dto.ActivityResponseDto
import de.deskhub.staff.data.remote.dto.CannedResponseListResponse
import de.deskhub.staff.data.remote.dto.CreateTicketRequest
import de.deskhub.staff.data.remote.dto.CreateTicketResponse
import de.deskhub.staff.data.remote.dto.CustomerListResponse
import de.deskhub.staff.data.remote.dto.CustomerProfileResponse
import de.deskhub.staff.data.remote.dto.AuditLogResponse
import de.deskhub.staff.data.remote.dto.BanBulkAddRequest
import de.deskhub.staff.data.remote.dto.BanBulkAddResponse
import de.deskhub.staff.data.remote.dto.BanCreateRequest
import de.deskhub.staff.data.remote.dto.BanDeleteRequest
import de.deskhub.staff.data.remote.dto.BanlistResponse
import de.deskhub.staff.data.remote.dto.BusinessHolidayAddRequest
import de.deskhub.staff.data.remote.dto.ChatContactsResponse
import de.deskhub.staff.data.remote.dto.ChatConversationResponse
import de.deskhub.staff.data.remote.dto.ChatSendRequest
import de.deskhub.staff.data.remote.dto.ChatSendResponse
import de.deskhub.staff.data.remote.dto.AutoCloseSaveRequest
import de.deskhub.staff.data.remote.dto.AvailabilityResponse
import de.deskhub.staff.data.remote.dto.AvailabilitySetRequest
import de.deskhub.staff.data.remote.dto.AvailabilitySetResponse
import de.deskhub.staff.data.remote.dto.LiveChatActionRequest
import de.deskhub.staff.data.remote.dto.LiveChatListResponse
import de.deskhub.staff.data.remote.dto.LiveChatMessagesResponse
import de.deskhub.staff.data.remote.dto.LiveChatSendRequest
import de.deskhub.staff.data.remote.dto.LiveChatSendResponse
import de.deskhub.staff.data.remote.dto.TicketRuleIdRequest
import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest
import de.deskhub.staff.data.remote.dto.TicketRuleSaveResponse
import de.deskhub.staff.data.remote.dto.TicketRulesResponse
import de.deskhub.staff.data.remote.dto.BusinessHolidayDeleteRequest
import de.deskhub.staff.data.remote.dto.BusinessHourDto
import de.deskhub.staff.data.remote.dto.BusinessHoursResponse
import de.deskhub.staff.data.remote.dto.BusinessHoursSaveRequest
import de.deskhub.staff.data.remote.dto.DashboardResponseDto
import de.deskhub.staff.data.remote.dto.DepartmentDeleteRequest
import de.deskhub.staff.data.remote.dto.DepartmentListResponse
import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest
import de.deskhub.staff.data.remote.dto.EmailAddressActionRequest
import de.deskhub.staff.data.remote.dto.EmailAddressActionResponse
import de.deskhub.staff.data.remote.dto.EmailAddressDeleteRequest
import de.deskhub.staff.data.remote.dto.EmailAddressListResponse
import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest
import de.deskhub.staff.data.remote.dto.EmailTemplateListResponse
import de.deskhub.staff.data.remote.dto.EmailTemplateResetRequest
import de.deskhub.staff.data.remote.dto.EmailTemplateSaveRequest
import de.deskhub.staff.data.remote.dto.ErrorEnvelope
import de.deskhub.staff.data.remote.dto.EscalationDeleteRequest
import de.deskhub.staff.data.remote.dto.EscalationRulesResponse
import de.deskhub.staff.data.remote.dto.EscalationSaveRequest
import de.deskhub.staff.data.remote.dto.EscalationToggleRequest
import de.deskhub.staff.data.remote.dto.HelpTopicDeleteRequest
import de.deskhub.staff.data.remote.dto.HelpTopicListResponse
import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest
import de.deskhub.staff.data.remote.dto.IdResponse
import de.deskhub.staff.data.remote.dto.KbCreateCategoryRequest
import de.deskhub.staff.data.remote.dto.KbDeleteArticleRequest
import de.deskhub.staff.data.remote.dto.KbDeleteCategoryRequest
import de.deskhub.staff.data.remote.dto.KbIdResponse
import de.deskhub.staff.data.remote.dto.KbResponse
import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest
import de.deskhub.staff.data.remote.dto.LoginRequest
import de.deskhub.staff.data.remote.dto.LoginResponse
import de.deskhub.staff.data.remote.dto.MessageRequest
import de.deskhub.staff.data.remote.dto.MessageResponse
import de.deskhub.staff.data.remote.dto.MetadataResponse
import de.deskhub.staff.data.remote.dto.OrganizationDeleteRequest
import de.deskhub.staff.data.remote.dto.OrganizationListResponse
import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest
import de.deskhub.staff.data.remote.dto.PriorityDeleteRequest
import de.deskhub.staff.data.remote.dto.PriorityListResponse
import de.deskhub.staff.data.remote.dto.PrioritySaveRequest
import de.deskhub.staff.data.remote.dto.RoleDeleteRequest
import de.deskhub.staff.data.remote.dto.RoleListResponse
import de.deskhub.staff.data.remote.dto.RoleSaveRequest
import de.deskhub.staff.data.remote.dto.StaffAdminDeleteRequest
import de.deskhub.staff.data.remote.dto.StaffAdminListResponse
import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest
import de.deskhub.staff.data.remote.dto.StaffResponse
import de.deskhub.staff.data.remote.dto.TaskCreateRequest
import de.deskhub.staff.data.remote.dto.TaskDeleteRequest
import de.deskhub.staff.data.remote.dto.TaskListResponse
import de.deskhub.staff.data.remote.dto.TaskUpdateStatusRequest
import de.deskhub.staff.data.remote.dto.TeamDeleteRequest
import de.deskhub.staff.data.remote.dto.TeamListResponse
import de.deskhub.staff.data.remote.dto.TeamSaveRequest
import de.deskhub.staff.data.remote.dto.TeamToggleRequest
import de.deskhub.staff.data.remote.dto.TicketPageResponse
import de.deskhub.staff.data.remote.dto.UploadImageResponse
import de.deskhub.staff.data.remote.dto.TicketResponse
import de.deskhub.staff.data.remote.dto.TicketSpamRequest
import de.deskhub.staff.data.remote.dto.TicketSpamResponse
import de.deskhub.staff.data.remote.dto.TicketSummaryResponse
import de.deskhub.staff.data.remote.dto.TimeEntriesResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.URI
import java.util.concurrent.TimeUnit
class ApiException(
val status: Int,
val code: String,
override val message: String,
) : Exception(message)
object DeskHubUrl {
fun normalize(input: String): String {
val candidate = input.trim().let {
if (it.contains("://")) it else "https://$it"
}
val uri = runCatching { URI(candidate) }
.getOrElse { throw IllegalArgumentException("Die Server-Adresse ist ungültig.") }
require(uri.scheme.equals("https", ignoreCase = true)) {
"Die Server-Adresse muss HTTPS verwenden."
}
require(uri.host != null && uri.userInfo == null && uri.query == null && uri.fragment == null) {
"Die Server-Adresse ist ungültig."
}
val path = uri.path.orEmpty().trimEnd('/')
return URI("https", null, uri.host, uri.port, path, null, null).toASCIIString()
}
}
class DeskHubApi(
serverUrl: String,
private val tokenProvider: () -> String?,
) {
private val baseUrl = DeskHubUrl.normalize(serverUrl)
private val gson = GsonBuilder().serializeNulls().create()
private val jsonType = "application/json; charset=utf-8".toMediaType()
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun login(request: LoginRequest): LoginResponse =
execute("login.php", "POST", request, LoginResponse::class.java, authenticated = false)
suspend fun me(): StaffResponse =
execute("me.php", "GET", null, StaffResponse::class.java)
suspend fun metadata(): MetadataResponse =
execute("metadata.php", "GET", null, MetadataResponse::class.java)
suspend fun tickets(query: Map<String, String>): TicketPageResponse =
execute("tickets.php", "GET", null, TicketPageResponse::class.java, query = query)
suspend fun ticket(number: String): TicketResponse =
execute(
"ticket.php",
"GET",
null,
TicketResponse::class.java,
query = mapOf("number" to number),
)
suspend fun updateTicket(request: Map<String, Any?>): TicketSummaryResponse =
execute("ticket_update.php", "PATCH", request, TicketSummaryResponse::class.java)
suspend fun addMessage(request: MessageRequest): MessageResponse =
execute("ticket_message.php", "POST", request, MessageResponse::class.java)
suspend fun ticketSpam(number: String, action: String): TicketSpamResponse =
execute("ticket_spam.php", "POST", TicketSpamRequest(number = number, action = action), TicketSpamResponse::class.java)
suspend fun cannedResponses(): CannedResponseListResponse =
execute("canned_responses.php", "GET", null, CannedResponseListResponse::class.java)
suspend fun dashboard(): DashboardResponseDto =
execute("dashboard.php", "GET", null, DashboardResponseDto::class.java)
suspend fun customers(query: String): CustomerListResponse =
execute(
"customers.php",
"GET",
null,
CustomerListResponse::class.java,
query = if (query.isBlank()) emptyMap() else mapOf("q" to query),
)
suspend fun customer(userId: Int?, email: String?): CustomerProfileResponse {
val query = buildMap {
if (userId != null) put("userId", userId.toString()) else email?.let { put("email", it) }
}
return execute("customer.php", "GET", null, CustomerProfileResponse::class.java, query = query)
}
suspend fun createTicket(request: CreateTicketRequest): CreateTicketResponse =
execute("ticket_create.php", "POST", request, CreateTicketResponse::class.java)
suspend fun activity(since: String): ActivityResponseDto =
execute("activity.php", "GET", null, ActivityResponseDto::class.java, query = mapOf("since" to since))
suspend fun logout() {
execute("logout.php", "POST", emptyMap<String, String>(), Map::class.java)
}
// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────
suspend fun kb(): KbResponse =
execute("kb.php", "GET", null, KbResponse::class.java)
suspend fun kbSaveArticle(request: KbSaveArticleRequest): KbIdResponse =
execute("kb.php", "POST", request, KbIdResponse::class.java)
suspend fun kbDeleteArticle(id: Int) {
execute("kb.php", "POST", KbDeleteArticleRequest(id = id), Map::class.java)
}
suspend fun kbCreateCategory(name: String): KbIdResponse =
execute("kb.php", "POST", KbCreateCategoryRequest(name = name), KbIdResponse::class.java)
suspend fun kbDeleteCategory(id: Int) {
execute("kb.php", "POST", KbDeleteCategoryRequest(id = id), Map::class.java)
}
// ─── Admin: Zeiterfassung ────────────────────────────────────────────────
suspend fun timeEntries(query: Map<String, String>): TimeEntriesResponse =
execute("time_entries.php", "GET", null, TimeEntriesResponse::class.java, query = query)
// ─── Admin: Eskalationsregeln ────────────────────────────────────────────
suspend fun escalationRules(): EscalationRulesResponse =
execute("escalation_rules.php", "GET", null, EscalationRulesResponse::class.java)
suspend fun escalationSave(request: EscalationSaveRequest): KbIdResponse =
execute("escalation_rules.php", "POST", request, KbIdResponse::class.java)
suspend fun escalationDelete(id: Int) {
execute("escalation_rules.php", "POST", EscalationDeleteRequest(id = id), Map::class.java)
}
suspend fun escalationToggle(id: Int) {
execute("escalation_rules.php", "POST", EscalationToggleRequest(id = id), Map::class.java)
}
// ─── Admin: Sperrliste ───────────────────────────────────────────────────
suspend fun banlist(): BanlistResponse =
execute("banlist.php", "GET", null, BanlistResponse::class.java)
suspend fun banCreate(matchType: String, value: String, reason: String?) {
execute("banlist.php", "POST", BanCreateRequest(matchType = matchType, value = value, reason = reason), Map::class.java)
}
suspend fun banBulkAdd(values: String, reason: String?): BanBulkAddResponse =
execute("banlist.php", "POST", BanBulkAddRequest(values = values, reason = reason), BanBulkAddResponse::class.java)
suspend fun banDelete(id: Int) {
execute("banlist.php", "POST", BanDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Abteilungen ──────────────────────────────────────────────────
suspend fun departments(): DepartmentListResponse =
execute("departments.php", "GET", null, DepartmentListResponse::class.java)
suspend fun departmentSave(request: DepartmentSaveRequest): IdResponse =
execute("departments.php", "POST", request, IdResponse::class.java)
suspend fun departmentDelete(id: Int) {
execute("departments.php", "POST", DepartmentDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Prioritäten ──────────────────────────────────────────────────
suspend fun report(query: Map<String, String>): de.deskhub.staff.data.remote.dto.ReportResponseDto =
execute("report_builder.php", "GET", null, de.deskhub.staff.data.remote.dto.ReportResponseDto::class.java, query = query)
suspend fun reportSave(request: de.deskhub.staff.data.remote.dto.ReportSaveRequest): IdResponse =
execute("report_builder.php", "POST", request, IdResponse::class.java)
suspend fun reportDelete(id: Int) {
execute("report_builder.php", "POST", de.deskhub.staff.data.remote.dto.ReportDeleteRequest(id = id), Map::class.java)
}
suspend fun savedViews(): de.deskhub.staff.data.remote.dto.SavedViewListResponse =
execute("queues.php", "GET", null, de.deskhub.staff.data.remote.dto.SavedViewListResponse::class.java)
suspend fun savedViewSave(request: de.deskhub.staff.data.remote.dto.SavedViewSaveRequest): IdResponse =
execute("queues.php", "POST", request, IdResponse::class.java)
suspend fun savedViewDelete(id: Int) {
execute("queues.php", "POST", de.deskhub.staff.data.remote.dto.SavedViewDeleteRequest(id = id), Map::class.java)
}
suspend fun slaPlans(): de.deskhub.staff.data.remote.dto.SlaPlanListResponse =
execute("sla_plans.php", "GET", null, de.deskhub.staff.data.remote.dto.SlaPlanListResponse::class.java)
suspend fun slaPlanSave(request: de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest): IdResponse =
execute("sla_plans.php", "POST", request, IdResponse::class.java)
suspend fun slaPlanDelete(id: Int) {
execute("sla_plans.php", "POST", de.deskhub.staff.data.remote.dto.SlaPlanDeleteRequest(id = id), Map::class.java)
}
suspend fun prioritiesAdmin(): PriorityListResponse =
execute("priorities.php", "GET", null, PriorityListResponse::class.java)
suspend fun prioritySave(request: PrioritySaveRequest): IdResponse =
execute("priorities.php", "POST", request, IdResponse::class.java)
suspend fun priorityDelete(id: Int) {
execute("priorities.php", "POST", PriorityDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Hilfe-Themen ─────────────────────────────────────────────────
suspend fun helpTopics(): HelpTopicListResponse =
execute("help_topics.php", "GET", null, HelpTopicListResponse::class.java)
suspend fun helpTopicSave(request: HelpTopicSaveRequest): IdResponse =
execute("help_topics.php", "POST", request, IdResponse::class.java)
suspend fun helpTopicDelete(id: Int) {
execute("help_topics.php", "POST", HelpTopicDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Organisationen ───────────────────────────────────────────────
suspend fun organizations(): OrganizationListResponse =
execute("organizations.php", "GET", null, OrganizationListResponse::class.java)
suspend fun organizationSave(request: OrganizationSaveRequest): IdResponse =
execute("organizations.php", "POST", request, IdResponse::class.java)
suspend fun organizationDelete(id: Int) {
execute("organizations.php", "POST", OrganizationDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Teams ────────────────────────────────────────────────────────
suspend fun teams(): TeamListResponse =
execute("teams.php", "GET", null, TeamListResponse::class.java)
suspend fun teamSave(request: TeamSaveRequest): IdResponse =
execute("teams.php", "POST", request, IdResponse::class.java)
suspend fun teamToggle(id: Int) {
execute("teams.php", "POST", TeamToggleRequest(id = id), Map::class.java)
}
suspend fun teamDelete(id: Int) {
execute("teams.php", "POST", TeamDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Rollen ───────────────────────────────────────────────────────
suspend fun roles(): RoleListResponse =
execute("roles.php", "GET", null, RoleListResponse::class.java)
suspend fun roleSave(request: RoleSaveRequest): IdResponse =
execute("roles.php", "POST", request, IdResponse::class.java)
suspend fun roleDelete(id: Int) {
execute("roles.php", "POST", RoleDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Mitarbeiter ──────────────────────────────────────────────────
suspend fun staffAdminList(): StaffAdminListResponse =
execute("staff_manage.php", "GET", null, StaffAdminListResponse::class.java)
suspend fun staffAdminSave(request: StaffAdminSaveRequest): IdResponse =
execute("staff_manage.php", "POST", request, IdResponse::class.java)
suspend fun staffAdminDelete(id: Int) {
execute("staff_manage.php", "POST", StaffAdminDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: Geschäftszeiten ──────────────────────────────────────────────
suspend fun businessHours(): BusinessHoursResponse =
execute("business_hours.php", "GET", null, BusinessHoursResponse::class.java)
suspend fun businessHoursSave(enabled: Boolean, hours: List<BusinessHourDto>) {
execute("business_hours.php", "POST", BusinessHoursSaveRequest(enabled = enabled, hours = hours), Map::class.java)
}
suspend fun businessHolidayAdd(date: String, name: String): IdResponse =
execute("business_hours.php", "POST", BusinessHolidayAddRequest(date = date, name = name), IdResponse::class.java)
suspend fun businessHolidayDelete(id: Int) {
execute("business_hours.php", "POST", BusinessHolidayDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: E-Mail-Adressen ──────────────────────────────────────────────
suspend fun emailAddresses(): EmailAddressListResponse =
execute("email_addresses.php", "GET", null, EmailAddressListResponse::class.java)
suspend fun emailAddressSave(request: EmailAddressSaveRequest): IdResponse =
execute("email_addresses.php", "POST", request, IdResponse::class.java)
suspend fun emailAddressAction(action: String, id: Int): EmailAddressActionResponse =
execute("email_addresses.php", "POST", EmailAddressActionRequest(action = action, id = id), EmailAddressActionResponse::class.java)
suspend fun emailAddressDelete(id: Int) {
execute("email_addresses.php", "POST", EmailAddressDeleteRequest(id = id), Map::class.java)
}
// ─── Admin: E-Mail-Vorlagen ──────────────────────────────────────────────
suspend fun emailTemplates(): EmailTemplateListResponse =
execute("email_templates.php", "GET", null, EmailTemplateListResponse::class.java)
suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) {
execute("email_templates.php", "POST", EmailTemplateSaveRequest(key = key, subject = subject, body = bodyText), Map::class.java)
}
suspend fun emailTemplateReset(key: String) {
execute("email_templates.php", "POST", EmailTemplateResetRequest(key = key), Map::class.java)
}
// ─── Admin: Admin-Protokoll ──────────────────────────────────────────────
suspend fun auditLog(page: Int): AuditLogResponse =
execute("audit_log.php", "GET", null, AuditLogResponse::class.java, query = mapOf("page" to page.toString()))
// ─── Aufgaben ────────────────────────────────────────────────────────────
suspend fun tasks(filter: String): TaskListResponse =
execute("tasks.php", "GET", null, TaskListResponse::class.java, query = mapOf("filter" to filter))
suspend fun taskCreate(request: TaskCreateRequest): IdResponse =
execute("tasks.php", "POST", request, IdResponse::class.java)
suspend fun taskUpdateStatus(id: Int, status: String) {
execute("tasks.php", "POST", TaskUpdateStatusRequest(id = id, status = status), Map::class.java)
}
suspend fun taskDelete(id: Int) {
execute("tasks.php", "POST", TaskDeleteRequest(id = id), Map::class.java)
}
// ─── Interner Chat ───────────────────────────────────────────────────────
suspend fun chatContacts(): ChatContactsResponse =
execute("chat.php", "GET", null, ChatContactsResponse::class.java)
suspend fun chatConversation(withId: Int): ChatConversationResponse =
execute("chat.php", "GET", null, ChatConversationResponse::class.java, query = mapOf("with" to withId.toString()))
suspend fun chatSend(toId: Int, message: String): ChatSendResponse =
execute("chat.php", "POST", ChatSendRequest(toId = toId, message = message), ChatSendResponse::class.java)
// ─── Live-Chat (Kunden-Widget) ───────────────────────────────────────────
suspend fun liveChatList(): LiveChatListResponse =
execute("live_chat.php", "GET", null, LiveChatListResponse::class.java, query = mapOf("action" to "list"))
suspend fun liveChatMessages(number: String, since: Int): LiveChatMessagesResponse =
execute(
"live_chat.php", "GET", null, LiveChatMessagesResponse::class.java,
query = mapOf("action" to "messages", "number" to number, "since" to since.toString()),
)
suspend fun liveChatSend(number: String, message: String): LiveChatSendResponse =
execute("live_chat.php", "POST", LiveChatSendRequest(number = number, message = message), LiveChatSendResponse::class.java)
suspend fun liveChatTyping(number: String) {
execute("live_chat.php", "POST", LiveChatActionRequest(action = "typing", number = number), Map::class.java)
}
suspend fun liveChatEnd(number: String) {
execute("live_chat.php", "POST", LiveChatActionRequest(action = "end", number = number), Map::class.java)
}
// ─── Verfügbarkeit (Status) ──────────────────────────────────────────────
suspend fun availability(): AvailabilityResponse =
execute("availability.php", "GET", null, AvailabilityResponse::class.java)
suspend fun setAvailability(status: String, note: String): AvailabilitySetResponse =
execute("availability.php", "POST", AvailabilitySetRequest(status, note), AvailabilitySetResponse::class.java)
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────
suspend fun ticketRules(): TicketRulesResponse =
execute("ticket_rules.php", "GET", null, TicketRulesResponse::class.java)
suspend fun saveTicketRule(request: TicketRuleSaveRequest): TicketRuleSaveResponse =
execute("ticket_rules.php", "POST", request, TicketRuleSaveResponse::class.java)
suspend fun toggleTicketRule(id: Int) {
execute("ticket_rules.php", "POST", TicketRuleIdRequest("toggle", id), Map::class.java)
}
suspend fun deleteTicketRule(id: Int) {
execute("ticket_rules.php", "POST", TicketRuleIdRequest("delete", id), Map::class.java)
}
suspend fun saveAutoClose(enabled: Boolean, days: Int) {
execute("ticket_rules.php", "POST", AutoCloseSaveRequest(enabled = enabled, days = days), Map::class.java)
}
private suspend fun <T> execute(
path: String,
method: String,
payload: Any?,
responseType: Class<T>,
authenticated: Boolean = true,
query: Map<String, String> = emptyMap(),
): T = withContext(Dispatchers.IO) {
val urlBuilder = "$baseUrl/api/staff/v1/$path".toHttpUrl().newBuilder()
query.forEach { (key, value) -> urlBuilder.addQueryParameter(key, value) }
val requestBuilder = Request.Builder()
.url(urlBuilder.build())
.header("Accept", "application/json")
.header("Cache-Control", "no-cache, no-store")
.header("Pragma", "no-cache")
if (authenticated) {
val token = tokenProvider()
?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.")
requestBuilder.header("Authorization", "Bearer $token")
}
val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) }
when (method) {
"GET" -> requestBuilder.get()
"POST" -> requestBuilder.post(body ?: ByteArray(0).toRequestBody(jsonType))
"PATCH" -> requestBuilder.patch(body ?: ByteArray(0).toRequestBody(jsonType))
else -> error("Nicht unterstützte HTTP-Methode: $method")
}
val call = try {
client.newCall(requestBuilder.build()).execute()
} catch (e: java.io.IOException) {
// Kein Netz, Server nicht erreichbar oder Zeitüberschreitung
ConnectionMonitor.set(true)
throw ApiException(0, "NETWORK", "Keine Verbindung zum Server.")
}
ConnectionMonitor.set(call.code in 502..504)
call.use { response ->
val responseBody = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull()
throw ApiException(
response.code,
envelope?.error?.code ?: "HTTP_${response.code}",
envelope?.error?.message ?: "Serverfehler (${response.code}).",
)
}
if (responseBody.isBlank()) {
throw ApiException(response.code, "EMPTY_RESPONSE", "Der Server hat keine Antwort geliefert.")
}
gson.fromJson(responseBody, responseType)
?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.")
}
}
/** Lädt ein Bild hoch (z.B. für eine Signatur) und liefert dessen öffentliche URL zurück. */
suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): UploadImageResponse =
withContext(Dispatchers.IO) {
val token = tokenProvider()
?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.")
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", filename, bytes.toRequestBody(mimeType.toMediaType()))
.build()
val request = Request.Builder()
.url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl())
.header("Accept", "application/json")
.header("Authorization", "Bearer $token")
.post(body)
.build()
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull()
throw ApiException(
response.code,
envelope?.error?.code ?: "HTTP_${response.code}",
envelope?.error?.message ?: "Serverfehler (${response.code}).",
)
}
gson.fromJson(responseBody, UploadImageResponse::class.java)
?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.")
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
package de.deskhub.staff.data.security
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.google.gson.Gson
import com.google.gson.JsonParseException
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class StoredSession(
val serverUrl: String = "",
val token: String = "",
val expiresAt: String = "",
val staffName: String = "",
)
class SessionStorageException(message: String, cause: Throwable) : Exception(message, cause)
class SecureSessionStore(context: Context) {
private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE)
private val gson = Gson()
fun save(session: StoredSession) {
check(preferences.edit().putString(PAYLOAD_KEY, encrypt(gson.toJson(session))).commit()) {
"Die geschützte Anmeldung konnte nicht gespeichert werden."
}
}
fun load(): StoredSession? {
val encrypted = preferences.getString(PAYLOAD_KEY, null) ?: return null
return try {
gson.fromJson(decrypt(encrypted), StoredSession::class.java)
} catch (error: GeneralSecurityException) {
invalidate(error)
} catch (error: IllegalArgumentException) {
invalidate(error)
} catch (error: JsonParseException) {
invalidate(error)
}
}
/**
* Löscht nur die geschützte Sitzung (Token), NICHT die zuletzt verwendete Server-Adresse –
* die bleibt bewusst erhalten, damit man sie nach einem Logout nicht erneut eintippen muss.
*/
fun clear() {
check(preferences.edit().remove(PAYLOAD_KEY).commit()) {
"Die lokale Anmeldung konnte nicht entfernt werden."
}
}
/** Server-Adresse ist nicht geheim – wird unverschlüsselt gespeichert und überlebt clear(). */
fun saveLastServerUrl(url: String) {
preferences.edit().putString(LAST_SERVER_URL_KEY, url).apply()
}
fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null)
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
val combined = cipher.iv + ciphertext
return Base64.encodeToString(combined, Base64.NO_WRAP)
}
private fun decrypt(value: String): String {
val combined = Base64.decode(value, Base64.NO_WRAP)
require(combined.size > IV_LENGTH) { "Ungültige verschlüsselte Sitzungsdaten." }
val iv = combined.copyOfRange(0, IV_LENGTH)
val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
return cipher.doFinal(ciphertext).toString(Charsets.UTF_8)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
private fun invalidate(error: Throwable): Nothing {
check(preferences.edit().clear().commit()) {
"Beschädigte Sitzungsdaten konnten nicht entfernt werden."
}
throw SessionStorageException(
"Die geschützte Anmeldung konnte nicht gelesen werden und wurde entfernt.",
error,
)
}
private companion object {
const val KEY_ALIAS = "deskhub_staff_session_v1"
const val PAYLOAD_KEY = "payload"
const val LAST_SERVER_URL_KEY = "last_server_url"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_LENGTH = 12
}
}
@@ -0,0 +1,708 @@
package de.deskhub.staff.domain.model
data class Staff(
val id: Int,
val name: String,
val email: String,
val role: String,
val permissions: Map<String, Boolean>,
)
data class NamedOption(
val id: Int,
val name: String,
)
data class StatusOption(
val value: String,
val label: String,
val color: String,
)
data class Priority(
val id: Int,
val name: String,
val color: String,
)
data class TicketSummary(
val id: Int,
val number: String,
val subject: String,
val requesterName: String,
val requesterEmail: String,
val status: String,
val priority: Priority,
val department: NamedOption,
val assignedStaff: NamedOption?,
val isOverdue: Boolean,
/** Fertig formatierter Fälligkeitstext vom Server ("in 3 Std 12 Min" / "vor 2 Std 5
* Min (überfällig)"), null wenn keine Fälligkeit gesetzt ist – siehe time_until() in
* includes/functions.php, dieselbe Quelle wie im Web, kein eigenes Datumsformat hier. */
val dueLabel: String? = null,
val isSpam: Boolean,
val createdAt: String,
val updatedAt: String,
)
data class ThreadAttachment(
val id: Int,
val name: String,
val size: Long,
)
data class ThreadComment(
val id: Int,
val message: String,
val staff: NamedOption,
val createdAt: String,
)
data class TicketThread(
val id: Int,
val authorType: String,
val authorName: String,
val message: String,
val isInternalNote: Boolean,
val createdAt: String,
val attachments: List<ThreadAttachment>,
val comments: List<ThreadComment>,
)
data class TicketDetail(
val summary: TicketSummary,
val source: String,
val firstResponseDueAt: String?,
val resolutionDueAt: String?,
// Name des Mitarbeiters, der diesen Live-Chat gerade aktiv bearbeitet (Web oder
// App) – null wenn frei oder man selbst der Besitzer ist. Nur bei source == "chat"
// relevant; siehe live_chat_locked_by_other() in includes/functions.php.
val lockedByOtherName: String?,
val threads: List<TicketThread>,
// Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte
val permissions: Map<String, Boolean>? = null,
)
data class SpamActionResult(
val ticket: TicketSummary,
val moved: Int,
val failed: List<String>,
)
data class Metadata(
val staff: Staff,
val departments: List<NamedOption>,
val priorities: List<Priority>,
val agents: List<NamedOption>,
val statuses: List<StatusOption>,
val tags: List<NamedOption> = emptyList(),
val liveChatEnabled: Boolean = false,
)
data class TicketPage(
val items: List<TicketSummary>,
val page: Int,
val total: Int,
val hasMore: Boolean,
)
data class CannedResponse(
val id: Int,
val title: String,
val body: String,
val departmentId: Int?,
)
data class DepartmentStat(
val name: String,
val open: Int,
val overdue: Int,
)
data class DashboardStats(
val myOpen: Int,
val overdue: Int,
val unassigned: Int = 0,
val unanswered: Int = 0,
val resolvedToday: Int,
val slaPercent: Double?,
/** "Priorisiert": mir zugewiesen oder unbearbeitet, dieselbe Auswahl wie im Web-Dashboard. */
val recent: List<TicketSummary> = emptyList(),
val departments: List<DepartmentStat>?,
)
data class Customer(
val id: Int?,
val name: String,
val email: String,
val organizationName: String?,
val ticketCount: Int,
val isGuest: Boolean,
)
data class CustomerDetail(
val id: Int?,
val name: String,
val email: String,
val organizationName: String?,
val isGuest: Boolean,
val createdAt: String,
)
data class CustomerProfile(
val customer: CustomerDetail,
val tickets: List<TicketSummary>,
)
/** Systemhinweis des Servers (z. B. Mail-Abruf fällt aus); nur für Verwalter von E-Mail-Adressen sichtbar. */
data class HealthIssue(val level: String, val message: String) {
val isError: Boolean get() = level == "error"
}
data class ActivityUpdate(
val newReplies: Int,
val newUnassigned: Int,
val serverTime: String,
val health: List<HealthIssue> = emptyList(),
)
data class TicketFilters(
val query: String = "",
val statuses: Set<String> = setOf("open", "pending", "answered", "on_hold"),
val assigned: String = "",
val departmentId: Int? = null,
val priorityId: Int? = null,
val tagId: Int? = null,
// "" (alle) | "overdue" | "at_risk" | "on_time" – siehe staff/tickets.php für dieselbe Logik.
val sla: String = "",
)
/** Gespeicherte Ticket-Ansicht (Filterkombination), eigene oder von Kollegen geteilte. */
data class SavedView(
val id: Int,
val name: String,
val isShared: Boolean,
val mine: Boolean,
val ownerName: String?,
val filters: TicketFilters,
)
// ─── Admin: Wissensdatenbank ────────────────────────────────────────────────
data class KbCategory(
val id: Int,
val name: String,
)
data class KbArticle(
val id: Int,
val categoryId: Int,
val categoryName: String,
val title: String,
val body: String,
val isPublished: Boolean,
val views: Int,
)
data class KbData(
val categories: List<KbCategory>,
val articles: List<KbArticle>,
)
// ─── Admin: Zeiterfassung ────────────────────────────────────────────────────
data class TimeEntry(
val id: Int,
val staffName: String,
val ticketNumber: String,
val subject: String,
val minutes: Int,
val note: String?,
val loggedAt: String,
)
data class TimeEntryTotal(
val staffName: String,
val minutes: Int,
)
data class TimeEntriesReport(
val staffOptions: List<NamedOption>,
val entries: List<TimeEntry>,
val totalsByStaff: List<TimeEntryTotal>,
val totalMinutes: Int,
)
// ─── Admin: Eskalationsregeln ────────────────────────────────────────────────
data class EscalationRule(
val id: Int,
val name: String,
val isActive: Boolean,
val triggerType: String,
val triggerHours: Int,
val conditionStatus: String?,
val conditionPriorityId: Int?,
val conditionPriorityName: String?,
val conditionDeptId: Int?,
val conditionDeptName: String?,
val actionSetPriorityId: Int?,
val actionSetPriorityName: String?,
val actionAssignStaffId: Int?,
val actionAssignStaffName: String?,
val actionSetStatus: String?,
val actionNotifyAdmin: Boolean,
val actionNotifyStaff: Boolean,
val sortOrder: Int,
val appliedCount: Int,
)
data class EscalationData(
val priorities: List<NamedOption>,
val staffOptions: List<NamedOption>,
val departments: List<NamedOption>,
val rules: List<EscalationRule>,
)
// ─── Admin: Sperrliste ───────────────────────────────────────────────────────
data class BanEntry(
val id: Int,
val matchType: String,
val value: String,
val reason: String?,
val addedByName: String?,
val createdAt: String,
)
// ─── Admin: Abteilungen ──────────────────────────────────────────────────────
data class Department(
val id: Int,
val name: String,
val email: String,
val isDefault: Boolean,
val signature: String?,
val isActive: Boolean = true,
val managerId: Int? = null,
val managerName: String? = null,
val slaPlanId: Int? = null,
val notes: String? = null,
val members: List<DepartmentMember> = emptyList(),
val updatedAt: String? = null,
)
data class DepartmentMember(val id: Int, val name: String, val isPrimary: Boolean)
data class DepartmentEmailOption(val email: String, val departmentName: String)
/** Eingaben des Formulars „Abteilung“. */
data class DepartmentInput(
val name: String,
val email: String,
val isDefault: Boolean,
val isActive: Boolean,
val managerId: Int?,
val slaPlanId: Int?,
val notes: String,
val signature: String?,
)
data class DepartmentData(
val departments: List<Department>,
val emailOptions: List<DepartmentEmailOption>,
val staffOptions: List<NamedOption>,
val slaPlans: List<NamedOption> = emptyList(),
)
data class SlaPlan(
val id: Int,
val name: String,
val responseHours: Int,
val resolutionHours: Int,
val isActive: Boolean,
val notes: String?,
val departmentCount: Int,
val helpTopicCount: Int,
)
// ─── Admin: Prioritäten ──────────────────────────────────────────────────────
data class PriorityFull(
val id: Int,
val name: String,
val color: String,
val responseHours: Int,
val resolutionHours: Int,
val sortOrder: Int,
)
// ─── Admin: Hilfe-Themen ─────────────────────────────────────────────────────
data class HelpTopic(
val id: Int,
val name: String,
val departmentId: Int,
val departmentName: String,
val priorityId: Int,
val priorityName: String,
val priorityColor: String,
val isActive: Boolean,
val sortOrder: Int,
val isPublic: Boolean = true,
val parentId: Int? = null,
val slaPlanId: Int? = null,
val path: String = "",
val notes: String? = null,
val updatedAt: String? = null,
)
/** Eingaben des Formulars „Hilfethema“ (Informationen + Optionen bei neuem Ticket). */
data class HelpTopicInput(
val name: String,
val isActive: Boolean,
val isPublic: Boolean,
val parentId: Int?,
val slaPlanId: Int?,
val notes: String,
val departmentId: Int,
val priorityId: Int,
val sortOrder: Int,
)
data class HelpTopicData(
val departments: List<NamedOption>,
val priorities: List<NamedOption>,
val slaPlans: List<NamedOption> = emptyList(),
val topics: List<HelpTopic>,
)
// ─── Admin: Organisationen ───────────────────────────────────────────────────
data class Organization(
val id: Int,
val name: String,
val notes: String?,
val memberCount: Int,
)
// ─── Admin: Teams ────────────────────────────────────────────────────────────
data class Team(
val id: Int,
val name: String,
val leadId: Int?,
val leadName: String?,
val notes: String?,
val isActive: Boolean,
val memberCount: Int,
val memberIds: List<Int>,
)
data class TeamData(
val staffOptions: List<NamedOption>,
val teams: List<Team>,
)
// ─── Admin: Rollen ───────────────────────────────────────────────────────────
data class Role(
val id: Int,
val name: String,
val permissions: Map<String, Boolean>,
val staffCount: Int,
)
data class RoleData(
val permissionKeys: List<String>,
val defaultAgentPermissions: Map<String, Boolean>,
val roles: List<Role>,
)
// ─── Admin: Mitarbeiter ──────────────────────────────────────────────────────
data class StaffExtraDept(
val departmentId: Int,
val notify: Boolean,
/** Rolle in dieser Abteilung; null = Hauptrolle des Mitarbeiters. */
val roleId: Int? = null,
)
data class StaffAdmin(
val id: Int,
val name: String,
val email: String,
val role: String,
val roleId: Int?,
val departmentId: Int?,
val departmentName: String?,
val isActive: Boolean,
val signature: String?,
val extraDepartments: List<StaffExtraDept>,
val teamIds: List<Int> = emptyList(),
val createdAt: String? = null,
)
data class StaffAdminData(
val departments: List<NamedOption>,
val roles: List<NamedOption>,
val staff: List<StaffAdmin>,
val teams: List<NamedOption> = emptyList(),
val canManageTeams: Boolean = false,
)
/** Eingaben des Formulars „Agent“ (Konto, Zugang, Berechtigungen, Teams). */
data class StaffAdminInput(
val name: String,
val email: String,
val role: String,
val roleId: Int?,
val departmentId: Int?,
val isActive: Boolean,
val password: String,
val signature: String?,
val extraDepartments: List<StaffExtraDept>,
val teamIds: List<Int>?,
)
// ─── Admin: Geschäftszeiten ──────────────────────────────────────────────────
data class BusinessHour(
val weekday: Int,
val isOpen: Boolean,
val openTime: String,
val closeTime: String,
)
data class BusinessHoliday(
val id: Int,
val date: String,
val name: String,
)
data class BusinessHoursData(
val enabled: Boolean,
val hours: List<BusinessHour>,
val holidays: List<BusinessHoliday>,
)
// ─── Admin: E-Mail-Adressen ──────────────────────────────────────────────────
data class EmailAddress(
val id: Int,
val email: String,
val label: String?,
val departmentId: Int,
val departmentName: String,
val priorityId: Int?,
val priorityName: String?,
val isDefault: Boolean,
val helpTopicId: Int? = null,
val isActive: Boolean = true,
val notes: String? = null,
val smtpEnabled: Boolean = false,
val smtpHost: String = "",
val smtpPort: Int = 587,
val smtpEncryption: String = "tls",
val smtpUsername: String = "",
val smtpHasPassword: Boolean = false,
val imapEnabled: Boolean = false,
val imapHost: String = "",
val imapPort: Int = 993,
val imapEncryption: String = "ssl",
val imapUsername: String = "",
val imapFolder: String = "INBOX",
val imapAfterFetch: String = "seen",
val imapArchiveFolder: String = "Processed",
val imapSpamFolder: String = "Junk",
val imapHasPassword: Boolean = false,
val imapLastRunAt: String? = null,
)
/** Eingaben des Formulars „E-Mail-Adresse“ (Konto + entferntes Postfach). */
data class EmailAddressInput(
val email: String,
val label: String?,
val departmentId: Int,
val priorityId: Int?,
val isDefault: Boolean,
val helpTopicId: Int?,
val isActive: Boolean,
val notes: String,
val smtpEnabled: Boolean,
val smtpHost: String,
val smtpPort: Int,
val smtpEncryption: String,
val smtpUsername: String,
val smtpPassword: String,
val imapEnabled: Boolean,
val imapHost: String,
val imapPort: Int,
val imapEncryption: String,
val imapUsername: String,
val imapPassword: String,
val imapFolder: String,
val imapAfterFetch: String,
val imapArchiveFolder: String,
val imapSpamFolder: String,
)
data class EmailAddressData(
val departments: List<NamedOption>,
val priorities: List<NamedOption>,
val helpTopics: List<NamedOption> = emptyList(),
val addresses: List<EmailAddress>,
)
// ─── Admin: E-Mail-Vorlagen ──────────────────────────────────────────────────
data class EmailTemplate(
val key: String,
val label: String,
val placeholders: List<String>,
val subject: String,
val body: String,
val isCustomized: Boolean,
)
// ─── Admin: Admin-Protokoll ──────────────────────────────────────────────────
data class AuditLogEntry(
val id: Int,
val staffName: String?,
val action: String,
val createdAt: String,
)
data class AuditLogPage(
val items: List<AuditLogEntry>,
val page: Int,
val hasMore: Boolean,
)
// ─── Aufgaben ────────────────────────────────────────────────────────────────
data class StaffTask(
val id: Int,
val title: String,
val description: String?,
val status: String,
val assignedToId: Int?,
val assignedName: String?,
val creatorName: String?,
val dueAt: String?,
val ticketNumber: String?,
val ticketSubject: String?,
)
data class TaskData(
val staffOptions: List<NamedOption>,
val tasks: List<StaffTask>,
)
// ─── Interner Chat ────────────────────────────────────────────────────────────
data class ChatContact(
val id: Int,
val name: String,
val unreadCount: Int,
val lastMessageAt: String?,
)
data class ChatMessage(
val id: Int,
val fromStaffId: Int,
val fromName: String,
val message: String,
val createdAt: String,
)
data class ChatConversation(
val with: NamedOption,
val messages: List<ChatMessage>,
)
// ─── Live-Chat (Kunden-Widget) ────────────────────────────────────────────────
data class LiveChatSummary(
val number: String,
val name: String,
val preview: String,
val needsReply: Boolean,
val updatedAt: String,
)
data class LiveChatAttachment(
val id: Int,
val name: String,
val size: Long,
)
data class LiveChatMessage(
val id: Int,
val authorType: String,
val authorName: String?,
val message: String,
val createdAt: String,
val attachments: List<LiveChatAttachment>,
)
data class LiveChatDetail(
val number: String,
val status: String,
val requesterName: String,
val requesterEmail: String,
val departmentName: String,
val lockedByOtherName: String?,
val customerTyping: Boolean,
val messages: List<LiveChatMessage>,
)
// ─── Verfügbarkeit (Status) ────────────────────────────────────────────────────
data class TeamMember(
val id: Int,
val name: String,
val status: String,
val note: String,
val isOnVacation: Boolean,
val updatedAt: String?,
)
data class AvailabilityData(
val myStatus: String,
val myNote: String,
val team: List<TeamMember>,
)
// ─── Ticket-Regeln (Automatisierung) ──────────────────────────────────────────
data class KeyLabel(val value: String, val label: String)
data class TicketRuleCondition(val field: String, val operator: String, val value: String)
data class TicketRuleAction(val type: String, val value: String, val valueLabel: String?)
data class TicketRule(
val id: Int,
val name: String,
val matchType: String,
val isActive: Boolean,
val sortOrder: Int,
val conditions: List<TicketRuleCondition>,
val actions: List<TicketRuleAction>,
)
data class TicketRulesData(
val fieldOptions: List<KeyLabel>,
val operatorOptions: List<KeyLabel>,
val actionOptions: List<KeyLabel>,
val departments: List<NamedOption>,
val priorities: List<NamedOption>,
val helpTopics: List<NamedOption>,
val staffOptions: List<NamedOption>,
val cannedResponses: List<NamedOption>,
val rules: List<TicketRule>,
val autoCloseEnabled: Boolean,
val autoCloseDays: Int,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
package de.deskhub.staff.ui
/**
* Darf der Mitarbeiter diese Aktion an einem Ticket ausführen?
* Rechte gelten pro Abteilung des Tickets; liefert ein älterer Server sie nicht mit,
* gelten die globalen Rechte des Mitarbeiters.
*/
fun ticketPermission(
ticketPermissions: Map<String, Boolean>?,
staffPermissions: Map<String, Boolean>?,
permission: String,
): Boolean = ticketPermissions?.get(permission) ?: (staffPermissions?.get(permission) == true)
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Base: deep ink -->
<path
android:fillColor="#16202A"
android:pathData="M0,0h108v108h-108z" />
<!-- Soft teal glow, upper-left -->
<path
android:fillColor="#0F6F6A"
android:fillAlpha="0.65"
android:pathData="M-24,-20 A72,72 0 1,0 120,-20 A72,72 0 1,0 -24,-20 Z" />
<!-- Warm accent glow, lower-right -->
<path
android:fillColor="#D97706"
android:fillAlpha="0.20"
android:pathData="M62,58 A46,46 0 1,0 154,58 A46,46 0 1,0 62,58 Z" />
</vector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Headset band -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="7"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M28,55 A26,26 0 0 1 80,55" />
<!-- Left ear cup -->
<path
android:fillColor="#FFFFFF"
android:pathData="M27,50 L28,50 A5,5 0 0 1 33,55 L33,65 A5,5 0 0 1 28,70 L27,70 A5,5 0 0 1 22,65 L22,55 A5,5 0 0 1 27,50 Z" />
<!-- Right ear cup -->
<path
android:fillColor="#FFFFFF"
android:pathData="M80,50 L81,50 A5,5 0 0 1 86,55 L86,65 A5,5 0 0 1 81,70 L80,70 A5,5 0 0 1 75,65 L75,55 A5,5 0 0 1 80,50 Z" />
<!-- Mic boom -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4.5"
android:strokeLineCap="round"
android:fillColor="#00000000"
android:pathData="M81,66 Q70,74 63,80" />
<!-- Mic tip (accent) -->
<path
android:fillColor="#D97706"
android:pathData="M59.5,80 A3.5,3.5 0 1,0 66.5,80 A3.5,3.5 0 1,0 59.5,80 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.DeskHub" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#0A1119</item>
<item name="android:navigationBarColor">#0A1119</item>
<item name="android:windowBackground">#0D141D</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">deskhub</string>
<string name="server_label">deskhub-Server</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.DeskHub" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#28405A</item>
<item name="android:navigationBarColor">#28405A</item>
<item name="android:windowBackground">#EEF1F5</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,21 @@
package de.deskhub.staff.data.remote
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionMonitorTest {
@After
fun reset() = ConnectionMonitor.set(false)
@Test
fun `starts online and follows set`() {
ConnectionMonitor.set(false)
assertFalse(ConnectionMonitor.offline.value)
ConnectionMonitor.set(true)
assertTrue(ConnectionMonitor.offline.value)
ConnectionMonitor.set(false)
assertFalse(ConnectionMonitor.offline.value)
}
}
@@ -0,0 +1,29 @@
package de.deskhub.staff.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class DeskHubUrlTest {
@Test
fun `adds https and preserves installation path`() {
assertEquals(
"https://support.example.de/deskhub",
DeskHubUrl.normalize("support.example.de/deskhub/"),
)
}
@Test
fun `rejects cleartext server`() {
assertThrows(IllegalArgumentException::class.java) {
DeskHubUrl.normalize("http://support.example.de")
}
}
@Test
fun `rejects credentials and query parameters`() {
assertThrows(IllegalArgumentException::class.java) {
DeskHubUrl.normalize("https://user@example.de?token=secret")
}
}
}
@@ -0,0 +1,84 @@
package de.deskhub.staff.data.remote.dto
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Prüft, dass typische Server-Antworten (Format der PHP-API) korrekt gelesen werden. */
class ApiDtoParsingTest {
private val gson = Gson()
@Test
fun `metadata contains health issues and permissions`() {
val json = """{"staff":{"id":3,"name":"Max","email":"m@x.de","role":"agent","permissions":{"view_reports":true,"reply_ticket":false}},
"departments":[{"id":1,"name":"Technik"}],"priorities":[],"agents":[],"statuses":[],"tags":[],"liveChatEnabled":true,
"health":[{"level":"error","code":"mailbox_down","message":"Postfach nicht erreichbar"}]}"""
val m = gson.fromJson(json, MetadataResponse::class.java)
assertEquals("Max", m.staff.name)
assertTrue(m.staff.permissions["view_reports"] == true)
assertFalse(m.staff.permissions["reply_ticket"] == true)
assertEquals(1, m.health.size)
assertEquals("mailbox_down", m.health[0].code)
assertEquals("error", m.health[0].level)
}
@Test
fun `old server without health or permissions still parses`() {
val m = gson.fromJson("""{"staff":{"id":1,"name":"A","email":"a@x.de","role":"admin"}}""", MetadataResponse::class.java)
assertTrue(m.health.isEmpty())
assertTrue(m.staff.permissions.isEmpty())
val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S"}""", TicketDetailDto::class.java)
assertNull(t.permissions)
assertTrue(t.threads.isEmpty())
}
@Test
fun `ticket detail carries per ticket permissions`() {
val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S","permissions":{"reply_ticket":true,"close_ticket":false}}""", TicketDetailDto::class.java)
assertEquals(true, t.permissions?.get("reply_ticket"))
assertEquals(false, t.permissions?.get("close_ticket"))
}
@Test
fun `activity response reports health`() {
val a = gson.fromJson("""{"newReplies":2,"newUnassigned":1,"serverTime":"2026-10-15 10:00:00","health":[{"level":"warning","code":"fetch_stale","message":"Abruf veraltet"}]}""", ActivityResponseDto::class.java)
assertEquals(2, a.newReplies)
assertEquals("fetch_stale", a.health.single().code)
}
@Test
fun `saved views parse filters`() {
val v = gson.fromJson("""{"items":[{"id":1,"name":"Überfällige","isShared":true,"mine":false,"ownerName":"Eva","filters":{"status":"open","sla":"overdue"}}]}""", SavedViewListResponse::class.java)
val view = v.items.single()
assertEquals("Überfällige", view.name)
assertTrue(view.isShared)
assertFalse(view.mine)
assertEquals("overdue", view.filters["sla"])
}
@Test
fun `sla plans parse`() {
val p = gson.fromJson("""{"items":[{"id":2,"name":"Premium","responseHours":2,"resolutionHours":8,"isActive":true,"departmentCount":1,"helpTopicCount":3}]}""", SlaPlanListResponse::class.java)
assertEquals(8, p.items.single().resolutionHours)
assertEquals(3, p.items.single().helpTopicCount)
}
@Test
fun `report response parses rows and totals`() {
val r = gson.fromJson("""{"config":{"dimension":"department"},"columns":[{"key":"label","label":"Abteilung"},{"key":"total","label":"Tickets"}],
"rows":[{"label":"Technik","total":42}],"totals":{"total":42},"dimensions":[],"metrics":[],"saved":[{"id":1,"name":"Monat","isShared":true,"mine":true}]}""", ReportResponseDto::class.java)
assertEquals(2, r.columns.size)
assertEquals("Technik", r.rows.single()["label"])
assertEquals("Monat", r.saved.single().name)
}
@Test
fun `error envelope falls back to defaults`() {
val e = gson.fromJson("""{"error":{"code":"FORBIDDEN","message":"Keine Berechtigung."}}""", ErrorEnvelope::class.java)
assertEquals("FORBIDDEN", e.error.code)
assertEquals("UNKNOWN", gson.fromJson("""{"error":{}}""", ErrorEnvelope::class.java).error.code)
}
}
@@ -0,0 +1,88 @@
package de.deskhub.staff.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import de.deskhub.staff.domain.model.HealthIssue
import de.deskhub.staff.domain.model.Metadata
import de.deskhub.staff.domain.model.Staff
import de.deskhub.staff.domain.model.StatusOption
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Compose-Oberflächentests auf der JVM (Robolectric) – kein Emulator nötig. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ComposeUiTest {
@get:Rule
val rule = createComposeRule()
@Test
fun offlineBannerAppearsAndRetryWorks() {
var retried = 0
rule.setContent {
MaterialTheme { StatusBanners(DeskHubState(offline = true), onRetry = { retried++ }) }
}
rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertIsDisplayed()
rule.onNodeWithText("Erneut").performClick()
assertEquals(1, retried)
}
@Test
fun noBannerWhenOnlineAndHealthy() {
rule.setContent { MaterialTheme { StatusBanners(DeskHubState(offline = false), onRetry = {}) } }
rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertDoesNotExist()
rule.onNodeWithText("Störung", substring = true).assertDoesNotExist()
}
@Test
fun serverHealthIssuesAreShownAsFaultOrHint() {
rule.setContent {
MaterialTheme {
StatusBanners(
DeskHubState(healthIssues = listOf(HealthIssue("error", "Postfach nicht erreichbar"), HealthIssue("warning", "Abruf veraltet"))),
onRetry = {},
)
}
}
rule.onNodeWithText("Störung: Postfach nicht erreichbar").assertIsDisplayed()
rule.onNodeWithText("Hinweis: Abruf veraltet").assertIsDisplayed()
}
@Test
fun statusPillShowsLabelFromMetadata() {
val staff = Staff(id = 1, name = "Max", email = "m@x.de", role = "agent", permissions = emptyMap())
val meta = Metadata(staff, emptyList(), emptyList(), emptyList(), listOf(StatusOption("open", "Offen", "#3b82f6")))
rule.setContent {
MaterialTheme {
StatusPill("open", meta)
StatusPill("custom_state", meta)
}
}
rule.onNodeWithText("Offen").assertIsDisplayed()
rule.onNodeWithText("custom_state").assertIsDisplayed() // unbekannter Status: Rohwert statt Absturz
}
@Test
fun themeMenuOffersAllModesAndReportsSelection() {
var chosen: ThemeMode? = null
rule.setContent {
MaterialTheme {
CompositionLocalProvider(LocalThemeController provides ThemeController(ThemeMode.System) { chosen = it }) { ThemeMenuButton() }
}
}
rule.onNodeWithContentDescription("Design wechseln").performClick()
listOf("Wie System", "Hell", "Dunkel").forEach { rule.onNodeWithText(it).assertIsDisplayed() }
rule.onNodeWithText("Dunkel").performClick()
assertTrue(chosen == ThemeMode.Dark)
}
}
@@ -0,0 +1,27 @@
package de.deskhub.staff.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PermissionsTest {
@Test
fun `ticket permission overrides global permission`() {
// Globales Recht vorhanden, aber in der Abteilung dieses Tickets nicht erlaubt
assertFalse(ticketPermission(mapOf("reply_ticket" to false), mapOf("reply_ticket" to true), "reply_ticket"))
// Globales Recht fehlt, aber die Abteilungsrolle erlaubt es
assertTrue(ticketPermission(mapOf("close_ticket" to true), mapOf("close_ticket" to false), "close_ticket"))
}
@Test
fun `falls back to global permissions for older servers`() {
assertTrue(ticketPermission(null, mapOf("post_note" to true), "post_note"))
assertFalse(ticketPermission(null, mapOf("post_note" to false), "post_note"))
}
@Test
fun `unknown permission is denied`() {
assertFalse(ticketPermission(emptyMap(), emptyMap(), "assign_ticket"))
assertFalse(ticketPermission(null, null, "assign_ticket"))
}
}