From 8e4df44c9cf247fda68f2ff466850fa7998bcd16 Mon Sep 17 00:00:00 2001 From: Joakim Valand Date: Wed, 12 Jun 2024 13:06:15 +0200 Subject: [PATCH 1/4] feat: handle registrations based on timestamp, avoid race conditions --- gradle.properties | 2 +- src/main/kotlin/no/javabin/App.kt | 22 ++- src/main/kotlin/no/javabin/config/Routing.kt | 25 ++-- .../dto/AdminWorkshopRegistrationDTO.kt | 4 +- .../no/javabin/dto/WorkshopRegistrationDTO.kt | 4 +- .../dto/WorkshopRegistrationMessage.kt | 14 ++ .../no/javabin/repository/AdminRepository.kt | 4 +- .../javabin/repository/SpeakerRepository.kt | 4 +- .../WorkshopRegistrationRepository.kt | 61 ++++++--- .../javabin/repository/WorkshopRepository.kt | 2 +- .../no/javabin/service/WorkshopService.kt | 127 +++++++++++------- .../resources/db/migration/V1__initial.sql | 13 +- src/test/kotlin/no/javabin/ApplicationTest.kt | 19 ++- 13 files changed, 200 insertions(+), 101 deletions(-) create mode 100644 src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt diff --git a/gradle.properties b/gradle.properties index 20bd5fe..9d5e465 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ logstash_version=7.4 flyway_version=10.6.0 hikari_version=5.1.0 postgres_version=42.7.1 -exposed_version=0.50.0 +exposed_version=0.50.1 h2_version=2.1.214 kotlinx_datetime_version=0.2.1 diff --git a/src/main/kotlin/no/javabin/App.kt b/src/main/kotlin/no/javabin/App.kt index f94a6cc..2fb06d3 100644 --- a/src/main/kotlin/no/javabin/App.kt +++ b/src/main/kotlin/no/javabin/App.kt @@ -1,9 +1,13 @@ package no.javabin -import no.javabin.config.* import com.inventy.plugins.DatabaseFactory import io.ktor.server.application.* -import no.javabin.repository.UserRepository +import no.javabin.config.configureAuth +import no.javabin.config.configureRouting +import no.javabin.config.configureSerialization +import no.javabin.repository.* +import no.javabin.service.UserService +import no.javabin.service.WorkshopService fun main(args: Array): Unit = @@ -20,6 +24,18 @@ fun Application.module() { embedded = environment.config.property("database.embedded").getString().toBoolean(), ).init() val userRepository = UserRepository() + val workshopRepository = WorkshopRepository() + val workshopRegistrationRepository = WorkshopRegistrationRepository() + val adminRepository = AdminRepository() + val speakerRepository = SpeakerRepository() + val workshopService = WorkshopService( + environment.config, + workshopRepository, + speakerRepository, + workshopRegistrationRepository + ) + val userService = UserService(environment.config, workshopService, userRepository) + configureAuth(userRepository) - configureRouting(userRepository) + configureRouting(userRepository, workshopService, userService, adminRepository) } diff --git a/src/main/kotlin/no/javabin/config/Routing.kt b/src/main/kotlin/no/javabin/config/Routing.kt index 5050df9..9158664 100644 --- a/src/main/kotlin/no/javabin/config/Routing.kt +++ b/src/main/kotlin/no/javabin/config/Routing.kt @@ -1,22 +1,21 @@ package no.javabin.config -import no.javabin.route.* -import no.javabin.service.UserService -import no.javabin.service.WorkshopService import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.response.* import io.ktor.server.routing.* -import no.javabin.repository.* - -fun Application.configureRouting(userRepository: UserRepository) { - val adminRepository = AdminRepository() - val workshopRepository = WorkshopRepository() - val workshopRegistrationRepository = WorkshopRegistrationRepository() - val speakerRepository = SpeakerRepository() - val workshopService = WorkshopService(environment.config, workshopRepository, speakerRepository, workshopRegistrationRepository ) - val userService = UserService(environment.config, workshopService, userRepository) +import no.javabin.repository.AdminRepository +import no.javabin.repository.UserRepository +import no.javabin.route.* +import no.javabin.service.UserService +import no.javabin.service.WorkshopService +fun Application.configureRouting( + userRepository: UserRepository, + workshopService: WorkshopService, + userService: UserService, + adminRepository: AdminRepository, +) { configureAuth0Route(userRepository) configureUserRoutes(userService) configureWorkshopRoutes(workshopService) @@ -26,7 +25,7 @@ fun Application.configureRouting(userRepository: UserRepository) { routing { authenticate("auth0-user") { get("/auth") { - call.respondText("Hello World!") + call.respondText("Hello World User!") } } diff --git a/src/main/kotlin/no/javabin/dto/AdminWorkshopRegistrationDTO.kt b/src/main/kotlin/no/javabin/dto/AdminWorkshopRegistrationDTO.kt index c88fe01..beda84f 100644 --- a/src/main/kotlin/no/javabin/dto/AdminWorkshopRegistrationDTO.kt +++ b/src/main/kotlin/no/javabin/dto/AdminWorkshopRegistrationDTO.kt @@ -1,7 +1,7 @@ package no.javabin.dto -import no.javabin.repository.WorkshopRegistrationState +import no.javabin.repository.WorkshopRegistrationStatus import kotlinx.serialization.Serializable @Serializable -data class AdminWorkshopRegistrationDTO(val firstName: String, val lastName: String, val email: String, val state: WorkshopRegistrationState) +data class AdminWorkshopRegistrationDTO(val firstName: String, val lastName: String, val email: String, val status: WorkshopRegistrationStatus) diff --git a/src/main/kotlin/no/javabin/dto/WorkshopRegistrationDTO.kt b/src/main/kotlin/no/javabin/dto/WorkshopRegistrationDTO.kt index 71d7431..ce7c912 100644 --- a/src/main/kotlin/no/javabin/dto/WorkshopRegistrationDTO.kt +++ b/src/main/kotlin/no/javabin/dto/WorkshopRegistrationDTO.kt @@ -2,12 +2,12 @@ package no.javabin.dto import kotlinx.datetime.Instant import kotlinx.serialization.Serializable -import no.javabin.repository.WorkshopRegistrationState +import no.javabin.repository.WorkshopRegistrationStatus @Serializable class WorkshopRegistrationDTO ( val workshopTitle: String, val workshopStartTime: Instant, val workshopEndTime: Instant, - val state: WorkshopRegistrationState + val status: WorkshopRegistrationStatus ) diff --git a/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt b/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt new file mode 100644 index 0000000..8f4d763 --- /dev/null +++ b/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt @@ -0,0 +1,14 @@ +package no.javabin.dto + +import kotlinx.datetime.Instant + +class WorkshopRegistrationMessage( + val userId: Int, + val workshopId: String, + val createdAt: Instant, + var messageType: WorkshopRegistrationMessageType, +); + +enum class WorkshopRegistrationMessageType { + REGISTER, CANCEL +} \ No newline at end of file diff --git a/src/main/kotlin/no/javabin/repository/AdminRepository.kt b/src/main/kotlin/no/javabin/repository/AdminRepository.kt index a9a7d1c..3ec3e25 100644 --- a/src/main/kotlin/no/javabin/repository/AdminRepository.kt +++ b/src/main/kotlin/no/javabin/repository/AdminRepository.kt @@ -12,7 +12,7 @@ class AdminRepository { userMap[it.user.id]!!.firstName, userMap[it.user.id]!!.lastName, userMap[it.user.id]!!.email, - it.state, + it.status, ) } AdminWorkshopDTO(workshop.value.title, workshop.value.teacherName, registrations) @@ -29,7 +29,7 @@ class AdminRepository { userMap[it.user.id]!!.firstName, userMap[it.user.id]!!.lastName, userMap[it.user.id]!!.email, - it.state, + it.status, ) } return AdminWorkshopDTO(workshop.title, workshop.teacherName, registrations) diff --git a/src/main/kotlin/no/javabin/repository/SpeakerRepository.kt b/src/main/kotlin/no/javabin/repository/SpeakerRepository.kt index fb16242..88ff2c7 100644 --- a/src/main/kotlin/no/javabin/repository/SpeakerRepository.kt +++ b/src/main/kotlin/no/javabin/repository/SpeakerRepository.kt @@ -16,8 +16,8 @@ data class Speaker( class SpeakerRepository { - internal object SpeakerTable : Table() { - val name = varchar("name", 256) + internal object SpeakerTable : Table("speaker") { + val name = varchar("full_name", 256) val bio = varchar("bio", 2048) val twitter = varchar("twitter", 256) val workshopId = reference("workshop_id", WorkshopRepository.WorkshopTable.id) diff --git a/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt b/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt index 16f34c6..94551bf 100644 --- a/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt +++ b/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt @@ -1,71 +1,87 @@ package no.javabin.repository import com.inventy.plugins.DatabaseFactory.Companion.dbQuery +import kotlinx.datetime.Clock import kotlinx.datetime.Instant import no.javabin.dto.WorkshopRegistrationDTO -import no.javabin.repository.WorkshopRegistrationRepository.WorkshopRegistrationTable import no.javabin.util.TimeUtil +import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.kotlin.datetime.timestamp -enum class WorkshopRegistrationState { +enum class WorkshopRegistrationStatus { PENDING, WAITLIST, APPROVED, CANCELLED, } -class WorkshopRegistration( - override val id: Int, +data class WorkshopRegistration( + override val id: Int?, val userId: Int, val workshopId: String, val createdAt: Instant, val updatedAt: Instant, - var state: WorkshopRegistrationState = WorkshopRegistrationState.PENDING, + var status: WorkshopRegistrationStatus = WorkshopRegistrationStatus.PENDING, ) : Model class WorkshopRegistrationRepository { - private object WorkshopRegistrationTable : IntIdTable("workshop_registration") { + internal object WorkshopRegistrationTable : Table("workshop_registration") { val userId = reference("user_id", UserRepository.UserTable.id) val workshopId = reference("workshop_id", WorkshopRepository.WorkshopTable.id) - val state = enumerationByName("state", 64) + val status = enumerationByName("status", 64) val createdAt = timestamp("created_at") val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey( + arrayOf( + userId, workshopId + ), "id" + ) + fun toModel(it: ResultRow) = WorkshopRegistration( - it[id].value, + null, it[userId].value, it[workshopId], it[createdAt], it[updatedAt], - it[state], + it[status], ) fun toDTO(it: ResultRow) = WorkshopRegistrationDTO( workshopTitle = it[WorkshopRepository.WorkshopTable.title], workshopStartTime = TimeUtil.toGmtPlus2(it[WorkshopRepository.WorkshopTable.startTime]), workshopEndTime = TimeUtil.toGmtPlus2(it[WorkshopRepository.WorkshopTable.endTime]), - state = it[state], + status = it[status], ) } - suspend fun create(registration: WorkshopRegistration): Int = dbQuery { - WorkshopRegistrationTable.insertAndGetId { + suspend fun create(registration: WorkshopRegistration) = dbQuery { + WorkshopRegistrationTable.insert { + it[userId] = registration.userId + it[workshopId] = registration.workshopId + it[status] = registration.status + it[createdAt] = registration.createdAt + it[updatedAt] = registration.updatedAt + } + } + + suspend fun createOrUpdate(registration: WorkshopRegistration) = dbQuery { + WorkshopRegistrationTable.upsert { it[userId] = registration.userId it[workshopId] = registration.workshopId - it[state] = registration.state + it[status] = registration.status it[createdAt] = registration.createdAt it[updatedAt] = registration.updatedAt - }.value + } } suspend fun list(userId: Int): List = dbQuery { - ( WorkshopRegistrationTable innerJoin WorkshopRepository.WorkshopTable ) + (WorkshopRegistrationTable innerJoin WorkshopRepository.WorkshopTable) .select( WorkshopRepository.WorkshopTable.title, WorkshopRepository.WorkshopTable.startTime, WorkshopRepository.WorkshopTable.endTime, - WorkshopRegistrationTable.state + WorkshopRegistrationTable.status ).where { WorkshopRegistrationTable.userId eq userId } .map(WorkshopRegistrationTable::toDTO) } @@ -89,14 +105,19 @@ class WorkshopRegistrationRepository { suspend fun getByWorkshop(workshopId: String): List { return dbQuery { WorkshopRegistrationTable.selectAll().where { WorkshopRegistrationTable.workshopId eq workshopId } + .orderBy(WorkshopRegistrationTable.updatedAt) .map(WorkshopRegistrationTable::toModel) } } - suspend fun updateState(id: Int, state: WorkshopRegistrationState) { + suspend fun updateStatus(userId: Int, workshopId: String, status: WorkshopRegistrationStatus): Int { return dbQuery { - WorkshopRegistrationTable.update({ WorkshopRegistrationTable.id eq id }) { - it[WorkshopRegistrationTable.state] = state + WorkshopRegistrationTable.update({ + (WorkshopRegistrationTable.userId eq userId) and (WorkshopRegistrationTable.workshopId eq workshopId) + }) + { + it[WorkshopRegistrationTable.status] = status + it[updatedAt] = Clock.System.now() } } } diff --git a/src/main/kotlin/no/javabin/repository/WorkshopRepository.kt b/src/main/kotlin/no/javabin/repository/WorkshopRepository.kt index fcf0c2e..c756afb 100644 --- a/src/main/kotlin/no/javabin/repository/WorkshopRepository.kt +++ b/src/main/kotlin/no/javabin/repository/WorkshopRepository.kt @@ -77,7 +77,7 @@ class WorkshopRepository { } } - suspend fun listByIdsNotInList(idLIst: List): List = dbQuery { + private suspend fun listByIdsNotInList(idLIst: List): List = dbQuery { WorkshopTable.selectAll().where(WorkshopTable.id notInList idLIst) .map(WorkshopTable::toModel) } diff --git a/src/main/kotlin/no/javabin/service/WorkshopService.kt b/src/main/kotlin/no/javabin/service/WorkshopService.kt index 6b87c27..d5b9ec0 100644 --- a/src/main/kotlin/no/javabin/service/WorkshopService.kt +++ b/src/main/kotlin/no/javabin/service/WorkshopService.kt @@ -1,23 +1,25 @@ package no.javabin.service -import no.javabin.config.defaultClient -import no.javabin.dto.WorkshopDTO -import no.javabin.dto.WorkshopListImportDTO import com.inventy.plugins.DatabaseFactory.Companion.dbQuery import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.server.config.* +import kotlinx.datetime.Clock import no.javabin.config.CustomPrincipal +import no.javabin.config.defaultClient +import no.javabin.dto.WorkshopDTO +import no.javabin.dto.WorkshopListImportDTO import no.javabin.dto.WorkshopRegistrationDTO import no.javabin.repository.* import org.slf4j.LoggerFactory + class WorkshopService( private val config: ApplicationConfig, private val workshopRepository: WorkshopRepository, private val speakerRepository: SpeakerRepository, - private val workshopRegistrationRepository: WorkshopRegistrationRepository + private val workshopRegistrationRepository: WorkshopRegistrationRepository, ) { private val log = LoggerFactory.getLogger(WorkshopService::class.java) @@ -63,61 +65,90 @@ class WorkshopService( } suspend fun registerWorkshop(workshopId: String, user: CustomPrincipal) { - log.info("Registering user ${user.email} for workshop $workshopId") - val workshop = workshopRepository.getById(workshopId) - if (workshop == null) { - log.warn("Workshop $workshopId not found") - return - } - if (!workshop.active) { - log.warn("Workshop $workshopId is not active") - return - } - - val registrations = workshopRegistrationRepository.getByWorkshop(workshopId) - if (registrations.size >= workshop.capacity) { - log.warn("Workshop $workshopId is full") - return - } - + log.info("Registering user [${user.userId},${user.email}] for workshop $workshopId") val registration = workshopRegistrationRepository.getByWorkshopAndUser(workshopId, user.userId) - if (registration != null) { - when(registration.state){ - WorkshopRegistrationState.APPROVED -> { - log.warn("User ${user.email} is already registered for workshop $workshopId") - return - } - WorkshopRegistrationState.PENDING, - WorkshopRegistrationState.WAITLIST, - WorkshopRegistrationState.CANCELLED -> { - log.info("Re-registering user ${user.email} for workshop $workshopId") - workshopRegistrationRepository.updateState(registration.id, WorkshopRegistrationState.APPROVED) - return - } - } - } + if (registration?.status == WorkshopRegistrationStatus.APPROVED) return + if (registration == null) { + createNewRegistration(user.userId, workshopId) + } else if (registration.status == WorkshopRegistrationStatus.CANCELLED) { + workshopRegistrationRepository.updateStatus( + registration.userId, + registration.workshopId, + WorkshopRegistrationStatus.WAITLIST + ) + } + updateWaitlistIfNeeded(workshopId) } suspend fun unregisterWorkshop(workshopId: String, user: CustomPrincipal) { log.info("Unregistering user ${user.email} for workshop $workshopId") - val workshop = workshopRepository.getById(workshopId) - if (workshop == null) { - log.warn("Workshop $workshopId not found") - return - } - if (!workshop.active) { - log.warn("Workshop $workshopId is not active") - return - } val registration = workshopRegistrationRepository.getByWorkshopAndUser(workshopId, user.userId) if (registration == null) { - log.warn("User ${user.email} is not registered for workshop $workshopId") + log.error("Registration not found for workshop ID: $workshopId and user ID: ${user.userId}") return - } else { - workshopRegistrationRepository.updateState(registration.id, WorkshopRegistrationState.CANCELLED) } + val updatedRows = workshopRegistrationRepository.updateStatus( + registration.userId, + registration.workshopId, + WorkshopRegistrationStatus.CANCELLED + ) + + if (updatedRows == 0) { + log.debug("No rows was updated") + } + log.info("Registration cancelled for user: ${registration.userId}, workshop: ${registration.workshopId}") + + updateWaitlistIfNeeded(workshopId) } + private suspend fun createNewRegistration( + userId: Int, + workshopId: String, + ) { + val now = Clock.System.now() + workshopRegistrationRepository.create( + WorkshopRegistration( + id = null, + userId = userId, + workshopId = workshopId, + createdAt = now, + updatedAt = now, + status = WorkshopRegistrationStatus.WAITLIST, + ) + ) + } + + private suspend fun updateWaitlistIfNeeded(workshopId: String) { + val capacity = workshopRepository.getById(workshopId)?.capacity ?: return + val registrations = workshopRegistrationRepository.getByWorkshop(workshopId) + val waitlistRegistrations = registrations.filter { it.status == WorkshopRegistrationStatus.WAITLIST } + val approvedCount = registrations.count { it.status == WorkshopRegistrationStatus.APPROVED } + val freeSpaces = capacity - approvedCount + if (capacity > approvedCount && waitlistRegistrations.isNotEmpty()) { + + log.info("Workshop [$workshopId] has [$freeSpaces] free spaces and [$waitlistRegistrations] in waitlist.\nPromoting waitlisters now.") + promoteWaitlistedRegistrations(waitlistRegistrations, freeSpaces) + + log.info("Updated waitlist for workshop: $workshopId") + } else { + log.info("Skipping waitlist promotion for workshop [$workshopId]. [Free spaces: $freeSpaces, waitlist size: $waitlistRegistrations] free spaces. Promoting waitlisters now.") + } + } + + private suspend fun promoteWaitlistedRegistrations( + waitlistRegistrations: List, + freeSpaces: Int + ) { + waitlistRegistrations + .take(freeSpaces) + .forEach { registration -> + workshopRegistrationRepository.updateStatus( + registration.userId, + registration.workshopId, + WorkshopRegistrationStatus.APPROVED + ) + } + } } diff --git a/src/main/resources/db/migration/V1__initial.sql b/src/main/resources/db/migration/V1__initial.sql index 8e8a908..c0cdf60 100644 --- a/src/main/resources/db/migration/V1__initial.sql +++ b/src/main/resources/db/migration/V1__initial.sql @@ -11,7 +11,8 @@ create table "user" INSERT INTO "user" (first_name, last_name, email, is_admin) VALUES ('Daud', 'Mohamed', 'daud.mohamed.adam@gmail.com', true), - ('Joakim', 'Valand', 'joakim.valand@gmail.com', true); + ('Joakim', 'Valand', 'joakim.valand@gmail.com', true), + ('Elise', 'BrÄtveit', 'elise@test.com', false); create table workshop ( @@ -50,7 +51,7 @@ VALUES ('18aaf407-ff02-4f49-b11d-29877a9de906', 'Moderne brukergrensesnitt i en create table speaker ( index int, - name varchar, + full_name varchar, workshop_id varchar(64), bio varchar, twitter varchar, @@ -63,17 +64,17 @@ create table speaker create table workshop_registration ( - id int GENERATED ALWAYS AS IDENTITY primary key, user_id int, workshop_id varchar(64), - state varchar, + status varchar, created_at timestamp with time zone not null default current_timestamp, updated_at timestamp with time zone not null default current_timestamp, constraint workshop_registration_fk_workshop foreign key (workshop_id) references workshop (id), - constraint workshop_registration_fk_user foreign key (user_id) references "user" (id) + constraint workshop_registration_fk_user foreign key (user_id) references "user" (id), + constraint unique_user_workshop UNIQUE (user_id, workshop_id) ); -INSERT INTO workshop_registration (user_id, workshop_id, state, created_at, updated_at) +INSERT INTO workshop_registration (user_id, workshop_id, status, created_at, updated_at) VALUES ('1', '18aaf407-ff02-4f49-b11d-29877a9de906', 'APPROVED', '2023-09-06T10:20', '2023-09-06T10:20'), ('1', '37cdf4dd-4f9a-4d93-ad9f-eb4994cb2f52', 'PENDING', '2023-09-06T13:00', '2023-09-06T13:00'); diff --git a/src/test/kotlin/no/javabin/ApplicationTest.kt b/src/test/kotlin/no/javabin/ApplicationTest.kt index 540e573..0257c09 100644 --- a/src/test/kotlin/no/javabin/ApplicationTest.kt +++ b/src/test/kotlin/no/javabin/ApplicationTest.kt @@ -5,6 +5,9 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* +import no.javabin.repository.* +import no.javabin.service.UserService +import no.javabin.service.WorkshopService import kotlin.test.* class ApplicationTest { @@ -12,7 +15,21 @@ class ApplicationTest { fun testRoot() = testApplication { System.setProperty("RUNNING_IN_TEST", "true") application { - configureRouting(userRepository) + + val userRepository = UserRepository() + val workshopRepository = WorkshopRepository() + val workshopRegistrationRepository = WorkshopRegistrationRepository() + val adminRepository = AdminRepository() + val speakerRepository = SpeakerRepository() + val workshopService = WorkshopService( + environment.config, + workshopRepository, + speakerRepository, + workshopRegistrationRepository + ) + val userService = UserService(environment.config, workshopService, userRepository) + + configureRouting(userRepository, workshopService, userService, adminRepository) } client.get("/").apply { assertEquals(HttpStatusCode.OK, status) From e16b89314bbc4c0764412f2639cf9d1d018f848e Mon Sep 17 00:00:00 2001 From: Joakim Valand Date: Fri, 14 Jun 2024 13:20:59 +0200 Subject: [PATCH 2/4] feat: add status pages to intercept exceptions and return http code --- build.gradle.kts | 2 +- src/main/kotlin/no/javabin/App.kt | 2 ++ .../kotlin/no/javabin/config/StatusPages.kt.kt | 15 +++++++++++++++ .../exception/DuplicateRegistrationException.kt | 3 +++ .../kotlin/no/javabin/service/WorkshopService.kt | 4 +++- 5 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/no/javabin/config/StatusPages.kt.kt create mode 100644 src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt diff --git a/build.gradle.kts b/build.gradle.kts index 35f7e23..4c8f7df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,7 +64,7 @@ dependencies { implementation("io.ktor:ktor-server-config-yaml:$ktor_version") implementation("io.ktor:ktor-server-cors-jvm:$ktor_version") implementation("io.ktor:ktor-server-sessions-jvm:$ktor_version") - + implementation("io.ktor:ktor-server-status-pages:$ktor_version") //Ktor client dependencies implementation("io.ktor:ktor-client-core:$ktor_version") diff --git a/src/main/kotlin/no/javabin/App.kt b/src/main/kotlin/no/javabin/App.kt index 2fb06d3..e9b44a6 100644 --- a/src/main/kotlin/no/javabin/App.kt +++ b/src/main/kotlin/no/javabin/App.kt @@ -5,6 +5,7 @@ import io.ktor.server.application.* import no.javabin.config.configureAuth import no.javabin.config.configureRouting import no.javabin.config.configureSerialization +import no.javabin.config.configureStatusPages import no.javabin.repository.* import no.javabin.service.UserService import no.javabin.service.WorkshopService @@ -23,6 +24,7 @@ fun Application.module() { databaseName = environment.config.property("database.databaseName").getString(), embedded = environment.config.property("database.embedded").getString().toBoolean(), ).init() + configureStatusPages() val userRepository = UserRepository() val workshopRepository = WorkshopRepository() val workshopRegistrationRepository = WorkshopRegistrationRepository() diff --git a/src/main/kotlin/no/javabin/config/StatusPages.kt.kt b/src/main/kotlin/no/javabin/config/StatusPages.kt.kt new file mode 100644 index 0000000..846bfd9 --- /dev/null +++ b/src/main/kotlin/no/javabin/config/StatusPages.kt.kt @@ -0,0 +1,15 @@ +package no.javabin.config + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.response.* +import no.javabin.exception.DuplicateRegistrationException + +fun Application.configureStatusPages() { + install(StatusPages) { + exception { call, cause -> + call.respond(HttpStatusCode.Conflict, cause.message ?: "Conflict") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt b/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt new file mode 100644 index 0000000..b027615 --- /dev/null +++ b/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt @@ -0,0 +1,3 @@ +package no.javabin.exception + +class DuplicateRegistrationException(message: String?) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/no/javabin/service/WorkshopService.kt b/src/main/kotlin/no/javabin/service/WorkshopService.kt index d5b9ec0..93bde52 100644 --- a/src/main/kotlin/no/javabin/service/WorkshopService.kt +++ b/src/main/kotlin/no/javabin/service/WorkshopService.kt @@ -11,6 +11,7 @@ import no.javabin.config.defaultClient import no.javabin.dto.WorkshopDTO import no.javabin.dto.WorkshopListImportDTO import no.javabin.dto.WorkshopRegistrationDTO +import no.javabin.exception.DuplicateRegistrationException import no.javabin.repository.* import org.slf4j.LoggerFactory @@ -68,7 +69,8 @@ class WorkshopService( log.info("Registering user [${user.userId},${user.email}] for workshop $workshopId") val registration = workshopRegistrationRepository.getByWorkshopAndUser(workshopId, user.userId) - if (registration?.status == WorkshopRegistrationStatus.APPROVED) return + if (registration?.status == WorkshopRegistrationStatus.APPROVED) throw DuplicateRegistrationException("Already registered") + if (registration == null) { createNewRegistration(user.userId, workshopId) } else if (registration.status == WorkshopRegistrationStatus.CANCELLED) { From 36384e5b9be0f8710194d90a47be4ae6a3fc461e Mon Sep 17 00:00:00 2001 From: Joakim Valand Date: Fri, 14 Jun 2024 13:28:45 +0200 Subject: [PATCH 3/4] feat: add index on workshop_registration --- src/main/resources/db/migration/V1__initial.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/db/migration/V1__initial.sql b/src/main/resources/db/migration/V1__initial.sql index c0cdf60..1f9cbe9 100644 --- a/src/main/resources/db/migration/V1__initial.sql +++ b/src/main/resources/db/migration/V1__initial.sql @@ -75,6 +75,9 @@ create table workshop_registration constraint unique_user_workshop UNIQUE (user_id, workshop_id) ); +CREATE INDEX ON workshop_registration (workshop_id, updated_at); +CREATE INDEX ON workshop_registration (user_id, workshop_id); + INSERT INTO workshop_registration (user_id, workshop_id, status, created_at, updated_at) VALUES ('1', '18aaf407-ff02-4f49-b11d-29877a9de906', 'APPROVED', '2023-09-06T10:20', '2023-09-06T10:20'), ('1', '37cdf4dd-4f9a-4d93-ad9f-eb4994cb2f52', 'PENDING', '2023-09-06T13:00', '2023-09-06T13:00'); From 9fbd4aaeaff7027bcbca81806ecd59260afaac4f Mon Sep 17 00:00:00 2001 From: Joakim Valand Date: Fri, 14 Jun 2024 15:23:25 +0200 Subject: [PATCH 4/4] refactor --- .../no/javabin/config/StatusPages.kt.kt | 2 +- .../dto/WorkshopRegistrationMessage.kt | 14 ------------- .../DuplicateRegistrationException.kt | 2 +- .../WorkshopRegistrationRepository.kt | 11 +++------- .../no/javabin/service/WorkshopService.kt | 20 +++++++------------ .../resources/db/migration/V1__initial.sql | 9 ++++++--- 6 files changed, 18 insertions(+), 40 deletions(-) delete mode 100644 src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt diff --git a/src/main/kotlin/no/javabin/config/StatusPages.kt.kt b/src/main/kotlin/no/javabin/config/StatusPages.kt.kt index 846bfd9..5cb2f6c 100644 --- a/src/main/kotlin/no/javabin/config/StatusPages.kt.kt +++ b/src/main/kotlin/no/javabin/config/StatusPages.kt.kt @@ -12,4 +12,4 @@ fun Application.configureStatusPages() { call.respond(HttpStatusCode.Conflict, cause.message ?: "Conflict") } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt b/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt deleted file mode 100644 index 8f4d763..0000000 --- a/src/main/kotlin/no/javabin/dto/WorkshopRegistrationMessage.kt +++ /dev/null @@ -1,14 +0,0 @@ -package no.javabin.dto - -import kotlinx.datetime.Instant - -class WorkshopRegistrationMessage( - val userId: Int, - val workshopId: String, - val createdAt: Instant, - var messageType: WorkshopRegistrationMessageType, -); - -enum class WorkshopRegistrationMessageType { - REGISTER, CANCEL -} \ No newline at end of file diff --git a/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt b/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt index b027615..4bd233d 100644 --- a/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt +++ b/src/main/kotlin/no/javabin/exception/DuplicateRegistrationException.kt @@ -1,3 +1,3 @@ package no.javabin.exception -class DuplicateRegistrationException(message: String?) : Exception(message) \ No newline at end of file +class DuplicateRegistrationException(message: String?) : Exception(message) diff --git a/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt b/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt index 94551bf..0032aa5 100644 --- a/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt +++ b/src/main/kotlin/no/javabin/repository/WorkshopRegistrationRepository.kt @@ -5,24 +5,21 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import no.javabin.dto.WorkshopRegistrationDTO import no.javabin.util.TimeUtil -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.kotlin.datetime.timestamp enum class WorkshopRegistrationStatus { - PENDING, WAITLIST, APPROVED, CANCELLED, + WAITLIST, APPROVED, CANCELLED, } data class WorkshopRegistration( - override val id: Int?, val userId: Int, val workshopId: String, val createdAt: Instant, val updatedAt: Instant, - var status: WorkshopRegistrationStatus = WorkshopRegistrationStatus.PENDING, -) : Model + var status: WorkshopRegistrationStatus = WorkshopRegistrationStatus.WAITLIST, +) class WorkshopRegistrationRepository { internal object WorkshopRegistrationTable : Table("workshop_registration") { @@ -39,7 +36,6 @@ class WorkshopRegistrationRepository { ) fun toModel(it: ResultRow) = WorkshopRegistration( - null, it[userId].value, it[workshopId], it[createdAt], @@ -101,7 +97,6 @@ class WorkshopRegistrationRepository { } } - suspend fun getByWorkshop(workshopId: String): List { return dbQuery { WorkshopRegistrationTable.selectAll().where { WorkshopRegistrationTable.workshopId eq workshopId } diff --git a/src/main/kotlin/no/javabin/service/WorkshopService.kt b/src/main/kotlin/no/javabin/service/WorkshopService.kt index 93bde52..a4cde12 100644 --- a/src/main/kotlin/no/javabin/service/WorkshopService.kt +++ b/src/main/kotlin/no/javabin/service/WorkshopService.kt @@ -71,14 +71,8 @@ class WorkshopService( if (registration?.status == WorkshopRegistrationStatus.APPROVED) throw DuplicateRegistrationException("Already registered") - if (registration == null) { - createNewRegistration(user.userId, workshopId) - } else if (registration.status == WorkshopRegistrationStatus.CANCELLED) { - workshopRegistrationRepository.updateStatus( - registration.userId, - registration.workshopId, - WorkshopRegistrationStatus.WAITLIST - ) + if (registration == null || registration.status == WorkshopRegistrationStatus.CANCELLED) { + createOrUpdateRegistration(user.userId, workshopId, WorkshopRegistrationStatus.WAITLIST) } updateWaitlistIfNeeded(workshopId) } @@ -87,7 +81,7 @@ class WorkshopService( log.info("Unregistering user ${user.email} for workshop $workshopId") val registration = workshopRegistrationRepository.getByWorkshopAndUser(workshopId, user.userId) if (registration == null) { - log.error("Registration not found for workshop ID: $workshopId and user ID: ${user.userId}") + log.warn("Registration not found for workshop ID: $workshopId and user ID: ${user.userId}") return } val updatedRows = workshopRegistrationRepository.updateStatus( @@ -104,19 +98,19 @@ class WorkshopService( updateWaitlistIfNeeded(workshopId) } - private suspend fun createNewRegistration( + private suspend fun createOrUpdateRegistration( userId: Int, workshopId: String, + status: WorkshopRegistrationStatus ) { val now = Clock.System.now() - workshopRegistrationRepository.create( + workshopRegistrationRepository.createOrUpdate( WorkshopRegistration( - id = null, userId = userId, workshopId = workshopId, createdAt = now, updatedAt = now, - status = WorkshopRegistrationStatus.WAITLIST, + status = status, ) ) } diff --git a/src/main/resources/db/migration/V1__initial.sql b/src/main/resources/db/migration/V1__initial.sql index 1f9cbe9..7d1f974 100644 --- a/src/main/resources/db/migration/V1__initial.sql +++ b/src/main/resources/db/migration/V1__initial.sql @@ -60,7 +60,11 @@ create table speaker constraint speaker_fk_workshop foreign key (workshop_id) references workshop (id) ); - +INSERT INTO speaker (index, full_name, workshop_id, bio, twitter) +VALUES (0, 'Eksempel Speaker', '18aaf407-ff02-4f49-b11d-29877a9de906', 'Liker java', 'javamannen1975'), + (1, 'Tor k', '18aaf407-ff02-4f49-b11d-29877a9de906', 'Liker Kotlin', ''), + (0, 'Eksempel Speaker', '37cdf4dd-4f9a-4d93-ad9f-eb4994cb2f52', 'Liker java', 'javamannen1975'), + (0, 'Eksempel Speaker', '3ba51b2d-c986-4678-8b74-c40257576cb6', 'Liker java', 'javamannen1975'); create table workshop_registration ( @@ -76,8 +80,7 @@ create table workshop_registration ); CREATE INDEX ON workshop_registration (workshop_id, updated_at); -CREATE INDEX ON workshop_registration (user_id, workshop_id); INSERT INTO workshop_registration (user_id, workshop_id, status, created_at, updated_at) VALUES ('1', '18aaf407-ff02-4f49-b11d-29877a9de906', 'APPROVED', '2023-09-06T10:20', '2023-09-06T10:20'), - ('1', '37cdf4dd-4f9a-4d93-ad9f-eb4994cb2f52', 'PENDING', '2023-09-06T13:00', '2023-09-06T13:00'); + ('1', '37cdf4dd-4f9a-4d93-ad9f-eb4994cb2f52', 'WAITLIST', '2023-09-06T13:00', '2023-09-06T13:00');