From 7090812e738704e4b33afbf2d88d6285b95f9d81 Mon Sep 17 00:00:00 2001 From: akuleshov7 Date: Sun, 8 Sep 2024 05:18:47 +0300 Subject: [PATCH] Experiment with User data --- README.md | 5 + backend/build.gradle.kts | 1 + .../controller/TelegramAuthController.kt | 44 +-- .../backend/controller/UserController.kt | 74 +++-- .../backend/entity/OurUserEntityFromDb.kt | 76 +++++ .../kotlin/ru/posidata/backend/entity/User.kt | 24 -- .../backend/repository/UserRepository.kt | 6 +- .../backend/service/TelegramAuthService.kt | 53 +--- .../posidata/backend/service/UserService.kt | 43 ++- backend/src/main/resources/application.yml | 14 +- .../db/changelog/changelog-master.yaml | 33 ++ common/build.gradle.kts | 4 + .../posidata/common/UserDataFromTelegram.kt | 25 ++ .../common/UserForSerializationDTO.kt | 21 ++ frontend/build.gradle.kts | 3 + .../kotlin/ru/posidata/views/main/MainView.kt | 103 +++++-- .../ru/posidata/views/main/QuestionCard.kt | 48 ++- .../ru/posidata/views/main/ResultCard.kt | 37 ++- .../kotlin/ru/posidata/views/main/Welcome.kt | 114 +++++-- .../views/utils/externals/telegram/TUser.kt | 6 +- .../views/utils/externals/telegram/User.kt | 11 - .../views/utils/internals/RequestUtils.kt | 286 ++++++++++++++++++ frontend/webpack.config.d/dev-server.js | 9 +- 23 files changed, 802 insertions(+), 238 deletions(-) create mode 100644 backend/src/main/kotlin/ru/posidata/backend/entity/OurUserEntityFromDb.kt delete mode 100644 backend/src/main/kotlin/ru/posidata/backend/entity/User.kt create mode 100644 backend/src/main/resources/db/changelog/changelog-master.yaml create mode 100644 common/src/commonMain/kotlin/ru/posidata/common/UserDataFromTelegram.kt create mode 100644 common/src/commonMain/kotlin/ru/posidata/common/UserForSerializationDTO.kt delete mode 100644 frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/User.kt create mode 100644 frontend/src/jsMain/kotlin/ru/posidata/views/utils/internals/RequestUtils.kt diff --git a/README.md b/README.md index 15b08ac..0586c62 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ +# Важно +Сделано за 3 ночи 1 человеком, который был и швец, и жнец, и на дуде игрец. И девопс, и бэкендер и фронтендер. +Спасибо ChatGpt и моей маме. + # Покемон Или BigData Шутливое приложение, которое показывает, насколько огромный зоопарк из названий образовался в дате. Оригинальная идея и отсылки к [этой форме](https://docs.google.com/a/octo.com/forms/d/1kckcq_uv8dk9-W5rIdtqRwCHN4Uh209ELPUjTEZJDxc/viewform) и к [этому](https://github.com/pixelastic/pokemonorbigdata) проекту. + ### How to 1) Build: `./gradlew build` diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 60daebb..089622a 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.liquibase:liquibase-core") implementation("com.h2database:h2:2.3.232") implementation("commons-codec:commons-codec:1.17.1") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") diff --git a/backend/src/main/kotlin/ru/posidata/backend/controller/TelegramAuthController.kt b/backend/src/main/kotlin/ru/posidata/backend/controller/TelegramAuthController.kt index f34f573..4d6a6b0 100644 --- a/backend/src/main/kotlin/ru/posidata/backend/controller/TelegramAuthController.kt +++ b/backend/src/main/kotlin/ru/posidata/backend/controller/TelegramAuthController.kt @@ -3,49 +3,13 @@ package ru.posidata.backend.controller import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import ru.posidata.backend.service.TelegramAuthService - @RestController @RequestMapping("api") -class TelegramAuthController(private val telegramAuthService: TelegramAuthService) { - // - // - // - // - // - // Telegram Login - // - // - // - //
- //

Login with Telegram

- //

Click the button below to log in using your Telegram account:

