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 internal data class CollectionShareDto( val collectionId: String, val token: String, var revoked: Boolean = false, var views: Long = 0, var uniqueVisitorHashes: MutableList = mutableListOf(), val createdAtIso: String = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( java.time.LocalDateTime.now(), ), var perListing: MutableMap = mutableMapOf(), ) internal class AnalyticsStore( private val gson: Gson, private val file: Path, ) { private val lock = Any() fun absolutePath(): String = file.toAbsolutePath().normalize().toString() fun shareFor(collectionId: String): CollectionShareDto? = synchronized(lock) { load().firstOrNull { it.collectionId == collectionId && !it.revoked } } fun byToken(token: String): CollectionShareDto? = synchronized(lock) { load().firstOrNull { it.token == token && !it.revoked } } fun issueOrReuse(collectionId: String): CollectionShareDto = synchronized(lock) { val list = load().toMutableList() val existing = list.firstOrNull { it.collectionId == collectionId && !it.revoked } if (existing != null) return@synchronized existing val token = randomToken() val dto = CollectionShareDto(collectionId = collectionId, token = token) list.add(0, dto) persist(list) dto } fun revoke(collectionId: String): Boolean = synchronized(lock) { val list = load().toMutableList() var changed = false for (i in list.indices) { if (list[i].collectionId == collectionId && !list[i].revoked) { list[i] = list[i].copy(revoked = true) changed = true } } if (changed) persist(list) changed } fun recordView( token: String, visitorHash: String, listingIds: List, ): CollectionShareDto? = synchronized(lock) { val list = load().toMutableList() val idx = list.indexOfFirst { it.token == token && !it.revoked } if (idx < 0) return@synchronized null val cur = list[idx] cur.views = cur.views + 1 if (visitorHash.isNotBlank() && visitorHash !in cur.uniqueVisitorHashes) { cur.uniqueVisitorHashes.add(visitorHash) if (cur.uniqueVisitorHashes.size > 500) { cur.uniqueVisitorHashes = cur.uniqueVisitorHashes.takeLast(500).toMutableList() } } listingIds.forEach { lid -> cur.perListing[lid] = (cur.perListing[lid] ?: 0L) + 1L } list[idx] = cur persist(list) cur } private fun load(): List { if (!Files.isRegularFile(file)) return emptyList() val text = Files.readString(file, StandardCharsets.UTF_8) if (text.isBlank()) return emptyList() return runCatching { gson.fromJson>(text, listType) ?: emptyList() } .getOrElse { emptyList() } } private fun persist(list: List) { Files.createDirectories(file.toAbsolutePath().parent) Files.writeString(file, gson.toJson(list), StandardCharsets.UTF_8) } private fun randomToken(): String { val alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" val rnd = java.security.SecureRandom() return buildString(20) { for (i in 0 until 20) append(alphabet[rnd.nextInt(alphabet.length)]) } } companion object { private val listType = object : TypeToken>() {}.type } }