package com.estateunified.panel import com.google.gson.GsonBuilder import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser import io.ktor.http.ContentDisposition import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.content.PartData import io.ktor.http.content.forEachPart import io.ktor.http.content.streamProvider import io.ktor.server.application.Application import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.plugins.cors.routing.CORS import io.ktor.http.HttpMethod import io.ktor.server.request.receiveMultipart import io.ktor.server.request.receiveText import io.ktor.server.response.header import io.ktor.server.response.respondBytes import io.ktor.server.response.respondText import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.patch import io.ktor.server.routing.post import io.ktor.server.routing.put import io.ktor.server.routing.route import io.ktor.server.routing.routing import java.net.NetworkInterface import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.security.MessageDigest private val gsonApi: com.google.gson.Gson = GsonBuilder().serializeNulls().create() private fun classpathUtf8(path: String): String { val stream = object {}.javaClass.classLoader.getResourceAsStream(path) ?: error("Не найден classpath-ресурс: $path") return stream.use { it.readBytes().toString(StandardCharsets.UTF_8) } } private fun normalizeAuthPhone(phone: String): String = UserStore.normalizePhone(phone) internal data class ListingsDataset( val rawJson: String, val byId: Map, ) private fun prepareListings(): ListingsDataset { val raw = classpathUtf8("mock-listings.json") val arr = JsonParser.parseString(raw).asJsonArray val map = LinkedHashMap() arr.forEach { el -> val o = el.asJsonObject map[o.getAsJsonPrimitive("id").asString] = o } return ListingsDataset(rawJson = raw, byId = map) } private fun mergeById( builtin: Map, userRows: List, ): LinkedHashMap { val m = LinkedHashMap(builtin) userRows.forEach { o -> val id = try { o.getAsJsonPrimitive("id").asString } catch (_: Exception) { return@forEach } m[id] = o } return m } private fun localNetworkAddresses(): List = NetworkInterface.getNetworkInterfaces().asSequence() .flatMap { it.inetAddresses.asSequence() } .filter { !it.isLoopbackAddress && it.hostAddress?.contains(':') == false } .mapNotNull { it.hostAddress } .distinct() .sorted() .toList() fun main() { val listings = prepareListings() val dataRoot: Path = System.getenv("ESTATE_PANEL_DATA") ?.let(Paths::get) ?: Paths.get(System.getProperty("user.dir")).resolve(".estateunified-panel") Files.createDirectories(dataRoot) val collectionStore = CollectionStore(gson = gsonApi, file = dataRoot.resolve("collections.json")) val userListingsStore = UserListingsStore(gson = gsonApi, file = dataRoot.resolve("user-listings.json")) val demoPaymentsStore = DemoPaymentsStore(gson = gsonApi, file = dataRoot.resolve("demo-payments.json")) val userStore = UserStore( gson = gsonApi, sessionsFile = dataRoot.resolve("sessions.json"), runtimeUsersFile = dataRoot.resolve("users-runtime.json"), ) val fixationStore = FixationStore(gson = gsonApi, file = dataRoot.resolve("fixations.json")) val dealStore = DealStore(gson = gsonApi, file = dataRoot.resolve("deals.json")) val analyticsStore = AnalyticsStore(gson = gsonApi, file = dataRoot.resolve("collection-shares.json")) val newsStore = NewsStore(gson = gsonApi) val chessStore = ChessBoardStore(gson = gsonApi, dataDir = dataRoot.resolve("chessboards")) val otpStore = OtpStore() val photosStore = ListingPhotosStore( gson = gsonApi, metaFile = dataRoot.resolve("listing-photos.json"), photosRoot = dataRoot.resolve("photos"), ) val favoritesStore = FavoritesStore(gson = gsonApi, file = dataRoot.resolve("favorites.json")) val savedSearchesStore = SavedSearchesStore(gson = gsonApi, file = dataRoot.resolve("saved-searches.json")) val leadsStore = LeadsStore(gson = gsonApi, file = dataRoot.resolve("leads.json")) val tasksStore = TasksStore(gson = gsonApi, file = dataRoot.resolve("tasks.json")) val ticketsStore = TicketsStore(gson = gsonApi, file = dataRoot.resolve("tickets.json")) val invitesStore = InvitesStore(gson = gsonApi, file = dataRoot.resolve("invites.json")) val subscriptionsStore = SubscriptionsStore(gson = gsonApi, file = dataRoot.resolve("subscriptions.json")) val topPlacementStore = TopPlacementStore(gson = gsonApi, file = dataRoot.resolve("top-placements.json")) val documentsStore = ListingDocumentsStore( gson = gsonApi, metaFile = dataRoot.resolve("listing-documents.json"), docsRoot = dataRoot.resolve("documents"), ) val videosStore = ListingVideosStore( gson = gsonApi, metaFile = dataRoot.resolve("listing-videos.json"), videosRoot = dataRoot.resolve("videos"), ) val rentalCalendarStore = RentalCalendarStore(gson = gsonApi, file = dataRoot.resolve("rental-calendar.json")) val rentalReviewsStore = RentalReviewsStore(gson = gsonApi, file = dataRoot.resolve("rental-reviews.json")) val geocodeService = GeocodeService( gson = gsonApi, cacheFile = dataRoot.resolve("geocode-cache.json"), enabled = (System.getenv("PANEL_GEOCODE_DISABLED")?.lowercase() != "1"), ) Files.createDirectories(dataRoot.resolve("photos")) Files.createDirectories(dataRoot.resolve("documents")) Files.createDirectories(dataRoot.resolve("videos")) Files.createDirectories(dataRoot.resolve("chessboards")) val port = System.getenv("PORT")?.toIntOrNull() ?: 8090 val host = System.getenv("HOST")?.trim()?.ifBlank { null } ?: "127.0.0.1" val payMode = System.getenv("PANEL_PAY_MODE")?.lowercase() ?: "success_always" println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") println(" EstateUnified Pro — локальная веб-панель") if (host == "0.0.0.0") { println(" На этом ПК: http://127.0.0.1:${port}/") val lanIps = localNetworkAddresses() if (lanIps.isEmpty()) { println(" В локальной сети: http://:${port}/") } else { lanIps.forEach { ip -> println(" В локальной сети: http://${ip}:${port}/") } } } else { println(" Откройте: http://${host}:${port}/") } println(" Логин: любой телефон +374… + код ${otpStore.acceptedCode}") println(" Демо-аккаунты:") println(" +37411000001 — Анна (агент)") println(" +37411000002 — Гарри (руководитель)") println(" +37411000003 — Дмитрий (застройщик)") println(" +37411000004 — Лусине (модератор)") println(" Pay mode: $payMode (env PANEL_PAY_MODE=success_always|random)") println(" Хранилище: ${dataRoot.toAbsolutePath()}") println(" Остановите: Ctrl+C") println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") embeddedServer(Netty, port = port, host = host) { install(CORS) { anyHost() allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.Accept) allowMethod(HttpMethod.Get) allowMethod(HttpMethod.Post) allowMethod(HttpMethod.Put) allowMethod(HttpMethod.Patch) allowMethod(HttpMethod.Delete) allowMethod(HttpMethod.Options) } configureRoutes( listings = listings, collections = collectionStore, userListings = userListingsStore, payments = demoPaymentsStore, users = userStore, fixations = fixationStore, deals = dealStore, analytics = analyticsStore, news = newsStore, chess = chessStore, otp = otpStore, photos = photosStore, favorites = favoritesStore, savedSearches = savedSearchesStore, leads = leadsStore, tasks = tasksStore, tickets = ticketsStore, invites = invitesStore, subscriptions = subscriptionsStore, topPlacements = topPlacementStore, documents = documentsStore, videos = videosStore, rentalCalendar = rentalCalendarStore, rentalReviews = rentalReviewsStore, geocode = geocodeService, payMode = payMode, ) }.start(wait = true) } private fun Application.configureRoutes( listings: ListingsDataset, collections: CollectionStore, userListings: UserListingsStore, payments: DemoPaymentsStore, users: UserStore, fixations: FixationStore, deals: DealStore, analytics: AnalyticsStore, news: NewsStore, chess: ChessBoardStore, otp: OtpStore, photos: ListingPhotosStore, favorites: FavoritesStore, savedSearches: SavedSearchesStore, leads: LeadsStore, tasks: TasksStore, tickets: TicketsStore, invites: InvitesStore, subscriptions: SubscriptionsStore, topPlacements: TopPlacementStore, documents: ListingDocumentsStore, videos: ListingVideosStore, rentalCalendar: RentalCalendarStore, rentalReviews: RentalReviewsStore, geocode: GeocodeService, payMode: String, ) { fun catalogById(): LinkedHashMap = mergeById(listings.byId, userListings.readAll()) fun catalogRawMerged(): String { val arr = JsonParser.parseString(listings.rawJson).asJsonArray userListings.readAll().forEach { arr.add(it) } return gsonApi.toJson(arr) } fun enrichWithPhotos(obj: JsonObject): JsonObject { val id = obj.optString("id") if (id.isBlank()) return obj val ph = photos.forListing(id) if (ph.isNotEmpty()) { val arr = JsonArray() ph.forEach { p -> arr.add( JsonObject().apply { addProperty("id", p.id) addProperty("url", "/api/listings/$id/photos/${p.storedName}") addProperty("isCover", p.isCover) addProperty("captionRu", p.captionRu) addProperty("sortOrder", p.sortOrder) addProperty("width", p.width) addProperty("height", p.height) }, ) } obj.add("photos", arr) val cover = ph.firstOrNull { it.isCover } ?: ph.firstOrNull() if (cover != null) { obj.addProperty("coverUrl", "/api/listings/$id/photos/${cover.storedName}") } } else if (obj.has("photos") && obj.get("photos").isJsonArray && obj.getAsJsonArray("photos").size() > 0) { val arr = obj.getAsJsonArray("photos") val coverEl = (0 until arr.size()) .map { arr.get(it).asJsonObject } .firstOrNull { it.has("isCover") && it.get("isCover").asBoolean } ?: arr.get(0).asJsonObject if (coverEl.has("url")) { obj.addProperty("coverUrl", coverEl.get("url").asString) } } else { obj.add("photos", JsonArray()) } return obj } fun enrichWithMedia(obj: JsonObject): JsonObject { enrichWithPhotos(obj) val id = obj.optString("id") if (id.isBlank()) return obj val vids = videos.forListing(id) val varr = JsonArray() vids.forEach { v -> varr.add( JsonObject().apply { addProperty("id", v.id) addProperty("url", "/api/listings/$id/videos/${v.storedName}") addProperty("mime", v.mime) addProperty("originalName", v.originalName) addProperty("captionRu", v.captionRu) addProperty("sortOrder", v.sortOrder) addProperty("sizeBytes", v.sizeBytes) }, ) } obj.add("videos", varr) obj.addProperty("hasVideo", vids.isNotEmpty()) val docs = documents.forListing(id) val darr = JsonArray() docs.forEach { d -> darr.add( JsonObject().apply { addProperty("id", d.id) addProperty("url", "/api/listings/$id/documents/${d.storedName}") addProperty("mime", d.mime) addProperty("titleRu", d.titleRu) addProperty("kind", d.kind) addProperty("originalName", d.originalName) addProperty("sortOrder", d.sortOrder) addProperty("sizeBytes", d.sizeBytes) }, ) } obj.add("documents", darr) obj.addProperty("hasDocuments", docs.isNotEmpty()) val top = topPlacements.get(id) if (top != null) { val now = java.time.LocalDateTime.now() val inTopUntil = runCatching { java.time.LocalDateTime.parse(top.inTopUntilIso) }.getOrNull() val cooldownUntil = runCatching { java.time.LocalDateTime.parse(top.cooldownUntilIso) }.getOrNull() val isInTop = inTopUntil != null && inTopUntil.isAfter(now) obj.addProperty("isInTop", isInTop) obj.addProperty("inTopUntilIso", top.inTopUntilIso) obj.addProperty("cooldownUntilIso", top.cooldownUntilIso) obj.addProperty("topPlan", top.plan) obj.addProperty( "topCooldownActive", cooldownUntil != null && cooldownUntil.isAfter(now) && !isInTop, ) } else { obj.addProperty("isInTop", false) } return obj } fun composeAddressForGeocode(o: JsonObject): String { val addr = o.optString("addressLine") val city = o.optString("city") return buildString { if (addr.isNotBlank()) { append(addr) } if (city.isNotBlank()) { if (isNotEmpty()) append(", ") append(city) } } } fun maybeFillCoords(o: JsonObject) { val hasLat = o.has("latitude") && !o.get("latitude").isJsonNull && runCatching { o.get("latitude").asDouble }.getOrDefault(0.0) != 0.0 val hasLng = o.has("longitude") && !o.get("longitude").isJsonNull && runCatching { o.get("longitude").asDouble }.getOrDefault(0.0) != 0.0 if (hasLat && hasLng) return val composed = composeAddressForGeocode(o) if (composed.isBlank()) return val result = geocode.lookup(composed) if (result != null) { o.addProperty("latitude", result.lat) o.addProperty("longitude", result.lon) o.addProperty("geocodedFromAddress", true) } } fun sortByTopFirst(arr: JsonArray): JsonArray { val topMap = topPlacements.activeTopMap() val withMeta = (0 until arr.size()).map { i -> val o = arr.get(i).asJsonObject val id = o.optString("id") val top = topMap[id] val until = if (top != null) runCatching { java.time.LocalDateTime.parse(top.inTopUntilIso) }.getOrNull() else null Triple(o, top != null, until) } val sorted = withMeta.sortedWith( compareByDescending> { it.second } .thenByDescending { it.third ?: java.time.LocalDateTime.MIN }, ) val out = JsonArray() sorted.forEach { out.add(it.first) } return out } routing { get("/") { call.respondText(classpathUtf8("panel/index.html"), ContentType.Text.Html) } get("/index.html") { call.respondText(classpathUtf8("panel/index.html"), ContentType.Text.Html) } get("/login.html") { call.respondText(classpathUtf8("panel/login.html"), ContentType.Text.Html) } get("/login.css") { call.respondText(classpathUtf8("panel/login.css"), ContentType.Text.CSS) } get("/login.js") { call.respondText(classpathUtf8("panel/login.js"), ContentType.Application.JavaScript) } get("/api-config.js") { call.respondText(classpathUtf8("panel/api-config.js"), ContentType.Application.JavaScript) } get("/content-i18n.js") { call.respondText(classpathUtf8("panel/content-i18n.js"), ContentType.Application.JavaScript) } get("/content/{locale}.json") { val locale = call.parameters["locale"] ?: return@get call.notFound() if (locale !in setOf("en", "am")) return@get call.notFound() call.respondText(classpathUtf8("panel/content/$locale.json"), ContentType.Application.Json) } get("/styles.css") { call.respondText(classpathUtf8("panel/styles.css"), ContentType.Text.CSS) } get("/app.js") { call.respondText(classpathUtf8("panel/app.js"), ContentType.Application.JavaScript) } get("/i18n.js") { call.respondText(classpathUtf8("panel/i18n.js"), ContentType.Application.JavaScript) } get("/tz") { call.respondText(classpathUtf8("panel/tz.html"), ContentType.Text.Html) } get("/preview") { call.respondText(classpathUtf8("panel/preview.html"), ContentType.Text.Html) } get("/preview.css") { call.respondText(classpathUtf8("panel/preview.css"), ContentType.Text.CSS) } get("/preview.js") { call.respondText(classpathUtf8("panel/preview.js"), ContentType.Application.JavaScript) } get("/share") { call.respondText(classpathUtf8("panel/share.html"), ContentType.Text.Html) } get("/share.css") { call.respondText(classpathUtf8("panel/share.css"), ContentType.Text.CSS) } get("/share.js") { call.respondText(classpathUtf8("panel/share.js"), ContentType.Application.JavaScript) } route("/api") { post("/auth/otp/send") { val body = parseBody(call.receiveText()) val phone = normalizeAuthPhone(body.getAsJsonPrimitive("phone")?.asString.orEmpty()) if (phone.isBlank() || !phone.startsWith("+")) { return@post call.badRequest("phone_required") } val req = otp.create(phone) call.respondText( gsonApi.toJson( JsonObject().apply { addProperty("requestId", req.requestId) addProperty("hint", "Демо: введите код ${otp.acceptedCode}") }, ), ContentType.Application.Json, ) } post("/auth/otp/verify") { val body = parseBody(call.receiveText()) val phone = normalizeAuthPhone(body.getAsJsonPrimitive("phone")?.asString.orEmpty()) val code = body.getAsJsonPrimitive("code")?.asString?.trim().orEmpty() if (!otp.verify(phone, code)) { return@post call.respondText( "{\"error\":\"otp_invalid\"}", ContentType.Application.Json, HttpStatusCode.Unauthorized, ) } val existing = users.findByPhone(phone) val user = existing ?: run { val name = body.getAsJsonPrimitive("name")?.asString?.trim().orEmpty().ifBlank { "Новый агент" } val role = body.getAsJsonPrimitive("role")?.asString?.trim()?.ifBlank { null } ?: "AGENT" val email = body.getAsJsonPrimitive("email")?.asString?.trim()?.ifBlank { null } val newUser = UserDto( id = "user-${java.util.UUID.randomUUID()}", phone = phone, name = name, email = email, role = role, agency = body.getAsJsonPrimitive("agency")?.asString.orEmpty(), verificationStatus = "PENDING", createdAtIso = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( java.time.LocalDateTime.now(), ), ) users.upsertRuntimeUser(newUser) } val session = users.createSession(user.id) val payload = JsonObject().apply { addProperty("token", session.token) add("user", gsonApi.toJsonTree(user)) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/auth/me") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val effective = users.effectivePlan(ctx.user) val obj = gsonApi.toJsonTree(ctx.user).asJsonObject obj.addProperty("effectivePlan", effective) call.respondText(gsonApi.toJson(obj), ContentType.Application.Json) } post("/auth/logout") { val token = call.bearerToken() if (!token.isNullOrBlank()) users.revokeSession(token) call.respondText("{}", ContentType.Application.Json) } patch("/profile") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val body = parseBody(call.receiveText()) var current = ctx.user val theme = body.getAsJsonPrimitive("themePreference")?.asString if (!theme.isNullOrBlank()) { current = users.updateTheme(ctx.user.id, theme) ?: current } val locale = body.getAsJsonPrimitive("locale")?.asString if (!locale.isNullOrBlank()) { current = users.updateLocale(ctx.user.id, locale) ?: current } val name = body.getAsJsonPrimitive("name")?.asString val email = body.getAsJsonPrimitive("email")?.asString val position = body.getAsJsonPrimitive("position")?.asString if (name != null || email != null || position != null) { current = users.updateProfile(ctx.user.id, name, email, position) ?: current } val obj = gsonApi.toJsonTree(current).asJsonObject obj.addProperty("effectivePlan", users.effectivePlan(current)) call.respondText(gsonApi.toJson(obj), ContentType.Application.Json) } get("/news") { call.respondText(gsonApi.toJson(news.all()), ContentType.Application.Json) } get("/news/{id}") { val id = call.parameters["id"] ?: return@get call.badRequest() val item = news.get(id) ?: return@get call.notFound() call.respondText(gsonApi.toJson(item), ContentType.Application.Json) } get("/listings") { val q = call.request.queryParameters val rawJson = catalogRawMerged() val arr = JsonParser.parseString(rawJson).asJsonArray val filtered = JsonArray() val dealType = q["dealType"]?.takeIf { it.isNotBlank() } val segment = q["segment"]?.takeIf { it.isNotBlank() } val marz = q["marz"]?.takeIf { it.isNotBlank() } val city = q["city"]?.takeIf { it.isNotBlank() }?.lowercase() val region = q["region"]?.takeIf { it.isNotBlank() }?.lowercase() val priceMin = q["priceMin"]?.toLongOrNull() val priceMax = q["priceMax"]?.toLongOrNull() val rooms = q["rooms"]?.toIntOrNull() val text = q["q"]?.trim()?.lowercase() val areaMin = q["areaMin"]?.toDoubleOrNull() val areaMax = q["areaMax"]?.toDoubleOrNull() val floorMin = q["floorMin"]?.toIntOrNull() val floorMax = q["floorMax"]?.toIntOrNull() val floorsTotalMin = q["floorsTotalMin"]?.toIntOrNull() val renovation = q["renovation"]?.takeIf { it.isNotBlank() } val furnishing = q["furnishing"]?.takeIf { it.isNotBlank() } val pets = q["pets"]?.takeIf { it.isNotBlank() } val kids = q["kids"]?.takeIf { it.isNotBlank() } val completionQuarter = q["completionQuarter"]?.takeIf { it.isNotBlank() }?.lowercase() val developer = q["developer"]?.takeIf { it.isNotBlank() }?.lowercase() val badgesParam = q["badges"]?.split(',')?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() val hasPhotos = q["hasPhotos"] val hasVideo = q["hasVideo"] val hasChessboard = q["hasChessboard"] val hasDocuments = q["hasDocuments"] val leaseMinMonthsMax = q["leaseMinMonthsMax"]?.toIntOrNull() val plotAreaMin = q["plotAreaMin"]?.toDoubleOrNull() val parkingType = q["parkingType"]?.takeIf { it.isNotBlank() } val landZoning = q["landZoning"]?.takeIf { it.isNotBlank() } val commercialUse = q["commercialUse"]?.takeIf { it.isNotBlank() } val topOnly = q["topOnly"]?.equals("1") == true || q["topOnly"]?.equals("true") == true val activeTopIds = topPlacements.activeTopListingIds() val docsByListing = mutableMapOf() val videosByListing = mutableMapOf() fun listingHasDocs(id: String): Boolean = docsByListing.getOrPut(id) { documents.forListing(id).isNotEmpty() } fun listingHasVideos(id: String): Boolean = videosByListing.getOrPut(id) { videos.forListing(id).isNotEmpty() } var geocodeBudget = q["geocode"]?.toIntOrNull()?.coerceIn(0, 5) ?: 0 arr.forEach { el -> val o = el.asJsonObject val lid = o.optString("id") if (geocodeBudget > 0) { val hasLat = o.has("latitude") && !o.get("latitude").isJsonNull && runCatching { o.get("latitude").asDouble }.getOrDefault(0.0) != 0.0 val hasLng = o.has("longitude") && !o.get("longitude").isJsonNull && runCatching { o.get("longitude").asDouble }.getOrDefault(0.0) != 0.0 if (!hasLat || !hasLng) { maybeFillCoords(o) geocodeBudget-- } } if (dealType != null && o.optString("dealType") != dealType) return@forEach if (segment != null && o.optString("segment") != segment) return@forEach if (marz != null && o.optString("provinceMarzCode") != marz) return@forEach if (city != null && !o.optString("city").lowercase().contains(city)) return@forEach if (region != null && !o.optString("regionHint").lowercase().contains(region)) return@forEach val price = o.optLong("priceAmd") if (priceMin != null && price < priceMin) return@forEach if (priceMax != null && price > priceMax) return@forEach if (rooms != null) { val r = if (o.has("rooms") && !o.get("rooms").isJsonNull) o.get("rooms").asInt else -1 if (r != rooms) return@forEach } val area = if (o.has("areaSqm") && !o.get("areaSqm").isJsonNull) { runCatching { o.get("areaSqm").asDouble }.getOrDefault(0.0) } else 0.0 if (areaMin != null && area < areaMin) return@forEach if (areaMax != null && area > 0.0 && area > areaMax) return@forEach val flr = if (o.has("floor") && !o.get("floor").isJsonNull) runCatching { o.get("floor").asInt }.getOrDefault(-1) else -1 if (floorMin != null && flr in 0 until floorMin) return@forEach if (floorMax != null && flr > floorMax) return@forEach val flrTotal = if (o.has("floorsTotal") && !o.get("floorsTotal").isJsonNull) runCatching { o.get("floorsTotal").asInt }.getOrDefault(0) else 0 if (floorsTotalMin != null && flrTotal in 1 until floorsTotalMin) return@forEach if (renovation != null && !o.optString("renovationRu").equals(renovation, ignoreCase = true)) return@forEach if (furnishing != null && !o.optString("furnishingRu").contains(furnishing, ignoreCase = true)) return@forEach if (pets != null && !o.optString("petsRu").contains(pets, ignoreCase = true)) return@forEach if (kids != null && !o.optString("kidsRu").contains(kids, ignoreCase = true)) return@forEach if (completionQuarter != null && !o.optString("completionQuarter").lowercase().contains(completionQuarter)) return@forEach if (developer != null && !o.optString("developer").lowercase().contains(developer)) return@forEach if (badgesParam.isNotEmpty()) { val badges = if (o.has("badges") && o.get("badges").isJsonArray) o.getAsJsonArray("badges").mapNotNull { runCatching { it.asString }.getOrNull() } else emptyList() if (badgesParam.none { needed -> badges.any { it.equals(needed, ignoreCase = true) } }) return@forEach } if (hasPhotos == "1") { val nFromMeta = photos.forListing(lid).size val nFromObj = if (o.has("photos") && o.get("photos").isJsonArray) o.getAsJsonArray("photos").size() else 0 if (nFromMeta + nFromObj == 0) return@forEach } if (hasVideo == "1" && !listingHasVideos(lid)) return@forEach if (hasChessboard == "1") { val cb = o.has("chessboardSupported") && !o.get("chessboardSupported").isJsonNull && o.get("chessboardSupported").asBoolean if (!cb) return@forEach } if (hasDocuments == "1" && !listingHasDocs(lid)) return@forEach if (leaseMinMonthsMax != null) { val lm = if (o.has("leaseMinMonths") && !o.get("leaseMinMonths").isJsonNull) runCatching { o.get("leaseMinMonths").asInt }.getOrDefault(0) else 0 if (lm > 0 && lm > leaseMinMonthsMax) return@forEach } if (plotAreaMin != null) { val ps = if (o.has("plotAreaSotka") && !o.get("plotAreaSotka").isJsonNull) runCatching { o.get("plotAreaSotka").asDouble }.getOrDefault(0.0) else 0.0 if (ps < plotAreaMin) return@forEach } if (parkingType != null && !o.optString("parkingType").equals(parkingType, ignoreCase = true)) return@forEach if (landZoning != null && !o.optString("landZoning").equals(landZoning, ignoreCase = true)) return@forEach if (commercialUse != null && !o.optString("commercialUseRu").contains(commercialUse, ignoreCase = true)) return@forEach if (topOnly && lid !in activeTopIds) return@forEach if (!text.isNullOrBlank()) { val blob = listOf( o.optString("title"), o.optString("city"), o.optString("addressLine"), o.optString("regionHint"), o.optString("developer"), o.optString("complexName"), ).joinToString(" ").lowercase() if (!blob.contains(text)) return@forEach } filtered.add(enrichWithMedia(o)) } val sorted = sortByTopFirst(filtered) call.respondText(gsonApi.toJson(sorted), ContentType.Application.Json) } get("/listings/cma") { val q = call.request.queryParameters val marz = q["marz"]?.takeIf { it.isNotBlank() } val segment = q["segment"]?.takeIf { it.isNotBlank() } val dealType = q["dealType"]?.takeIf { it.isNotBlank() } ?: "SALE" val arr = JsonParser.parseString(catalogRawMerged()).asJsonArray data class Row(val price: Long, val area: Double) val rows = mutableListOf() arr.forEach { el -> val o = el.asJsonObject if (o.optString("dealType") != dealType) return@forEach if (marz != null && o.optString("provinceMarzCode") != marz) return@forEach if (segment != null && o.optString("segment") != segment) return@forEach val price = o.optLong("priceAmd") val area = if (o.has("areaSqm") && !o.get("areaSqm").isJsonNull) { runCatching { o.get("areaSqm").asDouble }.getOrDefault(0.0) } else { 0.0 } if (price > 0 && area > 0) rows.add(Row(price, area)) } val perSqm = rows.map { (it.price.toDouble() / it.area) }.sorted() val payload = JsonObject().apply { addProperty("dealType", dealType) addProperty("marz", marz.orEmpty()) addProperty("segment", segment.orEmpty()) addProperty("sampleSize", rows.size) if (perSqm.isNotEmpty()) { addProperty("priceMin", rows.minOf { it.price }) addProperty("priceMax", rows.maxOf { it.price }) addProperty("priceAvg", rows.map { it.price }.average().toLong()) addProperty("perSqmMin", perSqm.first().toLong()) addProperty("perSqmMax", perSqm.last().toLong()) addProperty("perSqmMedian", perSqm[perSqm.size / 2].toLong()) addProperty("perSqmAvg", perSqm.average().toLong()) } else { addProperty("priceMin", 0L) addProperty("priceMax", 0L) addProperty("priceAvg", 0L) addProperty("perSqmMin", 0L) addProperty("perSqmMax", 0L) addProperty("perSqmMedian", 0L) addProperty("perSqmAvg", 0L) } if (perSqm.isNotEmpty()) { val bucket = 50_000.0 val buckets = sortedMapOf() perSqm.forEach { v -> val key = (v / bucket).toLong() * bucket.toLong() buckets[key] = (buckets[key] ?: 0) + 1 } val series = JsonArray() buckets.forEach { (k, v) -> series.add( JsonObject().apply { addProperty("bucket", k) addProperty("count", v) }, ) } add("histogram", series) } else { add("histogram", JsonArray()) } } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/listings/export.csv") { val arr = JsonParser.parseString(catalogRawMerged()).asJsonArray val sb = StringBuilder() sb.append("id;title;dealType;segment;rooms;areaSqm;priceAmd;marz;city;addressLine;complexName;developer\n") arr.forEach { el -> val o = el.asJsonObject fun csv(s: String) = "\"" + s.replace("\"", "\"\"") + "\"" sb.append(csv(o.optString("id"))).append(';') sb.append(csv(o.optString("title"))).append(';') sb.append(csv(o.optString("dealType"))).append(';') sb.append(csv(o.optString("segment"))).append(';') sb.append( if (o.has("rooms") && !o.get("rooms").isJsonNull) o.get("rooms").asString else "", ).append(';') sb.append( if (o.has("areaSqm") && !o.get("areaSqm").isJsonNull) o.get("areaSqm").asString else "", ).append(';') sb.append(o.optLong("priceAmd").toString()).append(';') sb.append(csv(o.optString("provinceMarzCode"))).append(';') sb.append(csv(o.optString("city"))).append(';') sb.append(csv(o.optString("addressLine"))).append(';') sb.append(csv(o.optString("complexName"))).append(';') sb.append(csv(o.optString("developer"))).append('\n') } val bytes = ("\uFEFF" + sb.toString()).toByteArray(StandardCharsets.UTF_8) call.response.header( HttpHeaders.ContentDisposition, ContentDisposition.Attachment.withParameter( ContentDisposition.Parameters.FileName, "estateunified-listings.csv", ).toString(), ) call.respondBytes(bytes, ContentType("text", "csv")) } post("/listings") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val jo = parseBody(call.receiveText()) if (jo.optString("title").isBlank()) return@post call.badRequest("title_required") if (!jo.has("ownerUserId")) jo.addProperty("ownerUserId", ctx.user.id) maybeFillCoords(jo) val saved = userListings.add(jo) call.respondText(gsonApi.toJson(saved), ContentType.Application.Json) } get("/listings/{id}") { val id = call.parameters["id"] ?: return@get call.badRequest() val obj = catalogById()[id] ?: return@get call.notFound() maybeFillCoords(obj) call.respondText(gsonApi.toJson(enrichWithMedia(obj)), ContentType.Application.Json) } patch("/listings/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() if (!id.startsWith("owner-")) return@patch call.forbidden("read_only_listing") val existing = userListings.readAll().firstOrNull { it.optString("id") == id } ?: return@patch call.notFound() if (existing.optString("ownerUserId").isNotBlank() && existing.optString("ownerUserId") != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR") ) { return@patch call.forbidden("not_owner") } val patch = parseBody(call.receiveText()) patch.entrySet().forEach { (k, v) -> existing.add(k, v) } existing.addProperty("id", id) maybeFillCoords(existing) val saved = userListings.add(existing) call.respondText(gsonApi.toJson(enrichWithMedia(saved)), ContentType.Application.Json) } delete("/listings/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val ok = userListings.delete(id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/listings/{id}/chessboard") { val id = call.parameters["id"] ?: return@get call.badRequest() val raw = chess.rawByListingId(id) ?: return@get call.notFound() call.respondText(raw, ContentType.Application.Json) } get("/listings/{id}/photos") { val id = call.parameters["id"] ?: return@get call.badRequest() val list = photos.forListing(id).map { p -> JsonObject().apply { addProperty("id", p.id) addProperty("url", "/api/listings/$id/photos/${p.storedName}") addProperty("isCover", p.isCover) addProperty("captionRu", p.captionRu) addProperty("sortOrder", p.sortOrder) addProperty("originalName", p.originalName) addProperty("uploadedAtIso", p.uploadedAtIso) } } val arr = JsonArray().also { a -> list.forEach { a.add(it) } } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } get("/listings/{id}/photos/{storedName}") { val id = call.parameters["id"] ?: return@get call.badRequest() val name = call.parameters["storedName"] ?: return@get call.badRequest() val bytes = photos.bytesOf(id, name) ?: return@get call.notFound() val mime = photos.mimeFor(name) call.response.header("Cache-Control", "public, max-age=86400") val parts = mime.split('/', limit = 2) val ct = if (parts.size == 2) ContentType(parts[0], parts[1]) else ContentType.Application.OctetStream call.respondBytes(bytes, ct) } post("/listings/{id}/photos") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() if (catalogById()[id] == null) return@post call.notFound() val multipart = call.receiveMultipart() val saved = mutableListOf() val maxSize = 8L * 1024L * 1024L val allowed = setOf("image/jpeg", "image/png", "image/webp", "image/gif", "image/heic") multipart.forEachPart { part -> if (part is PartData.FileItem) { val mime = part.contentType?.toString() ?: "image/jpeg" val nameOrig = part.originalFileName ?: "photo.jpg" if (mime.lowercase() !in allowed && !mime.startsWith("image/")) { part.dispose() return@forEachPart } val bytes = part.streamProvider().use { it.readBytes() } if (bytes.isNotEmpty() && bytes.size <= maxSize) { saved.add( photos.saveBytes( listingId = id, ownerUserId = ctx.user.id, originalName = nameOrig, mime = mime, bytes = bytes, ), ) } } part.dispose() } val arr = JsonArray() saved.forEach { p -> arr.add( JsonObject().apply { addProperty("id", p.id) addProperty("url", "/api/listings/$id/photos/${p.storedName}") addProperty("isCover", p.isCover) }, ) } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } delete("/listings/{id}/photos/{photoId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val photoId = call.parameters["photoId"] ?: return@delete call.badRequest() val ok = photos.delete(photoId) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } post("/listings/{id}/photos/{photoId}/cover") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val photoId = call.parameters["photoId"] ?: return@post call.badRequest() val p = photos.setCover(photoId) ?: return@post call.notFound() call.respondText(gsonApi.toJson(p), ContentType.Application.Json) } put("/listings/{id}/photos/order") { val ctx = call.authContext(users) if (ctx.user == null) return@put call.unauthorized() val id = call.parameters["id"] ?: return@put call.badRequest() val body = parseBody(call.receiveText()) val ids = body.getAsJsonArray("ids")?.mapNotNull { it.asString } ?: return@put call.badRequest("ids_required") val list = photos.reorder(id, ids) call.respondText(gsonApi.toJson(list), ContentType.Application.Json) } get("/listings/{id}/pdf") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val id = call.parameters["id"] ?: return@get call.badRequest() val obj = catalogById()[id] ?: return@get call.notFound() val bytes = PdfService.generateListingPdf(obj, ctx.user) call.response.header( HttpHeaders.ContentDisposition, "attachment; filename=\"listing-$id.pdf\"", ) call.respondBytes(bytes, ContentType.Application.Pdf) } get("/fixations") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val list = fixations.listForUser(ctx.user.id) call.respondText(gsonApi.toJson(list), ContentType.Application.Json) } post("/fixations") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val listingId = body.optString("listingId") val leadPhone = body.optString("leadPhone") val leadName = body.optString("leadName") if (listingId.isBlank() || leadPhone.isBlank() || leadName.isBlank()) { return@post call.badRequest("listingId_leadName_leadPhone_required") } fixations.findDuplicate(listingId, leadPhone)?.let { dup -> return@post call.respondText( gsonApi.toJson( JsonObject().apply { addProperty("error", "duplicate") add("existing", gsonApi.toJsonTree(dup)) }, ), ContentType.Application.Json, HttpStatusCode.Conflict, ) } val listing = catalogById()[listingId] val dto = FixationDto( ownerUserId = ctx.user.id, listingId = listingId, listingTitle = listing?.optString("title").orEmpty(), leadName = leadName, leadPhone = leadPhone, leadEmail = body.optString("leadEmail"), note = body.optString("note"), ) val saved = fixations.add(dto) call.respondText(gsonApi.toJson(saved), ContentType.Application.Json) } patch("/fixations/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val body = parseBody(call.receiveText()) val status = body.optString("status").ifBlank { return@patch call.badRequest("status_required") } val reason = body.optString("statusReason") val existing = fixations.get(id) ?: return@patch call.notFound() if (existing.ownerUserId != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@patch call.forbidden() } val updated = fixations.updateStatus(id, status, reason) ?: return@patch call.notFound() if (status == "CONFIRMED") { val hasDeal = deals.listForUser(existing.ownerUserId).any { it.fixationId == existing.id } if (!hasDeal) { val dealDto = DealDto( ownerUserId = existing.ownerUserId, fixationId = existing.id, listingId = existing.listingId, listingTitle = existing.listingTitle, leadName = existing.leadName, leadPhone = existing.leadPhone, status = "FIXED", timeline = mutableListOf( DealTimelineEntry( status = "INTEREST", occurredAtIso = existing.createdAtIso, note = "Создано из фиксации", ), DealTimelineEntry( status = "FIXED", occurredAtIso = updated.updatedAtIso, note = "Фиксация подтверждена", ), ), ) deals.add(dealDto) } } call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } get("/deals") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(deals.listForUser(ctx.user.id)), ContentType.Application.Json) } post("/deals") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val listingId = body.optString("listingId").ifBlank { return@post call.badRequest("listingId_required") } val listing = catalogById()[listingId] val now = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( java.time.LocalDateTime.now(), ) val dto = DealDto( ownerUserId = ctx.user.id, listingId = listingId, listingTitle = listing?.optString("title").orEmpty(), leadName = body.optString("leadName"), leadPhone = body.optString("leadPhone"), status = "INTEREST", fixationId = body.optString("fixationId").ifBlank { null }, timeline = mutableListOf( DealTimelineEntry( status = "INTEREST", occurredAtIso = now, note = body.optString("note"), ), ), ) call.respondText(gsonApi.toJson(deals.add(dto)), ContentType.Application.Json) } patch("/deals/{id}/status") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val body = parseBody(call.receiveText()) val status = body.optString("status").ifBlank { return@patch call.badRequest("status_required") } val cur = deals.get(id) ?: return@patch call.notFound() if (cur.ownerUserId != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@patch call.forbidden() } val updated = deals.setStatus(id, status, body.optString("note")) ?: return@patch call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } post("/payments/mock") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val dto = demoPaymentFromJson(body, ownerUserId = ctx.user.id) ?: return@post call.badRequest("invalid_body") val result = if (payMode == "random") { if (Math.random() > 0.25) "SUCCESS" else "DECLINE" } else { "SUCCESS" } val reason = if (result == "DECLINE") "Случайный отказ (демо-режим)" else "" val withResult = dto.copy(result = result, resultReason = reason) payments.add(withResult) call.respondText(gsonApi.toJson(withResult), ContentType.Application.Json) } get("/payments/history") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(payments.listForUser(ctx.user.id)), ContentType.Application.Json) } get("/demo-payments") { call.respondText(payments.readAllRaw(), ContentType.Application.Json) } post("/demo-payments") { val body = parseBody(call.receiveText()) val ctx = call.authContext(users) val dto = demoPaymentFromJson(body, ownerUserId = ctx.user?.id.orEmpty()) ?: return@post call.badRequest("invalid_body") payments.add(dto) call.respondText(gsonApi.toJson(dto), ContentType.Application.Json) } get("/collections") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(collections.list()), ContentType.Application.Json) } post("/collections") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val jo = parseBody(call.receiveText()) val created = collections.create(jo.optString("name")) ?: return@post call.badRequest("empty_name") call.respondText(gsonApi.toJson(created), ContentType.Application.Json) } delete("/collections/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val ok = collections.delete(id) analytics.revoke(id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } post("/collections/{id}/listings") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val cid = call.parameters["id"] ?: return@post call.badRequest() val body = parseBody(call.receiveText()) val lid = body.optString("listingId").ifBlank { return@post call.badRequest("listingId_required") } val updated = collections.addListing(cid, lid) ?: return@post call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } delete("/collections/{cid}/listings/{lid}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val cid = call.parameters["cid"] ?: return@delete call.badRequest() val lid = call.parameters["lid"] ?: return@delete call.badRequest() val updated = collections.removeListing(cid, lid) ?: return@delete call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } post("/collections/{id}/share-text") { val cid = call.parameters["id"] ?: return@post call.badRequest() val coll = collections.get(cid) ?: return@post call.notFound() val byId = catalogById() val text = buildString { appendLine("Подборка: ${coll.name}") appendLine() coll.listingIds.forEachIndexed { index, slug -> val o = byId[slug] if (o == null) { appendLine("${index + 1}. [нет в каталоге] $slug") } else { appendLine("${index + 1}. ${o.optString("title")}") val city = o.optString("city") val priceAmd = o.optLong("priceAmd") val dt = o.optString("dealType") val suffix = if (dt == "RENT") "/мес" else "" appendLine(" $city · $priceAmd AMD$suffix · $slug") appendLine() } } } call.respondText( gsonApi.toJson(JsonObject().apply { addProperty("text", text) }), ContentType.Application.Json, ) } post("/collections/{id}/share-link") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val cid = call.parameters["id"] ?: return@post call.badRequest() val coll = collections.get(cid) ?: return@post call.notFound() val share = analytics.issueOrReuse(cid) val host = call.request.headers[HttpHeaders.Host] ?: "127.0.0.1:8090" val url = "http://$host/share?token=${share.token}" val payload = JsonObject().apply { addProperty("token", share.token) addProperty("url", url) addProperty("collectionId", coll.id) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } post("/collections/{id}/revoke-share") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val cid = call.parameters["id"] ?: return@post call.badRequest() analytics.revoke(cid) call.respondText("{}", ContentType.Application.Json) } get("/collections/{id}/analytics") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val cid = call.parameters["id"] ?: return@get call.badRequest() val share = analytics.shareFor(cid) val payload = JsonObject().apply { if (share != null) { addProperty("token", share.token) addProperty("views", share.views) addProperty("uniqueViews", share.uniqueVisitorHashes.size) val per = JsonArray() share.perListing.forEach { (lid, v) -> per.add( JsonObject().apply { addProperty("listingId", lid) addProperty("views", v) }, ) } add("perListing", per) } else { addProperty("token", "") addProperty("views", 0L) addProperty("uniqueViews", 0) add("perListing", JsonArray()) } } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/public/collections/{token}") { val token = call.parameters["token"] ?: return@get call.badRequest() val share = analytics.byToken(token) ?: return@get call.notFound() val coll = collections.get(share.collectionId) ?: return@get call.notFound() val byId = catalogById() val resolved = JsonArray() coll.listingIds.forEach { id -> byId[id]?.let { resolved.add(enrichWithMedia(it)) } } val visitor = call.request.headers["User-Agent"].orEmpty() + "|" + (call.request.headers["X-Forwarded-For"] ?: call.request.local.remoteHost) val hash = sha1(visitor) analytics.recordView(token, hash, coll.listingIds) val payload = JsonObject().apply { addProperty("collectionId", coll.id) addProperty("name", coll.name) add("listings", resolved) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/favorites") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val list = favorites.listForUser(ctx.user.id) val byId = catalogById() val arr = JsonArray() list.forEach { fav -> val o = byId[fav.listingId] if (o != null) { arr.add(enrichWithMedia(o)) } } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } post("/favorites/toggle") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val id = body.optString("listingId").ifBlank { return@post call.badRequest("listingId_required") } val added = favorites.toggle(ctx.user.id, id) call.respondText( gsonApi.toJson( JsonObject().apply { addProperty("listingId", id) addProperty("added", added) }, ), ContentType.Application.Json, ) } get("/saved-searches") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val list = savedSearches.listForUser(ctx.user.id) val all = JsonParser.parseString(catalogRawMerged()).asJsonArray val payload = JsonArray() list.forEach { ss -> val matchingIds = mutableListOf() all.forEach { el -> val o = el.asJsonObject if (ss.dealType.isNotBlank() && o.optString("dealType") != ss.dealType) return@forEach if (ss.segment.isNotBlank() && o.optString("segment") != ss.segment) return@forEach if (ss.marz.isNotBlank() && o.optString("provinceMarzCode") != ss.marz) return@forEach val price = o.optLong("priceAmd") if (ss.priceMin != null && price < ss.priceMin) return@forEach if (ss.priceMax != null && price > ss.priceMax) return@forEach if (ss.rooms != null) { val r = if (o.has("rooms") && !o.get("rooms").isJsonNull) o.get("rooms").asInt else -1 if (r != ss.rooms) return@forEach } if (ss.query.isNotBlank()) { val blob = listOf( o.optString("title"), o.optString("city"), o.optString("addressLine"), o.optString("regionHint"), o.optString("complexName"), ).joinToString(" ").lowercase() if (!blob.contains(ss.query.lowercase())) return@forEach } matchingIds.add(o.optString("id")) } val newOnes = matchingIds.filter { it !in ss.seenListingIds } payload.add( JsonObject().apply { add("search", gsonApi.toJsonTree(ss)) addProperty("matchesNow", matchingIds.size) addProperty("newCount", newOnes.size) val arr = JsonArray() newOnes.forEach { arr.add(it) } add("newListingIds", arr) }, ) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } post("/saved-searches") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val name = body.optString("name").ifBlank { return@post call.badRequest("name_required") } val all = JsonParser.parseString(catalogRawMerged()).asJsonArray val dt = body.optString("dealType") val sg = body.optString("segment") val mz = body.optString("marz") val pMin = if (body.has("priceMin") && !body.get("priceMin").isJsonNull) body.get("priceMin").asLong else null val pMax = if (body.has("priceMax") && !body.get("priceMax").isJsonNull) body.get("priceMax").asLong else null val rms = if (body.has("rooms") && !body.get("rooms").isJsonNull) body.get("rooms").asInt else null val qs = body.optString("query") val matchingIds = mutableListOf() all.forEach { el -> val o = el.asJsonObject if (dt.isNotBlank() && o.optString("dealType") != dt) return@forEach if (sg.isNotBlank() && o.optString("segment") != sg) return@forEach if (mz.isNotBlank() && o.optString("provinceMarzCode") != mz) return@forEach val price = o.optLong("priceAmd") if (pMin != null && price < pMin) return@forEach if (pMax != null && price > pMax) return@forEach if (rms != null) { val r = if (o.has("rooms") && !o.get("rooms").isJsonNull) o.get("rooms").asInt else -1 if (r != rms) return@forEach } matchingIds.add(o.optString("id")) } val dto = SavedSearchDto( ownerUserId = ctx.user.id, name = name, dealType = dt, segment = sg, marz = mz, priceMin = pMin, priceMax = pMax, rooms = rms, query = qs, seenSnapshot = matchingIds.size, seenListingIds = matchingIds, ) call.respondText(gsonApi.toJson(savedSearches.add(dto)), ContentType.Application.Json) } post("/saved-searches/{id}/mark-seen") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val body = parseBody(call.receiveText()) val ids = body.getAsJsonArray("ids")?.mapNotNull { it.asString } ?: emptyList() val updated = savedSearches.markSeen(id, ctx.user.id, ids) ?: return@post call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } delete("/saved-searches/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val ok = savedSearches.delete(id, ctx.user.id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/leads") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(leads.listForUser(ctx.user.id)), ContentType.Application.Json) } post("/leads") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val name = body.optString("name").ifBlank { return@post call.badRequest("name_required") } val phone = body.optString("phone").ifBlank { return@post call.badRequest("phone_required") } val listingId = body.optString("listingId") val listingTitle = if (listingId.isNotBlank()) catalogById()[listingId]?.optString("title").orEmpty() else "" val dto = LeadDto( ownerUserId = ctx.user.id, listingId = listingId, listingTitle = listingTitle, name = name, phone = phone, email = body.optString("email"), source = body.optString("source").ifBlank { "MANUAL" }, channel = body.optString("channel").ifBlank { "CALL" }, message = body.optString("message"), ) call.respondText(gsonApi.toJson(leads.add(dto)), ContentType.Application.Json) } patch("/leads/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val body = parseBody(call.receiveText()) val status = body.optString("status").ifBlank { return@patch call.badRequest("status_required") } val updated = leads.setStatus(id, status) ?: return@patch call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } post("/leads/{id}/convert") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val lead = leads.get(id) ?: return@post call.notFound() if (lead.ownerUserId != ctx.user.id) return@post call.forbidden() if (lead.listingId.isBlank()) return@post call.badRequest("lead_without_listing") val existing = fixations.findDuplicate(lead.listingId, lead.phone) val fx = existing ?: fixations.add( FixationDto( ownerUserId = ctx.user.id, listingId = lead.listingId, listingTitle = lead.listingTitle, leadName = lead.name, leadPhone = lead.phone, leadEmail = lead.email, note = lead.message, ), ) leads.setStatus(id, "CONVERTED", fixationId = fx.id) call.respondText(gsonApi.toJson(fx), ContentType.Application.Json) } delete("/leads/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val ok = leads.delete(id, ctx.user.id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/tasks") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(tasks.listForUser(ctx.user.id)), ContentType.Application.Json) } post("/tasks") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val title = body.optString("title").ifBlank { return@post call.badRequest("title_required") } val dueAt = body.optString("dueAtIso").ifBlank { return@post call.badRequest("dueAtIso_required") } val dto = TaskDto( ownerUserId = ctx.user.id, title = title, dueAtIso = dueAt, kind = body.optString("kind").ifBlank { "GENERIC" }, dealId = body.optString("dealId").ifBlank { null }, listingId = body.optString("listingId").ifBlank { null }, leadId = body.optString("leadId").ifBlank { null }, note = body.optString("note"), ) call.respondText(gsonApi.toJson(tasks.add(dto)), ContentType.Application.Json) } patch("/tasks/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val body = parseBody(call.receiveText()) val status = body.optString("status").ifBlank { return@patch call.badRequest("status_required") } val updated = tasks.setStatus(id, ctx.user.id, status) ?: return@patch call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } delete("/tasks/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val ok = tasks.delete(id, ctx.user.id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/tickets") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() call.respondText(gsonApi.toJson(tickets.listForUser(ctx.user.id)), ContentType.Application.Json) } post("/tickets") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val subject = body.optString("subject").ifBlank { return@post call.badRequest("subject_required") } val bodyText = body.optString("body") val dto = TicketDto( ownerUserId = ctx.user.id, subject = subject, body = bodyText, category = body.optString("category").ifBlank { "GENERAL" }, ) call.respondText(gsonApi.toJson(tickets.add(dto)), ContentType.Application.Json) } post("/tickets/{id}/reply") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val body = parseBody(call.receiveText()) val msg = body.optString("body").ifBlank { return@post call.badRequest("body_required") } val updated = tickets.reply(id, TicketReply(author = ctx.user.name, body = msg)) ?: return@post call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } patch("/tickets/{id}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val body = parseBody(call.receiveText()) val st = body.optString("status").ifBlank { return@patch call.badRequest("status_required") } val updated = tickets.setStatus(id, ctx.user.id, st) ?: return@patch call.notFound() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } get("/invites") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() if (ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@get call.forbidden("only_lead_or_moderator") } call.respondText(gsonApi.toJson(invites.listForAgency(ctx.user.agency)), ContentType.Application.Json) } post("/invites") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() if (ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@post call.forbidden("only_lead_or_moderator") } val body = parseBody(call.receiveText()) val role = body.optString("role").ifBlank { "AGENT" } val dto = invites.create(agency = ctx.user.agency, role = role, createdByUserId = ctx.user.id) val host = call.request.headers[HttpHeaders.Host] ?: "127.0.0.1:8090" val payload = JsonObject().apply { addProperty("token", dto.token) addProperty("agency", dto.agency) addProperty("role", dto.role) addProperty("inviteUrl", "http://$host/#/login?invite=${dto.token}") } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/notifications") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val notifs = JsonArray() fixations.listForUser(ctx.user.id).filter { it.status == "PENDING" }.forEach { fx -> notifs.add( JsonObject().apply { addProperty("kind", "FIXATION_PENDING") addProperty("createdAtIso", fx.updatedAtIso) addProperty("title", "Фиксация ожидает подтверждения") addProperty("body", fx.leadName + " · " + fx.listingTitle) addProperty("link", "#/fixations") }, ) } deals.listForUser(ctx.user.id).filter { it.status == "DOCS" }.forEach { d -> notifs.add( JsonObject().apply { addProperty("kind", "DEAL_DOCS") addProperty("createdAtIso", d.updatedAtIso) addProperty("title", "Сделка на этапе документов") addProperty("body", d.leadName + " · " + d.listingTitle) addProperty("link", "#/deals") }, ) } leads.listForUser(ctx.user.id).filter { it.status == "NEW" }.forEach { l -> notifs.add( JsonObject().apply { addProperty("kind", "LEAD_NEW") addProperty("createdAtIso", l.createdAtIso) addProperty("title", "Новый лид") addProperty("body", l.name + " · " + l.phone) addProperty("link", "#/leads") }, ) } val now = java.time.LocalDateTime.now() tasks.listForUser(ctx.user.id).filter { it.status == "OPEN" }.forEach { t -> val due = runCatching { java.time.LocalDateTime.parse(t.dueAtIso) }.getOrNull() ?: return@forEach if (due.isBefore(now.plusDays(1))) { notifs.add( JsonObject().apply { addProperty("kind", if (due.isBefore(now)) "TASK_OVERDUE" else "TASK_DUE_SOON") addProperty("createdAtIso", t.dueAtIso) addProperty("title", if (due.isBefore(now)) "Задача просрочена" else "Задача скоро") addProperty("body", t.title) addProperty("link", "#/tasks") }, ) } } call.respondText(gsonApi.toJson(notifs), ContentType.Application.Json) } get("/agents/{id}/listings") { val id = call.parameters["id"] ?: return@get call.badRequest() val arr = JsonArray() userListings.readAll().forEach { o -> if (o.optString("ownerUserId") == id) arr.add(enrichWithMedia(o)) } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } get("/plans") { val arr = JsonArray() listOf(PlanCatalog.BASIC, PlanCatalog.PRO, PlanCatalog.PRO_PLUS).forEach { code -> val tier = PlanCatalog.tiers[code]!! arr.add( JsonObject().apply { addProperty("code", tier.code) addProperty("title", tier.titleRu) addProperty("priceAmd", tier.priceAmd) addProperty("topHours", tier.topHours) addProperty("cooldownHours", tier.cooldownHours) addProperty("canBoost", PlanCatalog.canBoost(tier.code)) }, ) } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } post("/subscriptions/activate") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val body = parseBody(call.receiveText()) val plan = body.optString("plan").uppercase().ifBlank { return@post call.badRequest("plan_required") } val tier = PlanCatalog.tiers[plan] ?: return@post call.badRequest("unknown_plan") val durationDays = if (body.has("durationDays") && !body.get("durationDays").isJsonNull) body.get("durationDays").asInt else 30 val now = java.time.LocalDateTime.now() val until = now.plusDays(durationDays.toLong()) val untilIso = java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(until) val updated = users.setPlan(ctx.user.id, plan, if (plan == PlanCatalog.BASIC) "" else untilIso) val record = subscriptions.activate( userId = ctx.user.id, plan = plan, durationDays = durationDays, priceAmd = tier.priceAmd, source = "MOCK", note = body.optString("note"), ) if (plan != PlanCatalog.BASIC && tier.priceAmd > 0) { payments.add( DemoPaymentDto( ownerUserId = ctx.user.id, listingId = "", listingTitle = "", purpose = "SUBSCRIPTION_MOCK", amountAmd = tier.priceAmd, note = "Активация тарифа ${tier.titleRu}", result = "SUCCESS", ), ) } val payload = JsonObject().apply { add("subscription", gsonApi.toJsonTree(record)) add("user", gsonApi.toJsonTree(updated ?: ctx.user)) addProperty("effectivePlan", users.effectivePlan(updated ?: ctx.user)) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/subscriptions/me") { val ctx = call.authContext(users) if (ctx.user == null) return@get call.unauthorized() val list = subscriptions.listForUser(ctx.user.id) val payload = JsonObject().apply { addProperty("plan", ctx.user.plan) addProperty("planUntilIso", ctx.user.planUntilIso) addProperty("effectivePlan", users.effectivePlan(ctx.user)) add("history", gsonApi.toJsonTree(list)) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } post("/listings/{id}/top/boost") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@post call.forbidden("not_owner") } val plan = users.effectivePlan(ctx.user) if (!PlanCatalog.canBoost(plan)) { return@post call.respondText( "{\"error\":\"plan_does_not_allow_boost\",\"plan\":\"" + plan + "\"}", ContentType.Application.Json, HttpStatusCode.Forbidden, ) } val res = topPlacements.boost(id, ctx.user.id, plan) if (!res.ok) { val payload = JsonObject().apply { addProperty("error", res.reason) if (res.record != null) add("placement", gsonApi.toJsonTree(res.record)) } return@post call.respondText( gsonApi.toJson(payload), ContentType.Application.Json, HttpStatusCode.Conflict, ) } val payload = JsonObject().apply { addProperty("ok", true) add("placement", gsonApi.toJsonTree(res.record)) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/listings/{id}/top") { val id = call.parameters["id"] ?: return@get call.badRequest() val placement = topPlacements.get(id) if (placement == null) { return@get call.respondText("{\"isInTop\":false}", ContentType.Application.Json) } val now = java.time.LocalDateTime.now() val inTopUntil = runCatching { java.time.LocalDateTime.parse(placement.inTopUntilIso) }.getOrNull() val cooldownUntil = runCatching { java.time.LocalDateTime.parse(placement.cooldownUntilIso) }.getOrNull() val payload = JsonObject().apply { addProperty("isInTop", inTopUntil != null && inTopUntil.isAfter(now)) addProperty("cooldownActive", cooldownUntil != null && cooldownUntil.isAfter(now)) add("placement", gsonApi.toJsonTree(placement)) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } get("/listings/{id}/documents") { val id = call.parameters["id"] ?: return@get call.badRequest() val list = documents.forListing(id).map { d -> JsonObject().apply { addProperty("id", d.id) addProperty("url", "/api/listings/$id/documents/${d.storedName}") addProperty("mime", d.mime) addProperty("titleRu", d.titleRu) addProperty("kind", d.kind) addProperty("originalName", d.originalName) addProperty("sortOrder", d.sortOrder) addProperty("sizeBytes", d.sizeBytes) addProperty("uploadedAtIso", d.uploadedAtIso) } } val arr = JsonArray().also { a -> list.forEach { a.add(it) } } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } get("/listings/{id}/documents/{storedName}") { val id = call.parameters["id"] ?: return@get call.badRequest() val name = call.parameters["storedName"] ?: return@get call.badRequest() val bytes = documents.bytesOf(id, name) ?: return@get call.notFound() val mime = documents.mimeFor(name) call.response.header("Cache-Control", "public, max-age=86400") val parts = mime.split('/', limit = 2) val ct = if (parts.size == 2) ContentType(parts[0], parts[1]) else ContentType.Application.OctetStream call.respondBytes(bytes, ct) } post("/listings/{id}/documents") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@post call.forbidden("not_owner") } val multipart = call.receiveMultipart() val saved = mutableListOf() var titleRu = "" var kind = "GENERAL" multipart.forEachPart { part -> when (part) { is PartData.FormItem -> { when (part.name) { "titleRu" -> titleRu = part.value "kind" -> kind = part.value } } is PartData.FileItem -> { val mime = part.contentType?.toString() ?: "application/octet-stream" val nameOrig = part.originalFileName ?: "document" val bytes = part.streamProvider().use { it.readBytes() } val mimeNorm = mime.lowercase() val ok = bytes.isNotEmpty() && bytes.size <= ListingDocumentsStore.MAX_BYTES && (mimeNorm in ListingDocumentsStore.allowedMimes || mimeNorm.startsWith("application/pdf") || mimeNorm.startsWith("image/")) if (ok) { saved.add( documents.saveBytes( listingId = id, ownerUserId = ctx.user.id, originalName = nameOrig, mime = mime, bytes = bytes, titleRu = titleRu, kind = kind, ), ) } } else -> {} } part.dispose() } val arr = JsonArray() saved.forEach { d -> arr.add( JsonObject().apply { addProperty("id", d.id) addProperty("url", "/api/listings/$id/documents/${d.storedName}") addProperty("titleRu", d.titleRu) addProperty("mime", d.mime) }, ) } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } delete("/listings/{id}/documents/{docId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val docId = call.parameters["docId"] ?: return@delete call.badRequest() val cur = documents.get(docId) ?: return@delete call.notFound() if (cur.ownerUserId != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@delete call.forbidden("not_owner") } val ok = documents.delete(docId) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/listings/{id}/videos") { val id = call.parameters["id"] ?: return@get call.badRequest() val list = videos.forListing(id).map { v -> JsonObject().apply { addProperty("id", v.id) addProperty("url", "/api/listings/$id/videos/${v.storedName}") addProperty("mime", v.mime) addProperty("originalName", v.originalName) addProperty("captionRu", v.captionRu) addProperty("sortOrder", v.sortOrder) addProperty("sizeBytes", v.sizeBytes) addProperty("uploadedAtIso", v.uploadedAtIso) } } val arr = JsonArray().also { a -> list.forEach { a.add(it) } } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } get("/listings/{id}/videos/{storedName}") { val id = call.parameters["id"] ?: return@get call.badRequest() val name = call.parameters["storedName"] ?: return@get call.badRequest() val bytes = videos.bytesOf(id, name) ?: return@get call.notFound() val mime = videos.mimeFor(name) call.response.header("Cache-Control", "public, max-age=86400") val parts = mime.split('/', limit = 2) val ct = if (parts.size == 2) ContentType(parts[0], parts[1]) else ContentType.Application.OctetStream call.respondBytes(bytes, ct) } post("/listings/{id}/videos") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@post call.forbidden("not_owner") } val multipart = call.receiveMultipart() val saved = mutableListOf() var captionRu = "" multipart.forEachPart { part -> when (part) { is PartData.FormItem -> { if (part.name == "captionRu") captionRu = part.value } is PartData.FileItem -> { val mime = part.contentType?.toString() ?: "video/mp4" val nameOrig = part.originalFileName ?: "video.mp4" val bytes = part.streamProvider().use { it.readBytes() } val mimeNorm = mime.lowercase() val ok = bytes.isNotEmpty() && bytes.size <= ListingVideosStore.MAX_BYTES && (mimeNorm in ListingVideosStore.allowedMimes || mimeNorm.startsWith("video/")) if (ok) { saved.add( videos.saveBytes( listingId = id, ownerUserId = ctx.user.id, originalName = nameOrig, mime = mime, bytes = bytes, captionRu = captionRu, ), ) } } else -> {} } part.dispose() } val arr = JsonArray() saved.forEach { v -> arr.add( JsonObject().apply { addProperty("id", v.id) addProperty("url", "/api/listings/$id/videos/${v.storedName}") addProperty("mime", v.mime) }, ) } call.respondText(gsonApi.toJson(arr), ContentType.Application.Json) } delete("/listings/{id}/videos/{videoId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val videoId = call.parameters["videoId"] ?: return@delete call.badRequest() val cur = videos.get(videoId) ?: return@delete call.notFound() if (cur.ownerUserId != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@delete call.forbidden("not_owner") } val ok = videos.delete(videoId) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } put("/listings/{id}/chessboard") { val ctx = call.authContext(users) if (ctx.user == null) return@put call.unauthorized() val id = call.parameters["id"] ?: return@put call.badRequest() val listing = catalogById()[id] ?: return@put call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@put call.forbidden("not_owner") } val body = parseBody(call.receiveText()) val saved = chess.save(id, body) call.respondText(gsonApi.toJson(saved), ContentType.Application.Json) } patch("/listings/{id}/chessboard/lots/{lotId}") { val ctx = call.authContext(users) if (ctx.user == null) return@patch call.unauthorized() val id = call.parameters["id"] ?: return@patch call.badRequest() val lotId = call.parameters["lotId"] ?: return@patch call.badRequest() val listing = catalogById()[id] ?: return@patch call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@patch call.forbidden("not_owner") } val patch = parseBody(call.receiveText()) chess.updateLot(id, lotId, patch) val updated = chess.objectByListingId(id) ?: JsonObject() call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } post("/listings/{id}/chessboard/plan/common") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@post call.forbidden("not_owner") } val multipart = call.receiveMultipart() var savedDoc: ListingDocumentDto? = null multipart.forEachPart { part -> if (part is PartData.FileItem) { val mime = part.contentType?.toString() ?: "image/jpeg" val nameOrig = part.originalFileName ?: "plan" val bytes = part.streamProvider().use { it.readBytes() } if (bytes.isNotEmpty() && bytes.size <= ListingDocumentsStore.MAX_BYTES) { savedDoc = documents.saveBytes( listingId = id, ownerUserId = ctx.user.id, originalName = nameOrig, mime = mime, bytes = bytes, titleRu = "Общий план", kind = "PLAN_COMMON", ) } } part.dispose() } val d = savedDoc ?: return@post call.badRequest("no_file") val url = "/api/listings/$id/documents/${d.storedName}" chess.setCommonPlan(id, url, d.titleRu) val payload = JsonObject().apply { addProperty("commonPlanUrl", url) addProperty("commonPlanName", d.titleRu) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } delete("/listings/{id}/chessboard/plan/common") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val listing = catalogById()[id] ?: return@delete call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@delete call.forbidden("not_owner") } chess.clearCommonPlan(id) call.respondText("{}", ContentType.Application.Json) } post("/listings/{id}/chessboard/plan/lot/{lotId}") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val lotId = call.parameters["lotId"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@post call.forbidden("not_owner") } val multipart = call.receiveMultipart() var savedDoc: ListingDocumentDto? = null multipart.forEachPart { part -> if (part is PartData.FileItem) { val mime = part.contentType?.toString() ?: "image/jpeg" val nameOrig = part.originalFileName ?: "plan-$lotId" val bytes = part.streamProvider().use { it.readBytes() } if (bytes.isNotEmpty() && bytes.size <= ListingDocumentsStore.MAX_BYTES) { savedDoc = documents.saveBytes( listingId = id, ownerUserId = ctx.user.id, originalName = nameOrig, mime = mime, bytes = bytes, titleRu = "План квартиры $lotId", kind = "PLAN_LOT", ) } } part.dispose() } val d = savedDoc ?: return@post call.badRequest("no_file") val url = "/api/listings/$id/documents/${d.storedName}" chess.setLotPlan(id, lotId, url) val payload = JsonObject().apply { addProperty("lotId", lotId) addProperty("planUrl", url) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } delete("/listings/{id}/chessboard/plan/lot/{lotId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val lotId = call.parameters["lotId"] ?: return@delete call.badRequest() val listing = catalogById()[id] ?: return@delete call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR", "DEVELOPER")) { return@delete call.forbidden("not_owner") } chess.clearLotPlan(id, lotId) call.respondText("{}", ContentType.Application.Json) } get("/listings/{id}/calendar") { val id = call.parameters["id"] ?: return@get call.badRequest() val cal = rentalCalendar.get(id) val stats = rentalCalendar.statsForListing(id) val payload = JsonObject().apply { addProperty("listingId", id) add("ranges", gsonApi.toJsonTree(cal.ranges)) add( "stats", JsonObject().apply { addProperty("rentCount", stats.first) addProperty("totalDays", stats.second) addProperty("lastRentedIso", stats.third) }, ) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } put("/listings/{id}/calendar") { val ctx = call.authContext(users) if (ctx.user == null) return@put call.unauthorized() val id = call.parameters["id"] ?: return@put call.badRequest() val listing = catalogById()[id] ?: return@put call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@put call.forbidden("not_owner") } val body = parseBody(call.receiveText()) val arr = if (body.has("ranges") && body.get("ranges").isJsonArray) body.getAsJsonArray("ranges") else JsonArray() val ranges = arr.mapNotNull { el -> val o = runCatching { el.asJsonObject }.getOrNull() ?: return@mapNotNull null val from = o.optString("fromIso").ifBlank { return@mapNotNull null } val to = o.optString("toIso").ifBlank { return@mapNotNull null } RentalRangeDto( id = o.optString("id").ifBlank { java.util.UUID.randomUUID().toString().take(10) }, fromIso = from, toIso = to, status = o.optString("status").ifBlank { "BOOKED" }, note = o.optString("note"), ) } val updated = rentalCalendar.replace(id, ctx.user.id, ranges) call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } post("/listings/{id}/calendar/range") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@post call.forbidden("not_owner") } val body = parseBody(call.receiveText()) val from = body.optString("fromIso").ifBlank { return@post call.badRequest("fromIso_required") } val to = body.optString("toIso").ifBlank { return@post call.badRequest("toIso_required") } val updated = rentalCalendar.addRange( id, ctx.user.id, RentalRangeDto( fromIso = from, toIso = to, status = body.optString("status").ifBlank { "BOOKED" }, note = body.optString("note"), ), ) call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } delete("/listings/{id}/calendar/range/{rangeId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val id = call.parameters["id"] ?: return@delete call.badRequest() val rangeId = call.parameters["rangeId"] ?: return@delete call.badRequest() val listing = catalogById()[id] ?: return@delete call.notFound() val owner = listing.optString("ownerUserId") if (owner.isNotBlank() && owner != ctx.user.id && ctx.user.role !in setOf("AGENCY_LEAD", "MODERATOR")) { return@delete call.forbidden("not_owner") } val updated = rentalCalendar.removeRange(id, rangeId) call.respondText(gsonApi.toJson(updated), ContentType.Application.Json) } get("/listings/{id}/reviews") { val id = call.parameters["id"] ?: return@get call.badRequest() val list = rentalReviews.forListing(id) val stats = rentalCalendar.statsForListing(id) val payload = JsonObject().apply { add("reviews", gsonApi.toJsonTree(list)) addProperty("count", list.size) addProperty("avgRating", rentalReviews.avgRating(id)) addProperty("rentCount", stats.first) addProperty("totalDays", stats.second) addProperty("lastRentedIso", stats.third) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } post("/listings/{id}/reviews") { val ctx = call.authContext(users) if (ctx.user == null) return@post call.unauthorized() val id = call.parameters["id"] ?: return@post call.badRequest() val listing = catalogById()[id] ?: return@post call.notFound() if (listing.optString("dealType") != "RENT") return@post call.badRequest("not_rent_listing") val body = parseBody(call.receiveText()) val rating = if (body.has("rating") && !body.get("rating").isJsonNull) body.get("rating").asInt else 5 val dto = RentalReviewDto( listingId = id, authorUserId = ctx.user.id, authorName = body.optString("authorName").ifBlank { ctx.user.name }, rating = rating, comment = body.optString("comment"), stayFromIso = body.optString("stayFromIso"), stayToIso = body.optString("stayToIso"), ) val saved = rentalReviews.add(dto) call.respondText(gsonApi.toJson(saved), ContentType.Application.Json) } delete("/reviews/{reviewId}") { val ctx = call.authContext(users) if (ctx.user == null) return@delete call.unauthorized() val rid = call.parameters["reviewId"] ?: return@delete call.badRequest() val ok = rentalReviews.delete(rid, ctx.user.id) if (!ok) return@delete call.notFound() call.respondText("{}", ContentType.Application.Json) } get("/geocode") { val q = call.request.queryParameters["q"]?.trim().orEmpty() if (q.isBlank()) return@get call.badRequest("q_required") val res = geocode.lookup(q) if (res == null) { return@get call.respondText("{\"found\":false}", ContentType.Application.Json) } val payload = JsonObject().apply { addProperty("found", true) addProperty("lat", res.lat) addProperty("lng", res.lon) addProperty("displayName", res.displayName) } call.respondText(gsonApi.toJson(payload), ContentType.Application.Json) } } } } private fun sha1(input: String): String { val md = MessageDigest.getInstance("SHA-1") val bytes = md.digest(input.toByteArray(StandardCharsets.UTF_8)) val sb = StringBuilder(bytes.size * 2) for (b in bytes) sb.append(String.format("%02x", b)) return sb.toString() } private fun parseBody(text: String): JsonObject = runCatching { JsonParser.parseString(text).asJsonObject }.getOrElse { JsonObject() } internal fun JsonObject.optString(name: String): String = if (has(name) && !get(name).isJsonNull) { runCatching { get(name).asString }.getOrElse { "" } } else { "" } internal fun JsonObject.optLong(name: String): Long = if (has(name) && !get(name).isJsonNull) { runCatching { get(name).asLong }.getOrElse { 0L } } else { 0L }