Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some fixes #254

Merged
merged 14 commits into from
Jun 6, 2021
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class DataCorrectnessRepository {
?.let {
DataCorrectnessConfirmationDetailDtoOut(
id = it[PatientDataCorrectnessConfirmation.id],
patientId = it[PatientDataCorrectnessConfirmation.patientId],
checked = it[PatientDataCorrectnessConfirmation.created],
dataAreCorrect = it[PatientDataCorrectnessConfirmation.dataAreCorrect],
notes = it[PatientDataCorrectnessConfirmation.notes],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class LocationRepository {
Locations.select { Locations.id eq id }.singleOrNull()?.mapLocation()
}

/**
* Returns true if the location exist.
*/
suspend fun locationIdExists(id: EntityId) = newSuspendedTransaction {
Locations.select { Locations.id eq id }.count() == 1L
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ class PatientRepository(
where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null
): List<PatientDtoOut> = newSuspendedTransaction { getAndMapPatients(where) }

/**
* Gets and maps patient by given ID.
*
* Returns null if no patient was found.
*/
suspend fun getAndMapById(patientId: EntityId): PatientDtoOut? =
getAndMapPatientsBy { Patients.id eq patientId }.singleOrNull()


/**
* Saves the given data to the database as a new patient registration record.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import blue.mild.covid.vaxx.dao.model.EntityId
import blue.mild.covid.vaxx.dao.model.VaccinationSlots
import blue.mild.covid.vaxx.dto.internal.VaccinationSlotDto
import blue.mild.covid.vaxx.dto.response.VaccinationSlotDtoOut
import blue.mild.covid.vaxx.utils.applyIfNotNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import mu.KLogging
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SqlExpressionBuilder
Expand All @@ -18,6 +20,10 @@ import java.time.Instant

class VaccinationSlotRepository(private val instantTimeProvider: TimeProvider<Instant>) {

private companion object : KLogging() {
val slotsBookingMutex = Mutex()
}

/**
* Insert all slots to the database.
*/
Expand All @@ -39,34 +45,57 @@ class VaccinationSlotRepository(private val instantTimeProvider: TimeProvider<In
}.map { it[VaccinationSlots.id] }
}

/**
* Updates vaccination slot entity with id [vaccinationSlotId].
*/
suspend fun updateVaccinationSlot(
vaccinationSlotId: EntityId,
patientId: EntityId? = null,
): Boolean = newSuspendedTransaction {
VaccinationSlots.update(
where = { VaccinationSlots.id eq vaccinationSlotId and VaccinationSlots.patientId.isNull() },
body = { it[VaccinationSlots.patientId] = patientId }
) == 1
}

/**
* Retrieves all vaccination slots from the database with given filter.
*/
suspend fun getAndMap(where: SqlExpressionBuilder.() -> Op<Boolean>, limit: Int? = null) =
suspend fun getAndMap(where: SqlExpressionBuilder.() -> Op<Boolean>) =
newSuspendedTransaction {
VaccinationSlots
.select(where)
.orderBy(VaccinationSlots.from)
.orderBy(VaccinationSlots.queue)
.orderBy(VaccinationSlots.id)
.applyIfNotNull(limit) { limit(it) }
.toList()
.let { data -> data.map { it.mapVaccinationSlot() } }
}

/**
* Tries to book slot for patient [attemptsLeft] times. If it's not successful returns null.
*
* The attempts are here for distributed environment where some different pod might have booked the slot before
* the one selecting the slot for booking. Thus we try multiple times and "hope for the best".
*
* TODO #267 this is reeeeeaaaaallly poor man's solution, but it works.. see concurrent booking test.
* We need to achieve atomic update in the database, but unfortunately postgres does not support limit on update
* and somehow lock the rows and then do not add them to filter why searching.
*/
tailrec suspend fun tryToBookSlotForPatient(patientId: EntityId, attemptsLeft: Int): VaccinationSlotDtoOut? =
if (attemptsLeft == 0) null
else slotsBookingMutex.withLock {
newSuspendedTransaction {
VaccinationSlots
.select { VaccinationSlots.patientId.isNull() }
.orderBy(VaccinationSlots.from)
.orderBy(VaccinationSlots.queue)
.limit(1)
.singleOrNull() // fetch the first available slot
?.getOrNull(VaccinationSlots.id)
?.let { slotId ->
// book the slot for patient
VaccinationSlots.update(
// "and patientId is null" is here in order not to override already booked slots
where = { VaccinationSlots.id eq slotId and VaccinationSlots.patientId.isNull() },
body = { it[VaccinationSlots.patientId] = patientId },
)
}
?.takeIf { it == 1 }
?.also { commit() } // commit only if some slot was updated

getAndMap { VaccinationSlots.patientId eq patientId }.singleOrNull()
}
} ?: tryToBookSlotForPatient(patientId, attemptsLeft - 1)


private fun ResultRow.mapVaccinationSlot() = VaccinationSlotDtoOut(
id = this[VaccinationSlots.id],
locationId = this[VaccinationSlots.locationId],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package blue.mild.covid.vaxx.dto.internal

sealed interface PatientValidationResultDto {
val status: PatientValidationResult
val patientId: String?
}

data class IsinValidationResultDto(
val status: IsinValidationResultStatus,
val patientId: String? = null
)
override val status: PatientValidationResult,
override val patientId: String? = null
) : PatientValidationResultDto
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package blue.mild.covid.vaxx.dto.internal

enum class IsinValidationResultStatus {
enum class PatientValidationResult {
PATIENT_FOUND,
PATIENT_NOT_FOUND,
WAS_NOT_VERIFIED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import java.time.Instant

data class DataCorrectnessConfirmationDetailDtoOut(
val id: EntityId,
val patientId: EntityId,
val checked: Instant,
val doctor: PersonnelDtoOut,
val nurse: PersonnelDtoOut?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
package blue.mild.covid.vaxx.dto.response

import blue.mild.covid.vaxx.dao.model.EntityId
import java.time.Instant

data class PatientRegistrationResponseDtoOut(
val email: String,
val slot: VaccinationSlot
) {
data class VaccinationSlot(
val locationId: EntityId,
val queue: Int,
val from: Instant,
val to: Instant
)
}
val patientId: EntityId,
val slot: VaccinationSlotDtoOut,
val location: LocationDtoOut
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import blue.mild.covid.vaxx.dao.model.EntityId
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit

data class VaccinationSlotDtoOut(
val id: EntityId,
Expand All @@ -27,4 +28,9 @@ data class VaccinationSlotDtoOut(
return this.from.atOffset(getFromWithOffset()).hour.toString().padStart(2, '0') + ":" +
this.from.atOffset(getFromWithOffset()).minute.toString().padStart(2, '0')
}

@Suppress("MagicNumber") // we want to show +15 minutes to user
fun toRoundedSlot() = copy(
to = to.plus(15, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MINUTES)
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package blue.mild.covid.vaxx.error

import blue.mild.covid.vaxx.extensions.createLogger
import blue.mild.covid.vaxx.security.auth.AuthorizationException
import blue.mild.covid.vaxx.security.auth.CaptchaFailedException
import blue.mild.covid.vaxx.security.auth.InsufficientRightsException
import blue.mild.covid.vaxx.security.auth.UserPrincipal
import blue.mild.covid.vaxx.utils.createLogger
import com.fasterxml.jackson.core.JacksonException
import com.fasterxml.jackson.databind.exc.InvalidFormatException
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
Expand All @@ -29,74 +29,76 @@ private val logger = createLogger("ExceptionHandler")
/**
* Registers exception handling.
*/
@Suppress("LongMethod") // it's fine as this is part of KTor
fun Application.installExceptionHandling() {
install(StatusPages) {
// TODO define correct logging level policy

exception<InsufficientRightsException> {
logger.warn {
logger.warn(it) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to asi neni potreba, it uz nepouzijes. Ne?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicmene bys mohl mozna...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tohle se pouzije, protoze se to propise do finalniho jsonu jako "exception": {...} tzn v AWS uvidime celej stacktrace - to mi prijde uzitecne

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jakoze bez toho it se nevypise stacktrace, jo? Potom to samozrejme chceme

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presne tak

call.principal<UserPrincipal>()
?.let { "$it tried to access resource \"${call.request.path()}\" that is not allowed." }
?.let { user -> "${user.userId} tried to access resource \"${call.request.path()}\" that is not allowed." }
?: "User without principals tried to access the resource ${call.request.path()}."
}
call.respond(HttpStatusCode.Forbidden)
}

exception<CaptchaFailedException> {
logger.debug { "Captcha verification failed." }
logger.warn(it) { "Captcha verification failed - $it.¬" }
call.errorResponse(HttpStatusCode.UnprocessableEntity, "Captcha verification failed.")
}

exception<AuthorizationException> {
logger.warn(it) { "Authorization failed - $it." }
call.respond(HttpStatusCode.Unauthorized)
}

exception<EntityNotFoundException> { cause ->
logger.debug { cause.message }
call.errorResponse(HttpStatusCode.NotFound, cause.message)
exception<EntityNotFoundException> {
logger.warn(it) { "Entity was not found - $it." }
call.errorResponse(HttpStatusCode.NotFound, it.message)
}

exception<InvalidSlotCreationRequest> { cause ->
logger.debug { "${cause.message} --> ${cause.entity}" }
call.errorResponse(HttpStatusCode.BadRequest, cause.message)
exception<InvalidSlotCreationRequest> {
logger.error(it) { "Invalid slot creation request - $it." }
call.errorResponse(HttpStatusCode.BadRequest, it.message)
}

exception<NoVaccinationSlotsFoundException> { cause ->
call.errorResponse(HttpStatusCode.NotFound, cause.message)
exception<NoVaccinationSlotsFoundException> {
logger.error(it) { "No vaccination slots found - $it." }
call.errorResponse(HttpStatusCode.NotFound, it.message)
}

jsonExceptions()

exception<IsinValidationException> { cause ->
logger.debug { cause.message }
call.errorResponse(HttpStatusCode.NotAcceptable, "Not acceptable: ${cause.message}.")
exception<IsinValidationException> {
logger.warn(it) { "ISIN validation failed - $it." }
call.errorResponse(HttpStatusCode.NotAcceptable, "Not acceptable: ${it.message}.")
}

// validation failed for some property
exception<ValidationException> { cause ->
logger.debug { cause.message }
call.errorResponse(HttpStatusCode.BadRequest, "Bad request: ${cause.message}.")
exception<ValidationException> {
logger.warn(it) { "Validation exception - $it." }
call.errorResponse(HttpStatusCode.BadRequest, "Bad request: ${it.message}.")
}

// open api serializer - missing parameters such as headers or query
exception<OpenAPIRequiredFieldException> { cause ->
logger.debug { "Missing data in request: ${cause.message}" }
call.errorResponse(HttpStatusCode.BadRequest, "Missing data in request: ${cause.message}.")
exception<OpenAPIRequiredFieldException> {
logger.error { "Missing data in request: ${it.message}." }
call.errorResponse(HttpStatusCode.BadRequest, "Missing data in request: ${it.message}.")
}

// exception from exposed, during saving to the database
exception<ExposedSQLException> { cause ->
logger.warn { "Attempt to store invalid data to the database: ${cause.message}." }
if (cause.message?.contains("already exists") == true) {
exception<ExposedSQLException> {
if (it.message?.contains("already exists", ignoreCase = true) == true) {
logger.warn(it) { "Requested entity already exists - ${it.message}." }
call.errorResponse(HttpStatusCode.Conflict, "Entity already exists!.")
} else {
logger.error(it) { "Unknown exposed SQL Exception." }
call.errorResponse(HttpStatusCode.BadRequest, "Bad request.")
}
}

// generic error handling
exception<Exception> { cause ->
logger.error(cause) { "Exception occurred in the application: ${cause.message}." }
exception<Exception> {
logger.error(it) { "Unknown exception occurred in the application: ${it.message}." }
call.errorResponse(
HttpStatusCode.InternalServerError,
"Server was unable to fulfill the request, please contact administrator with request ID: ${call.callId}."
Expand All @@ -111,25 +113,26 @@ private fun StatusPages.Configuration.jsonExceptions() {
}

// wrong format of some property
exception<InvalidFormatException> { cause ->
logger.debug { cause.message }
exception<InvalidFormatException> {
logger.error(it) { "Invalid data format." }
respond("Wrong data format.")
}

// server received JSON with additional properties it does not know
exception<UnrecognizedPropertyException> { cause ->
respond("Unrecognized body property ${cause.propertyName}.")
exception<UnrecognizedPropertyException> {
logger.error(it) { "Unrecognized property in the JSON." }
respond("Unrecognized body property ${it.propertyName}.")
}

// missing data in the request
exception<MissingKotlinParameterException> { cause ->
logger.debug { "Missing parameter in the request: ${cause.message}" }
respond("Missing parameter: ${cause.parameter}.")
exception<MissingKotlinParameterException> {
logger.error(it) { "Missing parameter in the request: ${it.message}." }
respond("Missing parameter: ${it.parameter}.")
}

// generic, catch-all exception from jackson serialization
exception<JacksonException> { cause ->
logger.debug { cause.message }
exception<JacksonException> {
logger.error(it) { "Could not deserialize data: ${it.message}." }
respond("Bad request, could not deserialize data.")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package blue.mild.covid.vaxx.error

import blue.mild.covid.vaxx.dto.internal.IsinValidationResultDto
import blue.mild.covid.vaxx.dto.internal.PatientValidationResultDto

sealed class ValidationException(
override val message: String
Expand All @@ -16,9 +16,9 @@ data class EmptyStringException(
) : ValidationException("Parameter $parameterName must not be empty.")

data class IsinValidationException(
val validationResult: IsinValidationResultDto
) : ValidationException("Problem occurred during isin validation: ${validationResult.status}")
val validationResult: PatientValidationResultDto
) : ValidationException("Problem occurred during ISIN validation: ${validationResult.status}.")

class EmptyUpdateException : ValidationException("No data given for the update.")
data class EmptyUpdateException(override val message: String = "No data given for the update.") : ValidationException(message)


Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package blue.mild.covid.vaxx.utils
package blue.mild.covid.vaxx.extensions

import mu.KLogging

Expand Down
Loading