diff --git a/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelDTO.kt b/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelDTO.kt index ad55683..cc48c38 100644 --- a/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelDTO.kt +++ b/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelDTO.kt @@ -8,7 +8,8 @@ data class FeedbackChannelDTO( val title: String, val speakers: List, val channelId: String, - val ratingCategories: List + val ratingCategories: List, + val isOpen: Boolean ) fun FeedbackChannel.toDTO(): FeedbackChannelDTO { @@ -21,6 +22,7 @@ fun FeedbackChannel.toDTO(): FeedbackChannelDTO { title = it.name, id = it.id, ) - } + }, + isOpen = isOpen ) } \ No newline at end of file diff --git a/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelUpdateDTO.kt b/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelUpdateDTO.kt new file mode 100644 index 0000000..aebac55 --- /dev/null +++ b/core/src/main/kotlin/no/javazone/feedback/request/channel/FeedbackChannelUpdateDTO.kt @@ -0,0 +1,8 @@ +package no.javazone.feedback.request.channel + +import kotlinx.serialization.Serializable + +@Serializable +data class FeedbackChannelUpdateDTO( + val isOpen: Boolean? = null +) diff --git a/core/src/main/kotlin/no/javazone/feedback/setupRouting.kt b/core/src/main/kotlin/no/javazone/feedback/setupRouting.kt index 97afe1e..a164235 100644 --- a/core/src/main/kotlin/no/javazone/feedback/setupRouting.kt +++ b/core/src/main/kotlin/no/javazone/feedback/setupRouting.kt @@ -9,12 +9,15 @@ import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.response.respondOutputStream import io.ktor.server.routing.get +import io.ktor.server.routing.patch import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing import no.javazone.feedback.database.isDatabaseHealthy import no.javazone.feedback.database.repository.FeedbackRepositoryDb import no.javazone.feedback.domain.adapters.FeedbackAdapter +import no.javazone.feedback.domain.FeedbackChannel +import no.javazone.feedback.domain.errors.ChannelClosedError import no.javazone.feedback.domain.errors.ChannelNotFoundError import no.javazone.feedback.domain.generators.ExternalIdGeneratorDefault import no.javazone.feedback.pages.feedbackPage @@ -23,6 +26,7 @@ import no.javazone.feedback.pages.thankYouFragment import no.javazone.feedback.qrcode.QRCodeGenerator import no.javazone.feedback.request.channel.FeedbackChannelCreationDTO import no.javazone.feedback.request.channel.FeedbackChannelRatingCategoryDTO +import no.javazone.feedback.request.channel.FeedbackChannelUpdateDTO import no.javazone.feedback.request.channel.FeedbackCreationDTO import no.javazone.feedback.request.channel.FeedbackDTO import no.javazone.feedback.request.channel.FeedbackRatingDTO @@ -99,6 +103,11 @@ fun Application.setupRouting() { HttpStatusCode.NotFound, e.message ) + } catch (e: ChannelClosedError) { + return@post call.respond( + HttpStatusCode.Forbidden, + e.message + ) } val feedbackDto = createdFeedback.let { feedbackWithComment -> @@ -126,6 +135,30 @@ fun Application.setupRouting() { call.respond(feedbackDto) } + patch("{channelId}") { + val channelId = call.parameters["channelId"] ?: return@patch call.respond( + HttpStatusCode.NotFound, + "Missing externalId" + ) + val updateInput = call.receive() + val existing = feedbackAdapter.findChannel(channelId) + ?: return@patch call.respond(HttpStatusCode.NotFound, "Channel with id $channelId not found.") + val merged = FeedbackChannel( + id = existing.id, + title = existing.title, + speakers = existing.speakers, + externalId = existing.externalId, + ratingCategories = existing.ratingCategories, + isOpen = updateInput.isOpen ?: existing.isOpen + ) + val updated = try { + feedbackAdapter.updateChannel(merged) + } catch (e: ChannelNotFoundError) { + return@patch call.respond(HttpStatusCode.NotFound, e.message) + } + call.respond(updated.toDTO()) + } + get("{channelId}/qrcode") { val channelId = call.parameters["channelId"] ?: return@get call.respond( HttpStatusCode.NotFound, diff --git a/core/src/test/kotlin/no/javazone/feedback/FeedbackEndpointsTest.kt b/core/src/test/kotlin/no/javazone/feedback/FeedbackEndpointsTest.kt index 934de9c..0cbea3a 100644 --- a/core/src/test/kotlin/no/javazone/feedback/FeedbackEndpointsTest.kt +++ b/core/src/test/kotlin/no/javazone/feedback/FeedbackEndpointsTest.kt @@ -3,8 +3,7 @@ package no.javazone.feedback import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* +import io.ktor.client.statement.*import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.testing.* import kotlinx.serialization.json.Json @@ -74,6 +73,131 @@ class FeedbackEndpointsTest { assertEquals(2, responseBody.ratingCategories.size) } + @Test + fun `newly created channel is closed by default`() = testApplication { + application { + module(TestDatabase.config()) + } + + val client = createClient { + install(ContentNegotiation) { + json() + } + } + + val channel = client.post("/v1/feedback/channel") { + contentType(ContentType.Application.Json) + setBody( + FeedbackChannelCreationDTO( + title = "Closed by default", + speakers = listOf("Speaker"), + ratingCategories = listOf(FeedbackChannelRatingCategoryDTO(id = null, title = "Rating")) + ) + ) + }.body() + + assertEquals(false, channel.isOpen) + } + + @Test + fun `submitting feedback to closed channel returns forbidden`() = testApplication { + application { + module(TestDatabase.config()) + } + + val client = createClient { + install(ContentNegotiation) { + json() + } + } + + val channel = client.post("/v1/feedback/channel") { + contentType(ContentType.Application.Json) + setBody( + FeedbackChannelCreationDTO( + title = "Closed", + speakers = listOf("Speaker"), + ratingCategories = listOf(FeedbackChannelRatingCategoryDTO(id = null, title = "Rating")) + ) + ) + }.body() + + val response = client.post("/v1/feedback/channel/${channel.channelId}/submit-feedback") { + contentType(ContentType.Application.Json) + setBody( + FeedbackCreationDTO( + ratings = listOf(FeedbackRatingCreationDTO(id = channel.ratingCategories[0].id!!, score = 5)), + detailedComment = null + ) + ) + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun `patch channel opens the channel and allows submissions`() = testApplication { + application { + module(TestDatabase.config()) + } + + val client = createClient { + install(ContentNegotiation) { + json() + } + } + + val channel = client.post("/v1/feedback/channel") { + contentType(ContentType.Application.Json) + setBody( + FeedbackChannelCreationDTO( + title = "Toggle me", + speakers = listOf("Speaker"), + ratingCategories = listOf(FeedbackChannelRatingCategoryDTO(id = null, title = "Rating")) + ) + ) + }.body() + + val patched = client.patch("/v1/feedback/channel/${channel.channelId}") { + contentType(ContentType.Application.Json) + setBody(FeedbackChannelUpdateDTO(isOpen = true)) + } + + assertEquals(HttpStatusCode.OK, patched.status) + assertEquals(true, patched.body().isOpen) + + val submit = client.post("/v1/feedback/channel/${channel.channelId}/submit-feedback") { + contentType(ContentType.Application.Json) + setBody( + FeedbackCreationDTO( + ratings = listOf(FeedbackRatingCreationDTO(id = channel.ratingCategories[0].id!!, score = 5)), + detailedComment = null + ) + ) + } + assertEquals(HttpStatusCode.OK, submit.status) + } + + @Test + fun `patch channel returns not found for unknown channel`() = testApplication { + application { + module(TestDatabase.config()) + } + + val client = createClient { + install(ContentNegotiation) { + json() + } + } + + val response = client.patch("/v1/feedback/channel/ZZZZ") { + contentType(ContentType.Application.Json) + setBody(FeedbackChannelUpdateDTO(isOpen = true)) + } + + assertEquals(HttpStatusCode.NotFound, response.status) + } + @Test fun `test submit feedback successfully`() = testApplication { application { @@ -103,6 +227,11 @@ class FeedbackEndpointsTest { val channelId = channel.channelId + client.patch("/v1/feedback/channel/$channelId") { + contentType(ContentType.Application.Json) + setBody(FeedbackChannelUpdateDTO(isOpen = true)) + } + // Now submit feedback val feedbackCreationDto = FeedbackCreationDTO( ratings = listOf( @@ -183,6 +312,11 @@ class FeedbackEndpointsTest { val channel = createChannelResponse.body() val channelId = channel.channelId + client.patch("/v1/feedback/channel/$channelId") { + contentType(ContentType.Application.Json) + setBody(FeedbackChannelUpdateDTO(isOpen = true)) + } + // Submit feedback without comment val feedbackCreationDto = FeedbackCreationDTO( ratings = listOf( diff --git a/database/src/main/kotlin/no/javazone/feedback/database/models/FeedbackChannels.kt b/database/src/main/kotlin/no/javazone/feedback/database/models/FeedbackChannels.kt index 566e920..2b66759 100644 --- a/database/src/main/kotlin/no/javazone/feedback/database/models/FeedbackChannels.kt +++ b/database/src/main/kotlin/no/javazone/feedback/database/models/FeedbackChannels.kt @@ -7,5 +7,6 @@ object FeedbackChannels : LongIdTable("feedback_channel") { val title = varchar("title", 255) val speakers = array("speakers") val externalId = varchar("external_id", 255) + val isOpen = bool("is_open").default(false) val createdAt = timestamp("created_at") } \ No newline at end of file diff --git a/database/src/main/kotlin/no/javazone/feedback/database/repository/FeedbackRepositoryDb.kt b/database/src/main/kotlin/no/javazone/feedback/database/repository/FeedbackRepositoryDb.kt index aae45d5..4be09a6 100644 --- a/database/src/main/kotlin/no/javazone/feedback/database/repository/FeedbackRepositoryDb.kt +++ b/database/src/main/kotlin/no/javazone/feedback/database/repository/FeedbackRepositoryDb.kt @@ -16,6 +16,7 @@ import org.jetbrains.exposed.sql.batchInsert import org.jetbrains.exposed.sql.insertReturning import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update import org.postgresql.util.PSQLState import java.time.Instant @@ -27,6 +28,7 @@ object FeedbackRepositoryDb : FeedbackRepository { it[title] = channel.title it[speakers] = channel.speakers it[externalId] = channel.externalId + it[isOpen] = channel.isOpen }.map { it[FeedbackChannels.id] }.first() @@ -50,7 +52,8 @@ object FeedbackRepositoryDb : FeedbackRepository { title = it[FeedbackChannels.title], speakers = it[FeedbackChannels.speakers], externalId = it[FeedbackChannels.externalId], - ratingCategories = ratingCategories + ratingCategories = ratingCategories, + isOpen = it[FeedbackChannels.isOpen] ) }.first() } @@ -106,7 +109,8 @@ object FeedbackRepositoryDb : FeedbackRepository { title = firstRow[FeedbackChannels.title], speakers = firstRow[FeedbackChannels.speakers], externalId = firstRow[FeedbackChannels.externalId], - ratingCategories = emptyList() + ratingCategories = emptyList(), + isOpen = firstRow[FeedbackChannels.isOpen] ) } } @@ -138,9 +142,21 @@ object FeedbackRepositoryDb : FeedbackRepository { title = firstRow[FeedbackChannels.title], speakers = firstRow[FeedbackChannels.speakers], externalId = firstRow[FeedbackChannels.externalId], - ratingCategories = ratingCategories + ratingCategories = ratingCategories, + isOpen = firstRow[FeedbackChannels.isOpen] ) } } } + + override fun updateChannel(channel: FeedbackChannel): FeedbackChannel? { + return transaction { + val updated = FeedbackChannels.update({ FeedbackChannels.id eq channel.id }) { + it[title] = channel.title + it[speakers] = channel.speakers + it[isOpen] = channel.isOpen + } + if (updated == 0) null else findByChannelId(channel.externalId) + } + } } \ No newline at end of file diff --git a/database/src/main/resources/changelog/changes/004-add-channel-is-open.sql b/database/src/main/resources/changelog/changes/004-add-channel-is-open.sql new file mode 100644 index 0000000..81e21d5 --- /dev/null +++ b/database/src/main/resources/changelog/changes/004-add-channel-is-open.sql @@ -0,0 +1,4 @@ +--liquibase formatted sql + +--changeset tanettrimas:8 +ALTER TABLE feedback_channel ADD COLUMN is_open BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/domain/src/main/kotlin/no/javazone/feedback/domain/FeedbackChannel.kt b/domain/src/main/kotlin/no/javazone/feedback/domain/FeedbackChannel.kt index bd793ec..4b5833b 100644 --- a/domain/src/main/kotlin/no/javazone/feedback/domain/FeedbackChannel.kt +++ b/domain/src/main/kotlin/no/javazone/feedback/domain/FeedbackChannel.kt @@ -5,7 +5,8 @@ class FeedbackChannel( val title: String, val speakers: List, val externalId: String, - val ratingCategories: List + val ratingCategories: List, + val isOpen: Boolean = false ) { init { require(speakers.all { it.isNotEmpty() }) { "All speakers must not be empty." } diff --git a/domain/src/main/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapter.kt b/domain/src/main/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapter.kt index ca7372a..6154c26 100644 --- a/domain/src/main/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapter.kt +++ b/domain/src/main/kotlin/no/javazone/feedback/domain/adapters/FeedbackAdapter.kt @@ -4,6 +4,7 @@ import no.javazone.feedback.domain.Feedback import no.javazone.feedback.domain.FeedbackChannel import no.javazone.feedback.domain.FeedbackChannelCreationInput import no.javazone.feedback.domain.FeedbackWithChannel +import no.javazone.feedback.domain.errors.ChannelClosedError import no.javazone.feedback.domain.errors.ChannelNotFoundError import no.javazone.feedback.domain.errors.ExternalIdAlreadyExistsError import no.javazone.feedback.domain.errors.ExternalIdGenerationException @@ -37,7 +38,10 @@ class FeedbackAdapter( fun submitFeedback(channelId: String, feedback: Feedback): FeedbackWithChannel { val feedbackChannel = repository.findByChannelId(channelId) - ?: throw ChannelNotFoundError("Channel with id $channelId does not exist") + ?: throw ChannelNotFoundError(channelId) + if (!feedbackChannel.isOpen) { + throw ChannelClosedError(channelId) + } val createdFeedback = repository.submitFeedback(feedback, feedbackChannel) return FeedbackWithChannel( channel = feedbackChannel, @@ -45,6 +49,11 @@ class FeedbackAdapter( ) } + fun updateChannel(channel: FeedbackChannel): FeedbackChannel { + return repository.updateChannel(channel) + ?: throw ChannelNotFoundError(channel.externalId) + } + fun findChannel(channelId: String): FeedbackChannel? { return repository.findByChannelId(channelId) } diff --git a/domain/src/main/kotlin/no/javazone/feedback/domain/errors/DomainErrors.kt b/domain/src/main/kotlin/no/javazone/feedback/domain/errors/DomainErrors.kt index 48bd4ab..df1f9bf 100644 --- a/domain/src/main/kotlin/no/javazone/feedback/domain/errors/DomainErrors.kt +++ b/domain/src/main/kotlin/no/javazone/feedback/domain/errors/DomainErrors.kt @@ -8,6 +8,9 @@ sealed class DomainErrors( class ChannelNotFoundError(channelId: String) : DomainErrors("Channel with id $channelId not found.") +class ChannelClosedError(channelId: String) : + DomainErrors("Channel with id $channelId is closed for feedback.") + class ExternalIdAlreadyExistsError(externalId: String, cause: Throwable? = null) : DomainErrors("Channel with external id $externalId already exists.", cause) diff --git a/domain/src/main/kotlin/no/javazone/feedback/domain/persistence/FeedbackRepository.kt b/domain/src/main/kotlin/no/javazone/feedback/domain/persistence/FeedbackRepository.kt index 7f47ae9..eba5199 100644 --- a/domain/src/main/kotlin/no/javazone/feedback/domain/persistence/FeedbackRepository.kt +++ b/domain/src/main/kotlin/no/javazone/feedback/domain/persistence/FeedbackRepository.kt @@ -8,4 +8,5 @@ interface FeedbackRepository { fun submitFeedback(feedback: Feedback, feedbackChannel: FeedbackChannel): Feedback fun findByChannelId(channelId: String): FeedbackChannel? fun findAllChannels(): List + fun updateChannel(channel: FeedbackChannel): FeedbackChannel? } \ No newline at end of file diff --git a/domain/src/test/kotlin/no/javazone/feedback/domain/FakeFeedbackRepository.kt b/domain/src/test/kotlin/no/javazone/feedback/domain/FakeFeedbackRepository.kt index 4104254..f9791d1 100644 --- a/domain/src/test/kotlin/no/javazone/feedback/domain/FakeFeedbackRepository.kt +++ b/domain/src/test/kotlin/no/javazone/feedback/domain/FakeFeedbackRepository.kt @@ -28,4 +28,18 @@ internal class FakeFeedbackRepository( override fun findAllChannels(): List { return channels.values.toList() } + + override fun updateChannel(channel: FeedbackChannel): FeedbackChannel? { + val existing = channels[channel.externalId] ?: return null + val updated = FeedbackChannel( + id = existing.id, + title = channel.title, + speakers = channel.speakers, + externalId = existing.externalId, + ratingCategories = existing.ratingCategories, + isOpen = channel.isOpen + ) + channels[channel.externalId] = updated + return updated + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e50dfa5..390c624 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ liquibase = "5.0.1" exposed = "0.61.0" hikari = "7.0.2" postgres = "42.7.8" -testcontainers = "1.20.0" +testcontainers = "1.21.4" qrcode = "4.5.0"