From efdc50eb1ddf65161bca0ef71efdf2ca55574e0b Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Mon, 6 Oct 2025 18:30:22 +0200 Subject: [PATCH] Implement contracts --- .gitignore | 2 + api/src/lu/foyer/App.scala | 26 ++- .../lu/foyer/CommandEngineController.scala | 48 ++-- api/src/lu/foyer/JsonApiController.scala | 214 +++++++++++++----- .../lu/foyer/clients/ClientController.scala | 11 +- .../foyer/contracts/ContractController.scala | 24 ++ .../ContractEventRepositoryInMemory.scala | 23 ++ .../ContractStateRepositoryInMemory.scala | 12 + .../foyer/contracts/EmployeeServiceImpl.scala | 24 ++ .../foyer/contracts/PremiumServiceImpl.scala | 53 +++++ build.sc | 8 +- core/src/lu/foyer/EventSourcing.scala | 3 +- core/src/lu/foyer/Repository.scala | 10 +- core/src/lu/foyer/clients/ClientEvent.scala | 25 +- core/src/lu/foyer/clients/ClientState.scala | 88 +++---- .../lu/foyer/contracts/ContractCommand.scala | 28 +++ .../lu/foyer/contracts/ContractEvent.scala | 39 ++++ .../lu/foyer/contracts/ContractHandlers.scala | 137 +++++++++++ .../lu/foyer/contracts/ContractReducer.scala | 26 +++ .../lu/foyer/contracts/ContractState.scala | 106 +++++++++ .../lu/foyer/contracts/EmployeeService.scala | 7 + .../lu/foyer/contracts/PremiumService.scala | 8 + flake.lock | 18 +- model/src/lu/foyer/AppError.scala | 7 + model/src/lu/foyer/CommonTypes.scala | 10 + model/src/lu/foyer/RefinedType.scala | 10 + model/src/lu/foyer/clients/Client.scala | 14 +- model/src/lu/foyer/clients/Country.scala | 2 +- model/src/lu/foyer/contracts/Employee.scala | 11 + .../src/lu/foyer/contracts/FormulaType.scala | 27 +++ .../src/lu/foyer/contracts/ProductType.scala | 8 + .../contracts/TerminationReasonType.scala | 7 + model/src/lu/foyer/contracts/Vehicle.scala | 16 ++ 33 files changed, 879 insertions(+), 173 deletions(-) create mode 100644 api/src/lu/foyer/contracts/ContractController.scala create mode 100644 api/src/lu/foyer/contracts/ContractEventRepositoryInMemory.scala create mode 100644 api/src/lu/foyer/contracts/ContractStateRepositoryInMemory.scala create mode 100644 api/src/lu/foyer/contracts/EmployeeServiceImpl.scala create mode 100644 api/src/lu/foyer/contracts/PremiumServiceImpl.scala create mode 100644 core/src/lu/foyer/contracts/ContractCommand.scala create mode 100644 core/src/lu/foyer/contracts/ContractEvent.scala create mode 100644 core/src/lu/foyer/contracts/ContractHandlers.scala create mode 100644 core/src/lu/foyer/contracts/ContractReducer.scala create mode 100644 core/src/lu/foyer/contracts/ContractState.scala create mode 100644 core/src/lu/foyer/contracts/EmployeeService.scala create mode 100644 core/src/lu/foyer/contracts/PremiumService.scala create mode 100644 model/src/lu/foyer/AppError.scala create mode 100644 model/src/lu/foyer/CommonTypes.scala create mode 100644 model/src/lu/foyer/contracts/Employee.scala create mode 100644 model/src/lu/foyer/contracts/FormulaType.scala create mode 100644 model/src/lu/foyer/contracts/ProductType.scala create mode 100644 model/src/lu/foyer/contracts/TerminationReasonType.scala create mode 100644 model/src/lu/foyer/contracts/Vehicle.scala diff --git a/.gitignore b/.gitignore index ad23fff..7e62d96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .devenv .direnv out +.bsp .bloop .metals +mill.* diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala index ad174d9..7f27fe5 100644 --- a/api/src/lu/foyer/App.scala +++ b/api/src/lu/foyer/App.scala @@ -1,6 +1,7 @@ package lu.foyer import lu.foyer.clients.* +import lu.foyer.contracts.* import zio.* import zio.Console.* import zio.http.* @@ -10,17 +11,26 @@ import zio.http.endpoint.* import zio.http.endpoint.openapi.OpenAPIGen import zio.http.endpoint.openapi.SwaggerUI import zio.schema.* +import zio.http.Middleware.cors +import zio.http.Middleware.CorsConfig +import zio.http.Header.AccessControlAllowOrigin import java.net.URI import java.time.LocalDate import java.util.UUID object HttpServer: + + val corsConfig = CorsConfig(_ => Some(AccessControlAllowOrigin.All)) + def routes = for - client <- ZIO.service[ClientController] - openAPI = OpenAPIGen.fromEndpoints(client.endpoints) - yield client.routes @@ Middleware.debug ++ SwaggerUI.routes("docs" / "openapi", openAPI) + client <- ZIO.service[ClientController] + contract <- ZIO.service[ContractController] + openAPI = OpenAPIGen.fromEndpoints(client.endpoints ++ contract.endpoints) + yield (client.routes ++ contract.routes) + @@ cors(corsConfig) @@ Middleware.debug + ++ SwaggerUI.routes("docs" / "openapi", openAPI) object App extends ZIOAppDefault: val app = @@ -31,9 +41,17 @@ object App extends ZIOAppDefault: override def run = app.provide( CommandEngine.layer[ClientCommand, ClientEvent, ClientState], + CommandEngine.layer[ContractCommand, ContractEvent, ContractState], ClientHandlers.layer, + ContractHandlers.layer, ClientReducer.layer, + ContractReducer.layer, ClientEventRepositoryInMemory.layer, + ContractEventRepositoryInMemory.layer, ClientStateRepositoryInMemory.layer, - ClientController.layer + ContractStateRepositoryInMemory.layer, + ClientController.layer, + ContractController.layer, + PremiumServiceImpl.layer, + EmployeeServiceImpl.layer ) diff --git a/api/src/lu/foyer/CommandEngineController.scala b/api/src/lu/foyer/CommandEngineController.scala index d99a205..0e1d393 100644 --- a/api/src/lu/foyer/CommandEngineController.scala +++ b/api/src/lu/foyer/CommandEngineController.scala @@ -1,5 +1,6 @@ package lu.foyer +import lu.foyer.JsonApiResponse.One import zio.* import zio.Console.* import zio.http.* @@ -11,34 +12,27 @@ import zio.schema.* import java.net.URI import java.time.LocalDate import java.util.UUID -import lu.foyer.JsonApiResponse.One -trait CommandEngineController[Command: Schema, Event: Schema, State: Schema]( - domain: String, - entityName: String) +trait CommandEngineController[Command: Schema, Event: Schema, State: Schema] extends JsonApiController: def commandEngine: CommandEngine[Command, Event, State] - val onthology = s"$domain:$entityName" - - private val fetchMany = + private lazy val fetchMany = Endpoint(Method.GET / entityName) - .query(HttpCodec.query[Page]) + // .query(HttpCodec.query[Page]) .jsonApiMany[State] - private val fetchOne = + private lazy val fetchOne = Endpoint(Method.GET / entityName / uuid("entityId")) .jsonApiOne[State] - private val fetchEventsMany = + private lazy val fetchEventsMany = Endpoint(Method.GET / entityName / uuid("entityId") / "events") - .query(HttpCodec.query[Page]) + // .query(HttpCodec.query[Page]) .jsonApiMany[Event] - private val fetchEventsOne: Endpoint[(UUID, UUID), (UUID, UUID), JsonApiResponse.Error, One[ - Event - ], zio.http.endpoint.AuthType.None.type] = + private lazy val fetchEventsOne = Endpoint(Method.GET / entityName / uuid("entityId") / "events" / uuid("eventId")) .jsonApiOne[Event] @@ -51,7 +45,7 @@ trait CommandEngineController[Command: Schema, Event: Schema, State: Schema]( given Schema[Command] = handler.commandSchema.asInstanceOf[Schema[Command]] val endpoint = Endpoint(Method.POST / entityName / "commands" / handler.name) .in[Command] - .jsonApiOne[Event] + .jsonApiOneWithStatus[Event](Status.Created) val route = endpoint.implementJsonApiOneEvent(command => for entityId <- Random.nextUUID @@ -73,32 +67,36 @@ trait CommandEngineController[Command: Schema, Event: Schema, State: Schema]( ) (endpoint, route) - private val (commands, commandsRoutes) = generateCommands.unzip + private lazy val (commands, commandsRoutes) = generateCommands.unzip - private val fetchManyRoute = - fetchMany.implementJsonApiManyEntity(commandEngine.stateRepo.fetchMany) + private lazy val fetchManyRoute = + fetchMany.implementJsonApiManyEntity(_ => + commandEngine.stateRepo.fetchMany(Page(None, None, totals = Some(true))) + ) - private val fetchOneRoute = + private lazy val fetchOneRoute = fetchOne.implementJsonApiOneEntity(commandEngine.stateRepo.fetchOne) - private val fetchEventsManyRoute = - fetchEventsMany.implementJsonApiManyEvent(commandEngine.eventRepo.fetchMany(_, _)) + private lazy val fetchEventsManyRoute = + fetchEventsMany.implementJsonApiManyEvent(entityId => + commandEngine.eventRepo.fetchMany(entityId, Page(None, None, totals = Some(true))) + ) - private val fetchEventsOneRoute = + private lazy val fetchEventsOneRoute = fetchEventsOne.implementJsonApiOneEvent(commandEngine.eventRepo.fetchOne(_, _)) - val endpoints = List( + lazy val endpoints = List( fetchMany, fetchOne, fetchEventsMany, fetchEventsOne ) ++ commands - val routes = Routes( + lazy val routes = (Routes( fetchManyRoute, fetchOneRoute, fetchEventsManyRoute, fetchEventsOneRoute - ) ++ Routes.fromIterable(commandsRoutes) + ) ++ Routes.fromIterable(commandsRoutes)) @@ proxyHeadersAspect end CommandEngineController diff --git a/api/src/lu/foyer/JsonApiController.scala b/api/src/lu/foyer/JsonApiController.scala index f82c873..9c5f1c7 100644 --- a/api/src/lu/foyer/JsonApiController.scala +++ b/api/src/lu/foyer/JsonApiController.scala @@ -1,41 +1,45 @@ package lu.foyer +import lu.foyer.JsonApiResponse.Many +import lu.foyer.JsonApiResponse.One import zio.* -import zio.schema.* import zio.http.* +import zio.http.Header.Forwarded import zio.http.codec.* import zio.http.codec.PathCodec.path import zio.http.endpoint.* -import lu.foyer.JsonApiResponse.Many -import lu.foyer.JsonApiResponse.One +import zio.schema.* +import zio.schema.annotation.discriminatorName +import zio.schema.annotation.fieldName + import java.util.UUID import scala.annotation.targetName -import zio.schema.annotation.discriminatorName object JsonApiResponse: - case class One[T]( - data: Entity[T], - links: Links) + case class One[T](data: Entity[T]) derives Schema + + case class Many[T](data: List[Entity[T]], links: Map[String, String], meta: Meta) derives Schema + + case class Meta(totalRecords: Option[Long], totalPages: Option[Long], page: Page) derives Schema + case class Page(number: Int, size: Int) derives Schema + + case class Entity[T]( + `type`: String, + id: UUID, + attributes: T, + relationships: Option[Relationships] = None, + links: Map[String, String]) derives Schema - case class Many[T]( - data: List[Entity[T]], - links: Links, - meta: Meta) - derives Schema - - case class Links( - self: String, - first: Option[String] = None, - prev: Option[String] = None, - next: Option[String] = None, - last: Option[String] = None) - derives Schema - - case class Meta(totalRecords: Option[Long], totalPages: Option[Long]) derives Schema - - case class Entity[T](id: UUID, `type`: String, attributes: T) derives Schema + case class Relationships(_entity: RelationshipsEntity) derives Schema + object Relationships: + def apply(id: UUID, `type`: String, entityUrl: String): Relationships = Relationships( + RelationshipsEntity(RelationshipsData(id, `type`), RelationshipsLinks(entityUrl)) + ) + case class RelationshipsEntity(data: RelationshipsData, links: RelationshipsLinks) derives Schema + case class RelationshipsData(id: UUID, `type`: String) derives Schema + case class RelationshipsLinks(related: String) derives Schema @discriminatorName("errorType") enum Error(title: String) derives Schema: @@ -46,16 +50,47 @@ object JsonApiResponse: given Schema[Error.InternalServerError] = DeriveSchema.gen end JsonApiResponse -trait JsonApiController: +final case class ProxyHeaders( + protocol: Option[String], + host: Option[String], + prefix: Option[String]) + derives Schema: + def rootUrl = + (for + proto <- protocol + host <- host + prefix <- prefix + yield s"$proto://$host$prefix") + .getOrElse("") +trait JsonApiController: def onthology: String + def entityName: String + def allEntities: List[String] + + val proxyHeadersAspect = HandlerAspect.interceptIncomingHandler( + Handler.fromFunction[Request](request => + ( + request, + ProxyHeaders( + protocol = request.headers.get("X-Forwarded-Proto"), + host = request.headers.get("X-Forwarded-Host"), + prefix = request.headers.get("X-Forwarded-Prefix") + ) + ) + ) + ) extension [PathInput, Input, Auth <: AuthType]( endpoint: Endpoint[PathInput, Input, ZNothing, ZNothing, AuthType.None] ) - def jsonApiOne[Output: Schema] = + inline def jsonApiOne[Output: Schema] + : Endpoint[PathInput, Input, JsonApiResponse.Error, One[Output], AuthType.None.type] = + jsonApiOneWithStatus(Status.Ok) + + def jsonApiOneWithStatus[Output: Schema](status: Status) = endpoint - .out[JsonApiResponse.One[Output]] + .out[JsonApiResponse.One[Output]](status) .outErrors[JsonApiResponse.Error]( HttpCodec.error[JsonApiResponse.Error.NotFound](Status.NotFound), HttpCodec.error[JsonApiResponse.Error.InternalServerError](Status.InternalServerError) @@ -71,36 +106,70 @@ trait JsonApiController: def implementJsonApiOne[Env, A]( f: Input => RIO[Env, Option[A]], getId: A => UUID, - getEntity: A => Output + getEntity: A => Output, + onthology: String = this.onthology, + links: (A, ProxyHeaders) => Map[String, String] = (_: A, _: ProxyHeaders) => Map.empty, + relationships: (A, ProxyHeaders) => Option[JsonApiResponse.Relationships] = ( + _: A, + _: ProxyHeaders + ) => None )(implicit trace: Trace - ): Route[Env, Nothing] = + ): Route[Env & ProxyHeaders, Nothing] = endpoint.implement(input => - f(input) - .mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) - .someOrFail(JsonApiResponse.Error.NotFound(input.toString)) - .map(item => - JsonApiResponse.One( - JsonApiResponse.Entity( - id = getId(item), - `type` = onthology, - attributes = getEntity(item) - ), - JsonApiResponse.Links("https://api.example.org") // TODO - ) + for + item <- f(input) + .mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) + .flatMap { + case Some(paged) => ZIO.succeed(paged) + case None => ZIO.fail(JsonApiResponse.Error.NotFound(input.toString)) + } + headers <- ZIO.service[ProxyHeaders] + yield JsonApiResponse.One( + JsonApiResponse.Entity( + id = getId(item), + `type` = onthology, + attributes = getEntity(item), + relationships = relationships(item, headers), + links = links(item, headers) ) + ) ) def implementJsonApiOneEntity[Env]( f: Input => RIO[Env, Option[Entity[Output]]] )(implicit trace: Trace - ): Route[Env, Nothing] = - implementJsonApiOne(f, _.entityId, _.data) + ): Route[Env & ProxyHeaders, Nothing] = + implementJsonApiOne( + f, + _.entityId, + _.data, + links = (e, headers) => + Map( + "self" -> s"${headers.rootUrl}/$entityName/${e.entityId}", + "events" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events" + ) + ) def implementJsonApiOneEvent[Env]( f: Input => RIO[Env, Option[Event[Output]]] )(implicit trace: Trace - ): Route[Env, Nothing] = - implementJsonApiOne(f, _.eventId, _.data) + ): Route[Env & ProxyHeaders, Nothing] = + implementJsonApiOne( + f, + _.eventId, + _.data, + onthology + ":event", + links = (e, headers) => + Map("self" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events/${e.eventId}"), + relationships = (e, headers) => + Some( + JsonApiResponse.Relationships( + e.entityId, + onthology, + s"${headers.rootUrl}/$entityName/${e.entityId}" + ) + ) + ) end extension extension [PathInput, Input, Output, Auth <: AuthType]( @@ -109,33 +178,60 @@ trait JsonApiController: def implementJsonApiMany[Env, A]( f: Input => RIO[Env, Paged[A]], getId: A => UUID, - getEntity: A => Output + getEntity: A => Output, + links: (A, ProxyHeaders) => Map[String, String] = (_: A, _: ProxyHeaders) => Map.empty )(implicit trace: Trace - ): Route[Env, Nothing] = - endpoint.implement( - f(_) - .map(paged => - JsonApiResponse.Many( - paged.items.map(item => - JsonApiResponse.Entity(id = getId(item), `type` = onthology, getEntity(item)) - ), - JsonApiResponse.Links("https://api.example.org"), - meta = JsonApiResponse.Meta(paged.totals, None) // TODO + ): Route[Env & ProxyHeaders, Nothing] = + endpoint.implement(input => + (for + paged <- f(input) + headers <- ZIO.service[ProxyHeaders] + yield JsonApiResponse.Many( + paged.items.map(item => + JsonApiResponse.Entity( + id = getId(item), + `type` = onthology, + attributes = getEntity(item), + links = links(item, headers) ) + ), + links = Map( // TODO compute proper pagination + "self" -> s"${headers.rootUrl}/$entityName", + "prev" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10", + "first" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10", + "next" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10", + "last" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10" ) + ++ allEntities.map(e => e -> s"${headers.rootUrl}/$e"), + meta = JsonApiResponse + .Meta( + totalRecords = paged.totals, + totalPages = Some(1), + page = JsonApiResponse.Page(number = 0, size = 10) + ) + )) .mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage)) ) inline def implementJsonApiManyEntity[Env]( f: Input => RIO[Env, Paged[Entity[Output]]] )(implicit trace: Trace - ): Route[Env, Nothing] = - implementJsonApiMany(f, _.entityId, _.data) + ): Route[Env & ProxyHeaders, Nothing] = + implementJsonApiMany( + f, + _.entityId, + _.data, + links = (e, headers) => + Map( + "self" -> s"${headers.rootUrl}/$entityName/${e.entityId}", + "events" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events" + ) + ) inline def implementJsonApiManyEvent[Env]( f: Input => RIO[Env, Paged[Event[Output]]] )(implicit trace: Trace - ): Route[Env, Nothing] = + ): Route[Env & ProxyHeaders, Nothing] = implementJsonApiMany(f, _.eventId, _.data) end extension diff --git a/api/src/lu/foyer/clients/ClientController.scala b/api/src/lu/foyer/clients/ClientController.scala index e312aa0..ca97dfe 100644 --- a/api/src/lu/foyer/clients/ClientController.scala +++ b/api/src/lu/foyer/clients/ClientController.scala @@ -13,12 +13,11 @@ import java.net.URI import java.time.LocalDate import java.util.UUID -class ClientController( - override val commandEngine: CommandEngine[ClientCommand, ClientEvent, ClientState]) - extends CommandEngineController[ClientCommand, ClientEvent, ClientState]( - "api:example:insurance", - "client" - ) +class ClientController(val commandEngine: CommandEngine[ClientCommand, ClientEvent, ClientState]) + extends CommandEngineController[ClientCommand, ClientEvent, ClientState]: + override val onthology = "org:example:insurance:client" + override val entityName = "clients" + override val allEntities = List("clients", "contracts") object ClientController: val layer = ZLayer.fromFunction(ClientController.apply) diff --git a/api/src/lu/foyer/contracts/ContractController.scala b/api/src/lu/foyer/contracts/ContractController.scala new file mode 100644 index 0000000..12e0d63 --- /dev/null +++ b/api/src/lu/foyer/contracts/ContractController.scala @@ -0,0 +1,24 @@ +package lu.foyer +package contracts + +import zio.* +import zio.Console.* +import zio.http.* +import zio.http.codec.* +import zio.http.codec.PathCodec.path +import zio.http.endpoint.* +import zio.schema.* + +import java.net.URI +import java.time.LocalDate +import java.util.UUID + +class ContractController( + val commandEngine: CommandEngine[ContractCommand, ContractEvent, ContractState]) + extends CommandEngineController[ContractCommand, ContractEvent, ContractState]: + override val onthology = "org:example:insurance:contract" + override val entityName = "contracts" + override val allEntities = List("clients", "contracts") + +object ContractController: + val layer = ZLayer.fromFunction(ContractController.apply) diff --git a/api/src/lu/foyer/contracts/ContractEventRepositoryInMemory.scala b/api/src/lu/foyer/contracts/ContractEventRepositoryInMemory.scala new file mode 100644 index 0000000..43358db --- /dev/null +++ b/api/src/lu/foyer/contracts/ContractEventRepositoryInMemory.scala @@ -0,0 +1,23 @@ +package lu.foyer +package contracts + +import java.util.UUID +import zio.* + +class ContractEventRepositoryInMemory(events: Ref[Map[UUID, Event[ContractEvent]]]) + extends EventRepository[ContractEvent] + with InMemoryRepository[Event[ContractEvent]](events): + def fetchOne(entityId: UUID, eventId: UUID): Task[Option[Event[ContractEvent]]] = + events.get.map(_.get(eventId)) + def fetchMany(entityId: UUID, page: Page): Task[Paged[Event[ContractEvent]]] = + events.get + .map(entities => + val items = entities.values + .filter(_.entityId == entityId) + .drop(page.number.getOrElse(0) * page.size.getOrElse(50)) + .take(page.size.getOrElse(50)) + Paged(items.toList, if page.totals.getOrElse(false) then Some(entities.size) else None) + ) + +object ContractEventRepositoryInMemory: + val layer = ZLayer.fromZIO(Ref.make(Map.empty).map(ContractEventRepositoryInMemory(_))) diff --git a/api/src/lu/foyer/contracts/ContractStateRepositoryInMemory.scala b/api/src/lu/foyer/contracts/ContractStateRepositoryInMemory.scala new file mode 100644 index 0000000..4769c98 --- /dev/null +++ b/api/src/lu/foyer/contracts/ContractStateRepositoryInMemory.scala @@ -0,0 +1,12 @@ +package lu.foyer +package contracts + +import java.util.UUID +import zio.* + +class ContractStateRepositoryInMemory(clients: Ref[Map[UUID, Entity[ContractState]]]) + extends StateRepository[ContractState] + with InMemoryRepository[Entity[ContractState]](clients) + +object ContractStateRepositoryInMemory: + val layer = ZLayer.fromZIO(Ref.make(Map.empty).map(ContractStateRepositoryInMemory(_))) diff --git a/api/src/lu/foyer/contracts/EmployeeServiceImpl.scala b/api/src/lu/foyer/contracts/EmployeeServiceImpl.scala new file mode 100644 index 0000000..3194122 --- /dev/null +++ b/api/src/lu/foyer/contracts/EmployeeServiceImpl.scala @@ -0,0 +1,24 @@ +package lu.foyer +package contracts + +import java.util.UUID +import zio.* +import lu.foyer.clients.Address +import scala.math.BigDecimal.RoundingMode +import java.util.Currency +import lu.foyer.clients.Country + +object EmployeeServiceImpl extends EmployeeService: + + def fetchOne(subject: String): Either[String, Employee] = + if subject == "usr:top" then + Right( + Employee( + subject, + EmployeeDisplayName.assume("MEIER Christoph"), + Email.assume("top@foyer.lu") + ) + ) + else Left("Invalid Employee") + + val layer = ZLayer.succeed(EmployeeServiceImpl) diff --git a/api/src/lu/foyer/contracts/PremiumServiceImpl.scala b/api/src/lu/foyer/contracts/PremiumServiceImpl.scala new file mode 100644 index 0000000..c584f29 --- /dev/null +++ b/api/src/lu/foyer/contracts/PremiumServiceImpl.scala @@ -0,0 +1,53 @@ +package lu.foyer +package contracts + +import java.util.UUID +import zio.* +import lu.foyer.clients.Address +import scala.math.BigDecimal.RoundingMode +import java.util.Currency +import lu.foyer.clients.Country + +object PremiumServiceImpl extends PremiumService: + private val EUR = Currency.getInstance("EUR") + + private val brands = + Map( + "renault" -> BigDecimal(0.5), + "fiat" -> BigDecimal(0.6), + "ford" -> BigDecimal(0.7), + "nissan" -> BigDecimal(0.75), + "peugeot" -> BigDecimal(0.8), + "volkswagen" -> BigDecimal(1.1), + "audi" -> BigDecimal(1.25), + "bmw" -> BigDecimal(1.25), + "mercedes" -> BigDecimal(1.25), + "ferrari" -> BigDecimal(2.3), + "bugatti" -> BigDecimal(2.6), + "tesla" -> BigDecimal(5.0) + ) + + private val countries = + Map(Country.CH -> BigDecimal(0.8), Country.LU -> BigDecimal(1.2)) + + private def computeAmountEUR(base: BigDecimal, brand: VehicleBrand, country: Country) + : BigDecimal = + val brandMultiplier = brands.getOrElse(brand.toLowerCase, BigDecimal(1)) + val countryMultiplier = countries.getOrElse(country, BigDecimal(1)) + val amount = base * brandMultiplier * countryMultiplier + amount.setScale(EUR.getDefaultFractionDigits, RoundingMode.HALF_UP).rounded + + override def computePremium(formula: FormulaType, vehicle: Vehicle, residentialAddress: Address) + : Either[String, Amount] = + if vehicle.insuredValue.value > formula.maxCoverage then + Left(s"The $formula formula only covers amounts up to ${formula.maxCoverage} EUR") + else + Right( + Amount( + computeAmountEUR(formula.basePremium, vehicle.brand, residentialAddress.country), + EUR + ) + ) + + val layer = ZLayer.succeed(PremiumServiceImpl) +end PremiumServiceImpl diff --git a/build.sc b/build.sc index a018887..f08ff54 100644 --- a/build.sc +++ b/build.sc @@ -11,7 +11,7 @@ object Versions { val zio = "2.1.15" val zioJson = "0.7.33" val zioSchema = "1.6.3" - val zioHttp = "3.0.1+97-29d12531-SNAPSHOT" + val zioHttp = "3.1.0" val zioPrelude = "1.0.0-RC39" } @@ -19,6 +19,8 @@ trait CommonModule extends ScalaModule { def scalaVersion = "3.6.3" def ivyDeps = Agg( ivy"dev.zio::zio:${Versions.zio}", + ivy"dev.zio::zio-json:${Versions.zioJson}", + ivy"dev.zio::zio-schema-json:${Versions.zioSchema}", ivy"dev.zio::zio-schema:${Versions.zioSchema}", ivy"dev.zio::zio-schema-derivation:${Versions.zioSchema}", ivy"dev.zio::zio-prelude:${Versions.zioPrelude}" @@ -38,8 +40,6 @@ object api extends CommonModule { def moduleDeps = Seq(core) def ivyDeps = Agg( ivy"dev.zio::zio:${Versions.zio}", - ivy"dev.zio::zio-http:${Versions.zioHttp}", - ivy"dev.zio::zio-json:${Versions.zioJson}", - ivy"dev.zio::zio-schema-json:${Versions.zioSchema}" + ivy"dev.zio::zio-http:${Versions.zioHttp}" ) } diff --git a/core/src/lu/foyer/EventSourcing.scala b/core/src/lu/foyer/EventSourcing.scala index 4212d79..de4c54d 100644 --- a/core/src/lu/foyer/EventSourcing.scala +++ b/core/src/lu/foyer/EventSourcing.scala @@ -1,9 +1,10 @@ package lu.foyer import zio.* -import java.util.UUID import zio.schema.Schema +import java.util.UUID + final case class Entity[T](entityId: UUID, data: T, version: Long) final case class Event[T](entityId: UUID, data: T, eventId: UUID) diff --git a/core/src/lu/foyer/Repository.scala b/core/src/lu/foyer/Repository.scala index 84e132b..b259e68 100644 --- a/core/src/lu/foyer/Repository.scala +++ b/core/src/lu/foyer/Repository.scala @@ -2,9 +2,17 @@ package lu.foyer import zio.* import zio.schema.* +import zio.schema.annotation.fieldName + import java.util.UUID -final case class Page(number: Option[Int], size: Option[Int], totals: Option[Boolean]) +final case class Page( + @fieldName("page[number]") + number: Option[Int], + @fieldName("page[size]") + size: Option[Int], + @fieldName("page[totals]") + totals: Option[Boolean]) derives Schema final case class Paged[T](items: List[T], totals: Option[Long]) diff --git a/core/src/lu/foyer/clients/ClientEvent.scala b/core/src/lu/foyer/clients/ClientEvent.scala index 85dcbda..2e32140 100644 --- a/core/src/lu/foyer/clients/ClientEvent.scala +++ b/core/src/lu/foyer/clients/ClientEvent.scala @@ -2,13 +2,17 @@ package lu.foyer package clients import zio.schema.* - -import java.time.LocalDate +import zio.schema.annotation.caseName import zio.schema.annotation.discriminatorName +import java.time.LocalDate + @discriminatorName("eventType") -enum ClientEvent derives Schema: - case Created( +sealed trait ClientEvent derives Schema +object ClientEvent: + + @caseName("created") + final case class Created( lastName: ClientLastName, firstName: ClientFirstName, birthDate: ClientBirthDate, @@ -16,7 +20,10 @@ enum ClientEvent derives Schema: phoneNumber: Option[PhoneNumberInput], email: Option[Email], address: Option[Address]) - case Updated( + extends ClientEvent derives Schema + + @caseName("updated") + final case class Updated( lastName: Option[ClientLastName], firstName: Option[ClientFirstName], birthDate: Option[ClientBirthDate], @@ -24,9 +31,7 @@ enum ClientEvent derives Schema: phoneNumber: Option[PhoneNumberInput], email: Option[Email], address: Option[Address]) - case Disabled(reason: ClientDisabledReason) + extends ClientEvent derives Schema -object ClientEvent: - given Schema[ClientEvent.Created] = DeriveSchema.gen - given Schema[ClientEvent.Updated] = DeriveSchema.gen - given Schema[ClientEvent.Disabled] = DeriveSchema.gen + @caseName("disabled") + final case class Disabled(reason: ClientDisabledReason) extends ClientEvent derives Schema diff --git a/core/src/lu/foyer/clients/ClientState.scala b/core/src/lu/foyer/clients/ClientState.scala index 3ee8363..ebad0f4 100644 --- a/core/src/lu/foyer/clients/ClientState.scala +++ b/core/src/lu/foyer/clients/ClientState.scala @@ -2,30 +2,59 @@ package lu.foyer package clients import zio.schema.* +import zio.schema.annotation.caseName import zio.schema.annotation.discriminatorName import java.time.LocalDate @discriminatorName("statusType") -enum ClientState derives Schema: - case Actif( - lastName: ClientLastName, - firstName: ClientFirstName, - birthDate: ClientBirthDate, - drivingLicenseDate: Option[ClientDrivingLicenseDate], - phoneNumber: Option[PhoneNumberInput], - email: Option[Email], - address: Option[Address]) - case Inactif( - lastName: ClientLastName, - firstName: ClientFirstName, - birthDate: ClientBirthDate, - drivingLicenseDate: Option[ClientDrivingLicenseDate], - phoneNumber: Option[PhoneNumberInput], - email: Option[Email], - address: Option[Address]) - +sealed trait ClientState derives Schema object ClientState: + + @caseName("actif") + final case class Actif( + lastName: ClientLastName, + firstName: ClientFirstName, + birthDate: ClientBirthDate, + drivingLicenseDate: Option[ClientDrivingLicenseDate], + phoneNumber: Option[PhoneNumberInput], + email: Option[Email], + address: Option[Address]) + extends ClientState derives Schema: + + def update(e: ClientEvent.Updated) = + ClientState.Actif( + e.lastName.getOrElse(lastName), + e.firstName.getOrElse(firstName), + e.birthDate.getOrElse(birthDate), + e.drivingLicenseDate.orElse(drivingLicenseDate), + e.phoneNumber.orElse(phoneNumber), + e.email.orElse(email), + e.address.orElse(address) + ) + def disable(e: ClientEvent.Disabled) = + ClientState.Inactif( + lastName, + firstName, + birthDate, + drivingLicenseDate, + phoneNumber, + email, + address + ) + end Actif + + @caseName("inactif") + final case class Inactif( + lastName: ClientLastName, + firstName: ClientFirstName, + birthDate: ClientBirthDate, + drivingLicenseDate: Option[ClientDrivingLicenseDate], + phoneNumber: Option[PhoneNumberInput], + email: Option[Email], + address: Option[Address]) + extends ClientState derives Schema + def create(event: ClientEvent.Created) = ClientState.Actif( event.lastName, @@ -36,25 +65,4 @@ object ClientState: event.email, event.address ) - -extension (s: ClientState.Actif) - def update(e: ClientEvent.Updated) = - ClientState.Actif( - e.lastName.getOrElse(s.lastName), - e.firstName.getOrElse(s.firstName), - e.birthDate.getOrElse(s.birthDate), - e.drivingLicenseDate.orElse(s.drivingLicenseDate), - e.phoneNumber.orElse(s.phoneNumber), - e.email.orElse(s.email), - e.address.orElse(s.address) - ) - def disable(e: ClientEvent.Disabled) = - ClientState.Inactif( - s.lastName, - s.firstName, - s.birthDate, - s.drivingLicenseDate, - s.phoneNumber, - s.email, - s.address - ) +end ClientState diff --git a/core/src/lu/foyer/contracts/ContractCommand.scala b/core/src/lu/foyer/contracts/ContractCommand.scala new file mode 100644 index 0000000..1786a72 --- /dev/null +++ b/core/src/lu/foyer/contracts/ContractCommand.scala @@ -0,0 +1,28 @@ +package lu.foyer +package contracts + +import zio.schema.* + +import java.time.LocalDate +import java.util.UUID + +enum ContractCommand derives Schema: + case Subscribe( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType) + case Amend( + product: Option[ProductType], + vehicle: Option[Vehicle], + formula: Option[FormulaType]) + case Approve() + case Reject(comment: String) + case Terminate(reason: TerminationReasonType) + +object ContractCommand: + given Schema[ContractCommand.Subscribe] = DeriveSchema.gen + given Schema[ContractCommand.Amend] = DeriveSchema.gen + given Schema[ContractCommand.Approve] = DeriveSchema.gen + given Schema[ContractCommand.Reject] = DeriveSchema.gen + given Schema[ContractCommand.Terminate] = DeriveSchema.gen diff --git a/core/src/lu/foyer/contracts/ContractEvent.scala b/core/src/lu/foyer/contracts/ContractEvent.scala new file mode 100644 index 0000000..3c362fd --- /dev/null +++ b/core/src/lu/foyer/contracts/ContractEvent.scala @@ -0,0 +1,39 @@ +package lu.foyer +package contracts + +import zio.schema.* +import zio.schema.annotation.caseName +import zio.schema.annotation.discriminatorName + +import java.time.LocalDate + +@discriminatorName("eventType") +sealed trait ContractEvent derives Schema +object ContractEvent: + + @caseName("created") + final case class Subscribed( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + extends ContractEvent derives Schema + + @caseName("amended") + final case class Amended( + product: ProductType, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + extends ContractEvent derives Schema + + @caseName("approved") + final case class Approved(approvedBy: Employee) extends ContractEvent derives Schema + + @caseName("rejected") + final case class Rejected(rejectedBy: Employee, comment: String) extends ContractEvent + derives Schema + + @caseName("terminated") + final case class Terminated(reason: TerminationReasonType) extends ContractEvent derives Schema diff --git a/core/src/lu/foyer/contracts/ContractHandlers.scala b/core/src/lu/foyer/contracts/ContractHandlers.scala new file mode 100644 index 0000000..c3b7957 --- /dev/null +++ b/core/src/lu/foyer/contracts/ContractHandlers.scala @@ -0,0 +1,137 @@ +package lu.foyer +package contracts + +import lu.foyer.clients.* +import zio.* +import java.util.UUID + +object ContractHandlers: + val layer = + ZLayer.fromFunction( + ( + clientStateRepo: StateRepository[ClientState], + premiumService: PremiumService, + employeeService: EmployeeService + ) => + List( + SubscribeHandler(clientStateRepo, premiumService), + AmendHandler(clientStateRepo, premiumService), + ApproveHandler(employeeService), + RejectHandler(employeeService), + TerminateHandler() + ) + ) + +class SubscribeHandler( + clientStateRepo: StateRepository[ClientState], + premiumService: PremiumService) + extends CommandHandlerCreate[ContractCommand.Subscribe, ContractEvent.Subscribed]: + + val name = "create" + + def onCommand(entityId: UUID, command: ContractCommand.Subscribe) + : Task[ContractEvent.Subscribed] = + for + holder <- clientStateRepo.fetchOne(command.holder).someOrFailException + residentialAddress <- + holder.data match + case ClientState.Actif(_, _, _, _, _, _, Some(address)) => + ZIO.succeed(address) + case _ => + ZIO.fail(new IllegalArgumentException(s"No active holder found for ${command.holder}")) + premium = premiumService.computePremium(command.formula, command.vehicle, residentialAddress) + premium <- + ZIO + .fromEither( + premiumService.computePremium(command.formula, command.vehicle, residentialAddress) + ) + .mapError(new IllegalArgumentException(_)) + yield ContractEvent.Subscribed( + command.product, + command.holder, + command.vehicle, + command.formula, + premium + ) +end SubscribeHandler + +class AmendHandler(clientStateRepo: StateRepository[ClientState], premiumService: PremiumService) + extends CommandHandlerUpdate[ContractCommand.Amend, ContractEvent.Amended, ContractState]: + + val name = "amend" + + def onCommand(entityId: UUID, state: ContractState, command: ContractCommand.Amend) + : Task[ContractEvent.Amended] = + for + holder <- clientStateRepo.fetchOne(state.holder).someOrFailException + residentialAddress <- + holder.data match + case ClientState.Actif(_, _, _, _, _, _, Some(address)) => + ZIO.succeed(address) + case _ => + ZIO.fail(new IllegalArgumentException(s"No active holder found for ${state.holder}")) + premium <- ZIO + .fromEither( + premiumService.computePremium( + command.formula.getOrElse(state.formula), + command.vehicle.getOrElse(state.vehicle), + residentialAddress + ) + ) + .mapError(new IllegalArgumentException(_)) + yield ContractEvent.Amended( + command.product.getOrElse(state.product), + command.vehicle.getOrElse(state.vehicle), + command.formula.getOrElse(state.formula), + premium + ) +end AmendHandler + +class ApproveHandler(employeeService: EmployeeService) + extends CommandHandlerUpdate[ + ContractCommand.Approve, + ContractEvent.Approved, + ContractState.Pending + ]: + + val name = "approve" + + def onCommand(entityId: UUID, state: ContractState.Pending, command: ContractCommand.Approve) + : Task[ContractEvent.Approved] = + for + user <- ZIO.succeed("") // TODO current user + employee <- ZIO + .fromEither(employeeService.fetchOne(user)) + .mapError(new IllegalArgumentException(_)) + yield ContractEvent.Approved(employee) + +class RejectHandler(employeeService: EmployeeService) + extends CommandHandlerUpdate[ + ContractCommand.Reject, + ContractEvent.Rejected, + ContractState.Pending + ]: + + val name = "reject" + + def onCommand(entityId: UUID, state: ContractState.Pending, command: ContractCommand.Reject) + : Task[ContractEvent.Rejected] = + for + user <- ZIO.succeed("") // TODO current user + employee <- ZIO + .fromEither(employeeService.fetchOne(user)) + .mapError(new IllegalArgumentException(_)) + yield ContractEvent.Rejected(employee, command.comment) + +class TerminateHandler() + extends CommandHandlerUpdate[ + ContractCommand.Terminate, + ContractEvent.Terminated, + ContractState + ]: + + val name = "reject" + + def onCommand(entityId: UUID, state: ContractState, command: ContractCommand.Terminate) + : Task[ContractEvent.Terminated] = + ZIO.succeed(ContractEvent.Terminated(command.reason)) diff --git a/core/src/lu/foyer/contracts/ContractReducer.scala b/core/src/lu/foyer/contracts/ContractReducer.scala new file mode 100644 index 0000000..1df35d4 --- /dev/null +++ b/core/src/lu/foyer/contracts/ContractReducer.scala @@ -0,0 +1,26 @@ +package lu.foyer +package contracts + +import zio.* + +class ContractReducer() extends Reducer[ContractEvent, ContractState]: + + override val fromEmpty = + case e: ContractEvent.Subscribed => ContractState.subscribe(e) + + override val fromState = + case (s: ContractState.Actif, e: ContractEvent.Amended) => s.amend(e) + case (s: ContractState.Actif, e: ContractEvent.Terminated) => s.terminate(e) + + case (s: ContractState.PendingSubscription, e: ContractEvent.Amended) => s.amend(e) + case (s: ContractState.PendingSubscription, e: ContractEvent.Approved) => s.approve(e) + case (s: ContractState.PendingSubscription, e: ContractEvent.Rejected) => s.reject(e) + case (s: ContractState.PendingSubscription, e: ContractEvent.Terminated) => s.terminate(e) + + case (s: ContractState.PendingAmendment, e: ContractEvent.Amended) => s.amend(e) + case (s: ContractState.PendingAmendment, e: ContractEvent.Approved) => s.approve(e) + case (s: ContractState.PendingAmendment, e: ContractEvent.Rejected) => s.reject(e) + case (s: ContractState.PendingAmendment, e: ContractEvent.Terminated) => s.terminate(e) + +object ContractReducer: + val layer: ULayer[Reducer[ContractEvent, ContractState]] = ZLayer.succeed(ContractReducer()) diff --git a/core/src/lu/foyer/contracts/ContractState.scala b/core/src/lu/foyer/contracts/ContractState.scala new file mode 100644 index 0000000..6349139 --- /dev/null +++ b/core/src/lu/foyer/contracts/ContractState.scala @@ -0,0 +1,106 @@ +package lu.foyer +package contracts + +import zio.schema.* +import zio.schema.annotation.caseName +import zio.schema.annotation.discriminatorName + +import java.time.LocalDate + +@discriminatorName("statusType") +sealed trait ContractState derives Schema: + def product: ProductType + def holder: ClientEntityId + def vehicle: Vehicle + def formula: FormulaType + def premium: Amount + +object ContractState: + + def subscribe(e: ContractEvent.Subscribed) = + PendingSubscription(e.product, e.holder, e.vehicle, e.formula, e.premium) + + @caseName("actif") + final case class Actif( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + extends ContractState derives Schema: + + def amend(e: ContractEvent.Amended) = + Actif(e.product, holder, e.vehicle, e.formula, e.premium) + + def terminate(e: ContractEvent.Terminated) = + Terminated(product, holder, vehicle, formula, premium) + + @discriminatorName("statusType") + sealed trait Pending extends ContractState + + @caseName("pending-subscription") + final case class PendingSubscription( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + extends Pending derives Schema: + + def amend(e: ContractEvent.Amended) = + PendingSubscription(e.product, holder, e.vehicle, e.formula, e.premium) + + def approve(e: ContractEvent.Approved) = + Actif(product, holder, vehicle, formula, premium) + + def reject(e: ContractEvent.Rejected) = + Terminated(product, holder, vehicle, formula, premium) + + def terminate(e: ContractEvent.Terminated) = + Terminated(product, holder, vehicle, formula, premium) + + final case class PendingChanges( + product: ProductType, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + derives Schema + + @caseName("pending-amendment") + final case class PendingAmendment( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount, + pendingChanges: PendingChanges) + extends Pending derives Schema: + + def amend(e: ContractEvent.Amended) = + copy(pendingChanges = PendingChanges(e.product, e.vehicle, e.formula, e.premium)) + + def approve(e: ContractEvent.Approved) = + Actif( + pendingChanges.product, + holder, + pendingChanges.vehicle, + pendingChanges.formula, + pendingChanges.premium + ) + + def reject(e: ContractEvent.Rejected) = + Actif(product, holder, vehicle, formula, premium) + + def terminate(e: ContractEvent.Terminated) = + ContractState.Terminated(product, holder, vehicle, formula, premium) + + @caseName("terminated") + final case class Terminated( + product: ProductType, + holder: ClientEntityId, + vehicle: Vehicle, + formula: FormulaType, + premium: Amount) + extends ContractState derives Schema + +end ContractState diff --git a/core/src/lu/foyer/contracts/EmployeeService.scala b/core/src/lu/foyer/contracts/EmployeeService.scala new file mode 100644 index 0000000..c2e1a25 --- /dev/null +++ b/core/src/lu/foyer/contracts/EmployeeService.scala @@ -0,0 +1,7 @@ +package lu.foyer +package contracts + +import lu.foyer.clients.Address + +trait EmployeeService: + def fetchOne(subject: String): Either[String, Employee] diff --git a/core/src/lu/foyer/contracts/PremiumService.scala b/core/src/lu/foyer/contracts/PremiumService.scala new file mode 100644 index 0000000..a3383b9 --- /dev/null +++ b/core/src/lu/foyer/contracts/PremiumService.scala @@ -0,0 +1,8 @@ +package lu.foyer +package contracts + +import lu.foyer.clients.Address + +trait PremiumService: + def computePremium(formula: FormulaType, vehicle: Vehicle, residentialAddress: Address) + : Either[String, Amount] diff --git a/flake.lock b/flake.lock index c371fe3..5334d2c 100644 --- a/flake.lock +++ b/flake.lock @@ -39,11 +39,11 @@ ] }, "locked": { - "lastModified": 1740460834, - "narHash": "sha256-RUL1r8zH5wG5L1YipNj1bmt0Oi8L9qwzXsf/ww8WxBc=", + "lastModified": 1742480343, + "narHash": "sha256-AN6X0t0pX0GLDh6CMG474aNffJUKPQtSEJ9aCZed474=", "owner": "cachix", "repo": "devenv", - "rev": "9e4003b2702483bd962dac3d4ff43e8dafb93cda", + "rev": "56fe80518d1c949520a2cea8e9c2a83ec3f9bdc5", "type": "github" }, "original": { @@ -102,11 +102,11 @@ ] }, "locked": { - "lastModified": 1737465171, - "narHash": "sha256-R10v2hoJRLq8jcL4syVFag7nIGE7m13qO48wRIukWNg=", + "lastModified": 1740849354, + "narHash": "sha256-oy33+t09FraucSZ2rZ6qnD1Y1c8azKKmQuCvF2ytUko=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", + "rev": "4a709a8ce9f8c08fa7ddb86761fe488ff7858a07", "type": "github" }, "original": { @@ -172,11 +172,11 @@ ] }, "locked": { - "lastModified": 1734114420, - "narHash": "sha256-n52PUzub5jZWc8nI/sR7UICOheU8rNA+YZ73YaHeCBg=", + "lastModified": 1741798497, + "narHash": "sha256-E3j+3MoY8Y96mG1dUIiLFm2tZmNbRvSiyN7CrSKuAVg=", "owner": "domenkozar", "repo": "nix", - "rev": "bde6a1a0d1f2af86caa4d20d23eca019f3d57eee", + "rev": "f3f44b2baaf6c4c6e179de8cbb1cc6db031083cd", "type": "github" }, "original": { diff --git a/model/src/lu/foyer/AppError.scala b/model/src/lu/foyer/AppError.scala new file mode 100644 index 0000000..77206a4 --- /dev/null +++ b/model/src/lu/foyer/AppError.scala @@ -0,0 +1,7 @@ +package lu.foyer + +import zio.schema.* + +enum AppError(description: String) extends Throwable: + case NotFound(desc: String) extends AppError(desc) + case Unexpected(desc: String) extends AppError(desc) diff --git a/model/src/lu/foyer/CommonTypes.scala b/model/src/lu/foyer/CommonTypes.scala new file mode 100644 index 0000000..d01ab8f --- /dev/null +++ b/model/src/lu/foyer/CommonTypes.scala @@ -0,0 +1,10 @@ +package lu.foyer + +import zio.schema.* +import java.util.UUID + +opaque type ClientEntityId <: UUID = UUID +object ClientEntityId extends RefinedUUID[ClientEntityId] + +opaque type Email <: String = String +object Email extends NonBlankString[Email] diff --git a/model/src/lu/foyer/RefinedType.scala b/model/src/lu/foyer/RefinedType.scala index 23b8776..72a8ec1 100644 --- a/model/src/lu/foyer/RefinedType.scala +++ b/model/src/lu/foyer/RefinedType.scala @@ -4,6 +4,7 @@ import zio.prelude.* import zio.schema.Schema import java.time.LocalDate +import java.util.UUID trait RefinedType[Base, New]: inline def assume(value: Base): New = value.asInstanceOf[New] @@ -15,6 +16,15 @@ trait RefinedString[New <: String] extends RefinedType[String, New]: Right(_) ) +trait RefinedUUID[New <: UUID] extends RefinedType[UUID, New]: + def apply(value: UUID): New = assume(value) + override def validation(value: UUID): Validation[String, New] = + Validation.succeed(assume(value)) + given Schema[New] = Schema[UUID].transformOrFail( + validation(_).toEither.left.map(_.toList.mkString(", ")), + Right(_) + ) + trait RefinedLocalDate[New <: LocalDate] extends RefinedType[LocalDate, New]: def apply(value: LocalDate): New = assume(value) override def validation(value: LocalDate): Validation[String, New] = diff --git a/model/src/lu/foyer/clients/Client.scala b/model/src/lu/foyer/clients/Client.scala index 4328d24..69dd8ef 100644 --- a/model/src/lu/foyer/clients/Client.scala +++ b/model/src/lu/foyer/clients/Client.scala @@ -18,21 +18,9 @@ object ClientBirthDate extends RefinedLocalDate[ClientBirthDate] opaque type ClientDrivingLicenseDate <: LocalDate = LocalDate object ClientDrivingLicenseDate extends RefinedLocalDate[ClientDrivingLicenseDate] -opaque type Email <: String = String -object Email extends NonBlankString[Email] - opaque type NationalNumber <: String = String object NationalNumber extends NonBlankString[NationalNumber] -case class Client( - lastName: ClientFirstName, - firstName: ClientLastName, - drivingLicenseDate: ClientDrivingLicenseDate, - phoneNumber: PhoneNumber, - email: Email, - address: Address) - derives Schema - case class PhoneNumber( country: Country, nationalNumber: NationalNumber, @@ -45,4 +33,4 @@ case class PhoneNumberInput( derives Schema enum ClientDisabledReason derives Schema: - case GDPR, Death + case gdpr, death diff --git a/model/src/lu/foyer/clients/Country.scala b/model/src/lu/foyer/clients/Country.scala index 2d1ddf5..ea5b81c 100644 --- a/model/src/lu/foyer/clients/Country.scala +++ b/model/src/lu/foyer/clients/Country.scala @@ -4,4 +4,4 @@ package clients import zio.schema.* enum Country derives Schema: - case LU, FR, BE + case LU, FR, BE, CH diff --git a/model/src/lu/foyer/contracts/Employee.scala b/model/src/lu/foyer/contracts/Employee.scala new file mode 100644 index 0000000..b50d690 --- /dev/null +++ b/model/src/lu/foyer/contracts/Employee.scala @@ -0,0 +1,11 @@ +package lu.foyer +package contracts + +import zio.schema.* +import java.util.Currency + +opaque type EmployeeDisplayName <: String = String +object EmployeeDisplayName extends NonBlankString[EmployeeDisplayName] + +final case class Employee(uid: String, displayName: EmployeeDisplayName, email: Email) + derives Schema diff --git a/model/src/lu/foyer/contracts/FormulaType.scala b/model/src/lu/foyer/contracts/FormulaType.scala new file mode 100644 index 0000000..da686e4 --- /dev/null +++ b/model/src/lu/foyer/contracts/FormulaType.scala @@ -0,0 +1,27 @@ +package lu.foyer +package contracts + +import zio.schema.* + +enum FormulaType(val maxCoverage: Long, val basePremium: BigDecimal): + case Bronze extends FormulaType(10_000, 600) + case Silver extends FormulaType(25_000, 900) + case Gold extends FormulaType(250_000, 1500) + +object FormulaType: + enum Json: + case bronze, silver, gold + + given Schema[FormulaType] = DeriveSchema + .gen[Json].transform( + { + case Json.bronze => FormulaType.Bronze + case Json.silver => FormulaType.Silver + case Json.gold => FormulaType.Gold + }, + { + case FormulaType.Bronze => Json.bronze + case FormulaType.Silver => Json.silver + case FormulaType.Gold => Json.gold + } + ) diff --git a/model/src/lu/foyer/contracts/ProductType.scala b/model/src/lu/foyer/contracts/ProductType.scala new file mode 100644 index 0000000..eba9148 --- /dev/null +++ b/model/src/lu/foyer/contracts/ProductType.scala @@ -0,0 +1,8 @@ +package lu.foyer +package contracts + +import zio.schema.* +import zio.schema.annotation.caseName + +enum ProductType derives Schema: + @caseName("car") case Car diff --git a/model/src/lu/foyer/contracts/TerminationReasonType.scala b/model/src/lu/foyer/contracts/TerminationReasonType.scala new file mode 100644 index 0000000..33f0c16 --- /dev/null +++ b/model/src/lu/foyer/contracts/TerminationReasonType.scala @@ -0,0 +1,7 @@ +package lu.foyer +package contracts + +import zio.schema.* + +enum TerminationReasonType derives Schema: + case Rejected, HolderDeceased, TerminatedByClient diff --git a/model/src/lu/foyer/contracts/Vehicle.scala b/model/src/lu/foyer/contracts/Vehicle.scala new file mode 100644 index 0000000..020c489 --- /dev/null +++ b/model/src/lu/foyer/contracts/Vehicle.scala @@ -0,0 +1,16 @@ +package lu.foyer +package contracts + +import zio.schema.* +import java.util.Currency + +opaque type VehiclePlate <: String = String +object VehiclePlate extends NonBlankString[VehiclePlate] + +opaque type VehicleBrand <: String = String +object VehicleBrand extends NonBlankString[VehicleBrand] + +final case class Amount(value: BigDecimal, currency: Currency) derives Schema + +final case class Vehicle(plate: VehiclePlate, brand: VehicleBrand, insuredValue: Amount) + derives Schema