- // - //
- // - // - // https://posidata.ru/api/test?id=221298772&first_name=Андрей&last_name=Кулешов&username=akuleshov7&photo_url=https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2Fd3fKyG306aXHDBCxZXfWTpGlii6fZqZMo1tBmMPEl_E.jpg&auth_date=1725656645&hash=742dda3a019e57821e1fb7acf9918aeb6f0d734cc5c8612913b6696aeab2c745 - @GetMapping("/get") - fun get(): String { +class TestController { + @GetMapping("/test") + fun get(): ResponseEntity { println("Hi!") - return "Hi" + return ResponseEntity.status(HttpStatus.OK).body("HI!") } } diff --git a/backend/src/main/kotlin/ru/posidata/backend/controller/UserController.kt b/backend/src/main/kotlin/ru/posidata/backend/controller/UserController.kt index 6840dd1..a21a5ec 100644 --- a/backend/src/main/kotlin/ru/posidata/backend/controller/UserController.kt +++ b/backend/src/main/kotlin/ru/posidata/backend/controller/UserController.kt @@ -7,6 +7,7 @@ import ru.posidata.backend.service.TelegramAuthService import ru.posidata.backend.service.UserService import ru.posidata.common.ResourceType import ru.posidata.common.Resources +import ru.posidata.common.UserDataFromTelegram @RestController @@ -17,41 +18,62 @@ class UserController( ) { @GetMapping("/get") fun getResults( - @RequestParam username: String?, - @RequestParam telegramId: Long, + @RequestParam authDate: Int, + @RequestParam firstName: String, + @RequestParam lastName: String, @RequestParam hash: String, - @RequestParam firstName: String?, - @RequestParam lastName: String?, + @RequestParam id: Int, + @RequestParam photoUrl: String, + @RequestParam username: String, ): ResponseEntity { - println("Received a request to get results from $username, $telegramId, $hash, $firstName, $lastName") - userService.findOrCreateUser( - username = username, - telegramId = telegramId, + val user = UserDataFromTelegram( + authDate = authDate, firstName = firstName, - lastName = lastName + lastName = lastName, + hash = hash, + id = id, + photoUrl = photoUrl, + username = username, ) - return ResponseEntity.status(HttpStatus.OK).body("") + println("Received a request to get results from $user. Converted to map: ${user.convertToMap()}") + + if(!telegramAuthService.isValidHash(user.convertToMap(), user.hash)) { + return ResponseEntity(HttpStatus.FORBIDDEN) + } + + println("Validation successful for ${user.username}") + + val responseUser = userService.findOrCreateUser(user) + return ResponseEntity.status(HttpStatus.OK).body(responseUser.toDTO()) } - @GetMapping("/answer") + @GetMapping("/update") fun submitAnswer( - @RequestParam pokemonName: String, - @RequestParam resourceType: ResourceType, - // @RequestParam gameNumber: Int, - @RequestParam username: String, + @RequestParam authDate: Int, + @RequestParam firstName: String, + @RequestParam lastName: String, @RequestParam hash: String, + @RequestParam id: Int, + @RequestParam photoUrl: String, + @RequestParam username: String, + @RequestParam isNextRound: Boolean ): ResponseEntity { - // user - println(pokemonName) - var count: Int = 0 - val pokemon = Resources.getByName(pokemonName) - ?: return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid pokemon name $pokemonName") - if (pokemon.type == resourceType) { - // correct answer - ++count - } else { - // incorrect answer + val user = UserDataFromTelegram( + authDate = authDate, + firstName = firstName, + lastName = lastName, + hash = hash, + id = id, + photoUrl = photoUrl, + username = username, + ) + println("Received a request to get results from $user. Converted to map: ${user.convertToMap()}") + + if(!telegramAuthService.isValidHash(user.convertToMap(), user.hash)) { + return ResponseEntity(HttpStatus.FORBIDDEN) } - return ResponseEntity.status(HttpStatus.OK).body(resourceType) + + val responseUser = userService.updateGameRound(user.username, isNextRound) + return ResponseEntity.status(HttpStatus.OK).body(responseUser?.toDTO()) } } diff --git a/backend/src/main/kotlin/ru/posidata/backend/entity/OurUserEntityFromDb.kt b/backend/src/main/kotlin/ru/posidata/backend/entity/OurUserEntityFromDb.kt new file mode 100644 index 0000000..70a2c4a --- /dev/null +++ b/backend/src/main/kotlin/ru/posidata/backend/entity/OurUserEntityFromDb.kt @@ -0,0 +1,76 @@ +package ru.posidata.backend.entity + +import jakarta.persistence.* +import ru.posidata.common.UserForSerializationDTO + +@Entity +@Table(name = "users") +class OurUserEntityFromDb( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long = 0, + var firstName: String?, + var lastName: String?, + var username: String?, + + var firstGameScore: Int, + var secondGameScore: Int, + var thirdGameScore: Int, +) { + fun toDTO(): UserForSerializationDTO { + return UserForSerializationDTO( + firstName = this.firstName, + lastName = this.lastName, + username = this.username, + firstGameScore = this.firstGameScore, + secondGameScore = this.secondGameScore, + thirdGameScore = this.thirdGameScore + ) + } + + fun currentGameNumber() = + when { + firstGameScore == -1 -> 1 + secondGameScore == -1 && firstGameScore != -1 -> 2 + thirdGameScore == -1 && firstGameScore != -1 && secondGameScore != -1 -> 3 + else -> 4 + } + + fun updateResultIn(currentGameNumber: Int, isNextRound: Boolean) { + when(currentGameNumber) { + 1 -> if(!isNextRound) ++firstGameScore else ++secondGameScore + 2 -> if(!isNextRound) ++secondGameScore else ++thirdGameScore + 3 -> if(!isNextRound) ++thirdGameScore + } + println(firstGameScore) + println(secondGameScore) + println(thirdGameScore) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as OurUserEntityFromDb + + if (id != other.id) return false + if (firstName != other.firstName) return false + if (lastName != other.lastName) return false + if (username != other.username) return false + if (firstGameScore != other.firstGameScore) return false + if (secondGameScore != other.secondGameScore) return false + if (thirdGameScore != other.thirdGameScore) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + (firstName?.hashCode() ?: 0) + result = 31 * result + (lastName?.hashCode() ?: 0) + result = 31 * result + (username?.hashCode() ?: 0) + result = 31 * result + (firstGameScore?.hashCode() ?: 0) + result = 31 * result + (secondGameScore?.hashCode() ?: 0) + result = 31 * result + (thirdGameScore?.hashCode() ?: 0) + return result + } +} diff --git a/backend/src/main/kotlin/ru/posidata/backend/entity/User.kt b/backend/src/main/kotlin/ru/posidata/backend/entity/User.kt deleted file mode 100644 index 8367d73..0000000 --- a/backend/src/main/kotlin/ru/posidata/backend/entity/User.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ru.posidata.backend.entity - -import jakarta.persistence.* - -@Entity -@Table(name = "users") -data class User( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, - @Column(unique = true) - val telegramId: Long, - val firstName: String?, - val lastName: String?, - val username: String?, - - @Column(nullable = true) - var firstGameScore: Int?, - - @Column(nullable = true) - var secondGameScore: Int?, - - @Column(nullable = true) - var thirdGameScore: Int?, -) diff --git a/backend/src/main/kotlin/ru/posidata/backend/repository/UserRepository.kt b/backend/src/main/kotlin/ru/posidata/backend/repository/UserRepository.kt index b6234a7..815be7e 100644 --- a/backend/src/main/kotlin/ru/posidata/backend/repository/UserRepository.kt +++ b/backend/src/main/kotlin/ru/posidata/backend/repository/UserRepository.kt @@ -2,9 +2,9 @@ package ru.posidata.backend.repository import org.springframework.data.repository.CrudRepository import org.springframework.stereotype.Repository -import ru.posidata.backend.entity.User +import ru.posidata.backend.entity.OurUserEntityFromDb @Repository -interface UserRepository : CrudRepository { - fun findByTelegramId(telegramId: Long): User? +interface UserRepository : CrudRepository { + fun findByUsername(username: String): OurUserEntityFromDb? } \ No newline at end of file diff --git a/backend/src/main/kotlin/ru/posidata/backend/service/TelegramAuthService.kt b/backend/src/main/kotlin/ru/posidata/backend/service/TelegramAuthService.kt index efe733c..7e4b233 100644 --- a/backend/src/main/kotlin/ru/posidata/backend/service/TelegramAuthService.kt +++ b/backend/src/main/kotlin/ru/posidata/backend/service/TelegramAuthService.kt @@ -3,56 +3,21 @@ package ru.posidata.backend.service import org.apache.commons.codec.digest.HmacUtils import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service +import java.security.MessageDigest @Service class TelegramAuthService( @Value("\${telegram.bot.token}") private val telegramToken: String, ) { - fun isValidHash(hash: String): Boolean { - val secreteKey = "1289756607:AAFYuBeHguJKYhDVzwSfYFLX4tF5YbkSU7M" - return true - } - -} -/* - -fun main() { - println(isValidHash()) -} - -// Bot token -const val BOT_TOKEN = "7518590686:AAFCT7m70_ANfOZfIACr2C7bOy0igdLNOpg" + fun isValidHash(parsedData: Map, hash: String): Boolean { + val dataKeys = parsedData.keys.filter { it != "hash" }.sorted() + val items = dataKeys.map { key -> "$key=${parsedData[key]}" } + val dataCheckString = items.joinToString("\n") -// Function to validate hash -fun isValidHash(): Boolean { - val hash = "742dda3a019e57821e1fb7acf9918aeb6f0d734cc5c8612913b6696aeab2c745" + val secretKey: ByteArray = MessageDigest.getInstance("SHA-256").digest(telegramToken.toByteArray()) + val initDataHash: String = HmacUtils("HmacSHA256", secretKey).hmacHex(dataCheckString) - val parsedData: Map = mapOf( - "id" to "221298772", - "first_name" to "Андрей", - "last_name" to "Кулешов", - "username" to "akuleshov7", - "photo_url" to "https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2Fd3fKyG306aXHDBCxZXfWTpGlii6fZqZMo1tBmMPEl_E.jpg", - "auth_date" to "1725656645", - "hash" to "742dda3a019e57821e1fb7acf9918aeb6f0d734cc5c8612913b6696aeab2c745" - ) - - // Remove 'hash' value & sort alphabetically - val dataKeys = parsedData.keys.filter { it != "hash" }.sorted() - - // Create line format key= - val items = dataKeys.map { key -> "$key=${parsedData[key]}" } - - // Create check string with '\n' as separator - val dataCheckString = items.joinToString("\n") - - val secretKey: ByteArray = HmacUtils("HmacSHA256", "WebAppData").hmac(BOT_TOKEN) - val initDataHash: String = HmacUtils("HmacSHA256", secretKey).hmacHex(dataCheckString) - - println(initDataHash) - println(hash) - // Return whether the generated hash matches the provided hash - return initDataHash == hash + return initDataHash == hash + } } -*/ diff --git a/backend/src/main/kotlin/ru/posidata/backend/service/UserService.kt b/backend/src/main/kotlin/ru/posidata/backend/service/UserService.kt index 9d27a73..6b68c72 100644 --- a/backend/src/main/kotlin/ru/posidata/backend/service/UserService.kt +++ b/backend/src/main/kotlin/ru/posidata/backend/service/UserService.kt @@ -1,27 +1,38 @@ package ru.posidata.backend.service import org.springframework.stereotype.Service -import ru.posidata.backend.entity.User +import ru.posidata.backend.entity.OurUserEntityFromDb import ru.posidata.backend.repository.UserRepository +import ru.posidata.common.UserDataFromTelegram @Service class UserService(private val userRepository: UserRepository) { - fun findOrCreateUser( - username: String?, - telegramId: Long, - firstName: String?, - lastName: String? - ): User { - return userRepository.findByTelegramId(telegramId) ?: userRepository.save( - User( - telegramId = telegramId, - username = username, - firstName = firstName, - lastName = lastName, - firstGameScore = 0, - secondGameScore = null, - thirdGameScore = null + fun findOrCreateUser(user: UserDataFromTelegram): OurUserEntityFromDb { + return userRepository.findByUsername(user.username) ?: userRepository.save( + OurUserEntityFromDb( + username = user.username, + firstName = user.firstName, + lastName = user.lastName, + firstGameScore = -1, + secondGameScore = -1, + thirdGameScore = -1 ) ) } + + fun updateGameRound(username: String?, isNextRound: Boolean): OurUserEntityFromDb? { + if (username != null) { + val value = userRepository.findByUsername(username) + if (value != null) { + value.updateResultIn(value.currentGameNumber(), isNextRound) + return userRepository.save(value) + } else { + println("Not able to find $username in DB") + return null + } + } else { + println("Tried to update user with null username") + return null + } + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 79d190c..eafffa8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,4 +1,15 @@ spring: + datasource: + url: jdbc:h2:mem:mydb + username: sa + password: sa + driverClassName: org.h2.Driver + jpa: + database-platform: org.hibernate.dialect.H2Dialect + h2: + console.enabled: true + liquibase: + change-log: classpath:db/changelog/changelog-master.yaml application: name: backend @@ -11,9 +22,6 @@ spring: servlet: SecurityAutoConfiguration -telegram: - bot: - token: server: port : 8081 diff --git a/backend/src/main/resources/db/changelog/changelog-master.yaml b/backend/src/main/resources/db/changelog/changelog-master.yaml new file mode 100644 index 0000000..acb4af2 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changelog-master.yaml @@ -0,0 +1,33 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: akuleshov + changes: + - createTable: + tableName: users + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: first_name + type: VARCHAR(255) + - column: + name: last_name + type: VARCHAR(255) + - column: + name: username + type: VARCHAR(255) + - column: + name: first_game_score + type: INT + - column: + name: second_game_score + type: INT + - column: + name: third_game_score + type: INT diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 435f979..3155521 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("multiplatform") + kotlin("plugin.serialization") version "2.0.20" } kotlin { @@ -10,6 +11,9 @@ kotlin { } sourceSets { + commonMain.dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2") + } commonTest.dependencies { implementation(kotlin("test")) } diff --git a/common/src/commonMain/kotlin/ru/posidata/common/UserDataFromTelegram.kt b/common/src/commonMain/kotlin/ru/posidata/common/UserDataFromTelegram.kt new file mode 100644 index 0000000..3d7b92f --- /dev/null +++ b/common/src/commonMain/kotlin/ru/posidata/common/UserDataFromTelegram.kt @@ -0,0 +1,25 @@ +package ru.posidata.common + +import kotlinx.serialization.Serializable + +@Serializable +data class UserDataFromTelegram ( + val authDate: Int, + val firstName: String, + val lastName: String, + val hash: String, + val id: Int, + val photoUrl: String, + val username: String, +) { + fun convertToMap(): Map = + mapOf( + "auth_date" to "$authDate", + "first_name" to firstName, + "hash" to hash, + "id" to "$id", + "last_name" to lastName, + "photo_url" to photoUrl, + "username" to username + ) +} diff --git a/common/src/commonMain/kotlin/ru/posidata/common/UserForSerializationDTO.kt b/common/src/commonMain/kotlin/ru/posidata/common/UserForSerializationDTO.kt new file mode 100644 index 0000000..931d269 --- /dev/null +++ b/common/src/commonMain/kotlin/ru/posidata/common/UserForSerializationDTO.kt @@ -0,0 +1,21 @@ +package ru.posidata.common + +import kotlinx.serialization.Serializable + +@Serializable +data class UserForSerializationDTO( + val firstName: String?, + val lastName: String?, + val username: String?, + val firstGameScore: Int?, + val secondGameScore: Int?, + val thirdGameScore: Int? +) { + fun gameNumber() = + when { + firstGameScore == -1 -> 1 + secondGameScore == -1 && firstGameScore != -1 -> 2 + thirdGameScore == -1 && firstGameScore != -1 && secondGameScore != -1 -> 3 + else -> 4 + } +} diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts index 728f269..d92e523 100644 --- a/frontend/build.gradle.kts +++ b/frontend/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("multiplatform") + kotlin("plugin.serialization") version "2.0.20" } kotlin { @@ -28,6 +29,8 @@ kotlin { jsMain.dependencies { api(project(":common")) implementation(project.dependencies.enforcedPlatform(libs.kotlin.wrappers.bom)) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2") implementation("org.jetbrains.kotlin-wrappers:kotlin-react") implementation("org.jetbrains.kotlin-wrappers:kotlin-extensions") implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom") diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/main/MainView.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/main/MainView.kt index bd4842e..d5b018b 100644 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/main/MainView.kt +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/main/MainView.kt @@ -2,6 +2,7 @@ package ru.posidata.views.main import js.objects.jso import react.* +import react.dom.html.ReactHTML import react.dom.html.ReactHTML.div import ru.posidata.common.Answer.NONE import ru.posidata.common.Selection @@ -9,7 +10,8 @@ import ru.posidata.common.Selection.QUESTION import ru.posidata.common.Selection.ANSWER import ru.posidata.common.Selection.RESULTS import ru.posidata.views.utils.externals.particles.Particles -import ru.posidata.views.utils.externals.telegram.User +import ru.posidata.common.UserDataFromTelegram +import ru.posidata.common.UserForSerializationDTO import web.cssom.* val mainView = FC { @@ -24,7 +26,8 @@ val mainView = FC { val (answers, setAnswers) = useState(MutableList(12) { NONE }) val (pokemonId, setPokemonId) = useState(0) val (uniqueRandom, setUniqueRandom) = useState>(listOf()) - val (user, setUser) = useState(null) + val (user, setUser) = useState(null) + val (tgUser, setTgUser) = useState(null) div { className = ClassName("full-width-container") @@ -52,47 +55,83 @@ val mainView = FC { minHeight = "53vh".unsafeCast() display = Display.flex } - when (selection) { - Selection.NONE -> { - welcomeCard { - this.setSelection = setSelection - this.setUser = setUser - this.user = user + if (user != null && user.gameNumber() == 4) { + ReactHTML.h6 { + className = ClassName("mb-2 text-white mx-2") + +"Ты отыграл уже три раза, в рейтинге участвовать больше не получится, обнови страницу если хочешь просто пройти тест. Твои результаты:" + + } + ReactHTML.h6 { + className = ClassName("mb-2 text-center") + style = jso { + color = "yellow".unsafeCast() } + +"${user.firstGameScore}/12" } - - QUESTION -> questionCard { - this.counter = counter - this.setCounter = setCounter - this.answers = answers - this.setAnswers = setAnswers - this.setPokemonId = setPokemonId - this.pokemonId = pokemonId - this.setSelection = setSelection - this.uniqueRandom = uniqueRandom - this.setUniqueRandom = setUniqueRandom - this.user = user + ReactHTML.h6 { + className = ClassName("mb-2 text-center") + style = jso { + color = "yellow".unsafeCast() + } + +"${user.firstGameScore}/12" } - - ANSWER -> { - answerCard { - this.setSelection = setSelection - this.counter = counter - this.pokemonId = pokemonId - this.answers = answers + ReactHTML.h6 { + className = ClassName("mb-2 text-center") + style = jso { + color = "yellow".unsafeCast() } + +"${user.thirdGameScore}/12" } + } else { + when (selection) { + Selection.NONE -> { + welcomeCard { + this.setSelection = setSelection + this.setUser = setUser + this.tgUser = tgUser + this.setTgUser = setTgUser + } + } - RESULTS -> { - resultCard { + QUESTION -> questionCard { this.counter = counter - this.answers = answers - this.setSelection = setSelection this.setCounter = setCounter + this.answers = answers this.setAnswers = setAnswers - this.setUniqueRandom = setUniqueRandom + this.setPokemonId = setPokemonId + this.pokemonId = pokemonId this.setSelection = setSelection + this.uniqueRandom = uniqueRandom + this.setUniqueRandom = setUniqueRandom + this.user = user + this.tgUser = tgUser + this.setUser = setUser + } + + ANSWER -> { + answerCard { + this.setSelection = setSelection + this.counter = counter + this.pokemonId = pokemonId + this.answers = answers + } + } + + RESULTS -> { + resultCard { + this.counter = counter + this.answers = answers + this.setSelection = setSelection + this.setCounter = setCounter + this.setAnswers = setAnswers + this.setUniqueRandom = setUniqueRandom + this.setSelection = setSelection + + this.user = user + this.tgUser = tgUser + this.setUser = setUser + } } } } diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/main/QuestionCard.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/main/QuestionCard.kt index 2f1d5b9..6526da8 100644 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/main/QuestionCard.kt +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/main/QuestionCard.kt @@ -1,20 +1,19 @@ package ru.posidata.views.main import js.objects.jso +import kotlinx.browser.window import react.* import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 import react.dom.html.ReactHTML.img +import ru.posidata.common.* import ru.posidata.common.Answer.WRONG import ru.posidata.common.Answer.CORRECT import ru.posidata.common.Selection.ANSWER import ru.posidata.common.ResourceType.BIG_DATA import ru.posidata.common.ResourceType.POKEMON -import ru.posidata.common.Resources import ru.posidata.views.components.neonLightingText -import ru.posidata.views.utils.externals.telegram.User -import ru.posidata.common.Answer -import ru.posidata.common.Selection +import ru.posidata.views.utils.internals.* import web.cssom.* import kotlin.random.Random @@ -30,9 +29,36 @@ val questionCard = FC { props -> randomNumber = Random.nextInt(0, Resources.entries.size) } } - val pokemon = Resources.getById(props.pokemonId) + val updateResult = useDeferredRequest { + console.log("Test") + if (props.tgUser != null) { + val response = get( + url = "${window.location.origin}/api/update", + params = jso { + authDate = props.tgUser?.authDate + firstName = props.tgUser?.firstName + lastName = props.tgUser?.lastName + hash = props.tgUser?.hash + id = props.tgUser?.id + photoUrl = props.tgUser?.photoUrl + username = props.tgUser?.username + isNextRound = false + }, + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> props.setUser(response.decodeFromJsonString()) + else -> window.alert("Failed to login with telegram") + } + } + } + + div { className = ClassName("row justify-content-center logo-main mt-3 px-0") style = jso { @@ -63,7 +89,9 @@ val questionCard = FC { props -> props.setSelection(ANSWER) props.setCounter(props.counter + 1) if (pokemon.type == BIG_DATA) { + updateResult() props.answers[props.counter] = CORRECT + console.log("a") } else { props.answers[props.counter] = WRONG } @@ -86,9 +114,15 @@ val questionCard = FC { props -> props.setSelection(ANSWER) props.setCounter(props.counter + 1) if (pokemon.type == POKEMON) { + console.log("Entering POKEMON condition") + updateResult() props.answers[props.counter] = CORRECT + console.log("Answer set to CORRECT") + console.log("tgUser: ${props.tgUser}") } else { + console.log("POKEMON condition not met") props.answers[props.counter] = WRONG + console.log("Answer set to WRONG") } props.setAnswers(props.answers) } @@ -166,5 +200,7 @@ external interface QuestionCard : Props { var setUniqueRandom: StateSetter> var uniqueRandom: List - var user: User? + var user: UserForSerializationDTO? + var setUser: StateSetter + var tgUser: UserDataFromTelegram? } diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/main/ResultCard.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/main/ResultCard.kt index dea8087..a8b5f7d 100644 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/main/ResultCard.kt +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/main/ResultCard.kt @@ -1,6 +1,7 @@ package ru.posidata.views.main import js.objects.jso +import kotlinx.browser.window import react.FC import react.Props import react.StateSetter @@ -15,15 +16,44 @@ import react.dom.html.ReactHTML.img import react.useState import ru.posidata.views.utils.externals.fontawesome.faGithub import ru.posidata.views.utils.externals.fontawesome.fontAwesomeIcon -import ru.posidata.views.utils.externals.telegram.User +import ru.posidata.common.UserDataFromTelegram import ru.posidata.common.Answer import ru.posidata.common.Answer.NONE import ru.posidata.common.Selection +import ru.posidata.common.UserForSerializationDTO +import ru.posidata.views.utils.internals.* import web.cssom.* val resultCard = FC { props -> val correctAnswers = props.answers.count { it == CORRECT } var (loading, setLoading) = useState(true) + + val updateRound = useDeferredRequest { + if (props.tgUser != null) { + val response = get( + url = "${window.location.origin}/api/update", + params = jso { + authDate = props.tgUser?.authDate + firstName = props.tgUser?.firstName + lastName = props.tgUser?.lastName + hash = props.tgUser?.hash + id = props.tgUser?.id + photoUrl = props.tgUser?.photoUrl + username = props.tgUser?.username + isNextRound = false + }, + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> props.setUser(response.decodeFromJsonString()) + else -> window.alert("Failed to login with telegram") + } + } + } + div { style = jso { display = (if (loading) "none" else "block").unsafeCast() @@ -89,6 +119,7 @@ val resultCard = FC { props -> props.setCounter(0) props.setAnswers(MutableList(12) { NONE }) props.setUniqueRandom(listOf()) + updateRound() } +"Еще раз!" } @@ -118,5 +149,7 @@ external interface ResultProps : Props { var setCounter: StateSetter var setAnswers: StateSetter> var setUniqueRandom: StateSetter> - var user: User? + var user: UserForSerializationDTO? + var tgUser: UserDataFromTelegram? + var setUser: StateSetter } \ No newline at end of file diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/main/Welcome.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/main/Welcome.kt index 39bc4a3..1197754 100644 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/main/Welcome.kt +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/main/Welcome.kt @@ -1,18 +1,75 @@ package ru.posidata.views.main import js.objects.jso +import kotlinx.browser.window import react.* import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 import react.dom.html.ReactHTML.h6 import react.dom.html.ReactHTML.img import ru.posidata.views.utils.externals.telegram.TLoginButton -import ru.posidata.views.utils.externals.telegram.User +import ru.posidata.common.UserDataFromTelegram import ru.posidata.common.Selection +import ru.posidata.common.UserForSerializationDTO +import ru.posidata.views.utils.internals.* import web.cssom.* val welcomeCard = FC { props -> + val getUser = useDeferredRequest { + if (props.tgUser != null) { + val response = get( + url = "${window.location.origin}/api/get", + params = jso { + authDate = props.tgUser!!.authDate + firstName = props.tgUser!!.firstName + lastName = props.tgUser!!.lastName + hash = props.tgUser!!.hash + id = props.tgUser!!.id + photoUrl = props.tgUser!!.photoUrl + username = props.tgUser!!.username + }, + headers = jsonHeaders, + loadingHandler = ::noopLoadingHandler, + responseHandler = ::noopResponseHandler, + ) + + when { + response.ok -> props.setUser(response.decodeFromJsonString()) + else -> window.alert("Failed to login with telegram") + } + } + } + + // just a small test + /*div { + button { + onClick = { + console.log(props.tgUser) + getUser() + } + } + } + + div { + button { + onClick = { + val feUser = UserDataFromTelegram( + authDate = 1725741859, + firstName = "Андрей", + lastName = "Кулешов", + hash = "4a8cb14838a9797d8994bf73ac9734e3fd634a5abec5be7371f83737d1dc82e8", + id = 221298772, + photoUrl = "https://t.me/i/userpic/320/d3fKyG306aXHDBCxZXfWTpGlii6fZqZMo1tBmMPEl_E.jpg", + username = "akuleshov7", + ) + props.setTgUser(feUser) + } + } + } +*/ + div { className = ClassName("row justify-content-center mt-1 px-0") div { @@ -46,44 +103,48 @@ val welcomeCard = FC { props -> // (https://docs.google.com/a/octo.com/forms/d/1kckcq_uv8dk9-W5rIdtqRwCHN4Uh209ELPUjTEZJDxc/viewform) // (https://github.com/pixelastic/pokemonorbigdata) className = ClassName("mt-3 mb-3 text-start") - +("Шуточный тест из 12 вопросов, чтобы показать " + - "насколько большой зоопарк из названий и технологий образовался в отрасли. " + - "Изначальная идея родилась из " + +("Шуточный тест из 12 вопросов, сделаный за пару ночей, чтобы показать " + + "насколько большой зоопарк из названий образовался в дате. " + + "Изначальная идея родилась " ) a { href = "https://docs.google.com/forms/d/e/1FAIpQLScRsfRHXPTuEXdNvUcI8DzJIU5iazqlpksWucPF0d8l2ztkkA/viewform" className = ClassName("text-info") - +"этой" + +"тут" } - +" гугл-формы и " + +" и " a { href = "https://pixelastic.github.io/pokemonorbigdata/" className = ClassName("text-info") - +"этого" + +"тут" } - +" проекта. А исходный код этого сайта открыт " + +" Иходный код этого сайта открыт " a { href = "https://github.com/orchestr7/PokemonOrBigData" className = ClassName("text-info") - +"тут" + +"здесь" } - +"." - + +". ${if (props.tgUser == null) "Чтобы участвовать в розыгрыше и рейтинге - залогинься:" else ""}" } - if (props.user == null) { + if (props.tgUser == null) { TLoginButton { botName = "PosiDataBot" buttonSize = "large" + redirectUrl = null + cornerRadius = 15.0 + requestAccess = "write" + usePic = null + lang = null + additionalClassNames = "d-flex justify-content-center zIndex1000" onAuthCallback = { user -> - console.log(user) - val feUser = User( + val feUser = UserDataFromTelegram( authDate = user.auth_date, firstName = user.first_name, lastName = user.last_name, @@ -92,25 +153,22 @@ val welcomeCard = FC { props -> photoUrl = user.photo_url, username = user.username, ) - props.setUser( - feUser - ) + props.setTgUser(feUser) } - redirectUrl = null - cornerRadius = 15.0 - requestAccess = "write" - usePic = null - lang = null - additionalClassNames = "d-flex justify-content-center zIndex1000" } } else { h6 { - +"Привет, ${props.user?.username}" + className = ClassName("mb-2") + style = jso { + color = "yellow".unsafeCast() + } + +"Привет, ${props.tgUser?.username}! Теперь ты участвуешь в розыгрыше на SmartData. Если захочешь просто поиграть - обнови страницу." } } img { - className = ClassName("animate__animated animate__shakeX mt-1 border border-info border-5 img-glow3 ") + className = + ClassName("animate__animated animate__shakeX mt-1 border border-info border-5 img-glow3 mb-3") src = "img/pokemonVSBigData.jpeg" style = jso { width = "100%".unsafeCast() @@ -119,6 +177,7 @@ val welcomeCard = FC { props -> cursor = "pointer".unsafeCast() } onClick = { + getUser() props.setSelection(Selection.QUESTION) } } @@ -128,6 +187,7 @@ val welcomeCard = FC { props -> external interface WelcomeCardProps : Props { var setSelection: StateSetter - var user: User? - var setUser: StateSetter + var setUser: StateSetter + var tgUser: UserDataFromTelegram? + var setTgUser: StateSetter } diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/TUser.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/TUser.kt index ed84817..2d6f0e4 100644 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/TUser.kt +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/TUser.kt @@ -7,9 +7,9 @@ package ru.posidata.views.utils.externals.telegram external interface TUser { var auth_date: Int var first_name: String - var last_name: String? + var last_name: String var hash: String var id: Int - var photo_url: String? - var username: String? + var photo_url: String + var username: String } diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/User.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/User.kt deleted file mode 100644 index 979bbf1..0000000 --- a/frontend/src/jsMain/kotlin/ru/posidata/views/utils/externals/telegram/User.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ru.posidata.views.utils.externals.telegram - -data class User ( - val authDate: Int, - val firstName: String, - val lastName: String?, - val hash: String, - val id: Int, - val photoUrl: String?, - val username: String?, -) diff --git a/frontend/src/jsMain/kotlin/ru/posidata/views/utils/internals/RequestUtils.kt b/frontend/src/jsMain/kotlin/ru/posidata/views/utils/internals/RequestUtils.kt new file mode 100644 index 0000000..823b309 --- /dev/null +++ b/frontend/src/jsMain/kotlin/ru/posidata/views/utils/internals/RequestUtils.kt @@ -0,0 +1,286 @@ +/** + * Contains custom react hooks + * + * Keep in mind that hooks could only be used from functional components! + */ + +package ru.posidata.views.utils.internals + +import js.objects.jso +import kotlinext.js.assign +import kotlinx.browser.window +import react.* +import kotlinx.coroutines.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.w3c.dom.url.URLSearchParams +import org.w3c.fetch.Headers +import org.w3c.fetch.RequestCredentials +import org.w3c.fetch.RequestInit +import org.w3c.fetch.Response + +val requestStatusContext: Context = createContext() + +data class RequestStatusContext( + val setResponse: StateSetter, + val setRedirectToFallbackView: StateSetter, +) + +/** + * Hook to get callbacks to perform requests in functional components. + * + * @param request + * @return a function to trigger request execution. + */ +fun useDeferredRequest( + request: suspend WithRequestStatusContext.() -> R, +): () -> Unit { + val scope = CoroutineScope(Dispatchers.Default) + val context = useRequestStatusContext() + val (isSending, setIsSending) = useState(false) + useEffect(isSending) { + if (!isSending) { + return@useEffect + } + scope.launch { + request(context) + setIsSending(false) + }.invokeOnCompletion { + if (it != null && it !is CancellationException) { + setIsSending(false) + } + } + cleanup { + if (scope.isActive) { + scope.cancel() + } + } + } + val initiateSending: () -> Unit = { + if (!isSending) { + setIsSending(true) + } + } + return initiateSending +} + +fun useRequest( + dependencies: Array = emptyArray(), + request: suspend WithRequestStatusContext.() -> R, +) { + val scope = CoroutineScope(Dispatchers.Default) + val context = useRequestStatusContext() + + useEffect(*dependencies) { + scope.launch { + request(context) + } + cleanup { + if (scope.isActive) { + scope.cancel() + } + } + } +} + + +fun useRequestStatusContext(): WithRequestStatusContext { + val statusContext = useContext(requestStatusContext) + val context = object : WithRequestStatusContext { + override val coroutineScope = CoroutineScope(Dispatchers.Default) + override fun setResponse(response: Response) { + statusContext?.run { + setResponse(response) + } + } + + override fun setRedirectToFallbackView(isNeedRedirect: Boolean, response: Response) { + statusContext?.run { + setRedirectToFallbackView( + isNeedRedirect && response.status == 404.toShort() + ) + } + } + + override fun setLoadingCounter(transform: (oldValue: Int) -> Int) { + statusContext?.run { setLoadingCounter(transform) } + } + } + return context +} + +suspend fun WithRequestStatusContext.get( + url: String, + params: T = jso { }, + headers: Headers, + loadingHandler: suspend (suspend () -> Response) -> Response, + responseHandler: (Response) -> Unit = this::withModalResponseHandler, +): Response = request( + url = url.withParams(params), + method = "GET", + headers = headers, + loadingHandler = loadingHandler, +) + +fun String.withParams(params: T): String { + val paramString = URLSearchParams(params).toString() + + return when { + paramString.isEmpty() -> this + endsWith('?') -> this + paramString + contains('?') -> "$this&$paramString" + else -> "$this?$paramString" + } +} + +private fun ComponentWithScope<*, *>.withModalResponseHandler( + response: Response, + isNeedRedirect: Boolean +) { + if (!response.ok) { + val statusContext: RequestStatusContext = this.asDynamic().context + statusContext.setRedirectToFallbackView(isNeedRedirect && response.status == 404.toShort()) + statusContext.setResponse.invoke(response) + } +} + +suspend fun request( + url: String, + method: String, + headers: Headers, + body: dynamic = undefined, + credentials: RequestCredentials? = undefined, + loadingHandler: suspend (suspend () -> Response) -> Response, +): Response = loadingHandler { + window.fetch( + input = url, + RequestInit( + method = method, + headers = headers, + body = body, + credentials = credentials, + ) + ) + .await() +} + +interface WithRequestStatusContext { + /** + * Coroutine used for processing [setLoadingCounter] + */ + val coroutineScope: CoroutineScope + + /** + * @param response + */ + fun setResponse(response: Response) + + /** + * @param isNeedRedirect + * @param response + */ + fun setRedirectToFallbackView(isNeedRedirect: Boolean, response: Response) + + /** + * @param transform + */ + fun setLoadingCounter(transform: (oldValue: Int) -> Int) +} + +fun noopResponseHandler(@Suppress("UNUSED_PARAMETER") response: Response) = Unit + +abstract class ComponentWithScope

