package com.estateunified.panel import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption internal data class TopPlacementDto( val listingId: String, val ownerUserId: String, val plan: String, val activatedAtIso: String, val inTopUntilIso: String, val cooldownUntilIso: String, ) internal data class TopBoostResult( val ok: Boolean, val reason: String, val record: TopPlacementDto?, ) internal class TopPlacementStore( private val gson: Gson, private val file: Path, ) { private val lock = Any() fun absolutePath(): String = file.toAbsolutePath().normalize().toString() fun all(): List = synchronized(lock) { load().toList() } fun get(listingId: String): TopPlacementDto? = synchronized(lock) { load().firstOrNull { it.listingId == listingId } } fun listForUser(userId: String): List = synchronized(lock) { load().filter { it.ownerUserId == userId } } fun activeTopListingIds(): Set { val now = java.time.LocalDateTime.now() return synchronized(lock) { load() .filter { runCatching { java.time.LocalDateTime.parse(it.inTopUntilIso).isAfter(now) }.getOrDefault(false) } .map { it.listingId } .toSet() } } fun activeTopMap(): Map { val now = java.time.LocalDateTime.now() return synchronized(lock) { load() .filter { runCatching { java.time.LocalDateTime.parse(it.inTopUntilIso).isAfter(now) }.getOrDefault(false) } .associateBy { it.listingId } } } fun boost( listingId: String, ownerUserId: String, plan: String, ): TopBoostResult { val tier = PlanCatalog.tiers[plan] if (tier == null || !PlanCatalog.canBoost(plan)) { return TopBoostResult(false, "plan_does_not_allow_boost", null) } synchronized(lock) { val list = load().toMutableList() val now = java.time.LocalDateTime.now() val existing = list.firstOrNull { it.listingId == listingId } if (existing != null) { val cooldownUntil = runCatching { java.time.LocalDateTime.parse(existing.cooldownUntilIso) }.getOrNull() if (cooldownUntil != null && cooldownUntil.isAfter(now)) { val inTopUntil = runCatching { java.time.LocalDateTime.parse(existing.inTopUntilIso) }.getOrNull() if (inTopUntil != null && inTopUntil.isAfter(now)) { return TopBoostResult(false, "already_in_top", existing) } return TopBoostResult(false, "cooldown_active", existing) } list.remove(existing) } val fmt = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME val inTopUntil = now.plusHours(tier.topHours.toLong()) val cooldownUntil = inTopUntil.plusHours(tier.cooldownHours.toLong()) val record = TopPlacementDto( listingId = listingId, ownerUserId = ownerUserId, plan = plan, activatedAtIso = fmt.format(now), inTopUntilIso = fmt.format(inTopUntil), cooldownUntilIso = fmt.format(cooldownUntil), ) list.add(0, record) persist(list) return TopBoostResult(true, "ok", record) } } fun remove(listingId: String): Boolean = synchronized(lock) { val list = load().toMutableList() val removed = list.removeIf { it.listingId == listingId } if (removed) persist(list) removed } private fun load(): MutableList { if (!Files.isRegularFile(file)) return mutableListOf() val text = Files.readString(file, StandardCharsets.UTF_8) if (text.isBlank()) return mutableListOf() return runCatching { gson.fromJson>(text, listType) ?: mutableListOf() }.getOrElse { mutableListOf() } } private fun persist(list: List) { Files.createDirectories(file.toAbsolutePath().parent) val tmp = file.resolveSibling(file.fileName.toString() + ".tmp") Files.writeString(tmp, gson.toJson(list), StandardCharsets.UTF_8) Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } private companion object { val listType = object : TypeToken>() {}.type } }