diff --git a/backend/src/main/java/com/paligot/confily/backend/events/application/EventRepositoryExposed.kt b/backend/src/main/java/com/paligot/confily/backend/events/application/EventRepositoryExposed.kt index 1c2f99119..ee2458bf8 100644 --- a/backend/src/main/java/com/paligot/confily/backend/events/application/EventRepositoryExposed.kt +++ b/backend/src/main/java/com/paligot/confily/backend/events/application/EventRepositoryExposed.kt @@ -35,6 +35,7 @@ import com.paligot.confily.backend.qanda.infrastructure.exposed.QAndAActionEntit import com.paligot.confily.backend.qanda.infrastructure.exposed.QAndAActionsTable import com.paligot.confily.backend.qanda.infrastructure.exposed.QAndAEntity import com.paligot.confily.backend.qanda.infrastructure.exposed.toModel +import com.paligot.confily.backend.quiz.infrastructure.exposed.QuizQuestionEntity import com.paligot.confily.backend.speakers.infrastructure.exposed.SpeakerEntity import com.paligot.confily.backend.team.infrastructure.exposed.TeamEntity import com.paligot.confily.backend.team.infrastructure.exposed.TeamGroupEntity @@ -170,6 +171,8 @@ class EventRepositoryExposed( hasMenus = menus.isNotEmpty(), hasQAndA = qanda.isNotEmpty(), hasBilletWebTicket = integration != null, + hasQuiz = features1[FeatureKey.Quiz] == true || + QuizQuestionEntity.findByEvent(eventUuid).empty().not(), openFeedbackEnabled = features1[FeatureKey.OpenFeedback] ?: true ) val socials = EventSocialsTable.findByEventId(eventUuid) @@ -236,6 +239,8 @@ class EventRepositoryExposed( hasMenus = menus.isNotEmpty(), hasQAndA = qanda.isNotEmpty(), hasBilletWebTicket = integration != null, + hasQuiz = features1[FeatureKey.Quiz] == true || + QuizQuestionEntity.findByEvent(eventUuid).empty().not(), openFeedbackEnabled = features1[FeatureKey.OpenFeedback] ?: true ) val socials = EventSocialsTable.findByEventId(eventUuid) @@ -293,6 +298,8 @@ class EventRepositoryExposed( hasMenus = menus.isNotEmpty(), hasQAndA = qanda.empty().not(), hasBilletWebTicket = integration != null, + hasQuiz = features1[FeatureKey.Quiz] == true || + QuizQuestionEntity.findByEvent(eventUuid).empty().not(), openFeedbackEnabled = features1[FeatureKey.OpenFeedback] ?: true ) val socials = EventSocialsTable.findByEventId(eventUuid) @@ -352,6 +359,8 @@ class EventRepositoryExposed( hasMenus = menus.isNotEmpty(), hasQAndA = qanda.empty().not(), hasBilletWebTicket = integration != null, + hasQuiz = features1[FeatureKey.Quiz] == true || + QuizQuestionEntity.findByEvent(eventUuid).empty().not(), openFeedbackEnabled = features1[FeatureKey.OpenFeedback] ?: true ) @@ -445,6 +454,8 @@ class EventRepositoryExposed( hasMenus = menus.isNotEmpty(), hasQAndA = true, hasBilletWebTicket = true, + hasQuiz = features1[FeatureKey.Quiz] == true || + QuizQuestionEntity.findByEvent(eventUuid).empty().not(), openFeedbackEnabled = features1[FeatureKey.OpenFeedback] ?: true ), team = groups.associate { group -> diff --git a/backend/src/main/java/com/paligot/confily/backend/events/infrastructure/exposed/EventFeaturesTable.kt b/backend/src/main/java/com/paligot/confily/backend/events/infrastructure/exposed/EventFeaturesTable.kt index a5ad96d02..2f929b044 100644 --- a/backend/src/main/java/com/paligot/confily/backend/events/infrastructure/exposed/EventFeaturesTable.kt +++ b/backend/src/main/java/com/paligot/confily/backend/events/infrastructure/exposed/EventFeaturesTable.kt @@ -20,5 +20,6 @@ object EventFeaturesTable : UUIDTable("event_features") { enum class FeatureKey(val key: String) { Networking("has_networking"), - OpenFeedback("open_feedback_enabled") + OpenFeedback("open_feedback_enabled"), + Quiz("has_quiz") } diff --git a/backend/src/main/java/com/paligot/confily/backend/quiz/infrastructure/exposed/QuizQuestionEntity.kt b/backend/src/main/java/com/paligot/confily/backend/quiz/infrastructure/exposed/QuizQuestionEntity.kt index 8ef391350..ed7104aeb 100644 --- a/backend/src/main/java/com/paligot/confily/backend/quiz/infrastructure/exposed/QuizQuestionEntity.kt +++ b/backend/src/main/java/com/paligot/confily/backend/quiz/infrastructure/exposed/QuizQuestionEntity.kt @@ -12,6 +12,9 @@ import java.util.UUID class QuizQuestionEntity(id: EntityID) : UUIDEntity(id) { companion object : UUIDEntityClass(QuizQuestionsTable) { + fun findByEvent(eventId: UUID): SizedIterable = this + .find { QuizQuestionsTable.eventId eq eventId } + fun findByPartner(partnerId: UUID): SizedIterable = this .find { QuizQuestionsTable.partnerId eq partnerId } diff --git a/backend/src/test/java/com/paligot/confily/backend/events/EventFeaturesQuizTest.kt b/backend/src/test/java/com/paligot/confily/backend/events/EventFeaturesQuizTest.kt new file mode 100644 index 000000000..175ec545e --- /dev/null +++ b/backend/src/test/java/com/paligot/confily/backend/events/EventFeaturesQuizTest.kt @@ -0,0 +1,55 @@ +package com.paligot.confily.backend.events + +import com.paligot.confily.backend.addresses.infrastructure.provider.GeocodeApi +import com.paligot.confily.backend.events.application.EventRepositoryExposed +import com.paligot.confily.backend.events.infrastructure.exposed.EventEntity +import com.paligot.confily.backend.events.infrastructure.exposed.EventFeatureEntity +import com.paligot.confily.backend.events.infrastructure.exposed.FeatureKey +import com.paligot.confily.backend.internals.infrastructure.exposed.DatabaseFactory +import com.paligot.confily.backend.quiz.QuizTestFixtures +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.transaction +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventFeaturesQuizTest { + private lateinit var database: Database + private lateinit var repository: EventRepositoryExposed + + @BeforeTest + fun setup() { + database = DatabaseFactory.createTestDatabase() + repository = EventRepositoryExposed(database, mockk(relaxed = true)) + } + + @Test + fun `hasQuiz is false for an event with no quiz content`() = runBlocking { + val eventId = QuizTestFixtures.createEvent(database) + assertFalse(repository.getV4(eventId.toString()).features.hasQuiz) + } + + @Test + fun `hasQuiz is true when the event has at least one quiz question`() = runBlocking { + val eventId = QuizTestFixtures.createEvent(database) + val partnerId = QuizTestFixtures.createPartner(database, eventId, quizCode = "ABCD") + QuizTestFixtures.createQuestion(database, eventId, partnerId, "Q1", listOf("a", "b"), correctIndex = 0) + assertTrue(repository.getV4(eventId.toString()).features.hasQuiz) + } + + @Test + fun `hasQuiz is true when the Quiz feature flag is enabled even without questions`() = runBlocking { + val eventId = QuizTestFixtures.createEvent(database) + transaction(database) { + EventFeatureEntity.new { + this.event = EventEntity[eventId] + this.featureKey = FeatureKey.Quiz + this.enabled = true + } + } + assertTrue(repository.getV4(eventId.toString()).features.hasQuiz) + } +} diff --git a/backend/src/test/java/com/paligot/confily/backend/quiz/QuizQuestionEntityTest.kt b/backend/src/test/java/com/paligot/confily/backend/quiz/QuizQuestionEntityTest.kt new file mode 100644 index 000000000..749a067d3 --- /dev/null +++ b/backend/src/test/java/com/paligot/confily/backend/quiz/QuizQuestionEntityTest.kt @@ -0,0 +1,33 @@ +package com.paligot.confily.backend.quiz + +import com.paligot.confily.backend.internals.infrastructure.exposed.DatabaseFactory +import com.paligot.confily.backend.quiz.infrastructure.exposed.QuizQuestionEntity +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.transactions.transaction +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuizQuestionEntityTest { + private lateinit var database: Database + + @BeforeTest + fun setup() { + database = DatabaseFactory.createTestDatabase() + } + + @Test + fun `findByEvent returns questions for the event and is empty for another`() { + val eventId = QuizTestFixtures.createEvent(database) + val otherEventId = QuizTestFixtures.createEvent(database) + val partnerId = QuizTestFixtures.createPartner(database, eventId, quizCode = "ABCD") + QuizTestFixtures.createQuestion(database, eventId, partnerId, "Q1", listOf("a", "b"), correctIndex = 0) + + transaction(database) { + assertTrue(QuizQuestionEntity.findByEvent(eventId).empty().not()) + assertEquals(1, QuizQuestionEntity.findByEvent(eventId).count()) + assertTrue(QuizQuestionEntity.findByEvent(otherEventId).empty()) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 183ac3192..8e5d8d10b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -188,6 +188,7 @@ ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version ktor-client-ios = { group = "io.ktor", name = "ktor-client-ios", version.ref = "ktor" } ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = "ktor" } ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktor" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } diff --git a/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/ConferenceApi.kt b/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/ConferenceApi.kt index 77599f19b..d029458d9 100644 --- a/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/ConferenceApi.kt +++ b/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/ConferenceApi.kt @@ -6,8 +6,13 @@ import com.paligot.confily.models.CreatedMap import com.paligot.confily.models.EventList import com.paligot.confily.models.EventMap import com.paligot.confily.models.EventV5 +import com.paligot.confily.models.LeaderboardEntry import com.paligot.confily.models.PartnersActivities +import com.paligot.confily.models.QuizQuestion +import com.paligot.confily.models.QuizSubmissionResult import com.paligot.confily.models.inputs.MapInput +import com.paligot.confily.models.inputs.QuizPlayerInput +import com.paligot.confily.models.inputs.QuizSubmissionInput import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine @@ -25,6 +30,7 @@ import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -115,6 +121,39 @@ class ConferenceApi( ) }.body() + suspend fun registerQuizPlayer(eventId: String, input: QuizPlayerInput) { + client.post("$baseUrl/events/$eventId/quiz/players") { + contentType(ContentType.Application.Json) + setBody(input) + } + } + + suspend fun fetchQuizQuestions(eventId: String, code: String): List { + val response = client.get("$baseUrl/events/$eventId/quiz/partners/$code") + if (response.status == HttpStatusCode.NotFound) throw QuizException.CodeNotFound(code) + return response.body() + } + + suspend fun submitQuiz( + eventId: String, + code: String, + input: QuizSubmissionInput + ): QuizSubmissionResult { + val response = client.post("$baseUrl/events/$eventId/quiz/partners/$code/submit") { + contentType(ContentType.Application.Json) + setBody(input) + } + when (response.status) { + HttpStatusCode.Conflict -> throw QuizException.AlreadySubmitted() + HttpStatusCode.NotFound -> throw QuizException.PlayerNotRegistered() + else -> Unit + } + return response.body() + } + + suspend fun fetchQuizLeaderboard(eventId: String): List = + client.get("$baseUrl/events/$eventId/quiz/leaderboard").body() + companion object { fun create( baseUrl: String, diff --git a/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/QuizException.kt b/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/QuizException.kt new file mode 100644 index 000000000..e6470d8b5 --- /dev/null +++ b/shared/core-api/src/commonMain/kotlin/com/paligot/confily/core/api/QuizException.kt @@ -0,0 +1,7 @@ +package com.paligot.confily.core.api + +sealed class QuizException(message: String) : Exception(message) { + class CodeNotFound(code: String) : QuizException("No quiz found for code $code") + class AlreadySubmitted : QuizException("Answers already submitted for this quiz") + class PlayerNotRegistered : QuizException("Player not registered for this device") +} diff --git a/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/4.sqm b/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/4.sqm new file mode 100644 index 000000000..3cd58fd73 --- /dev/null +++ b/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/4.sqm @@ -0,0 +1 @@ +ALTER TABLE FeaturesActivated ADD COLUMN has_quiz INTEGER NOT NULL DEFAULT 0; diff --git a/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/FeaturesActivated.sq b/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/FeaturesActivated.sq index c26de4f6e..68bfe2d40 100644 --- a/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/FeaturesActivated.sq +++ b/shared/core-db/src/commonMain/sqldelight/com/paligot/confily/db/FeaturesActivated.sq @@ -10,15 +10,16 @@ has_qanda INTEGER AS Boolean NOT NULL DEFAULT 0, has_billet_web_ticket INTEGER AS Boolean NOT NULL DEFAULT 0, has_team_members INTEGER AS Boolean NOT NULL DEFAULT 0, has_maps INTEGER AS Boolean NOT NULL DEFAULT 0, +has_quiz INTEGER AS Boolean NOT NULL DEFAULT 0, open_feedback_enabled INTEGER AS Boolean NOT NULL DEFAULT 1 ); insertFeatures: INSERT OR REPLACE INTO FeaturesActivated( -event_id, has_networking, has_speaker_list, has_partner_list, has_menus, has_qanda, has_billet_web_ticket, has_team_members, has_maps, open_feedback_enabled -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +event_id, has_networking, has_speaker_list, has_partner_list, has_menus, has_qanda, has_billet_web_ticket, has_team_members, has_maps, has_quiz, open_feedback_enabled +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); selectFeatures: -SELECT event_id, has_networking, has_speaker_list, has_partner_list, has_menus, has_qanda, has_billet_web_ticket, has_team_members, has_maps, open_feedback_enabled +SELECT event_id, has_networking, has_speaker_list, has_partner_list, has_menus, has_qanda, has_billet_web_ticket, has_team_members, has_maps, has_quiz, open_feedback_enabled FROM FeaturesActivated WHERE event_id == ?; diff --git a/shared/core-di/src/androidMain/kotlin/com/paligot/confily/core/di/AndroidPlatformModule.kt b/shared/core-di/src/androidMain/kotlin/com/paligot/confily/core/di/AndroidPlatformModule.kt index 011651255..79c124380 100644 --- a/shared/core-di/src/androidMain/kotlin/com/paligot/confily/core/di/AndroidPlatformModule.kt +++ b/shared/core-di/src/androidMain/kotlin/com/paligot/confily/core/di/AndroidPlatformModule.kt @@ -1,6 +1,8 @@ package com.paligot.confily.core.di import android.content.Context +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.DeviceIdProviderAndroid import com.paligot.confily.core.Platform import com.paligot.confily.core.QrCodeGenerator import com.paligot.confily.core.QrCodeGeneratorAndroid @@ -33,4 +35,5 @@ actual val platformModule = module { } single(named(AcceptLanguageNamed)) { Locale.getDefault().toLanguageTag() } single { QrCodeGeneratorAndroid() } + single { DeviceIdProviderAndroid(androidContext()) } } diff --git a/shared/core-di/src/commonMain/kotlin/com/paligot/confily/core/di/RepositoryModule.kt b/shared/core-di/src/commonMain/kotlin/com/paligot/confily/core/di/RepositoryModule.kt index eb9ac1d1d..119d4ad40 100644 --- a/shared/core-di/src/commonMain/kotlin/com/paligot/confily/core/di/RepositoryModule.kt +++ b/shared/core-di/src/commonMain/kotlin/com/paligot/confily/core/di/RepositoryModule.kt @@ -4,6 +4,7 @@ import com.paligot.confily.core.events.EventRepository import com.paligot.confily.core.maps.MapRepository import com.paligot.confily.core.networking.UserRepository import com.paligot.confily.core.partners.PartnerRepository +import com.paligot.confily.core.quiz.QuizRepository import com.paligot.confily.core.schedules.SessionRepository import com.paligot.confily.core.speakers.SpeakerRepository import org.koin.dsl.module @@ -56,4 +57,11 @@ val repositoriesModule = module { mapDao = get() ) } + single { + QuizRepository.Factory.create( + api = get(), + settings = get(), + deviceIdProvider = get() + ) + } } diff --git a/shared/core-di/src/desktopMain/kotlin/com/paligot/confily/core/di/PlatformModule.desktop.kt b/shared/core-di/src/desktopMain/kotlin/com/paligot/confily/core/di/PlatformModule.desktop.kt index 42278f2b2..c72959170 100644 --- a/shared/core-di/src/desktopMain/kotlin/com/paligot/confily/core/di/PlatformModule.desktop.kt +++ b/shared/core-di/src/desktopMain/kotlin/com/paligot/confily/core/di/PlatformModule.desktop.kt @@ -1,6 +1,8 @@ package com.paligot.confily.core.di import com.paligot.confily.core.AlarmScheduler +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.DeviceIdProviderDesktop import com.paligot.confily.core.Platform import com.paligot.confily.core.QrCodeGenerator import com.paligot.confily.core.QrCodeGeneratorJvm @@ -22,4 +24,5 @@ actual val platformModule = module { single(named(AcceptLanguageNamed)) { Locale.getDefault().toLanguageTag() } single { QrCodeGeneratorJvm() } single { AlarmScheduler(repository = get()) } + single { DeviceIdProviderDesktop() } } diff --git a/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/Helper.kt b/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/Helper.kt index e93b2368e..fe977df49 100644 --- a/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/Helper.kt +++ b/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/Helper.kt @@ -7,6 +7,7 @@ import com.paligot.confily.core.networking.UserInteractor import com.paligot.confily.core.networking.UserRepository import com.paligot.confily.core.partners.PartnerInteractor import com.paligot.confily.core.partners.PartnerRepository +import com.paligot.confily.core.quiz.QuizRepository import com.paligot.confily.core.schedules.SessionRepository import com.paligot.confily.core.sessions.SessionInteractor import com.paligot.confily.core.speakers.SpeakerInteractor @@ -35,6 +36,7 @@ class RepositoryHelper : KoinComponent { val eventRepository: EventRepository by inject() val speakerRepository: SpeakerRepository by inject() val userRepository: UserRepository by inject() + val quizRepository: QuizRepository by inject() } class InteractorHelper : KoinComponent { diff --git a/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/IOSPlatformModule.kt b/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/IOSPlatformModule.kt index 7a715e677..ef9802f15 100644 --- a/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/IOSPlatformModule.kt +++ b/shared/core-di/src/iosMain/kotlin/com/paligot/confily/core/di/IOSPlatformModule.kt @@ -1,5 +1,7 @@ package com.paligot.confily.core.di +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.DeviceIdProviderIos import com.paligot.confily.core.Platform import com.paligot.confily.core.QrCodeGenerator import com.paligot.confily.core.QrCodeGeneratoriOS @@ -23,4 +25,5 @@ actual val platformModule = module { single { NSUserDefaultsSettings(standardUserDefaults) } single(named(AcceptLanguageNamed)) { NSLocale.preferredLanguages.first().toString() } single { QrCodeGeneratoriOS() } + single { DeviceIdProviderIos() } } diff --git a/shared/core-di/src/wasmJsMain/kotlin/com/paligot/confily/core/di/PlatformModule.wasmJs.kt b/shared/core-di/src/wasmJsMain/kotlin/com/paligot/confily/core/di/PlatformModule.wasmJs.kt index 27d7b4deb..4fd1489c4 100644 --- a/shared/core-di/src/wasmJsMain/kotlin/com/paligot/confily/core/di/PlatformModule.wasmJs.kt +++ b/shared/core-di/src/wasmJsMain/kotlin/com/paligot/confily/core/di/PlatformModule.wasmJs.kt @@ -1,5 +1,7 @@ package com.paligot.confily.core.di +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.DeviceIdProviderWasmJs import com.paligot.confily.core.Platform import com.paligot.confily.core.QrCodeGenerator import com.paligot.confily.core.QrCodeGeneratorWasm @@ -21,4 +23,5 @@ actual val platformModule: Module = module { navigatorLanguage().unsafeCast().toString() } single { QrCodeGeneratorWasm() } + single { DeviceIdProviderWasmJs() } } diff --git a/shared/core-kvalue/src/commonMain/kotlin/com/paligot/confily/core/kvalue/ConferenceSettings.kt b/shared/core-kvalue/src/commonMain/kotlin/com/paligot/confily/core/kvalue/ConferenceSettings.kt index 31495b828..71119bd79 100644 --- a/shared/core-kvalue/src/commonMain/kotlin/com/paligot/confily/core/kvalue/ConferenceSettings.kt +++ b/shared/core-kvalue/src/commonMain/kotlin/com/paligot/confily/core/kvalue/ConferenceSettings.kt @@ -27,4 +27,18 @@ class ConferenceSettings( fun upsertOnlyFavoritesFlag(onlyFavorites: Boolean) = settings.putBoolean("ONLY_FAVORITES", onlyFavorites) + + fun getQuizDeviceId(): String? = settings.getStringOrNull("QUIZ_DEVICE_ID") + + fun putQuizDeviceId(deviceId: String) = settings.putString("QUIZ_DEVICE_ID", deviceId) + + fun fetchQuizUsername(): Flow = settings.getStringOrNullFlow("QUIZ_USERNAME") + + fun getQuizUsername(): String? = settings.getStringOrNull("QUIZ_USERNAME") + + fun putQuizUsername(username: String) = settings.putString("QUIZ_USERNAME", username) + + fun getQuizResults(): String? = settings.getStringOrNull("QUIZ_RESULTS") + + fun putQuizResults(resultsJson: String) = settings.putString("QUIZ_RESULTS", resultsJson) } diff --git a/shared/core/build.gradle.kts b/shared/core/build.gradle.kts index bef6eb8b2..28a9c97e2 100644 --- a/shared/core/build.gradle.kts +++ b/shared/core/build.gradle.kts @@ -75,6 +75,17 @@ kotlin { api(libs.jetbrains.kotlinx.coroutines) implementation(libs.lyricist) + implementation(libs.jetbrains.kotlinx.serialization.json) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.jetbrains.kotlinx.coroutines.test) + implementation(libs.settings.test) + implementation(libs.ktor.client.mock) + implementation(libs.ktor.client.negotiation) + implementation(libs.ktor.serialization.json) } } val mobileMain by creating { @@ -105,11 +116,6 @@ kotlin { dependsOn(commonMain) dependsOn(mobileMain) } - wasmJsMain { - dependencies { - implementation(libs.jetbrains.kotlinx.serialization.json) - } - } } sourceSets.all { diff --git a/shared/core/src/androidMain/kotlin/com/paligot/confily/core/DeviceIdProviderAndroid.kt b/shared/core/src/androidMain/kotlin/com/paligot/confily/core/DeviceIdProviderAndroid.kt new file mode 100644 index 000000000..4fd7d1d1d --- /dev/null +++ b/shared/core/src/androidMain/kotlin/com/paligot/confily/core/DeviceIdProviderAndroid.kt @@ -0,0 +1,19 @@ +package com.paligot.confily.core + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceIdProviderAndroid(private val context: Context) : DeviceIdProvider { + @OptIn(ExperimentalUuidApi::class) + @SuppressLint("HardwareIds") + override fun deviceId(): String { + val androidId = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) + return if (androidId.isNullOrBlank()) Uuid.random().toString() else androidId + } +} diff --git a/shared/core/src/commonMain/kotlin/com/paligot/confily/core/DeviceIdProvider.kt b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/DeviceIdProvider.kt new file mode 100644 index 000000000..dcbfcc9c8 --- /dev/null +++ b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/DeviceIdProvider.kt @@ -0,0 +1,6 @@ +package com.paligot.confily.core + +interface DeviceIdProvider { + /** Returns a non-blank, best-effort stable identifier for this device/install. */ + fun deviceId(): String +} diff --git a/shared/core/src/commonMain/kotlin/com/paligot/confily/core/events/entities/FeatureFlags.kt b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/events/entities/FeatureFlags.kt index 1e90784c5..7ccd4f68b 100644 --- a/shared/core/src/commonMain/kotlin/com/paligot/confily/core/events/entities/FeatureFlags.kt +++ b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/events/entities/FeatureFlags.kt @@ -6,6 +6,7 @@ import kotlin.native.ObjCName class FeatureFlags( val hasSpeakerList: Boolean, val hasNetworking: Boolean, + val hasQuiz: Boolean, val hasPartnerList: Boolean, val hasMenus: Boolean, val hasQAndA: Boolean, diff --git a/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepository.kt b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepository.kt new file mode 100644 index 000000000..33349bd67 --- /dev/null +++ b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepository.kt @@ -0,0 +1,26 @@ +package com.paligot.confily.core.quiz + +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.api.ConferenceApi +import com.paligot.confily.core.kvalue.ConferenceSettings +import com.paligot.confily.models.QuizQuestion +import com.paligot.confily.models.QuizSubmissionResult +import com.paligot.confily.models.inputs.QuizAnswerInput +import kotlinx.coroutines.flow.Flow + +interface QuizRepository { + suspend fun register(username: String) + fun storedUsername(): Flow + suspend fun questions(code: String): List + suspend fun submit(code: String, answers: List): QuizSubmissionResult + fun savedResult(code: String): QuizSubmissionResult? + suspend fun cumulativeScore(): Int? + + object Factory { + fun create( + api: ConferenceApi, + settings: ConferenceSettings, + deviceIdProvider: DeviceIdProvider + ): QuizRepository = QuizRepositoryImpl(api, settings, deviceIdProvider) + } +} diff --git a/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepositoryImpl.kt b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepositoryImpl.kt new file mode 100644 index 000000000..b1ad405a7 --- /dev/null +++ b/shared/core/src/commonMain/kotlin/com/paligot/confily/core/quiz/QuizRepositoryImpl.kt @@ -0,0 +1,70 @@ +package com.paligot.confily.core.quiz + +import com.paligot.confily.core.DeviceIdProvider +import com.paligot.confily.core.api.ConferenceApi +import com.paligot.confily.core.kvalue.ConferenceSettings +import com.paligot.confily.models.QuizQuestion +import com.paligot.confily.models.QuizSubmissionResult +import com.paligot.confily.models.inputs.QuizAnswerInput +import com.paligot.confily.models.inputs.QuizPlayerInput +import com.paligot.confily.models.inputs.QuizSubmissionInput +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal class QuizRepositoryImpl( + private val api: ConferenceApi, + private val settings: ConferenceSettings, + private val deviceIdProvider: DeviceIdProvider +) : QuizRepository { + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun register(username: String) { + val eventId = settings.getEventId() + api.registerQuizPlayer( + eventId = eventId, + input = QuizPlayerInput(username = username, deviceId = deviceId()) + ) + settings.putQuizUsername(username) + } + + override fun storedUsername(): Flow = settings.fetchQuizUsername() + + override suspend fun questions(code: String): List = + api.fetchQuizQuestions(eventId = settings.getEventId(), code = code) + + override suspend fun submit( + code: String, + answers: List + ): QuizSubmissionResult { + val result = api.submitQuiz( + eventId = settings.getEventId(), + code = code, + input = QuizSubmissionInput(deviceId = deviceId(), answers = answers) + ) + saveResult(code, result) + return result + } + + override fun savedResult(code: String): QuizSubmissionResult? = readResults()[code] + + override suspend fun cumulativeScore(): Int? { + val username = settings.getQuizUsername() ?: return null + return api.fetchQuizLeaderboard(settings.getEventId()) + .firstOrNull { it.username == username } + ?.score + } + + private fun deviceId(): String = + settings.getQuizDeviceId() ?: deviceIdProvider.deviceId().also { settings.putQuizDeviceId(it) } + + private fun readResults(): Map = + settings.getQuizResults()?.let { + runCatching { json.decodeFromString>(it) }.getOrNull() + } ?: emptyMap() + + private fun saveResult(code: String, result: QuizSubmissionResult) { + val updated = readResults() + (code to result) + settings.putQuizResults(json.encodeToString(updated)) + } +} diff --git a/shared/core/src/commonTest/kotlin/com/paligot/confily/core/SmokeTest.kt b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/SmokeTest.kt new file mode 100644 index 000000000..2a84a3810 --- /dev/null +++ b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/SmokeTest.kt @@ -0,0 +1,11 @@ +package com.paligot.confily.core + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SmokeTest { + @Test + fun commonTest_source_set_runs() { + assertEquals(4, 2 + 2) + } +} diff --git a/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/FakeDeviceIdProvider.kt b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/FakeDeviceIdProvider.kt new file mode 100644 index 000000000..71e23b478 --- /dev/null +++ b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/FakeDeviceIdProvider.kt @@ -0,0 +1,13 @@ +package com.paligot.confily.core.quiz + +import com.paligot.confily.core.DeviceIdProvider + +class FakeDeviceIdProvider(private val id: String = "device-1") : DeviceIdProvider { + var callCount: Int = 0 + private set + + override fun deviceId(): String { + callCount++ + return id + } +} diff --git a/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizApiTestSupport.kt b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizApiTestSupport.kt new file mode 100644 index 000000000..5b01b9517 --- /dev/null +++ b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizApiTestSupport.kt @@ -0,0 +1,41 @@ +package com.paligot.confily.core.quiz + +import com.paligot.confily.core.api.ConferenceApi +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + +class RecordingResponder { + val requestedPaths = mutableListOf() + val requestBodies = mutableListOf() + val routes = mutableMapOf>() + + fun on(pathSuffix: String, status: HttpStatusCode = HttpStatusCode.OK, body: String = "") { + routes[pathSuffix] = status to body + } +} + +fun conferenceApiWith(responder: RecordingResponder): ConferenceApi { + val engine = MockEngine { request -> + responder.requestedPaths.add(request.url.encodedPath) + responder.requestBodies.add((request.body as? io.ktor.http.content.TextContent)?.text ?: "") + val match = responder.routes.entries.firstOrNull { request.url.encodedPath.endsWith(it.key) } + ?: error("No mock route for ${request.url.encodedPath}") + respond(content = match.value.second, status = match.value.first, headers = jsonHeaders) + } + val client = HttpClient(engine) { + install(ContentNegotiation) { + json(Json { isLenient = true; ignoreUnknownKeys = true }) + } + } + return ConferenceApi(client = client, baseUrl = "https://test.local") +} diff --git a/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizRepositoryTest.kt b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizRepositoryTest.kt new file mode 100644 index 000000000..16afbd387 --- /dev/null +++ b/shared/core/src/commonTest/kotlin/com/paligot/confily/core/quiz/QuizRepositoryTest.kt @@ -0,0 +1,153 @@ +package com.paligot.confily.core.quiz + +import com.paligot.confily.core.api.QuizException +import com.paligot.confily.core.kvalue.ConferenceSettings +import com.paligot.confily.models.inputs.QuizAnswerInput +import com.russhwolf.settings.MapSettings +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QuizRepositoryTest { + private fun settingsWithEvent(): ConferenceSettings = + ConferenceSettings(MapSettings()).apply { insertEventId("event-1") } + + private val questionsJson = """ + [{"id":"q1","order":0,"question":"Capital of France?","options":[ + {"id":"a1","label":"Paris","order":0},{"id":"a2","label":"Lyon","order":1}]}] + """.trimIndent() + + private val submissionResultJson = """ + {"correct_count":1,"total_count":1,"per_question":[ + {"question_id":"q1","chosen_answer_id":"a1","correct_answer_id":"a1","is_correct":true}]} + """.trimIndent() + + @Test + fun register_persists_username_and_posts_player() = runTest { + val responder = RecordingResponder().apply { on("/quiz/players", HttpStatusCode.Created, """{"id":"p1"}""") } + val settings = settingsWithEvent() + val provider = FakeDeviceIdProvider("device-1") + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settings, provider) + + repo.register("Alice") + + assertEquals("Alice", settings.getQuizUsername()) + assertEquals("device-1", settings.getQuizDeviceId()) + assertTrue(responder.requestedPaths.any { it.endsWith("/quiz/players") }) + assertTrue(responder.requestBodies.any { it.contains("Alice") && it.contains("device-1") }) + } + + @Test + fun questions_returns_parsed_list() = runTest { + val responder = RecordingResponder().apply { on("/quiz/partners/ABCD", HttpStatusCode.OK, questionsJson) } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settingsWithEvent(), FakeDeviceIdProvider()) + + val questions = repo.questions("ABCD") + + assertEquals(1, questions.size) + assertEquals("Capital of France?", questions[0].question) + assertEquals(listOf("Paris", "Lyon"), questions[0].options.map { it.label }) + } + + @Test + fun questions_maps_404_to_CodeNotFound() = runTest { + val responder = RecordingResponder().apply { on("/quiz/partners/ZZZZ", HttpStatusCode.NotFound, "") } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settingsWithEvent(), FakeDeviceIdProvider()) + + assertFailsWith { repo.questions("ZZZZ") } + } + + @Test + fun submit_returns_result_and_persists_it() = runTest { + val responder = RecordingResponder().apply { + on("/quiz/partners/ABCD/submit", HttpStatusCode.Created, submissionResultJson) + } + val settings = settingsWithEvent() + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settings, FakeDeviceIdProvider()) + + val result = repo.submit("ABCD", listOf(QuizAnswerInput("q1", "a1"))) + + assertEquals(1, result.correctCount) + assertEquals(1, result.totalCount) + assertEquals(result.correctCount, repo.savedResult("ABCD")?.correctCount) + } + + @Test + fun submit_maps_409_to_AlreadySubmitted() = runTest { + val responder = RecordingResponder().apply { on("/quiz/partners/ABCD/submit", HttpStatusCode.Conflict, "") } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settingsWithEvent(), FakeDeviceIdProvider()) + + assertFailsWith { repo.submit("ABCD", listOf(QuizAnswerInput("q1", "a1"))) } + } + + @Test + fun submit_maps_404_to_PlayerNotRegistered() = runTest { + val responder = RecordingResponder().apply { on("/quiz/partners/ABCD/submit", HttpStatusCode.NotFound, "") } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settingsWithEvent(), FakeDeviceIdProvider()) + + assertFailsWith { repo.submit("ABCD", listOf(QuizAnswerInput("q1", "a1"))) } + } + + @Test + fun savedResult_is_null_before_submission() = runTest { + val repo = QuizRepository.Factory.create(conferenceApiWith(RecordingResponder()), settingsWithEvent(), FakeDeviceIdProvider()) + assertNull(repo.savedResult("ABCD")) + } + + @Test + fun cumulativeScore_matches_stored_username() = runTest { + val responder = RecordingResponder().apply { + on("/quiz/leaderboard", HttpStatusCode.OK, """[{"rank":1,"username":"Bob","score":9},{"rank":2,"username":"Alice","score":4}]""") + } + val settings = settingsWithEvent().apply { putQuizUsername("Alice") } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settings, FakeDeviceIdProvider()) + + assertEquals(4, repo.cumulativeScore()) + } + + @Test + fun cumulativeScore_is_null_when_user_not_in_leaderboard() = runTest { + val responder = RecordingResponder().apply { on("/quiz/leaderboard", HttpStatusCode.OK, "[]") } + val settings = settingsWithEvent().apply { putQuizUsername("Alice") } + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settings, FakeDeviceIdProvider()) + + assertNull(repo.cumulativeScore()) + } + + @Test + fun cumulativeScore_is_null_when_no_username_stored() = runTest { + val repo = QuizRepository.Factory.create( + conferenceApiWith(RecordingResponder()), + settingsWithEvent(), + FakeDeviceIdProvider() + ) + assertNull(repo.cumulativeScore()) + } + + @Test + fun savedResult_returns_null_for_corrupt_stored_results() = runTest { + val settings = settingsWithEvent().apply { putQuizResults("not-valid-json") } + val repo = QuizRepository.Factory.create(conferenceApiWith(RecordingResponder()), settings, FakeDeviceIdProvider()) + assertNull(repo.savedResult("ABCD")) + } + + @Test + fun deviceId_is_reused_after_first_registration() = runTest { + val responder = RecordingResponder().apply { + on("/quiz/players", HttpStatusCode.Created, """{"id":"p1"}""") + on("/quiz/partners/ABCD/submit", HttpStatusCode.Created, submissionResultJson) + } + val settings = settingsWithEvent() + val provider = FakeDeviceIdProvider("device-1") + val repo = QuizRepository.Factory.create(conferenceApiWith(responder), settings, provider) + + repo.register("Alice") + repo.submit("ABCD", listOf(QuizAnswerInput("q1", "a1"))) + + assertEquals(1, provider.callCount) + } +} diff --git a/shared/core/src/desktopMain/kotlin/com/paligot/confily/core/DeviceIdProviderDesktop.kt b/shared/core/src/desktopMain/kotlin/com/paligot/confily/core/DeviceIdProviderDesktop.kt new file mode 100644 index 000000000..9714bce28 --- /dev/null +++ b/shared/core/src/desktopMain/kotlin/com/paligot/confily/core/DeviceIdProviderDesktop.kt @@ -0,0 +1,9 @@ +package com.paligot.confily.core + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceIdProviderDesktop : DeviceIdProvider { + @OptIn(ExperimentalUuidApi::class) + override fun deviceId(): String = Uuid.random().toString() +} diff --git a/shared/core/src/iosMain/kotlin/com/paligot/confily/core/DeviceIdProviderIos.kt b/shared/core/src/iosMain/kotlin/com/paligot/confily/core/DeviceIdProviderIos.kt new file mode 100644 index 000000000..7874ee81c --- /dev/null +++ b/shared/core/src/iosMain/kotlin/com/paligot/confily/core/DeviceIdProviderIos.kt @@ -0,0 +1,11 @@ +package com.paligot.confily.core + +import platform.UIKit.UIDevice +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceIdProviderIos : DeviceIdProvider { + @OptIn(ExperimentalUuidApi::class) + override fun deviceId(): String = + UIDevice.currentDevice.identifierForVendor?.UUIDString ?: Uuid.random().toString() +} diff --git a/shared/core/src/mobileMain/kotlin/com/paligot/confily/core/events/EventDaoSQLDelight.kt b/shared/core/src/mobileMain/kotlin/com/paligot/confily/core/events/EventDaoSQLDelight.kt index 719ff7796..ed6c1113f 100644 --- a/shared/core/src/mobileMain/kotlin/com/paligot/confily/core/events/EventDaoSQLDelight.kt +++ b/shared/core/src/mobileMain/kotlin/com/paligot/confily/core/events/EventDaoSQLDelight.kt @@ -99,6 +99,7 @@ class EventDaoSQLDelight( .map { features -> FeatureFlags( hasNetworking = features?.has_networking ?: false, + hasQuiz = features?.has_quiz ?: false, hasSpeakerList = features?.has_speaker_list ?: false, hasPartnerList = features?.has_partner_list ?: false, hasMenus = features?.has_menus ?: false, @@ -200,6 +201,7 @@ class EventDaoSQLDelight( has_billet_web_ticket = event.features.hasBilletWebTicket, has_team_members = event.team.isNotEmpty(), has_maps = event.maps.isNotEmpty(), + has_quiz = event.features.hasQuiz, open_feedback_enabled = event.features.openFeedbackEnabled ) } diff --git a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/DeviceIdProviderWasmJs.kt b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/DeviceIdProviderWasmJs.kt new file mode 100644 index 000000000..cfd71f452 --- /dev/null +++ b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/DeviceIdProviderWasmJs.kt @@ -0,0 +1,9 @@ +package com.paligot.confily.core + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceIdProviderWasmJs : DeviceIdProvider { + @OptIn(ExperimentalUuidApi::class) + override fun deviceId(): String = Uuid.random().toString() +} diff --git a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/agenda/NetMappers.kt b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/agenda/NetMappers.kt index e0c75cb2e..3c735c0ba 100644 --- a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/agenda/NetMappers.kt +++ b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/agenda/NetMappers.kt @@ -100,6 +100,7 @@ fun FeaturesActivated.convertToModelDb( hasBilletWebTicket = hasBilletWebTicket, hasTeamMembers = hasTeamMembers, hasMaps = hasMaps, + hasQuiz = hasQuiz, openFeedbackEnabled = openFeedbackEnabled ) diff --git a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/DbModels.kt b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/DbModels.kt index 4840e1022..4df4ee325 100644 --- a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/DbModels.kt +++ b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/DbModels.kt @@ -42,6 +42,7 @@ class FeaturesActivatedDb( val hasBilletWebTicket: Boolean, val hasTeamMembers: Boolean, val hasMaps: Boolean = false, + val hasQuiz: Boolean = false, val openFeedbackEnabled: Boolean = true ) diff --git a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/EventDaoSettings.kt b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/EventDaoSettings.kt index 3e8fcbe7c..a56d60974 100644 --- a/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/EventDaoSettings.kt +++ b/shared/core/src/wasmJsMain/kotlin/com/paligot/confily/core/events/EventDaoSettings.kt @@ -89,6 +89,7 @@ class EventDaoSettings( .map { features -> FeatureFlags( hasNetworking = false, + hasQuiz = features?.hasQuiz ?: false, hasSpeakerList = true, hasPartnerList = features?.hasPartnerList ?: false, hasMenus = features?.hasMenus ?: false, diff --git a/shared/models/src/commonMain/kotlin/com/paligot/confily/models/Event.kt b/shared/models/src/commonMain/kotlin/com/paligot/confily/models/Event.kt index 4988f78ae..4f481dee1 100644 --- a/shared/models/src/commonMain/kotlin/com/paligot/confily/models/Event.kt +++ b/shared/models/src/commonMain/kotlin/com/paligot/confily/models/Event.kt @@ -56,6 +56,8 @@ data class FeaturesActivated( val hasQAndA: Boolean, @SerialName("has_billet_web_ticket") val hasBilletWebTicket: Boolean, + @SerialName("has_quiz") + val hasQuiz: Boolean = false, @SerialName("open_feedback_enabled") val openFeedbackEnabled: Boolean = true )