package com.estateunified.panel import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.reflect.TypeToken import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.security.SecureRandom internal data class UserDto( val id: String, val phone: String, val name: String, val email: String? = null, val role: String = "AGENT", val agency: String = "", val position: String = "", val agentNumber: String = "", val verificationStatus: String = "ACTIVE", var themePreference: String = "system", var locale: String = "ru", var plan: String = "BASIC", var planUntilIso: String = "", val createdAtIso: String = "", ) internal data class SessionDto( val token: String, val userId: String, val createdAtIso: String, ) internal class UserStore( private val gson: Gson, private val sessionsFile: Path, private val runtimeUsersFile: Path, ) { private val lock = Any() private val classpathUsers: List = loadClasspathUsers() private val random = SecureRandom() fun absolutePath(): String = sessionsFile.toAbsolutePath().normalize().toString() fun allUsers(): List = synchronized(lock) { (classpathUsers + loadRuntimeUsers()).distinctBy { it.id } } fun findByPhone(phone: String): UserDto? { val normalized = normalizePhone(phone) return synchronized(lock) { allUsers().firstOrNull { normalizePhone(it.phone) == normalized } } } fun upsertRuntimeUser(user: UserDto): UserDto = synchronized(lock) { val normalized = user.copy(phone = normalizePhone(user.phone)) val list = loadRuntimeUsers().toMutableList() val idx = list.indexOfFirst { it.id == normalized.id } if (idx >= 0) list[idx] = normalized else list.add(normalized) persistRuntimeUsers(list) normalized } fun updateTheme( userId: String, theme: String, ): UserDto? = synchronized(lock) { val runtime = loadRuntimeUsers().toMutableList() val idx = runtime.indexOfFirst { it.id == userId } if (idx >= 0) { val updated = runtime[idx].copy(themePreference = theme) runtime[idx] = updated persistRuntimeUsers(runtime) return@synchronized updated } val classpath = classpathUsers.firstOrNull { it.id == userId } ?: return@synchronized null val updated = classpath.copy(themePreference = theme) runtime.add(updated) persistRuntimeUsers(runtime) updated } fun updateProfile( userId: String, name: String?, email: String?, position: String?, ): UserDto? = synchronized(lock) { val runtime = loadRuntimeUsers().toMutableList() val idx = runtime.indexOfFirst { it.id == userId } val base = if (idx >= 0) runtime[idx] else classpathUsers.firstOrNull { it.id == userId } ?: return@synchronized null val updated = base.copy( name = name?.trim()?.takeIf { it.isNotBlank() } ?: base.name, email = email?.trim() ?: base.email, position = position?.trim() ?: base.position, ) if (idx >= 0) runtime[idx] = updated else runtime.add(updated) persistRuntimeUsers(runtime) updated } fun updateLocale( userId: String, locale: String, ): UserDto? = synchronized(lock) { val runtime = loadRuntimeUsers().toMutableList() val idx = runtime.indexOfFirst { it.id == userId } val base = if (idx >= 0) runtime[idx] else classpathUsers.firstOrNull { it.id == userId } ?: return@synchronized null val updated = base.copy(locale = locale.ifBlank { "ru" }) if (idx >= 0) runtime[idx] = updated else runtime.add(updated) persistRuntimeUsers(runtime) updated } fun setPlan( userId: String, plan: String, planUntilIso: String, ): UserDto? = synchronized(lock) { val runtime = loadRuntimeUsers().toMutableList() val idx = runtime.indexOfFirst { it.id == userId } val base = if (idx >= 0) runtime[idx] else classpathUsers.firstOrNull { it.id == userId } ?: return@synchronized null val updated = base.copy(plan = plan, planUntilIso = planUntilIso) if (idx >= 0) runtime[idx] = updated else runtime.add(updated) persistRuntimeUsers(runtime) updated } fun effectivePlan(user: UserDto): String { if (user.plan.isBlank() || user.plan == "BASIC") return "BASIC" if (user.planUntilIso.isBlank()) return user.plan val until = runCatching { java.time.LocalDateTime.parse(user.planUntilIso) }.getOrNull() ?: return user.plan return if (until.isAfter(java.time.LocalDateTime.now())) user.plan else "BASIC" } fun createSession(userId: String): SessionDto { val token = randomToken() val dto = SessionDto( token = token, userId = userId, createdAtIso = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( java.time.LocalDateTime.now(), ), ) synchronized(lock) { val list = loadSessions().toMutableList() list.add(0, dto) if (list.size > 200) list.subList(200, list.size).clear() persistSessions(list) } return dto } fun userByToken(token: String?): UserDto? { if (token.isNullOrBlank()) return null val sessions = synchronized(lock) { loadSessions() } val s = sessions.firstOrNull { it.token == token } ?: return null return allUsers().firstOrNull { it.id == s.userId } } fun revokeSession(token: String): Boolean = synchronized(lock) { val list = loadSessions().toMutableList() val removed = list.removeIf { it.token == token } if (removed) persistSessions(list) removed } private fun loadClasspathUsers(): List { val stream = this.javaClass.classLoader.getResourceAsStream("mock-users.json") ?: return emptyList() val text = stream.use { it.readBytes().toString(StandardCharsets.UTF_8) } return runCatching { gson.fromJson>(text, listType) ?: emptyList() }.getOrElse { emptyList() } .map(::sanitize) } // Gson populates fields purely from JSON and ignores Kotlin default values, // so a non-null field absent from older persisted JSON ends up as null at // runtime. Re-apply the defaults via copy() so the rest of the code can rely // on these fields being non-null. The @Suppress is needed because the static // type is non-null even though Gson can produce null at runtime. @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") private fun sanitize(user: UserDto): UserDto { fun nn(value: String?, fallback: String): String = value ?: fallback return user.copy( role = nn(user.role, "AGENT"), agency = nn(user.agency, ""), position = nn(user.position, ""), agentNumber = nn(user.agentNumber, ""), verificationStatus = nn(user.verificationStatus, "ACTIVE"), themePreference = nn(user.themePreference, "system"), locale = nn(user.locale, "ru"), plan = nn(user.plan, "BASIC"), planUntilIso = nn(user.planUntilIso, ""), createdAtIso = nn(user.createdAtIso, ""), ) } private fun loadRuntimeUsers(): List { if (!Files.isRegularFile(runtimeUsersFile)) return emptyList() val text = Files.readString(runtimeUsersFile, StandardCharsets.UTF_8) if (text.isBlank()) return emptyList() return runCatching { gson.fromJson>(text, listType) ?: emptyList() } .getOrElse { emptyList() } .map(::sanitize) } private fun persistRuntimeUsers(list: List) { Files.createDirectories(runtimeUsersFile.toAbsolutePath().parent) Files.writeString(runtimeUsersFile, gson.toJson(list), StandardCharsets.UTF_8) } private fun loadSessions(): List { if (!Files.isRegularFile(sessionsFile)) return emptyList() val text = Files.readString(sessionsFile, StandardCharsets.UTF_8) if (text.isBlank()) return emptyList() return runCatching { gson.fromJson>(text, sessionListType) ?: emptyList() } .getOrElse { emptyList() } } private fun persistSessions(list: List) { Files.createDirectories(sessionsFile.toAbsolutePath().parent) Files.writeString(sessionsFile, gson.toJson(list), StandardCharsets.UTF_8) } private fun randomToken(): String { val bytes = ByteArray(24) random.nextBytes(bytes) val alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" return buildString(32) { for (b in bytes) { val idx = (b.toInt() and 0xff) % alphabet.length append(alphabet[idx]) } for (i in 0 until 8) { val idx = random.nextInt(alphabet.length) append(alphabet[idx]) } } } companion object { private val listType = object : TypeToken>() {}.type private val sessionListType = object : TypeToken>() {}.type fun normalizePhone(phone: String): String { val trimmed = phone.trim() if (!trimmed.startsWith("+")) return trimmed return "+" + trimmed.drop(1).replace("\\s+".toRegex(), "") } fun fallbackParseJson(text: String): JsonObject? = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() } }