diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index d091a894a..eeae65f4e 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -5,7 +5,7 @@ import java.time.{LocalDate, ZonedDateTime} import java.util.UUID import actions._ -import cats.implicits.catsSyntaxTuple2Semigroupal +import cats.implicits.{catsSyntaxOptionId, catsSyntaxTuple2Semigroupal} import cats.syntax.all._ import constants.Constants import forms.FormsPlusMap @@ -309,9 +309,11 @@ case class ApplicationController @Inject() ( applicationData.selectedSubject.contains[String](applicationData.subject), category = applicationData.category, files = newAttachments ++ pendingAttachments, - mandatType = DataModel.Application.MandatType - .dataModelDeserialization(applicationData.mandatType), - mandatDate = Some(applicationData.mandatDate) + mandat = ( + DataModel.Application.Mandat.MandatType + .dataModelDeserialization(applicationData.mandatType), + applicationData.mandatDate.some + ).mapN(Application.Mandat.apply) ) if (applicationService.createApplication(application)) { notificationsService.newApplication(application) diff --git a/app/models/Answer.scala b/app/models/Answer.scala index eb9ed9589..82ff92b0e 100644 --- a/app/models/Answer.scala +++ b/app/models/Answer.scala @@ -3,6 +3,15 @@ package models import java.time.ZonedDateTime import java.util.UUID +import anorm.Column.nonNull +import anorm.{MetaDataItem, TypeDoesNotMatch} +import cats.implicits.catsSyntaxEitherId +import helper.Time +import play.api.libs.json.{Json, Reads, Writes} +import serializers.Anorm.className + +import serializers.JsonFormats._ + case class Answer( id: UUID, applicationId: UUID, @@ -24,3 +33,29 @@ case class Answer( } } + +object Answer { + + implicit val Reads: Reads[Answer] = Json + .reads[Answer] + .map(answer => + answer.copy(creationDate = answer.creationDate.withZoneSameInstant(Time.timeZoneParis)) + ) + + implicit val Writes: Writes[Answer] = Json.writes[Answer] + + implicit val answerListParser: anorm.Column[List[Answer]] = + nonNull { (value, meta) => + val MetaDataItem(qualified, _, _) = meta + value match { + case json: org.postgresql.util.PGobject => + Json.parse(json.getValue).as[List[Answer]].asRight[Nothing] + case json: String => Json.parse(json).as[List[Answer]].asRight[Nothing] + case _ => + TypeDoesNotMatch( + s"Cannot convert $value: ${className(value)} to List[Answer] for column $qualified" + ).asLeft[List[Answer]] + } + } + +} diff --git a/app/models/Application.scala b/app/models/Application.scala index 383597946..67aba23fd 100644 --- a/app/models/Application.scala +++ b/app/models/Application.scala @@ -7,7 +7,10 @@ import java.util.UUID import cats.Eq import cats.syntax.all._ import helper.BooleanHelper.not +import models.Application.Mandat +import models.Application.Mandat.MandatType import models.Authorization.{isExpert, isHelper, isInstructor, UserRights} +import serializers.JsonFormats._ case class Application( id: UUID, @@ -21,18 +24,17 @@ case class Application( invitedUsers: Map[UUID, String], area: UUID, irrelevant: Boolean, - answers: List[Answer] = List(), + answers: List[Answer] = List.empty[Answer], internalId: Int = -1, closed: Boolean = false, - seenByUserIds: List[UUID] = List(), + seenByUserIds: List[UUID] = List.empty[UUID], usefulness: Option[String] = None, - closedDate: Option[ZonedDateTime] = None, + closedDate: Option[ZonedDateTime] = Option.empty[ZonedDateTime], expertInvited: Boolean = false, hasSelectedSubject: Boolean = false, - category: Option[String] = None, - files: Map[String, Long] = Map(), - mandatType: Option[Application.MandatType], - mandatDate: Option[String] + category: Option[String] = Option.empty[String], + files: Map[String, Long] = Map.empty[String, Long], + mandat: Option[Mandat] ) extends AgeModel { lazy val filesAvailabilityLeftInDays: Option[Int] = if (ageInDays > 8) { @@ -186,14 +188,20 @@ case class Application( object Application { - sealed trait MandatType + final case class Mandat(type_ : MandatType, date: String) - object MandatType { - case object Sms extends MandatType - case object Phone extends MandatType - case object Paper extends MandatType + object Mandat { - implicit val Eq: Eq[MandatType] = (x: MandatType, y: MandatType) => x == y + sealed trait MandatType + + object MandatType { + case object Sms extends MandatType + case object Phone extends MandatType + case object Paper extends MandatType + + implicit val Eq: Eq[MandatType] = (x: MandatType, y: MandatType) => x == y + + } } diff --git a/app/models/sql/ApplicationRow.scala b/app/models/sql/ApplicationRow.scala new file mode 100644 index 000000000..d0910a8ae --- /dev/null +++ b/app/models/sql/ApplicationRow.scala @@ -0,0 +1,104 @@ +package models.sql + +import java.time.ZonedDateTime +import java.util.UUID + +import anorm.{Macro, RowParser} +import cats.implicits.catsSyntaxTuple2Semigroupal +import helper.Time +import models.Application.Mandat +import models.{Answer, Application} +import serializers.DataModel + +final case class ApplicationRow( + id: UUID, + creationDate: ZonedDateTime, + creatorUserName: String, + creatorUserId: UUID, + subject: String, + description: String, + userInfos: Map[String, String], + invitedUsers: Map[UUID, String], + area: UUID, + irrelevant: Boolean, + answers: List[Answer] = List.empty[Answer], + internalId: Int = -1, + closed: Boolean = false, + seenByUserIds: List[UUID] = List.empty[UUID], + usefulness: Option[String] = Option.empty[String], + closedDate: Option[ZonedDateTime] = Option.empty[ZonedDateTime], + expertInvited: Boolean = false, + hasSelectedSubject: Boolean = false, + category: Option[String] = Option.empty[String], + files: Map[String, Long] = Map.empty[String, Long], + mandatType: Option[Application.Mandat.MandatType], + mandatDate: Option[String] +) + +object ApplicationRow { + + import serializers.Anorm._ + import DataModel.Application.Mandat.MandatType.MandatTypeParser + + val ApplicationRowParser: RowParser[ApplicationRow] = Macro + .parser[ApplicationRow]( + "id", + "creation_date", + "creator_user_name", + "creator_user_id", + "subject", + "description", + "user_infos", + "invited_users", + "area", + "irrelevant", + "answers", + "internal_id", + "closed", + "seen_by_user_ids", + "usefulness", + "closed_date", + "expert_invited", + "has_selected_subject", + "category", + "files", + "mandat_type", + "mandat_date" + ) + .map(application => + application.copy( + creationDate = application.creationDate.withZoneSameInstant(Time.timeZoneParis), + answers = application.answers.map(answer => + answer.copy(creationDate = answer.creationDate.withZoneSameInstant(Time.timeZoneParis)) + ) + ) + ) + + def toApplication(row: ApplicationRow): Application = { + import row._ + Application( + id, + creationDate, + creatorUserName, + creatorUserId, + subject, + description = description, + userInfos = userInfos, + invitedUsers = invitedUsers, + area = area, + irrelevant = irrelevant, + answers = answers, + internalId = internalId, + closed = closed, + seenByUserIds = seenByUserIds, + usefulness = usefulness, + closedDate = closedDate, + expertInvited = expertInvited, + hasSelectedSubject = hasSelectedSubject, + category = category, + files = files, + mandat = (row.mandatType, row.mandatDate).mapN(Mandat.apply) + ) + } + +} diff --git a/app/serializers/DataModel.scala b/app/serializers/DataModel.scala index 5d07575f6..78d317c60 100644 --- a/app/serializers/DataModel.scala +++ b/app/serializers/DataModel.scala @@ -1,29 +1,40 @@ package serializers -import models.Application.MandatType +import anorm.Column +import cats.implicits.catsSyntaxOptionId +import models.Application.Mandat.MandatType import play.api.libs.json._ object DataModel { object Application { - object MandatType { - import models.Application.MandatType._ + object Mandat { - def dataModelSerialization(entity: MandatType): String = - entity match { - case Sms => "sms" - case Phone => "phone" - case Paper => "paper" - } + object MandatType { - def dataModelDeserialization(raw: String): Option[MandatType] = - raw match { - case "sms" => Some(Sms) - case "phone" => Some(Phone) - case "paper" => Some(Paper) - case _ => None - } + import models.Application.Mandat.MandatType._ + + implicit val MandatTypeParser: Column[Option[MandatType]] = + implicitly[Column[Option[String]]] + .map(_.flatMap(dataModelDeserialization)) + + def dataModelSerialization(entity: MandatType): String = + entity match { + case Sms => "sms" + case Phone => "phone" + case Paper => "paper" + } + + def dataModelDeserialization(raw: String): Option[MandatType] = + raw match { + case "sms" => Sms.some + case "phone" => Phone.some + case "paper" => Paper.some + case _ => None + } + + } } @@ -53,20 +64,18 @@ object DataModel { } implicit val smsApiWrites: Writes[Sms] = - Writes( - _ match { - case sms: Sms.Outgoing => - smsOutgoingFormat.writes(sms) match { - case obj: JsObject => obj + ("tag" -> JsString("outgoing")) - case other => other - } - case sms: Sms.Incoming => - smsIncomingFormat.writes(sms) match { - case obj: JsObject => obj + ("tag" -> JsString("incoming")) - case other => other - } - } - ) + Writes { + case sms: Sms.Outgoing => + smsOutgoingFormat.writes(sms) match { + case obj: JsObject => obj + ("tag" -> JsString("outgoing")) + case other => other + } + case sms: Sms.Incoming => + smsIncomingFormat.writes(sms) match { + case obj: JsObject => obj + ("tag" -> JsString("incoming")) + case other => other + } + } } diff --git a/app/serializers/JsonFormats.scala b/app/serializers/JsonFormats.scala index f39c2642c..bdc8749c6 100644 --- a/app/serializers/JsonFormats.scala +++ b/app/serializers/JsonFormats.scala @@ -3,43 +3,38 @@ package serializers import constants.Constants import helper.{StringHelper, UUIDHelper} import java.util.UUID + import models.mandat.{Mandat, SmsMandatInitiation} import play.api.libs.json.Json.JsValueWrapper import play.api.libs.json._ import play.api.libs.functional.syntax._ +import play.api.libs.json.JsonConfiguration.Aux import play.api.mvc.Results.InternalServerError object JsonFormats { - implicit val jsonConfiguration = JsonConfiguration(naming = JsonNaming.SnakeCase) - - implicit val mapUUIDReads = new Reads[Map[UUID, String]] { - - def reads(jv: JsValue): JsResult[Map[UUID, String]] = - JsSuccess(jv.as[Map[String, String]].map { case (k, v) => - UUIDHelper.fromString(k).get -> v.asInstanceOf[String] - }) - - } - implicit val mapUUIDWrites = new Writes[Map[UUID, String]] { + implicit val jsonConfiguration: Aux[Json.MacroOptions] = + JsonConfiguration(naming = JsonNaming.SnakeCase) - def writes(map: Map[UUID, String]): JsValue = - Json.obj(map.map { case (s, o) => - val ret: (String, JsValueWrapper) = s.toString -> JsString(o) - ret - }.toSeq: _*) + implicit val mapUUIDReads: Reads[Map[UUID, String]] = (jv: JsValue) => + JsSuccess(jv.as[Map[String, String]].map { case (k, v) => + UUIDHelper.fromString(k).get -> v.asInstanceOf[String] + }) - } + implicit val mapUUIDWrites: Writes[Map[UUID, String]] = (map: Map[UUID, String]) => + Json.obj(map.map { case (s, o) => + val ret: (String, JsValueWrapper) = s.toString -> JsString(o) + ret + }.toSeq: _*) - implicit val mapUUIDFormat = Format(mapUUIDReads, mapUUIDWrites) + implicit val mapUUIDFormat: Format[Map[UUID, String]] = Format(mapUUIDReads, mapUUIDWrites) // // Mandat // import serializers.DataModel.SmsFormats._ - implicit val mandatIdReads: Reads[Mandat.Id] = - implicitly[Reads[UUID]].map(Mandat.Id.apply) + implicit val mandatIdReads: Reads[Mandat.Id] = implicitly[Reads[UUID]].map(Mandat.Id.apply) implicit val mandatIdWrites: Writes[Mandat.Id] = implicitly[Writes[UUID]].contramap((id: Mandat.Id) => id.underlying) diff --git a/app/services/ApplicationService.scala b/app/services/ApplicationService.scala index 15499b379..9c2b4d75b 100644 --- a/app/services/ApplicationService.scala +++ b/app/services/ApplicationService.scala @@ -3,12 +3,11 @@ package services import java.time.ZonedDateTime import java.util.UUID -import anorm.Column.nonNull import anorm._ import cats.syntax.all._ -import helper.Time import javax.inject.Inject import models.Authorization.UserRights +import models.sql.ApplicationRow import models.{Answer, Application, Authorization, Error, EventType} import play.api.db.Database import play.api.libs.json.Json @@ -22,100 +21,39 @@ class ApplicationService @Inject() ( dependencies: ServicesDependencies ) { import dependencies.databaseExecutionContext - import serializers.Anorm._ import serializers.JsonFormats._ - // Note: - // anorm.Column[String] => anorm.Column[Option[Application.MandatType]] does not work - // throws exception "AnormException: 'mandat_type' not found, available columns: ..." - implicit val mandatTypeAnormParser: anorm.Column[Option[Application.MandatType]] = - implicitly[anorm.Column[Option[String]]] - .map(_.flatMap(DataModel.Application.MandatType.dataModelDeserialization)) - - private implicit val answerReads = Json.reads[Answer] - private implicit val answerWrite = Json.writes[Answer] - - implicit val answerListParser: anorm.Column[List[Answer]] = - nonNull { (value, meta) => - val MetaDataItem(qualified, _, _) = meta - value match { - case json: org.postgresql.util.PGobject => - Right(Json.parse(json.getValue).as[List[Answer]]) - case json: String => - Right(Json.parse(json).as[List[Answer]]) - case _ => - Left( - TypeDoesNotMatch( - s"Cannot convert $value: ${className(value)} to List[Answer] for column $qualified" - ) - ) - } - } - - private val simpleApplication: RowParser[Application] = Macro - .parser[Application]( - "id", - "creation_date", - "creator_user_name", - "creator_user_id", - "subject", - "description", - "user_infos", - "invited_users", - "area", - "irrelevant", - "answers", // Data have been left bad migrated from answser_unsed - "internal_id", - "closed", - "seen_by_user_ids", - "usefulness", - "closed_date", - "expert_invited", - "has_selected_subject", - "category", - "files", - "mandat_type", - "mandat_date" - ) - .map(application => - application.copy( - creationDate = application.creationDate.withZoneSameInstant(Time.timeZoneParis), - answers = application.answers.map(answer => - answer.copy(creationDate = answer.creationDate.withZoneSameInstant(Time.timeZoneParis)) - ) - ) - ) - def byId(id: UUID, fromUserId: UUID, rights: UserRights): Future[Either[Error, Application]] = Future { db.withConnection { implicit connection => val result = SQL( "UPDATE application SET seen_by_user_ids = seen_by_user_ids || {seen_by_user_id}::uuid WHERE id = {id}::uuid RETURNING *" ).on("id" -> id, "seen_by_user_id" -> fromUserId) - .as(simpleApplication.singleOpt) + .as(ApplicationRow.ApplicationRowParser.singleOpt) + .map(ApplicationRow.toApplication) result match { case None => - Left( - Error.EntityNotFound( + Error + .EntityNotFound( EventType.ApplicationNotFound, s"Tentative d'accès à une application inexistante: $id" ) - ) + .asLeft[Application] case Some(application) => if (Authorization.canSeeApplication(application)(rights)) { if (Authorization.canSeePrivateDataOfApplication(application)(rights)) { - Right(application) + application.asRight[Error] } else { - Right(application.anonymousApplication) + application.anonymousApplication.asRight[Error] } } else { - Left( - Error.Authorization( + Error + .Authorization( EventType.ApplicationUnauthorized, s"Tentative d'accès à une application non autorisé: $id" ) - ) + .asLeft[Application] } } } @@ -125,7 +63,7 @@ class ApplicationService @Inject() ( db.withConnection { implicit connection => SQL( s"SELECT * FROM application WHERE closed = false AND age(creation_date) > '$day days' AND expert_invited = false" - ).as(simpleApplication.*) + ).as(ApplicationRow.ApplicationRowParser.*).map(ApplicationRow.toApplication) } def allOpenOrRecentForUserId( @@ -139,12 +77,10 @@ class ApplicationService @Inject() ( | (closed = FALSE OR DATE_PART('day', {referenceDate} - closed_date) < 30) |ORDER BY creation_date DESC""".stripMargin) .on("userId" -> userId, "referenceDate" -> referenceDate) - .as(simpleApplication.*) - if (anonymous) { - result.map(_.anonymousApplication) - } else { - result - } + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) + if (anonymous) result.map(_.anonymousApplication) + else result } def allOpenAndCreatedByUserIdAnonymous(userId: UUID): Future[List[Application]] = @@ -156,7 +92,8 @@ class ApplicationService @Inject() ( AND closed = false ORDER BY creation_date DESC""" ).on("userId" -> userId) - .as(simpleApplication.*) + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) result.map(_.anonymousApplication) } } @@ -166,7 +103,8 @@ class ApplicationService @Inject() ( val result = SQL( "SELECT * FROM application WHERE creator_user_id = {userId}::uuid OR invited_users ?? {userId} ORDER BY creation_date DESC" ).on("userId" -> userId) - .as(simpleApplication.*) + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) if (anonymous) { result.map(_.anonymousApplication) } else { @@ -178,7 +116,8 @@ class ApplicationService @Inject() ( Future { db.withConnection { implicit connection => SQL"SELECT * FROM application WHERE ARRAY[$userIds]::uuid[] @> ARRAY[creator_user_id]::uuid[] OR ARRAY(select jsonb_object_keys(invited_users))::uuid[] && ARRAY[$userIds]::uuid[] ORDER BY creation_date DESC" - .as(simpleApplication.*) + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) .map(_.anonymousApplication) } } @@ -188,7 +127,8 @@ class ApplicationService @Inject() ( val result = SQL("SELECT * FROM application WHERE area = {areaId}::uuid ORDER BY creation_date DESC") .on("areaId" -> areaId) - .as(simpleApplication.*) + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) if (anonymous) { result.map(_.anonymousApplication) } else { @@ -200,7 +140,8 @@ class ApplicationService @Inject() ( Future { db.withConnection { implicit connection => SQL"""SELECT * FROM application WHERE ARRAY[$areaIds]::uuid[] @> ARRAY[area]::uuid[] ORDER BY creation_date DESC""" - .as(simpleApplication.*) + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) .map(_.anonymousApplication) } } @@ -208,17 +149,25 @@ class ApplicationService @Inject() ( def all(): Future[List[Application]] = Future { db.withConnection { implicit connection => - SQL"""SELECT * FROM application""".as(simpleApplication.*).map(_.anonymousApplication) + SQL"""SELECT * FROM application""" + .as(ApplicationRow.ApplicationRowParser.*) + .map(ApplicationRow.toApplication) + .map(_.anonymousApplication) } } - def createApplication(newApplication: Application) = + def createApplication(application: Application) = db.withConnection { implicit connection => - val invitedUserJson = Json.toJson(newApplication.invitedUsers.map { case (key, value) => + val invitedUserJson = Json.toJson(application.invitedUsers.map { case (key, value) => key.toString -> value }) val mandatType = - newApplication.mandatType.map(DataModel.Application.MandatType.dataModelSerialization) + application.mandat + .map(_.type_) + .map(DataModel.Application.Mandat.MandatType.dataModelSerialization) + + val mandatDate = application.mandat.map(_.date) + SQL""" INSERT INTO application ( id, @@ -236,20 +185,20 @@ class ApplicationService @Inject() ( mandat_type, mandat_date ) VALUES ( - ${newApplication.id}::uuid, - ${newApplication.creationDate}, - ${newApplication.creatorUserName}, - ${newApplication.creatorUserId}::uuid, - ${newApplication.subject}, - ${newApplication.description}, - ${Json.toJson(newApplication.userInfos)}, - ${invitedUserJson}, - ${newApplication.area}::uuid, - ${newApplication.hasSelectedSubject}, - ${newApplication.category}, - ${Json.toJson(newApplication.files)}::jsonb, + ${application.id}::uuid, + ${application.creationDate}, + ${application.creatorUserName}, + ${application.creatorUserId}::uuid, + ${application.subject}, + ${application.description}, + ${Json.toJson(application.userInfos)}, + $invitedUserJson, + ${application.area}::uuid, + ${application.hasSelectedSubject}, + ${application.category}, + ${Json.toJson(application.files)}::jsonb, $mandatType, - ${newApplication.mandatDate} + $mandatDate ) """.executeUpdate() === 1 } diff --git a/app/services/UserService.scala b/app/services/UserService.scala index ffe933da3..3805df960 100644 --- a/app/services/UserService.scala +++ b/app/services/UserService.scala @@ -4,17 +4,11 @@ import java.util.UUID import anorm._ import cats.syntax.all._ -import cats.implicits.{catsKernelStdMonoidForString, catsSyntaxOption} import helper.{Hash, Time} import javax.inject.Inject import models.User -import javax.inject.Inject -import models.User -import models.formModels.ValidateSubscriptionForm import org.postgresql.util.PSQLException import play.api.db.Database -import play.api.db.Database -import views.html.helper.form import scala.concurrent.Future diff --git a/app/views/createApplication.scala.html b/app/views/createApplication.scala.html index 3909ccf5c..da7a3bf52 100644 --- a/app/views/createApplication.scala.html +++ b/app/views/createApplication.scala.html @@ -15,7 +15,7 @@ }{
Mes demandes > Nouvelle Demande
- +
@if(applicationForm.hasErrors) {
@@ -77,7 +77,7 @@
@if(applicationForm("users").hasErrors) {

@applicationForm("users").errors.map(_.format).mkString(", ")

} - + @for(organisationGroup <- organisationGroups.sortBy(_.name)) { @defining(organisationGroup.organisationSetOrDeducted) { organisation: Option[Organisation] => @@ -258,14 +258,14 @@
@defining(( if (canCreatePhoneMandat) List( - Application.MandatType.Sms -> "par sms", - Application.MandatType.Phone -> "par téléphone (à l’oral)", - Application.MandatType.Paper -> "par mandat signé" + Application.Mandat.MandatType.Sms -> "par sms", + Application.Mandat.MandatType.Phone -> "par téléphone (à l’oral)", + Application.Mandat.MandatType.Paper -> "par mandat signé" ) else List( - Application.MandatType.Sms -> "par sms", - Application.MandatType.Paper -> "par mandat signé" + Application.Mandat.MandatType.Sms -> "par sms", + Application.Mandat.MandatType.Paper -> "par mandat signé" ) - ).map(option => (DataModel.Application.MandatType.dataModelSerialization(option._1), option._2))) { options => + ).map(option => (DataModel.Application.Mandat.MandatType.dataModelSerialization(option._1), option._2))) { options => @options.map { case (optionValue, optionLabel) =>