: CComponent() { + /** + * A [CoroutineScope] that should be used by implementing classes. Will be cancelled on unmounting. + */ + val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) + + override fun componentWillUnmount() { + if (scope.isActive) { + scope.cancel() + } + } +} + +abstract class CComponent

: Component { + constructor() : super() { + state = jso { init() } + } + constructor(props: P) : super(props) { + state = jso { init(props) } + } + + @Suppress( + "WRONG_OVERLOADING_FUNCTION_ARGUMENTS", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION", + ) + open fun S.init() {} + + /** + * @param props + */ + @Suppress( + "WRONG_OVERLOADING_FUNCTION_ARGUMENTS", + "EMPTY_BLOCK_STRUCTURE_ERROR", + "MISSING_KDOC_CLASS_ELEMENTS" + ) + open fun S.init(props: P) {} + + /** + * Wrapper for convenient use of `ChildrenBuilder#render()` + */ + override fun render(): ReactNode? = Fragment.create { + render() + } + + /** + * Method that should be overridden in order to render the component + */ + abstract fun ChildrenBuilder.render() + + /** + * State setter + * + * @param stateSetter lambda to set a state + */ + fun setState(stateSetter: S.() -> Unit) { + super.setState({ assign(it, stateSetter) }) + } +} + +private fun WithRequestStatusContext.withModalResponseHandler( + response: Response, +) { + if (!response.ok) { + setResponse(response) + } +} + +val jsonHeaders = Headers() + .withAcceptJson() + .withContentTypeJson() + +fun Headers.withContentTypeJson() = apply { + set("Content-Type", "application/json") +} + +fun Headers.withAcceptJson() = apply { + set("Accept", "application/json") +} + +suspend fun noopLoadingHandler(request: suspend () -> Response) = request() + +suspend fun Response.unpackMessageOrNull(): String? = decodeFieldFromJsonStringOrNull("message") + +suspend inline fun Response.decodeFieldFromJsonStringOrNull(fieldName: String): String? = text().await() + .let { Json.parseToJsonElement(it) } + .let { it as? JsonObject } + ?.let { it[fieldName] } + ?.let { it as? JsonPrimitive } + ?.content + +suspend inline fun Response.decodeFromJsonString() = Json.decodeFromString(text().await()) + diff --git a/frontend/webpack.config.d/dev-server.js b/frontend/webpack.config.d/dev-server.js index 7ce6b9e..73e19ba 100644 --- a/frontend/webpack.config.d/dev-server.js +++ b/frontend/webpack.config.d/dev-server.js @@ -3,6 +3,13 @@ config.devServer = Object.assign( config.devServer || {}, { port: 8080, - historyApiFallback: true + historyApiFallback: true, + proxy: [ + { + context: ["/api/**"], + target: 'http://localhost:8081', + logLevel: 'debug', + }, + ] } );