diff --git a/build.sbt b/build.sbt index 0f8d48c4..85b5b381 100644 --- a/build.sbt +++ b/build.sbt @@ -16,6 +16,7 @@ val circeVersion = "0.14.14" lazy val root = (project in file(".")) .settings( + scalacOptions ++= Seq("-Xmax-inlines", "64"), name := "CookCookHnya-backend", scalacOptions ++= Seq( "-Wunused:imports" diff --git a/src/main/scala/api/EndpointErrorVariants.scala b/src/main/scala/api/EndpointErrorVariants.scala index d66264e0..32005059 100644 --- a/src/main/scala/api/EndpointErrorVariants.scala +++ b/src/main/scala/api/EndpointErrorVariants.scala @@ -9,6 +9,7 @@ import domain.{ StorageAccessForbidden, StorageNotFound, UserNotFound, + PublicationRequestNotFound } import io.circe.{Decoder, Encoder} @@ -28,6 +29,7 @@ extension (sc: StatusCode) object EndpointErrorVariants: val ingredientNotFoundVariant = NotFound.variantJson[IngredientNotFound] val storageNotFoundVariant = NotFound.variantJson[StorageNotFound] + val publicationRequestNotFound = NotFound.variantJson[PublicationRequestNotFound] val storageAccessForbiddenVariant = Forbidden.variantJson[StorageAccessForbidden] val recipeAccessForbiddenVariant = NotFound.variantJson[RecipeAccessForbidden] val userNotFoundVariant = NotFound.variantJson[UserNotFound] diff --git a/src/main/scala/api/Endpoints.scala b/src/main/scala/api/Endpoints.scala index 0a884fa1..d22997d2 100644 --- a/src/main/scala/api/Endpoints.scala +++ b/src/main/scala/api/Endpoints.scala @@ -6,7 +6,7 @@ import api.users.usersEndpoints import api.recipes.recipeEndpoints import api.shoppinglist.shoppingListEndpoints import api.invitations.invitationEndpoints - +import api.moderation.moderationEndpoints import sttp.tapir.ztapir.ZServerEndpoint import sttp.tapir.ztapir.RichZServerEndpoint @@ -18,3 +18,5 @@ object AppEndpoints: ++ recipeEndpoints.map(_.widen) ++ shoppingListEndpoints.map(_.widen) ++ invitationEndpoints.map(_.widen) + ++ moderationEndpoints.map(_.widen) + :+ getPublicationRequestsHistory.widen diff --git a/src/main/scala/api/GetPublicationRequestsHistory.scala b/src/main/scala/api/GetPublicationRequestsHistory.scala new file mode 100644 index 00000000..768cdb2f --- /dev/null +++ b/src/main/scala/api/GetPublicationRequestsHistory.scala @@ -0,0 +1,68 @@ +package api + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.EndpointErrorVariants.serverErrorVariant +import api.common.search.PaginationParams +import api.moderation.ModerationHistoryResponse +import db.repositories.{IngredientPublicationRequestsRepo, RecipePublicationRequestsRepo} +import db.tables.{DbIngredient, DbRecipe} +import db.tables.publication.{DbIngredientPublicationRequest, DbRecipePublicationRequest} +import domain.InternalServerError +import io.circe.Decoder +import io.circe.derivation.Configuration +import io.circe.generic.auto.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +private type GetPublicationRequestsHistoryEnv + = RecipePublicationRequestsRepo + & IngredientPublicationRequestsRepo + +val getPublicationRequestsHistory: ZServerEndpoint[GetPublicationRequestsHistoryEnv, Any] = + endpoint + .get + .in("publication-requests" / PaginationParams.query) + .errorOut(oneOf(serverErrorVariant)) + .out(jsonBody[List[ModerationHistoryResponse]]) + .zSecuredServerLogic(getPublicationRequestsHistoryHandler) + +def getPublicationRequestsHistoryHandler(paginationParams: PaginationParams): + ZIO[ + AuthenticatedUser & GetPublicationRequestsHistoryEnv, + InternalServerError, + List[ModerationHistoryResponse] + ] = + for + dbRecipeRequests <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ + .getAllCreatedBy + .orElseFail(InternalServerError()) + ) + recipeRequests = dbRecipeRequests.map { + case (dbRecipeReq: DbRecipePublicationRequest, recipe: DbRecipe) => ModerationHistoryResponse( + recipe.name, + "recipe", + dbRecipeReq.createdAt, + dbRecipeReq.updatedAt, + PublicationRequestStatusResp.fromDomain(dbRecipeReq.status.toDomain(dbRecipeReq.reason)), + dbRecipeReq.reason + ) + } + + dbIngredientRequests <- ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_ + .getAllCreatedBy + .orElseFail(InternalServerError()) + ) + ingredientRequests = dbIngredientRequests.map { + case (dbRecipeReq: DbIngredientPublicationRequest, ingredient: DbIngredient) => ModerationHistoryResponse( + ingredient.name, + "ingredient", + dbRecipeReq.createdAt, + dbRecipeReq.updatedAt, + PublicationRequestStatusResp.fromDomain(dbRecipeReq.status.toDomain(dbRecipeReq.reason)), + dbRecipeReq.reason + ) + } + yield (recipeRequests ++ ingredientRequests) + .sortBy(_.updatedAt) diff --git a/src/main/scala/api/PublicationRequestStatusResp.scala b/src/main/scala/api/PublicationRequestStatusResp.scala new file mode 100644 index 00000000..200ac566 --- /dev/null +++ b/src/main/scala/api/PublicationRequestStatusResp.scala @@ -0,0 +1,35 @@ +package api + +import domain.PublicationRequestStatus +import io.circe.{Decoder, DecodingFailure, Encoder, HCursor, Json} + +enum PublicationRequestStatusResp: + case Pending + case Accepted + case Rejected(reason: Option[String]) + +object PublicationRequestStatusResp: + def fromDomain(domainModel: PublicationRequestStatus): PublicationRequestStatusResp = + domainModel match + case PublicationRequestStatus.Accepted => Accepted + case PublicationRequestStatus.Pending => Pending + case PublicationRequestStatus.Rejected(reason) => Rejected(reason) + + given Encoder[PublicationRequestStatusResp] = Encoder.instance { + case Pending => Json.obj("type" -> Json.fromString("pending")) + case Accepted => Json.obj("type" -> Json.fromString("accepted")) + case Rejected(reason) => + val baseObj = Json.obj("type" -> Json.fromString("rejected")) + reason match + case Some(r) => baseObj.deepMerge(Json.obj("reason" -> Json.fromString(r))) + case None => baseObj + } + + given Decoder[PublicationRequestStatusResp] = (c: HCursor) => + c.downField("type").as[String].flatMap { + case "pending" => Right(Pending) + case "accepted" => Right(Accepted) + case "rejected" => + c.downField("reason").as[Option[String]].map(Rejected.apply) + case other => Left(DecodingFailure(s"Unknown status type: $other", c.history)) + } \ No newline at end of file diff --git a/src/main/scala/api/common/search/PaginationParams.scala b/src/main/scala/api/common/search/PaginationParams.scala index db3069c2..f797a3a3 100644 --- a/src/main/scala/api/common/search/PaginationParams.scala +++ b/src/main/scala/api/common/search/PaginationParams.scala @@ -1,6 +1,6 @@ package api.common.search -import scala.collection.immutable.IndexedSeqOps +import scala.collection.IterableOps import sttp.tapir case class PaginationParams( @@ -15,7 +15,7 @@ object PaginationParams: (PaginationParams.apply.tupled) {case PaginationParams(size, offset) => (size, offset)} -extension[A, CC[_], C](seq: IndexedSeqOps[A, CC, C]) +extension[A, CC[_], C](seq: IterableOps[A, CC, C]) def paginate(paginationParams: PaginationParams): C = seq.slice(paginationParams.offset, paginationParams.offset + paginationParams.size) diff --git a/src/main/scala/api/ingredients/Create.scala b/src/main/scala/api/ingredients/Create.scala index c9317152..aa1fd318 100644 --- a/src/main/scala/api/ingredients/Create.scala +++ b/src/main/scala/api/ingredients/Create.scala @@ -17,6 +17,7 @@ import sttp.tapir.ztapir.* import zio.ZIO private type CreateEnv = IngredientsRepo & DataSource + private val create: ZServerEndpoint[CreateEnv, Any] = ingredientsEndpoint .post @@ -42,4 +43,4 @@ private def createHandler(reqBody: CreateIngredientReqBody): .map(_.id) .orElseFail(InternalServerError()) ) - yield ingredientId \ No newline at end of file + yield ingredientId diff --git a/src/main/scala/api/ingredients/Endpoints.scala b/src/main/scala/api/ingredients/Endpoints.scala index 3adcbcde..c93f57e0 100644 --- a/src/main/scala/api/ingredients/Endpoints.scala +++ b/src/main/scala/api/ingredients/Endpoints.scala @@ -3,6 +3,7 @@ package api.ingredients import sttp.tapir.Endpoint import sttp.tapir.ztapir.* +import api.ingredients.admin.adminIngredientsEndpoints import api.ingredients.public.publicEndpoints import api.TapirExtensions.subTag @@ -21,5 +22,7 @@ val ingredientsEndpoints = List( search.widen, searchForRecipe.widen, searchForStorage.widen, + searchForShoppingList.widen, requestPublication.widen ) ++ publicEndpoints.map(_.widen) + ++ adminIngredientsEndpoints.map(_.widen) diff --git a/src/main/scala/api/ingredients/IngredientResp.scala b/src/main/scala/api/ingredients/IngredientResp.scala index a472cd89..4559ce2d 100644 --- a/src/main/scala/api/ingredients/IngredientResp.scala +++ b/src/main/scala/api/ingredients/IngredientResp.scala @@ -1,10 +1,15 @@ package api.ingredients import api.common.search.Searchable +import api.PublicationRequestStatusResp import domain.IngredientId import db.tables.DbIngredient -final case class IngredientResp(id: IngredientId, name: String) extends Searchable +final case class IngredientResp( + id: IngredientId, + name: String, + moderationStatus: Option[PublicationRequestStatusResp] = None +) extends Searchable object IngredientResp: def fromDb(dbIngredient: DbIngredient): IngredientResp = diff --git a/src/main/scala/api/ingredients/RequestPublication.scala b/src/main/scala/api/ingredients/RequestPublication.scala index 2e270c53..80fd362d 100644 --- a/src/main/scala/api/ingredients/RequestPublication.scala +++ b/src/main/scala/api/ingredients/RequestPublication.scala @@ -4,30 +4,29 @@ import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} import api.variantJson import api.EndpointErrorVariants.{ingredientNotFoundVariant, serverErrorVariant} import db.repositories.{ - IngredientPublicationRequestsRepo, IngredientsQueries, + IngredientPublicationRequestsRepo, IngredientsRepo, IngredientPublicationRequestsQueries } -import domain.{IngredientId, IngredientNotFound, InternalServerError, RecipeId} -import db.tables.publication.DbPublicationRequestStatus.given +import domain.{IngredientId, IngredientNotFound, InternalServerError, PublicationRequestId} import db.QuillConfig.provideDS import db.QuillConfig.ctx.* +import io.circe.generic.auto.* import io.getquill.* import javax.sql.DataSource -import io.circe.generic.auto.* -import sttp.model.StatusCode.BadRequest +import sttp.model.StatusCode.{BadRequest, Created} import sttp.tapir.generic.auto.* import sttp.tapir.ztapir.* import zio.ZIO -private final case class IngredientAlreadyPublished( +final case class IngredientAlreadyPublished( ingredientId: IngredientId, message: String = "Ingredient already published", ) object IngredientAlreadyPublished: val variant = BadRequest.variantJson[IngredientAlreadyPublished] -private final case class IngredientAlreadyPending( +final case class IngredientAlreadyPending( ingredientId: IngredientId, message: String = "Ingredient already pending" ) @@ -35,21 +34,24 @@ object IngredientAlreadyPending: val variant = BadRequest.variantJson[IngredientAlreadyPending] private type RequestPublicationEnv = IngredientPublicationRequestsRepo & IngredientsRepo & DataSource + private val requestPublication: ZServerEndpoint[RequestPublicationEnv, Any] = ingredientsEndpoint .post .in(path[IngredientId]("ingredientId") / "request-publication") + .out(statusCode(Created) and plainBody[PublicationRequestId]) .errorOut(oneOf( serverErrorVariant, IngredientAlreadyPublished.variant, IngredientAlreadyPending.variant, ingredientNotFoundVariant )) .zSecuredServerLogic(requestPublicationHandler) -def requestPublicationHandler(ingredientId: IngredientId): +private def requestPublicationHandler(ingredientId: IngredientId): ZIO[ AuthenticatedUser & RequestPublicationEnv, - InternalServerError | IngredientAlreadyPublished | IngredientAlreadyPending | IngredientNotFound, - Unit + InternalServerError | IngredientAlreadyPublished + | IngredientAlreadyPending | IngredientNotFound, + PublicationRequestId ] = for ingredient <- ZIO.serviceWithZIO[IngredientsRepo](_ @@ -58,19 +60,19 @@ def requestPublicationHandler(ingredientId: IngredientId): ) _ <- ZIO.fail(IngredientAlreadyPublished(ingredientId)) - .when(ingredient.isPublished) + .when(ingredient.isPublished) dataSource <- ZIO.service[DataSource] alreadyPending <- run( IngredientPublicationRequestsQueries - .pendingRequestsByIdQ(lift(ingredientId)).nonEmpty + .pendingRequestsByIngredientIdQ(lift(ingredientId)).nonEmpty ).provideDS(using dataSource) .orElseFail(InternalServerError()) _ <- ZIO.fail(IngredientAlreadyPending(ingredientId)) .when(alreadyPending) - _ <- ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_ + reqId <- ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_ .requestPublication(ingredientId) .orElseFail(InternalServerError()) ) - yield () + yield reqId diff --git a/src/main/scala/api/ingredients/Search.scala b/src/main/scala/api/ingredients/Search.scala index 058b42bd..28693cde 100644 --- a/src/main/scala/api/ingredients/Search.scala +++ b/src/main/scala/api/ingredients/Search.scala @@ -1,13 +1,14 @@ package api.ingredients -import api.Authentication.{zSecuredServerLogic, AuthenticatedUser} -import api.common.search.{PaginationParams, paginate, SearchParams, Searchable} +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.common.search.{PaginationParams, SearchParams, Searchable, paginate} import api.EndpointErrorVariants.serverErrorVariant -import db.repositories.{IngredientsRepo, StorageIngredientsRepo} +import api.ingredients.SearchIngredientsFilter.Custom +import api.PublicationRequestStatusResp +import db.repositories.{IngredientPublicationRequestsRepo, IngredientsRepo, StorageIngredientsRepo} import domain.{IngredientId, InternalServerError} - import io.circe.generic.auto.* -import sttp.tapir.{Codec, Schema, Validator, EndpointInput} +import sttp.tapir.{Codec, EndpointInput, Schema, Validator} import sttp.tapir.generic.auto.* import sttp.tapir.json.circe.* import sttp.tapir.ztapir.* @@ -25,7 +26,7 @@ object SearchIngredientsFilter: given Codec.PlainCodec[SearchIngredientsFilter] = Codec.derivedEnumeration.defaultStringBased -private type SearchEnv = IngredientsRepo & StorageIngredientsRepo +private type SearchEnv = IngredientsRepo & StorageIngredientsRepo & IngredientPublicationRequestsRepo private val search: ZServerEndpoint[SearchEnv, Any] = ingredientsEndpoint @@ -41,7 +42,17 @@ private def searchHandler( searchParams: SearchParams, paginationParams: PaginationParams, filter: SearchIngredientsFilter, -): ZIO[AuthenticatedUser & SearchEnv, InternalServerError, SearchResp[IngredientResp]] = +): ZIO[AuthenticatedUser & SearchEnv, InternalServerError, SearchResp[IngredientResp]] = { + + def getLastPublicationRequestStatus(ingredientId: IngredientId) = + ZIO.serviceWithZIO[IngredientPublicationRequestsRepo]( + _.getAllRequestsForIngredient(ingredientId).map( + _.sortBy(_.updatedAt).lastOption.map( + req => PublicationRequestStatusResp.fromDomain(req.status.toDomain(req.reason)) + ) + ) + ) + for getIngredients <- ZIO.serviceWith[IngredientsRepo](filter match case SearchIngredientsFilter.Custom => _.getAllCustom @@ -52,4 +63,10 @@ private def searchHandler( .map(_.map(IngredientResp.fromDb)) .orElseFail(InternalServerError()) res = Searchable.search(Vector.from(allIngredients), searchParams) - yield SearchResp(res.paginate(paginationParams), res.length) \ No newline at end of file + res <- ZIO.foreach(res) { resp => + getLastPublicationRequestStatus(resp.id) + .map(status => resp.copy(moderationStatus = status)) + .orElseFail(InternalServerError()) + }.when(filter == Custom).someOrElse(res) + yield SearchResp(res.paginate(paginationParams), res.length) +} diff --git a/src/main/scala/api/ingredients/SearchForShoppingList.scala b/src/main/scala/api/ingredients/SearchForShoppingList.scala new file mode 100644 index 00000000..fd43418e --- /dev/null +++ b/src/main/scala/api/ingredients/SearchForShoppingList.scala @@ -0,0 +1,56 @@ +package api.ingredients + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.common.search.* +import api.EndpointErrorVariants.serverErrorVariant +import db.QuillConfig.provideDS +import db.repositories.IngredientsQueries +import db.tables.DbShoppingList +import domain.{IngredientId, InternalServerError} + +import io.circe.generic.auto.* +import io.getquill.{query => quillQuery, *} +import javax.sql.DataSource +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +final case class IngredientsForShoppingListResp( + id: IngredientId, + name: String, + isInShopList: Boolean +) extends Searchable + +private type SearchForShopListEnv = DataSource + +private val searchForShoppingList: ZServerEndpoint[SearchForShopListEnv, Any] = + ingredientsEndpoint( + path="ingredients-for-shopping-list" + ).get + .in(SearchParams.query) + .in(PaginationParams.query) + .out(jsonBody[SearchResp[IngredientsForShoppingListResp]]) + .errorOut(oneOf(serverErrorVariant)) + .zSecuredServerLogic(searchForShoppingListHandler) + +private def searchForShoppingListHandler( + searchParams: SearchParams, + paginationParams: PaginationParams, +): ZIO[AuthenticatedUser & SearchForShopListEnv, + InternalServerError, + SearchResp[IngredientsForShoppingListResp]] = + import db.QuillConfig.ctx.* + for + dataSource <- ZIO.service[DataSource] + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + allIngredientsAvailability <- run( + IngredientsQueries.visibleIngredientsQ(lift(userId)) + .leftJoin(quillQuery[DbShoppingList].filter(_.ownerId == lift(userId))) + .on((i, si) => i.id == si.ingredientId) + .map((i, si) => IngredientsForShoppingListResp(i.id, i.name, si.map(_.ownerId).isDefined)) + ).provideDS(using dataSource) + .map(Vector.from) + .orElseFail(InternalServerError()) + res = Searchable.search(allIngredientsAvailability, searchParams) + yield SearchResp(res.paginate(paginationParams), res.length) diff --git a/src/main/scala/api/ingredients/admin/CreatePublic.scala b/src/main/scala/api/ingredients/admin/CreatePublic.scala new file mode 100644 index 00000000..56211c41 --- /dev/null +++ b/src/main/scala/api/ingredients/admin/CreatePublic.scala @@ -0,0 +1,31 @@ +package api.ingredients.admin + +import api.EndpointErrorVariants.serverErrorVariant +import api.ingredients.CreateIngredientReqBody +import db.repositories.{IngredientsRepo} +import domain.{ IngredientId, InternalServerError} + +import io.circe.generic.auto.* +import sttp.model.StatusCode.Created +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +private type CreatePublicEnv = IngredientsRepo + +private val createPublic: ZServerEndpoint[CreatePublicEnv, Any] = + adminIngredientsEndpoint + .post + .in(jsonBody[CreateIngredientReqBody]) + .out(plainBody[IngredientId] and statusCode(Created)) + .errorOut(oneOf(serverErrorVariant)) + .zServerLogic(createPublicHandler) + +private def createPublicHandler(req: CreateIngredientReqBody): + ZIO[CreatePublicEnv, InternalServerError, IngredientId] = + ZIO.serviceWithZIO[IngredientsRepo](_ + .addPublic(req.name) + .orElseFail(InternalServerError()) + .map(_.id) + ) diff --git a/src/main/scala/api/ingredients/admin/Endpoints.scala b/src/main/scala/api/ingredients/admin/Endpoints.scala new file mode 100644 index 00000000..0aa56ff2 --- /dev/null +++ b/src/main/scala/api/ingredients/admin/Endpoints.scala @@ -0,0 +1,16 @@ +package api.ingredients.admin + +import sttp.tapir.Endpoint +import sttp.tapir.ztapir.* + +import api.ingredients.ingredientsEndpoint +import api.TapirExtensions.superTag + +val adminIngredientsEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = + ingredientsEndpoint + .prependIn("admin") + .superTag("Admin") + +val adminIngredientsEndpoints = List( + createPublic.widen, +) diff --git a/src/main/scala/api/invitations/Activate.scala b/src/main/scala/api/invitations/Activate.scala index fb3d5c39..c4b3b03e 100644 --- a/src/main/scala/api/invitations/Activate.scala +++ b/src/main/scala/api/invitations/Activate.scala @@ -2,9 +2,13 @@ package api.invitations import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} import api.EndpointErrorVariants.{invalidInvitationHashVariant, serverErrorVariant} +import api.storages.StorageSummaryResp import db.repositories.{InvitationsRepo, StorageMembersRepo} import domain.{InternalServerError, InvalidInvitationHash} +import io.circe.generic.auto.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* import sttp.tapir.ztapir.* import zio.ZIO @@ -12,12 +16,14 @@ private type ActivateEnv = InvitationsRepo & StorageMembersRepo val activate: ZServerEndpoint[ActivateEnv, Any] = invitationEndpoint .post .in(path[String]("invitationHash") / "activate") + .out(jsonBody[StorageSummaryResp]) .errorOut(oneOf(invalidInvitationHashVariant, serverErrorVariant)) .zSecuredServerLogic(activateHandler) def activateHandler(invitationHash: String): - ZIO[AuthenticatedUser & ActivateEnv, InvalidInvitationHash | InternalServerError, Unit] = - ZIO.serviceWithZIO[InvitationsRepo](_.activate(invitationHash)).debug + ZIO[AuthenticatedUser & ActivateEnv, InvalidInvitationHash | InternalServerError, StorageSummaryResp] = + ZIO.serviceWithZIO[InvitationsRepo](_.activate(invitationHash)) + .map{case (storageId, storageName) => StorageSummaryResp(storageId, storageName)} .mapError { case e: InvalidInvitationHash => e case _ => InternalServerError() diff --git a/src/main/scala/api/invitations/Create.scala b/src/main/scala/api/invitations/Create.scala index fe1bfe85..933dcd3a 100644 --- a/src/main/scala/api/invitations/Create.scala +++ b/src/main/scala/api/invitations/Create.scala @@ -18,11 +18,16 @@ val create: ZServerEndpoint[CreateEnv, Any] = invitationEndpoint def createHandler(storageId: StorageId): ZIO[AuthenticatedUser & CreateEnv, InternalServerError | StorageNotFound, String] = for - storageExists <- ZIO.serviceWithZIO[StoragesRepo](_.getById(storageId)) + storageExists <- ZIO.serviceWithZIO[StoragesRepo](_ + .getById(storageId) .map(_.isDefined) .orElseFail(InternalServerError()) - _ <- ZIO.fail(StorageNotFound(storageId.toString)).unless(storageExists) + ) + _ <- ZIO.fail(StorageNotFound(storageId.toString)) + .unless(storageExists) - inviteHash <- ZIO.serviceWithZIO[InvitationsRepo](_.create(storageId)) + inviteHash <- ZIO.serviceWithZIO[InvitationsRepo](_ + .create(storageId) .orElseFail(InternalServerError()) + ) yield inviteHash diff --git a/src/main/scala/api/moderation/Endpoints.scala b/src/main/scala/api/moderation/Endpoints.scala new file mode 100644 index 00000000..40c93c31 --- /dev/null +++ b/src/main/scala/api/moderation/Endpoints.scala @@ -0,0 +1,13 @@ +package api.moderation + +import api.moderation.pubrequests.publicationRequestEndpoints + +import sttp.tapir.Endpoint +import sttp.tapir.ztapir.* + +val moderationEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = + endpoint + .tag("Moderation") + .in("moderation") + +val moderationEndpoints = publicationRequestEndpoints diff --git a/src/main/scala/api/moderation/ModerationHistoryResponse.scala b/src/main/scala/api/moderation/ModerationHistoryResponse.scala new file mode 100644 index 00000000..772a7457 --- /dev/null +++ b/src/main/scala/api/moderation/ModerationHistoryResponse.scala @@ -0,0 +1,14 @@ +package api.moderation + +import api.PublicationRequestStatusResp + +import java.time.OffsetDateTime + +final case class ModerationHistoryResponse( + name: String, + requestType: String, + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime, + status: PublicationRequestStatusResp, + reason: Option[String] +) diff --git a/src/main/scala/api/moderation/pubrequests/Endpoints.scala b/src/main/scala/api/moderation/pubrequests/Endpoints.scala new file mode 100644 index 00000000..6eb38c2a --- /dev/null +++ b/src/main/scala/api/moderation/pubrequests/Endpoints.scala @@ -0,0 +1,18 @@ +package api.moderation.pubrequests + +import api.moderation.moderationEndpoint +import api.TapirExtensions.subTag + +import sttp.tapir.Endpoint +import sttp.tapir.ztapir.* + +val publicationRequestEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = + moderationEndpoint + .subTag("Publication Requests") + .in("publication-requests") + +val publicationRequestEndpoints = List( + getSomePending.widen, + getRequest.widen, + updatePublicationRequest.widen +) diff --git a/src/main/scala/api/moderation/pubrequests/Get.scala b/src/main/scala/api/moderation/pubrequests/Get.scala new file mode 100644 index 00000000..850623b0 --- /dev/null +++ b/src/main/scala/api/moderation/pubrequests/Get.scala @@ -0,0 +1,83 @@ +package api.moderation.pubrequests + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.EndpointErrorVariants.{publicationRequestNotFound, serverErrorVariant} +import api.PublicationRequestStatusResp +import api.moderation.pubrequests.PublicationRequestTypeResp.* +import db.DbError +import domain.{IngredientPublicationRequest, InternalServerError, PublicationRequestId, PublicationRequestNotFound, RecipePublicationRequest} +import db.repositories.{IngredientPublicationRequestsRepo, RecipePublicationRequestsRepo} +import io.circe.generic.auto.* + +import java.time.OffsetDateTime +import java.util.UUID +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.jsonBody +import sttp.tapir.ztapir.* +import zio.ZIO + +final case class PublicationRequestResp( + id: UUID, + requestType: PublicationRequestTypeResp, + entityId: UUID, + entityName: String, + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime, + status: PublicationRequestStatusResp +) + +private type GetReqEnv + = RecipePublicationRequestsRepo + & IngredientPublicationRequestsRepo + +private val getRequest: ZServerEndpoint[GetReqEnv, Any] = + publicationRequestEndpoint + .get + .in(path[UUID]("id")) + .out(jsonBody[PublicationRequestResp]) + .errorOut(oneOf(serverErrorVariant, publicationRequestNotFound)) + .zSecuredServerLogic(getRequestHandler) + +private def getRequestHandler(reqId: PublicationRequestId): + ZIO[AuthenticatedUser & GetReqEnv, + InternalServerError | PublicationRequestNotFound, + PublicationRequestResp] = + + def getIngredientRequest = + ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_.getWithIngredient(reqId)) + .someOrFail(PublicationRequestNotFound(reqId)) + .map { + case (dbReq, dbIngredient) => dbReq.toDomain match + case IngredientPublicationRequest(id, ingredientId, createdAt, updatedAt, status) => + PublicationRequestResp( + reqId, + Ingredient, + ingredientId, + dbIngredient.name, + createdAt, + updatedAt, + PublicationRequestStatusResp.fromDomain(status) + ) + } + + def getRecipeRequest = + ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.getWithRecipe(reqId)) + .someOrFail(PublicationRequestNotFound(reqId)) + .map { + case (dbReq, dbRecipe) => dbReq.toDomain match + case RecipePublicationRequest(id, ingredientId, createdAt, updatedAt, status) => + PublicationRequestResp( + reqId, + Ingredient, + ingredientId, + dbRecipe.name, + createdAt, + updatedAt, + PublicationRequestStatusResp.fromDomain(status) + ) + } + + getIngredientRequest.orElse(getRecipeRequest).mapError { + case _: DbError => InternalServerError() + case x: PublicationRequestNotFound => x + } diff --git a/src/main/scala/api/moderation/pubrequests/GetSomePending.scala b/src/main/scala/api/moderation/pubrequests/GetSomePending.scala new file mode 100644 index 00000000..db26d834 --- /dev/null +++ b/src/main/scala/api/moderation/pubrequests/GetSomePending.scala @@ -0,0 +1,55 @@ +package api.moderation.pubrequests + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.common.search.{PaginationParams, paginate} +import api.EndpointErrorVariants.serverErrorVariant +import api.moderation.pubrequests.PublicationRequestTypeResp.* +import db.repositories.{IngredientPublicationRequestsRepo, RecipePublicationRequestsRepo} +import domain.{InternalServerError, PublicationRequestId} + +import io.circe.generic.auto.* +import java.time.OffsetDateTime +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.jsonBody +import sttp.tapir.ztapir.* +import zio.ZIO + +final case class PublicationRequestSummary( + id: PublicationRequestId, + requestType: PublicationRequestTypeResp, + entityName: String, + createdAt: OffsetDateTime +) + +private type GetSomePendingEnv + = RecipePublicationRequestsRepo + & IngredientPublicationRequestsRepo + +private val getSomePending: ZServerEndpoint[GetSomePendingEnv, Any] = + publicationRequestEndpoint + .get + .in(PaginationParams.query) + .out(jsonBody[Seq[PublicationRequestSummary]]) + .errorOut(oneOf(serverErrorVariant)) + .zSecuredServerLogic(getSomePendingHandler) + +private def getSomePendingHandler(paginationParams: PaginationParams): + ZIO[AuthenticatedUser & GetSomePendingEnv, InternalServerError, Seq[PublicationRequestSummary]] = + for + pendingIngredientReqs <- ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_ + .getPendingRequestsWithIngredients + .orElseFail(InternalServerError()) + .map(_.map { case (req, ingredient) => + PublicationRequestSummary(req.id, Ingredient, ingredient.name, req.createdAt) + }) + ) + pendingRecipeReqs <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ + .getPendingRequestsWithRecipes + .orElseFail(InternalServerError()) + .map(_.map { case (req, recipe) => + PublicationRequestSummary(req.id, Recipe, recipe.name, req.createdAt) + }) + ) + yield (pendingRecipeReqs ++ pendingIngredientReqs) + .sortBy(_.createdAt.toEpochSecond) + .paginate(paginationParams) diff --git a/src/main/scala/api/moderation/pubrequests/PublicationRequestTypeResp.scala b/src/main/scala/api/moderation/pubrequests/PublicationRequestTypeResp.scala new file mode 100644 index 00000000..3a6fbbff --- /dev/null +++ b/src/main/scala/api/moderation/pubrequests/PublicationRequestTypeResp.scala @@ -0,0 +1,4 @@ +package api.moderation.pubrequests + +enum PublicationRequestTypeResp: + case Ingredient, Recipe diff --git a/src/main/scala/api/moderation/pubrequests/Update.scala b/src/main/scala/api/moderation/pubrequests/Update.scala new file mode 100644 index 00000000..711e25f5 --- /dev/null +++ b/src/main/scala/api/moderation/pubrequests/Update.scala @@ -0,0 +1,82 @@ +package api.moderation.pubrequests + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.EndpointErrorVariants.{publicationRequestNotFound, serverErrorVariant} +import api.PublicationRequestStatusResp +import api.moderation.pubrequests.PublicationRequestTypeResp.* +import db.repositories.{IngredientPublicationRequestsRepo, IngredientsRepo, RecipePublicationRequestsRepo, RecipesRepo} +import domain.{InternalServerError, PublicationRequestId, PublicationRequestNotFound, PublicationRequestStatus} +import io.circe.Encoder +import io.circe.generic.auto.* + +import java.util.UUID +import sttp.model.StatusCode.NoContent +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.jsonBody +import sttp.tapir.ztapir.* +import zio.ZIO + +enum PublicationRequestStatusReq: + case Pending + case Accepted + case Rejected + +final case class UpdatePublicationRequestReqBody( + status: PublicationRequestStatusResp, +): + def getDomainStatus: PublicationRequestStatus = status match + case PublicationRequestStatusResp.Pending => PublicationRequestStatus.Pending + case PublicationRequestStatusResp.Accepted => PublicationRequestStatus.Accepted + case PublicationRequestStatusResp.Rejected(reason) => PublicationRequestStatus.Rejected(reason) + +private type UpdateReqEnv + = RecipePublicationRequestsRepo + & IngredientPublicationRequestsRepo + & IngredientsRepo + & RecipesRepo + +private val updatePublicationRequest: ZServerEndpoint[UpdateReqEnv, Any] = + publicationRequestEndpoint + .patch + .in(query[UUID]("id")) + .in(jsonBody[UpdatePublicationRequestReqBody]) + .out(statusCode(NoContent)) + .errorOut(oneOf(publicationRequestNotFound, serverErrorVariant)) + .zSecuredServerLogic(updatePublicationRequestHandler) + +private def updatePublicationRequestHandler(id: PublicationRequestId, reqBody: UpdatePublicationRequestReqBody): + ZIO[AuthenticatedUser & UpdateReqEnv, InternalServerError | PublicationRequestNotFound, Unit] = + val status = reqBody.getDomainStatus + + def publishIngredient = + ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_.getWithIngredient(id)) + .some + .flatMap { + case (_, dbIngredient) => + val ingredientId = dbIngredient.id + ZIO.serviceWithZIO[IngredientsRepo](_.publish(ingredientId)) + }.orElseFail(InternalServerError()) + + def publishRecipe = + ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.getWithRecipe(id)) + .some + .flatMap { + case (_, dbRecipe) => + val recipeId = dbRecipe.id + ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) + }.orElseFail(InternalServerError()) + + for + rowsUpdated <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ + .updateStatus(id, status) + .orElseFail(InternalServerError()) + ) + rowsUpdated <- ZIO.serviceWithZIO[IngredientPublicationRequestsRepo](_ + .updateStatus(id, status) + .orElseFail(InternalServerError()) + ).unless(rowsUpdated).someOrElse(false) + _ <- ZIO.fail(PublicationRequestNotFound(id)).unless(rowsUpdated) + _ <- ZIO.when(status == PublicationRequestStatus.Accepted) { + publishRecipe.orElse(publishIngredient) + } + yield () diff --git a/src/main/scala/api/recipes/Create.scala b/src/main/scala/api/recipes/Create.scala index 64635528..d08e0513 100644 --- a/src/main/scala/api/recipes/Create.scala +++ b/src/main/scala/api/recipes/Create.scala @@ -18,12 +18,6 @@ import sttp.tapir.ztapir.* import zio.ZIO import db.repositories.IngredientsQueries -final case class CreateRecipeReqBody( - name: String, - sourceLink: Option[String], - ingredients: List[IngredientId] -) - private type CreateEnv = RecipesRepo & RecipeIngredientsRepo & DataSource private val create: ZServerEndpoint[CreateEnv, Any] = @@ -34,26 +28,28 @@ private val create: ZServerEndpoint[CreateEnv, Any] = .errorOut(oneOf(serverErrorVariant, ingredientNotFoundVariant)) .zSecuredServerLogic(createHandler) -private def createHandler(recipe: CreateRecipeReqBody): - ZIO[AuthenticatedUser & CreateEnv, InternalServerError | IngredientNotFound, RecipeId] = for - userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) - dataSource <- ZIO.service[DataSource] - existingIngredientIds <- run( - IngredientsQueries.visibleIngredientsQ(lift(userId)) - .map(_.id) - .filter(id => liftQuery(recipe.ingredients).contains(id)) - ).provideDS(using dataSource).orElseFail(InternalServerError()) - unknownIngredientIds = recipe.ingredients.diff(existingIngredientIds) - _ <- ZIO.fail(IngredientNotFound(unknownIngredientIds.head.toString)) - .when(unknownIngredientIds.nonEmpty) +private def createHandler(recipeReq: CreateRecipeReqBody): + ZIO[AuthenticatedUser & CreateEnv, InternalServerError | IngredientNotFound, RecipeId] = + val recipe = recipeReq.copy(ingredients=recipeReq.ingredients.distinct) + for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + dataSource <- ZIO.service[DataSource] + existingIngredientIds <- run( + IngredientsQueries.visibleIngredientsQ(lift(userId)) + .map(_.id) + .filter(id => liftQuery(recipe.ingredients).contains(id)) + ).provideDS(using dataSource).orElseFail(InternalServerError()) + unknownIngredientIds = recipe.ingredients.diff(existingIngredientIds) + _ <- ZIO.fail(IngredientNotFound(unknownIngredientIds.head.toString)) + .when(unknownIngredientIds.nonEmpty) - recipeId <- ZIO.serviceWithZIO[RecipesRepo](_ - .addRecipe(recipe.name, recipe.sourceLink, recipe.ingredients) - .mapError { - case DbNotRespondingError(_) => InternalServerError() - case e: FailedDbQuery => handleFailedSqlQuery(e) - .flatMap(toIngredientNotFound) - .getOrElse(InternalServerError()) - } - ) -yield recipeId + recipeId <- ZIO.serviceWithZIO[RecipesRepo](_ + .addRecipe(recipe.name, recipe.sourceLink, recipe.ingredients) + .mapError { + case DbNotRespondingError(_) => InternalServerError() + case e: FailedDbQuery => handleFailedSqlQuery(e) + .flatMap(toIngredientNotFound) + .getOrElse(InternalServerError()) + } + ) + yield recipeId diff --git a/src/main/scala/api/recipes/CreateRecipeReqBody.scala b/src/main/scala/api/recipes/CreateRecipeReqBody.scala new file mode 100644 index 00000000..7dea42b2 --- /dev/null +++ b/src/main/scala/api/recipes/CreateRecipeReqBody.scala @@ -0,0 +1,10 @@ +package api.recipes + +import domain.IngredientId + +final case class CreateRecipeReqBody( + name: String, + sourceLink: Option[String], + ingredients: List[IngredientId], +) + diff --git a/src/main/scala/api/recipes/Delete.scala b/src/main/scala/api/recipes/Delete.scala index b5d475ee..22ca1eb2 100644 --- a/src/main/scala/api/recipes/Delete.scala +++ b/src/main/scala/api/recipes/Delete.scala @@ -30,7 +30,7 @@ private def deleteHandler(recipeId: RecipeId): _ <- mRecipe match case None => ZIO.unit case Some(recipe) => - if recipe.creatorId == userId then + if recipe.creatorId.contains(userId) then ZIO.serviceWithZIO[RecipesRepo](_ .deleteRecipe(recipeId) .orElseFail(InternalServerError()) diff --git a/src/main/scala/api/recipes/Endpoints.scala b/src/main/scala/api/recipes/Endpoints.scala index ea201d10..2ffa6cb6 100644 --- a/src/main/scala/api/recipes/Endpoints.scala +++ b/src/main/scala/api/recipes/Endpoints.scala @@ -2,8 +2,10 @@ package api.recipes import sttp.tapir.Endpoint import sttp.tapir.ztapir.* - import api.recipes.ingredients.recipesIngredientsEndpoints +import api.recipes.publicationRequests.recipesPublicationRequestsEndpoints +import api.recipes.admin.adminRecipesEndpoints +import api.recipes.public.publicRecipesEndpoints val recipesEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = recipesEndpoint() @@ -19,5 +21,7 @@ val recipeEndpoints = List( get.widen, searchAll.widen, delete.widen, - requestPublication.widen, ) ++ recipesIngredientsEndpoints.map(_.widen) + ++ recipesPublicationRequestsEndpoints.map(_.widen) + ++ adminRecipesEndpoints.map(_.widen) + ++ publicRecipesEndpoints.map(_.widen) diff --git a/src/main/scala/api/recipes/Get.scala b/src/main/scala/api/recipes/Get.scala index dd5a4cc2..97095544 100644 --- a/src/main/scala/api/recipes/Get.scala +++ b/src/main/scala/api/recipes/Get.scala @@ -1,19 +1,11 @@ package api.recipes -import api.{handleFailedSqlQuery, toUserNotFound} -import api.Authentication.{zSecuredServerLogic, AuthenticatedUser} +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} import api.EndpointErrorVariants.{recipeNotFoundVariant, serverErrorVariant, userNotFoundVariant} -import db.{DbError, handleDbError} -import db.tables.{usersTable, ingredientsTable, recipeIngredientsTable, recipesTable, storageIngredientsTable, storageMembersTable, storagesTable} -import domain.{ - IngredientId, - InternalServerError, - RecipeId, - RecipeNotFound, - StorageId, - UserId, - UserNotFound, -} +import api.PublicationRequestStatusResp +import db.repositories.RecipePublicationRequestsRepo +import db.tables.{ingredientsTable, recipeIngredientsTable, recipesTable, storageIngredientsTable, storageMembersTable, storagesTable, usersTable} +import domain.{IngredientId, InternalServerError, RecipeId, RecipeNotFound, StorageId, UserId, UserNotFound} import com.augustnagro.magnum.magzio.* import com.augustnagro.magnum.Query @@ -44,10 +36,11 @@ final case class RecipeResp( ingredients: Vector[IngredientResp], name: String, sourceLink: Option[String], - creator: RecipeCreatorResp, + creator: Option[RecipeCreatorResp], + moderationStatus: Option[PublicationRequestStatusResp] ) -private type GetEnv = Transactor +private type GetEnv = Transactor & RecipePublicationRequestsRepo private val get: ZServerEndpoint[GetEnv, Any] = recipesEndpoint @@ -61,41 +54,50 @@ private val get: ZServerEndpoint[GetEnv, Any] = private case class RawRecipeResult( name: String, sourceLink: Option[String], - creatorId: UserId, - creatorFullName: String, + creatorId: Option[UserId], + creatorFullName: Option[String], ingredients: String, // JSON string from PostgreSQL ) private def getHandler(recipeId: RecipeId): - ZIO[AuthenticatedUser & GetEnv, - InternalServerError | RecipeNotFound | UserNotFound, - RecipeResp] = - ZIO.serviceWithZIO[AuthenticatedUser] { authenticatedUser => - val userId = authenticatedUser.userId - ZIO.serviceWithZIO[Transactor](_ + ZIO[ + AuthenticatedUser & GetEnv, + InternalServerError | RecipeNotFound | UserNotFound, + RecipeResp + ] = + def getLastPublicationRequestStatus = + ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ + .getAllByRecipeId(recipeId) + .map(_ + .maxByOption(_.updatedAt) + .map(req => PublicationRequestStatusResp.fromDomain(req.status.toDomain(req.reason))) + ).orElseFail(InternalServerError()) + ) + + for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + rawResult <- ZIO.serviceWithZIO[Transactor](_ .transact(rawRecipeQuery(userId, recipeId).run().headOption) - .mapError(handleDbError) + .orElseFail(InternalServerError()) + .someOrFail(RecipeNotFound(recipeId)) ) - }.someOrFail(RecipeNotFound(recipeId)).flatMap { rawResult => - // Parse the JSON ingredients string - ZIO.fromEither(decode[Vector[IngredientResp]](rawResult.ingredients)) - .map(RecipeResp( - _, - rawResult.name, - rawResult.sourceLink, - RecipeCreatorResp( - rawResult.creatorId, - rawResult.creatorFullName, - ), - )) - .orElseFail(InternalServerError(s"Failed to parse ingredients JSON: ${rawResult.ingredients}")) - }.mapError { - case e: DbError.FailedDbQuery => handleFailedSqlQuery(e) - .flatMap(toUserNotFound) - .getOrElse(InternalServerError()) - case _: DbError => InternalServerError() - case e: (InternalServerError | RecipeNotFound | UserNotFound) => e - } + status <- getLastPublicationRequestStatus + result <- ZIO.fromEither(decode[Vector[IngredientResp]](rawResult.ingredients)) + // Parse the JSON ingredients string + .map { ingredients => + val recipeCreatorResp = for + creatorId <- rawResult.creatorId + creatorFullName <- rawResult.creatorFullName + yield RecipeCreatorResp(creatorId, creatorFullName) + RecipeResp( + ingredients, + rawResult.name, + rawResult.sourceLink, + recipeCreatorResp, + status + ) + }.orElseFail(InternalServerError(s"Failed to parse ingredients JSON: ${rawResult.ingredients}")) + yield result private inline def rawRecipeQuery( inline userId: UserId, @@ -129,7 +131,6 @@ private inline def rawRecipeQuery( FROM $storagesTable WHERE ${storagesTable.ownerId} = $userId ) - ), '[]'::json ) @@ -142,7 +143,7 @@ private inline def rawRecipeQuery( '[]'::json ) AS "ingredients" FROM $recipesTable r - RIGHT JOIN $usersTable u ON r.${recipesTable.creatorId} = u.${usersTable.id} + LEFT JOIN $usersTable u ON r.${recipesTable.creatorId} = u.${usersTable.id} WHERE r.${recipesTable.id} = $recipeId - AND (r.${recipesTable.isPublished} = true OR r.${recipesTable.creatorId} = $userId); + AND (r.${recipesTable.isPublished} OR r.${recipesTable.creatorId} = $userId); """.query[RawRecipeResult] diff --git a/src/main/scala/api/recipes/admin/CreatePublic.scala b/src/main/scala/api/recipes/admin/CreatePublic.scala new file mode 100644 index 00000000..fba943f7 --- /dev/null +++ b/src/main/scala/api/recipes/admin/CreatePublic.scala @@ -0,0 +1,55 @@ +package api.recipes.admin + +import api.EndpointErrorVariants.{ingredientNotFoundVariant, serverErrorVariant} +import api.recipes.CreateRecipeReqBody +import api.{toIngredientNotFound, handleFailedSqlQuery} +import db.DbError.{DbNotRespondingError, FailedDbQuery} +import db.QuillConfig.ctx.* +import db.QuillConfig.provideDS +import db.repositories.{RecipeIngredientsRepo, RecipesRepo, IngredientsQueries} +import domain.{IngredientNotFound, IngredientId, InternalServerError, RecipeId} + +import io.circe.generic.auto.* +import io.getquill.* +import javax.sql.DataSource +import sttp.model.StatusCode.Created +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +private type CreatePublicEnv = RecipesRepo & RecipeIngredientsRepo & DataSource + +private val createPublic: ZServerEndpoint[CreatePublicEnv, Any] = + adminRecipesEndpoint + .post + .in(jsonBody[CreateRecipeReqBody]) + .out(plainBody[RecipeId] and statusCode(Created)) + .errorOut(oneOf(serverErrorVariant, ingredientNotFoundVariant)) + .zServerLogic(createPublicHandler) + +private def createPublicHandler(recipeReq: CreateRecipeReqBody): + ZIO[CreatePublicEnv, InternalServerError | IngredientNotFound, RecipeId] = + val recipe = recipeReq.copy(ingredients=recipeReq.ingredients.distinct) + for + dataSource <- ZIO.service[DataSource] + existingIngredientIds <- run( + IngredientsQueries.publicIngredientsQ + .map(_.id) + .filter(id => liftQuery(recipe.ingredients).contains(id)) + ).provideDS(using dataSource) + .orElseFail(InternalServerError()) + unknownIngredientIds = recipe.ingredients.diff(existingIngredientIds) + _ <- ZIO.fail(IngredientNotFound(unknownIngredientIds.head.toString)) + .when(unknownIngredientIds.nonEmpty) + + recipeId <- ZIO.serviceWithZIO[RecipesRepo](_ + .createPublic(recipe.name, recipe.sourceLink, recipe.ingredients) + .mapError { + case _: DbNotRespondingError => InternalServerError() + case e: FailedDbQuery => handleFailedSqlQuery(e) + .flatMap(toIngredientNotFound) + .getOrElse(InternalServerError()) + } + ) + yield recipeId diff --git a/src/main/scala/api/recipes/admin/Endpoints.scala b/src/main/scala/api/recipes/admin/Endpoints.scala new file mode 100644 index 00000000..c1d8004b --- /dev/null +++ b/src/main/scala/api/recipes/admin/Endpoints.scala @@ -0,0 +1,16 @@ +package api.recipes.admin + +import sttp.tapir.Endpoint +import sttp.tapir.ztapir.* + +import api.recipes.recipesEndpoint +import api.TapirExtensions.superTag + +val adminRecipesEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = + recipesEndpoint + .prependIn("admin") + .superTag("Admin") + +val adminRecipesEndpoints = List( + createPublic.widen, +) diff --git a/src/main/scala/api/recipes/public/Endpoint.scala b/src/main/scala/api/recipes/public/Endpoint.scala new file mode 100644 index 00000000..78132e18 --- /dev/null +++ b/src/main/scala/api/recipes/public/Endpoint.scala @@ -0,0 +1,17 @@ +package api.recipes.public + +import sttp.tapir.Endpoint +import sttp.tapir.ztapir.* + +import api.recipes.recipesEndpoint +import api.TapirExtensions.superTag + +val publicRecipesEndpoint: Endpoint[Unit, Unit, Unit, Unit, Any] = + recipesEndpoint + .prependIn("public") + .superTag("Public") + +val publicRecipesEndpoints = List( + getPublic.widen, + searchPublic.widen, +) diff --git a/src/main/scala/api/recipes/public/Get.scala b/src/main/scala/api/recipes/public/Get.scala new file mode 100644 index 00000000..d2adc84d --- /dev/null +++ b/src/main/scala/api/recipes/public/Get.scala @@ -0,0 +1,70 @@ +package api.recipes.public + +import api.ingredients.IngredientResp +import api.EndpointErrorVariants.{recipeNotFoundVariant, serverErrorVariant, userNotFoundVariant} +import db.QuillConfig.ctx.* +import db.QuillConfig.provideDS +import db.repositories.{IngredientsQueries, RecipeIngredientsQueries, RecipesQueries} +import db.tables.{DbIngredient, DbRecipe, DbUser} +import domain.{IngredientId, InternalServerError, RecipeId, RecipeNotFound, UserId} + +import io.circe.generic.auto.* +import io.getquill.{query => quillQuery, *} +import java.sql.SQLException +import javax.sql.DataSource +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +final case class RecipeCreatorResp( + id: UserId, + fullName: String +) + +final case class RecipeResp( + ingredients: Seq[IngredientResp], + name: String, + sourceLink: Option[String], + creator: Option[RecipeCreatorResp], +) + +private type GetPublicEnv = DataSource + +private val getPublic: ZServerEndpoint[GetPublicEnv, Any] = + publicRecipesEndpoint + .get + .in(path[RecipeId]("recipeId")) + .errorOut(oneOf(serverErrorVariant, recipeNotFoundVariant, userNotFoundVariant)) + .out(jsonBody[RecipeResp]) + .zServerLogic(getPublicHandler) + +private def getPublicHandler(recipeId: RecipeId): + ZIO[GetPublicEnv, InternalServerError | RecipeNotFound, RecipeResp] = for + dataSource <- ZIO.service[DataSource] + recipe <- getRecipe(recipeId).provideDS(using dataSource) + .orElseFail(InternalServerError()) + .someOrFail(RecipeNotFound(recipeId)) + yield recipe + +def getRecipe(recipeId: RecipeId): ZIO[DataSource, Throwable, Option[RecipeResp]] = transaction { + for + mRecipe <- run( + RecipesQueries.getPublicRecipeQ(lift(recipeId)) + .leftJoin(quillQuery[DbUser]) + .on(_.creatorId contains _.id) + .value + ) + mRecipeWithIngredients <- ZIO.foreach(mRecipe) { (recipe, mCreator) => + val DbRecipe(id, name, creatorId, isPublished, sourceLink) = recipe + val creatorResp = mCreator.map(creator => RecipeCreatorResp(creator.id, creator.fullName)) + run( + RecipeIngredientsQueries.getAllIngredientsQ(lift(recipeId)) + .join(IngredientsQueries.publicIngredientsQ) + .on(_ == _.id) + .map(_._2) + ).map(_.map{case DbIngredient(id, _, name, _) => IngredientResp(id, name)}) + .map(RecipeResp(_, name, sourceLink, creatorResp)) + } + yield mRecipeWithIngredients +} diff --git a/src/main/scala/api/recipes/public/Search.scala b/src/main/scala/api/recipes/public/Search.scala new file mode 100644 index 00000000..9dc26433 --- /dev/null +++ b/src/main/scala/api/recipes/public/Search.scala @@ -0,0 +1,38 @@ +package api.recipes.public + +import api.common.search.* +import api.recipes.{SearchAllRecipesResp, RecipeSearchResp} +import api.EndpointErrorVariants.serverErrorVariant +import db.repositories.{RecipesRepo, StorageIngredientsRepo} +import db.tables.DbRecipe +import domain.{RecipeId, InternalServerError} + +import io.circe.generic.auto.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +private type SearchPublicEnv = RecipesRepo & StorageIngredientsRepo + +private val searchPublic: ZServerEndpoint[SearchPublicEnv, Any] = + publicRecipesEndpoint + .get + .in(SearchParams.query) + .in(PaginationParams.query) + .out(jsonBody[SearchAllRecipesResp]) + .errorOut(oneOf(serverErrorVariant)) + .zServerLogic(searchPublicHandler) + +private def searchPublicHandler( + searchParams: SearchParams, + paginationParams: PaginationParams, +): ZIO[SearchPublicEnv, InternalServerError, SearchAllRecipesResp] = for + allDbRecipes <- ZIO.serviceWithZIO[RecipesRepo](_ + .getAllPublic + .map(Vector.from) + .orElseFail(InternalServerError()) + ) + allRecipes = allDbRecipes.map(RecipeSearchResp.fromDb) + res = Searchable.search(allRecipes, searchParams) +yield SearchAllRecipesResp(res.paginate(paginationParams), res.length) diff --git a/src/main/scala/api/recipes/RequestPublication.scala b/src/main/scala/api/recipes/publicationRequests/Create.scala similarity index 58% rename from src/main/scala/api/recipes/RequestPublication.scala rename to src/main/scala/api/recipes/publicationRequests/Create.scala index e351cf91..3b445259 100644 --- a/src/main/scala/api/recipes/RequestPublication.scala +++ b/src/main/scala/api/recipes/publicationRequests/Create.scala @@ -1,27 +1,27 @@ -package api.recipes +package api.recipes.publicationRequests import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} import api.EndpointErrorVariants.{recipeNotFoundVariant, serverErrorVariant} import api.variantJson -import domain.{IngredientId, InternalServerError, RecipeId, RecipeNotFound} -import db.repositories.{RecipePublicationRequestsQueries, IngredientsQueries, RecipeIngredientsRepo, RecipePublicationRequestsRepo, RecipesRepo} -import db.QuillConfig.provideDS import db.QuillConfig.ctx.* +import db.QuillConfig.provideDS +import db.repositories.{RecipePublicationRequestsQueries, IngredientsQueries, RecipeIngredientsRepo, RecipePublicationRequestsRepo, RecipesRepo} +import domain.{IngredientId, InternalServerError, RecipeId, RecipeNotFound, PublicationRequestId} + import io.circe.generic.auto.* import io.getquill.* - import javax.sql.DataSource -import sttp.model.StatusCode.{BadRequest, NoContent} +import sttp.model.StatusCode.{BadRequest, Created} import sttp.tapir.generic.auto.* import sttp.tapir.ztapir.* import zio.ZIO -final case class CannotPublishRecipeWithCustomIngredients( +final case class CannotPublishRecipeWithPrivateIngredients( ingredients: Seq[IngredientId], - message: String = "Cannot publish recipe with custom ingredients", + message: String = "Cannot publish recipe with private ingredients", ) -object CannotPublishRecipeWithCustomIngredients: - val variant = BadRequest.variantJson[CannotPublishRecipeWithCustomIngredients] +object CannotPublishRecipeWithPrivateIngredients: + val variant = BadRequest.variantJson[CannotPublishRecipeWithPrivateIngredients] final case class RecipeAlreadyPublished( recipeId: RecipeId, @@ -30,40 +30,39 @@ final case class RecipeAlreadyPublished( object RecipeAlreadyPublished: val variant = BadRequest.variantJson[RecipeAlreadyPublished] - -private final case class RecipeAlreadyPending( +final case class RecipeAlreadyPending( recipeId: RecipeId, message: String = "Recipe already pending" ) - object RecipeAlreadyPending: val variant = BadRequest.variantJson[RecipeAlreadyPending] -private type PublishEnv +private type CreateEnv = RecipesRepo & RecipeIngredientsRepo & RecipePublicationRequestsRepo & DataSource -private val requestPublication: ZServerEndpoint[PublishEnv, Any] = - recipesEndpoint +private val create: ZServerEndpoint[CreateEnv, Any] = + recipesPublicationRequestsEndpoint .post - .in(path[RecipeId]("recipeId") / "request-publication") + .out(plainBody[PublicationRequestId] and statusCode(Created)) .errorOut(oneOf( serverErrorVariant, recipeNotFoundVariant, - CannotPublishRecipeWithCustomIngredients.variant, + CannotPublishRecipeWithPrivateIngredients.variant, RecipeAlreadyPending.variant, RecipeAlreadyPublished.variant, )) - .out(statusCode(NoContent)) - .zSecuredServerLogic(requestPublicationHandler) + .zSecuredServerLogic(createHandler) -private def requestPublicationHandler(recipeId: RecipeId): - ZIO[AuthenticatedUser & PublishEnv, - InternalServerError | RecipeAlreadyPublished | RecipeAlreadyPending | - CannotPublishRecipeWithCustomIngredients | RecipeNotFound, - Unit] = +private def createHandler(recipeId: RecipeId): + ZIO[ + AuthenticatedUser & CreateEnv, + InternalServerError | RecipeAlreadyPublished | RecipeAlreadyPending + | CannotPublishRecipeWithPrivateIngredients | RecipeNotFound, + PublicationRequestId + ] = for recipe <- ZIO.serviceWithZIO[RecipesRepo](_ .getRecipe(recipeId) @@ -75,26 +74,25 @@ private def requestPublicationHandler(recipeId: RecipeId): dataSource <- ZIO.service[DataSource] alreadyPending <- run( RecipePublicationRequestsQueries - .pendingRequestsByIdQ(lift(recipeId)).nonEmpty - ) - .provideDS(using dataSource) + .pendingRequestsByRecipeIdQ(lift(recipeId)).nonEmpty + ).provideDS(using dataSource) .orElseFail(InternalServerError()) _ <- ZIO.fail(RecipeAlreadyPending(recipeId)) .when(alreadyPending) userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) - customIngredientIdsInRecipe <- run( - IngredientsQueries.customIngredientsQ(lift(userId)) + privateIngredientIdsInRecipe <- run( + IngredientsQueries.privateIngredientsQ(lift(userId)) .filter(i => liftQuery(recipe.ingredients).contains(i.id)) .map(_.id) ).provideDS(using dataSource) .orElseFail(InternalServerError()) - _ <- ZIO.fail(CannotPublishRecipeWithCustomIngredients(customIngredientIdsInRecipe)) - .when(customIngredientIdsInRecipe.nonEmpty) + _ <- ZIO.fail(CannotPublishRecipeWithPrivateIngredients(privateIngredientIdsInRecipe)) + .when(privateIngredientIdsInRecipe.nonEmpty) - _ <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ - .requestPublication(recipeId) + reqId <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_ + .createPublicationRequest(recipeId) .orElseFail(InternalServerError()) ) - yield () + yield reqId diff --git a/src/main/scala/api/recipes/publicationRequests/Endpoints.scala b/src/main/scala/api/recipes/publicationRequests/Endpoints.scala new file mode 100644 index 00000000..ca6e5b24 --- /dev/null +++ b/src/main/scala/api/recipes/publicationRequests/Endpoints.scala @@ -0,0 +1,17 @@ +package api.recipes.publicationRequests + +import sttp.tapir.ztapir.* + +import api.recipes.{recipesEndpoint} +import api.TapirExtensions.subTag +import domain.RecipeId + +val recipesPublicationRequestsEndpoint = + recipesEndpoint + .subTag("Publication Requests") + .in(path[RecipeId]("recipeId") / "publication-requests") + +val recipesPublicationRequestsEndpoints = List( + create.widen, + getAll.widen, +) diff --git a/src/main/scala/api/recipes/publicationRequests/GetAll.scala b/src/main/scala/api/recipes/publicationRequests/GetAll.scala new file mode 100644 index 00000000..494d763a --- /dev/null +++ b/src/main/scala/api/recipes/publicationRequests/GetAll.scala @@ -0,0 +1,55 @@ +package api.recipes.publicationRequests + +import api.Authentication.{AuthenticatedUser, zSecuredServerLogic} +import api.EndpointErrorVariants.{recipeNotFoundVariant, serverErrorVariant} +import api.PublicationRequestStatusResp +import db.repositories.{RecipePublicationRequestsRepo, RecipesRepo} +import domain.{InternalServerError, RecipeId, RecipeNotFound} + +import io.circe.Decoder +import io.circe.derivation.Configuration +import io.circe.generic.auto.* +import java.time.OffsetDateTime +import sttp.tapir.generic.auto.* +import sttp.tapir.json.circe.* +import sttp.tapir.ztapir.* +import zio.ZIO + +final case class RecipeModerationHistoryResponse( + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime, + status: PublicationRequestStatusResp, +) + +private type GetAllEnv = RecipePublicationRequestsRepo & RecipesRepo + +val getAll: ZServerEndpoint[GetAllEnv, Any] = + recipesPublicationRequestsEndpoint + .get + .out(jsonBody[List[RecipeModerationHistoryResponse]]) + .errorOut(oneOf(serverErrorVariant, recipeNotFoundVariant)) + .zSecuredServerLogic(getAllHandler) + +private def getAllHandler(recipeId: RecipeId): + ZIO[ + AuthenticatedUser & GetAllEnv, + InternalServerError | RecipeNotFound, + List[RecipeModerationHistoryResponse] + ] = + for + userIsOwner <- ZIO.serviceWithZIO[RecipesRepo](_.isUserOwner(recipeId)) + .orElseFail(InternalServerError()) + _ <- ZIO.fail(RecipeNotFound(recipeId)) + .unless(userIsOwner) + + dbRequests <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.getAllByRecipeId(recipeId)) + .orElseFail(InternalServerError()) + res = dbRequests + .map( + dbReq => RecipeModerationHistoryResponse( + dbReq.createdAt, dbReq.updatedAt, + PublicationRequestStatusResp.fromDomain(dbReq.status.toDomain(dbReq.reason)), + ) + ) + .sortBy(_.updatedAt) + yield res diff --git a/src/main/scala/api/storages/GetAll.scala b/src/main/scala/api/storages/GetAll.scala index d1d38eff..7b20694f 100644 --- a/src/main/scala/api/storages/GetAll.scala +++ b/src/main/scala/api/storages/GetAll.scala @@ -3,7 +3,7 @@ package api.storages import api.Authentication.{zSecuredServerLogic, AuthenticatedUser} import api.EndpointErrorVariants.serverErrorVariant import db.repositories.StoragesRepo -import domain.{UserId, StorageId, InternalServerError} +import domain.{StorageId, InternalServerError} import io.circe.generic.auto.* import sttp.tapir.generic.auto.* diff --git a/src/main/scala/api/storages/GetSummary.scala b/src/main/scala/api/storages/GetSummary.scala index 5ab1dcf8..eba34478 100644 --- a/src/main/scala/api/storages/GetSummary.scala +++ b/src/main/scala/api/storages/GetSummary.scala @@ -13,26 +13,33 @@ import sttp.tapir.json.circe.* import sttp.tapir.ztapir.* import zio.ZIO -private type GetSummaryEnv = StoragesRepo & StorageMembersRepo +final case class GetStorageResp( + ownerId: UserId, + name: String, +) +object GetStorageResp: + def fromDb(dbStorage: DbStorage): GetStorageResp = + GetStorageResp(dbStorage.ownerId, dbStorage.name) -private val getSummary: ZServerEndpoint[GetSummaryEnv, Any] = +private type GetEnv = StoragesRepo & StorageMembersRepo + +private val getSummary: ZServerEndpoint[GetEnv, Any] = storagesEndpoint - .get - .in(path[StorageId]("storageId")) - .out(jsonBody[StorageSummaryResp]) - .errorOut(oneOf(serverErrorVariant, storageNotFoundVariant)) - .zSecuredServerLogic(getSummaryHandler) + .get + .in(path[StorageId]("storageId")) + .out(jsonBody[GetStorageResp]) + .errorOut(oneOf(serverErrorVariant, storageNotFoundVariant)) + .zSecuredServerLogic(getHandler) -private def getSummaryHandler(storageId: StorageId): - ZIO[AuthenticatedUser & GetSummaryEnv, - InternalServerError | StorageNotFound, - StorageSummaryResp] = +private def getHandler(storageId: StorageId): + ZIO[ + AuthenticatedUser & GetEnv, + InternalServerError | StorageNotFound, + GetStorageResp + ] = ZIO.serviceWithZIO[StoragesRepo](_ .getById(storageId) + .orElseFail(InternalServerError()) .someOrFail(StorageNotFound(storageId.toString)) - .map(StorageSummaryResp.fromDb) - .mapError { - case e: StorageNotFound => e - case _ => InternalServerError() - } + .map(GetStorageResp.fromDb) ) diff --git a/src/main/scala/api/storages/StorageSummaryResp.scala b/src/main/scala/api/storages/StorageSummaryResp.scala index 2cb814ff..a04f2da9 100644 --- a/src/main/scala/api/storages/StorageSummaryResp.scala +++ b/src/main/scala/api/storages/StorageSummaryResp.scala @@ -1,10 +1,10 @@ package api.storages import db.tables.DbStorage -import domain.{StorageId, UserId} +import domain.StorageId -final case class StorageSummaryResp(id: StorageId, ownerId: UserId, name: String) +final case class StorageSummaryResp(id: StorageId, name: String) object StorageSummaryResp: def fromDb(dbStorage: DbStorage): StorageSummaryResp = - StorageSummaryResp(dbStorage.id, dbStorage.ownerId, dbStorage.name) + StorageSummaryResp(dbStorage.id, dbStorage.name) diff --git a/src/main/scala/db/CreateTables.scala b/src/main/scala/db/CreateTables.scala index 54d9d2fd..d82bdd66 100644 --- a/src/main/scala/db/CreateTables.scala +++ b/src/main/scala/db/CreateTables.scala @@ -61,7 +61,7 @@ def createTables(xa: Transactor) = CREATE TABLE IF NOT EXISTS $recipesTable( ${recipesTable.id} UUID PRIMARY KEY DEFAULT gen_random_uuid(), ${recipesTable.name} VARCHAR(255) NOT NULL, - ${recipesTable.creatorId} BIGINT NOT NULL, + ${recipesTable.creatorId} BIGINT, ${recipesTable.isPublished} BOOLEAN NOT NULL, ${recipesTable.sourceLink} VARCHAR(255), FOREIGN KEY (${recipesTable.creatorId}) REFERENCES $usersTable(${usersTable.id}) ON DELETE CASCADE @@ -96,7 +96,9 @@ def createTables(xa: Transactor) = FOREIGN KEY (${storageInvitationTable.storageId}) REFERENCES $storagesTable(${storagesTable.id}) ON DELETE CASCADE ) """, + sql(DbRecipePublicationRequest.createTable), + sql(DbIngredientPublicationRequest.createTable), ) tableList.map(_.update.run()) diff --git a/src/main/scala/db/repositories/IngredientPublicationRequestRepo.scala b/src/main/scala/db/repositories/IngredientPublicationRequestRepo.scala index fb64931a..509654aa 100644 --- a/src/main/scala/db/repositories/IngredientPublicationRequestRepo.scala +++ b/src/main/scala/db/repositories/IngredientPublicationRequestRepo.scala @@ -1,18 +1,24 @@ package db.repositories +import api.Authentication.AuthenticatedUser import db.DbError -import db.tables.publication.DbIngredientPublicationRequest -import db.tables.publication.DbPublicationRequestStatus.Pending -import domain.IngredientId -import io.getquill.* -import zio.{IO, RLayer, ZLayer} +import db.tables.DbIngredient +import db.tables.publication.{DbIngredientPublicationRequest, DbPublicationRequestStatus} +import domain.{IngredientId, PublicationRequestId, PublicationRequestStatus, UserId} +import io.getquill.* import javax.sql.DataSource +import java.util.UUID +import zio.{IO, RLayer, ZIO, ZLayer} trait IngredientPublicationRequestsRepo: - def requestPublication(ingredientId: IngredientId): IO[DbError, Unit] - -private inline def ingredientPublicationRequests = query[DbIngredientPublicationRequest] + def requestPublication(ingredientId: IngredientId): IO[DbError, PublicationRequestId] + def getPendingRequestsWithIngredients: IO[DbError, Seq[(DbIngredientPublicationRequest, DbIngredient)]] + def get(id: PublicationRequestId): IO[DbError, Option[DbIngredientPublicationRequest]] + def getWithIngredient(id: PublicationRequestId): IO[DbError, Option[(DbIngredientPublicationRequest, DbIngredient)]] + def updateStatus(id: PublicationRequestId, status: PublicationRequestStatus): IO[DbError, Boolean] + def getAllCreatedBy: ZIO[AuthenticatedUser, DbError, List[(DbIngredientPublicationRequest, DbIngredient)]] + def getAllRequestsForIngredient(id: IngredientId): IO[DbError, Seq[DbIngredientPublicationRequest]] final case class IngredientPublicationRequestsRepoLive(dataSource: DataSource) extends IngredientPublicationRequestsRepo: @@ -22,17 +28,90 @@ final case class IngredientPublicationRequestsRepoLive(dataSource: DataSource) private given DataSource = dataSource - override def requestPublication(ingredientId: IngredientId): IO[DbError, Unit] = - run(requestPublicationQ(lift(ingredientId))).unit.provideDS + override def requestPublication(ingredientId: IngredientId): IO[DbError, PublicationRequestId] = + run(requestPublicationQ(lift(ingredientId))).provideDS + + override def getPendingRequestsWithIngredients: + IO[DbError, Seq[(DbIngredientPublicationRequest, DbIngredient)]] = + run( + pendingRequestsQ + .join(IngredientsQueries.ingredientsQ) + .on(_.ingredientId == _.id) + ).provideDS + + override def get(id: PublicationRequestId): IO[DbError, Option[DbIngredientPublicationRequest]] = + run(getQ(lift(id))).map(_.headOption).provideDS + + override def updateStatus(id: PublicationRequestId, status: PublicationRequestStatus): + IO[DbError, Boolean] = + val (dbStatus, reason) = DbPublicationRequestStatus.fromDomain(status) + run(updateQ(lift(id), lift(dbStatus), lift(reason))).map(_ > 0).provideDS + + override def getWithIngredient(id: PublicationRequestId): + IO[DbError, Option[(DbIngredientPublicationRequest, DbIngredient)]] = + + run( + requestsQ.filter(_.id == lift(id)) + .join(IngredientsQueries.ingredientsQ) + .on(_.ingredientId == _.id) + ).map(_.headOption).provideDS + + override def getAllCreatedBy: ZIO[AuthenticatedUser, DbError, List[(DbIngredientPublicationRequest, DbIngredient)]] = + for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + res <- run(getAllCreatedByQ(lift(userId))).provideDS + yield res + + override def getAllRequestsForIngredient(id: IngredientId): + IO[DbError, List[DbIngredientPublicationRequest]] = + run( + requestsQ.filter(_.ingredientId == lift(id)) + ).provideDS object IngredientPublicationRequestsQueries: - import db.QuillConfig.ctx.* - inline def requestPublicationQ(inline ingredientId: IngredientId): Insert[DbIngredientPublicationRequest] = - ingredientPublicationRequests.insert(_.ingredientId -> ingredientId) - - inline def allPendingQ = ingredientPublicationRequests.filter(_.status == lift(Pending)) - inline def pendingRequestsByIdQ(inline ingredientId: IngredientId) = allPendingQ.filter(_.ingredientId == ingredientId) + inline def requestsQ: EntityQuery[DbIngredientPublicationRequest] = + query[DbIngredientPublicationRequest] + + inline def requestPublicationQ(inline ingredientId: IngredientId): + ActionReturning[DbIngredientPublicationRequest, UUID] = + requestsQ + .insert(_.ingredientId -> ingredientId) + .returningGenerated(_.id) + + inline def pendingRequestsQ: EntityQuery[DbIngredientPublicationRequest] = + requestsQ + .filter(r => + infix"${r.status} = 'pending'::publication_request_status" + .asCondition + ) + + inline def pendingRequestsByIngredientIdQ(inline ingredientId: IngredientId): + EntityQuery[DbIngredientPublicationRequest] = + pendingRequestsQ + .filter(_.ingredientId == ingredientId) + + inline def getQ(inline id: PublicationRequestId): EntityQuery[DbIngredientPublicationRequest] = + requestsQ + .filter(_.id == id) + + inline def updateQ( + inline id: PublicationRequestId, + inline status: DbPublicationRequestStatus, + inline reason: Option[String], + ): Update[DbIngredientPublicationRequest] = + requestsQ + .filter(_.id == id) + .update( + _.status -> status, + _.reason -> reason, + ) + + inline def getAllCreatedByQ(inline userId: UserId): Query[(DbIngredientPublicationRequest, DbIngredient)] = + query[DbIngredientPublicationRequest] + .join(query[DbIngredient]) + .on(_.ingredientId == _.id) + .filter(_._2.ownerId.contains(userId)) object IngredientPublicationRequestsRepo: def layer: RLayer[DataSource, IngredientPublicationRequestsRepo] = - ZLayer.fromFunction(IngredientPublicationRequestsRepoLive.apply) \ No newline at end of file + ZLayer.fromFunction(IngredientPublicationRequestsRepoLive.apply) diff --git a/src/main/scala/db/repositories/IngredientsRepo.scala b/src/main/scala/db/repositories/IngredientsRepo.scala index 1b579f3f..de8e3634 100644 --- a/src/main/scala/db/repositories/IngredientsRepo.scala +++ b/src/main/scala/db/repositories/IngredientsRepo.scala @@ -30,7 +30,7 @@ private final case class IngredientsRepoLive(dataSource: DataSource) extends Ing override def addPublic(name: String): IO[DbError, DbIngredient] = run( ingredientsQ - .insert(_.name -> lift(name), _.isPublished -> false) + .insert(_.name -> lift(name), _.isPublished -> true) .returning(ingredient => ingredient) ).provideDS @@ -86,23 +86,26 @@ private final case class IngredientsRepoLive(dataSource: DataSource) extends Ing def publish(ingredientId: IngredientId): IO[DbError, Unit] = run(getIngredientsQ(lift(ingredientId)).update(_.isPublished -> true)).unit.provideDS - + object IngredientsQueries: inline def ingredientsQ: EntityQuery[DbIngredient] = query[DbIngredient] - + inline def publishedIngredientsQ: EntityQuery[DbIngredient] = ingredientsQ.filter(_.isPublished) inline def publicIngredientsQ: EntityQuery[DbIngredient] = - ingredientsQ.filter(_.ownerId.isEmpty) + ingredientsQ.filter(_.isPublished) inline def customIngredientsQ(inline userId: UserId): EntityQuery[DbIngredient] = - ingredientsQ.filter(_.ownerId == Some(userId)) + ingredientsQ.filter(_.ownerId.contains(userId)) + + inline def privateIngredientsQ(inline userId: UserId): EntityQuery[DbIngredient] = + customIngredientsQ(userId).filter(!_.isPublished) inline def visibleIngredientsQ(inline userId: UserId): EntityQuery[DbIngredient] = - ingredientsQ.filter(i => i.ownerId == None || i.ownerId == Some(userId)) - + ingredientsQ.filter(i => i.isPublished || i.ownerId.contains(userId)) + inline def getIngredientsQ(inline ingredientId: IngredientId): EntityQuery[DbIngredient] = ingredientsQ.filter(_.id == ingredientId) diff --git a/src/main/scala/db/repositories/InvitationsRepo.scala b/src/main/scala/db/repositories/InvitationsRepo.scala index cd86f089..6e761714 100644 --- a/src/main/scala/db/repositories/InvitationsRepo.scala +++ b/src/main/scala/db/repositories/InvitationsRepo.scala @@ -2,30 +2,37 @@ package db.repositories import api.Authentication.AuthenticatedUser import db.{DbError, handleDbError} -import db.tables.{DbStorageInvitation, storageInvitationTable} +import db.tables.{DbStorage, DbStorageInvitation} import domain.{InternalServerError, InvalidInvitationHash, StorageAccessForbidden, StorageId} import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.concurrent.TimeUnit +import io.getquill.* + +import javax.sql.DataSource import com.augustnagro.magnum.magzio.* -import zio.{URLayer, Layer, System, ZIO, ZLayer, Clock} +import zio.{Clock, Layer, System, URLayer, ZIO, ZLayer} trait InvitationsRepo: def create(storageId: StorageId): ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InternalServerError | StorageAccessForbidden, String] def activate(hash: String): - ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InvalidInvitationHash, Unit] + ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InvalidInvitationHash, (StorageId, String)] -private final case class InvitationsRepoLive(xa: Transactor, secretKey: InvitationsSecretKey) +private final case class InvitationsRepoLive(xa: Transactor, dataSource: DataSource, secretKey: InvitationsSecretKey) extends Repo[DbStorageInvitation, DbStorageInvitation, Null] with InvitationsRepo: + private given DataSource = dataSource + import db.QuillConfig.ctx.* + import db.QuillConfig.provideDS + import InvitationQueries.* override def create(storageId: StorageId): ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InternalServerError, String] = for currentTime <- Clock.currentTime(TimeUnit.NANOSECONDS) input = s"$currentTime:$storageId:${secretKey.value}" - digest = MessageDigest.getInstance("SHA-256") + digest = MessageDigest.getInstance("MD5") hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)) invitationHash = hashBytes.map("%02x".format(_)).mkString _ <- xa.transact { @@ -34,13 +41,13 @@ private final case class InvitationsRepoLive(xa: Transactor, secretKey: Invitati yield invitationHash override def activate(invitationHash: String): - ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InvalidInvitationHash, Unit] = + ZIO[AuthenticatedUser & StorageMembersRepo, DbError | InvalidInvitationHash, (StorageId, String)] = for - dbInvitation <- xa.transact { - findAll(Spec[DbStorageInvitation] - .where(sql"${storageInvitationTable.invitation} = $invitationHash") - ).headOption - }.mapError(handleDbError).someOrFail(InvalidInvitationHash(invitationHash)) + dbInvitationWithName <- run(getInvitationWithStorageNameByHashQ(lift(invitationHash))) + .provideDS + .map(_.headOption) + .someOrFail(InvalidInvitationHash(invitationHash)) + (dbInvitation, storageName) = dbInvitationWithName isMemberOrOwner <- ZIO.serviceWithZIO[StorageMembersRepo](_.checkForMembership(dbInvitation.storageId)) _ <- ZIO.unless(isMemberOrOwner) { for @@ -49,15 +56,24 @@ private final case class InvitationsRepoLive(xa: Transactor, secretKey: Invitati _ <- ZIO.serviceWithZIO[StorageMembersRepo](_.addMemberToStorageById(dbInvitation.storageId, userId)) yield () } - yield () + yield (dbInvitation.storageId, storageName) + +object InvitationQueries: + inline def getInvitationWithStorageNameByHashQ(inline invitationHash: String): Query[(DbStorageInvitation, String)] = + query[DbStorageInvitation] + .filter(_.invitation == invitationHash) + .join(query[DbStorage]) + .on(_.storageId == _.id) + .map((i, s) => (i, s.name)) object InvitationsRepo: - val layer: URLayer[Transactor & InvitationsSecretKey, InvitationsRepoLive] = + val layer: URLayer[Transactor & DataSource & InvitationsSecretKey, InvitationsRepoLive] = ZLayer.fromZIO( for xa <- ZIO.service[Transactor] + dataSource <- ZIO.service[DataSource] secretKey <- ZIO.service[InvitationsSecretKey] - yield InvitationsRepoLive(xa, secretKey) + yield InvitationsRepoLive(xa, dataSource, secretKey) ) final case class InvitationsSecretKey(value: String) diff --git a/src/main/scala/db/repositories/RecipeIngredientsRepo.scala b/src/main/scala/db/repositories/RecipeIngredientsRepo.scala index a7061039..e70da67e 100644 --- a/src/main/scala/db/repositories/RecipeIngredientsRepo.scala +++ b/src/main/scala/db/repositories/RecipeIngredientsRepo.scala @@ -14,8 +14,6 @@ trait RecipeIngredientsRepo: def addIngredients(recipeId: RecipeId, ingredientIds: List[IngredientId]): IO[DbError, Unit] def removeIngredient(recipeId: RecipeId, ingredientId: IngredientId): IO[DbError, Unit] -private inline def recipeIngredients = query[DbRecipeIngredient] - final case class RecipeIngredientsRepoLive(dataSource: DataSource) extends RecipeIngredientsRepo: import db.QuillConfig.ctx.* import db.QuillConfig.provideDS @@ -50,16 +48,19 @@ final case class RecipeIngredientsRepoLive(dataSource: DataSource) extends Recip object RecipeIngredientsQueries: import db.QuillConfig.ctx.* + inline def recipeIngredientsQ: EntityQuery[DbRecipeIngredient] = + query[DbRecipeIngredient] + inline def getAllIngredientsQ(inline recipeId: RecipeId) = - recipeIngredients + recipeIngredientsQ .filter(_.recipeId == recipeId) .map(_.ingredientId) inline def addIngredientQ(inline recipeId: RecipeId, inline ingredientIds: IngredientId) = - recipeIngredients.insertValue((DbRecipeIngredient(recipeId, ingredientIds))) + recipeIngredientsQ.insertValue((DbRecipeIngredient(recipeId, ingredientIds))) inline def deleteIngredientQ(inline recipeId: RecipeId, inline ingredientId: IngredientId) = - recipeIngredients + recipeIngredientsQ .filter(ri => ri.recipeId == recipeId && ri.ingredientId == ingredientId) .delete diff --git a/src/main/scala/db/repositories/RecipePublicationRequestsRepo.scala b/src/main/scala/db/repositories/RecipePublicationRequestsRepo.scala index a600f1ee..8ce0cacd 100644 --- a/src/main/scala/db/repositories/RecipePublicationRequestsRepo.scala +++ b/src/main/scala/db/repositories/RecipePublicationRequestsRepo.scala @@ -1,18 +1,26 @@ package db.repositories +import api.Authentication.AuthenticatedUser import db.DbError -import db.tables.publication.DbPublicationRequestStatus.Pending -import db.tables.publication.DbRecipePublicationRequest -import domain.RecipeId +import db.QuillConfig.ctx +import db.tables.DbRecipe +import db.tables.publication.{DbPublicationRequestStatus, DbRecipePublicationRequest} +import domain.{PublicationRequestId, PublicationRequestStatus, RecipeId, UserId} import io.getquill.* -import zio.{IO, RLayer, ZLayer} +import java.util.UUID import javax.sql.DataSource +import zio.{IO, RLayer, ZIO, ZLayer} trait RecipePublicationRequestsRepo: - def requestPublication(recipeId: RecipeId): IO[DbError, Unit] - -private inline def recipePublicationRequests = query[DbRecipePublicationRequest] + def createPublicationRequest(recipeId: RecipeId): IO[DbError, PublicationRequestId] + def getPendingRequestsWithRecipes: IO[DbError, Seq[(DbRecipePublicationRequest, DbRecipe)]] + def get(id: PublicationRequestId): IO[DbError, Option[DbRecipePublicationRequest]] + def getWithRecipe(id: PublicationRequestId): IO[DbError, Option[(DbRecipePublicationRequest, DbRecipe)]] + def updateStatus(id: PublicationRequestId, status: PublicationRequestStatus): + IO[DbError, Boolean] + def getAllByRecipeId(recipeId: RecipeId): IO[DbError, List[DbRecipePublicationRequest]] + def getAllCreatedBy: ZIO[AuthenticatedUser, DbError, List[(DbRecipePublicationRequest, DbRecipe)]] final case class RecipePublicationRequestsRepoLive(dataSource: DataSource) extends RecipePublicationRequestsRepo: @@ -22,18 +30,95 @@ final case class RecipePublicationRequestsRepoLive(dataSource: DataSource) private given DataSource = dataSource - override def requestPublication(recipeId: RecipeId): IO[DbError, Unit] = - run(requestPublicationQ(lift(recipeId))).unit.provideDS + override def createPublicationRequest(recipeId: RecipeId): IO[DbError, PublicationRequestId] = + run( + requestPublicationQ(lift(recipeId)) + ).provideDS + + override def getPendingRequestsWithRecipes: + IO[DbError, List[(DbRecipePublicationRequest, DbRecipe)]] = + run( + pendingRequestsQ + .join(RecipesQueries.recipesQ) + .on(_.recipeId == _.id) + ).provideDS + + override def get(id: PublicationRequestId): IO[DbError, Option[DbRecipePublicationRequest]] = + run(getQ(lift(id)).value).provideDS + + override def getWithRecipe(id: PublicationRequestId): + IO[DbError, Option[(DbRecipePublicationRequest, DbRecipe)]] = + + run( + requestsQ.filter(_.id == lift(id)) + .join(RecipesQueries.recipesQ) + .on(_.recipeId == _.id) + ).map(_.headOption).provideDS + + override def updateStatus(id: PublicationRequestId, status: PublicationRequestStatus): + IO[DbError, Boolean] = + val (dbStatus, reason) = DbPublicationRequestStatus.fromDomain(status) + run( + updateQ(lift(id), lift(dbStatus), lift(reason)) + ).map(_ > 0).provideDS + + override def getAllByRecipeId(recipeId: RecipeId): IO[DbError, List[DbRecipePublicationRequest]] = + run(getAllByRecipeIdQ(lift(recipeId))).provideDS + + override def getAllCreatedBy: ZIO[AuthenticatedUser, DbError, List[(DbRecipePublicationRequest, DbRecipe)]] = + for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + res <- run(getAllCreatedByQ(lift(userId))).provideDS + yield res + object RecipePublicationRequestsQueries: - import db.QuillConfig.ctx.* - - inline def requestPublicationQ(inline recipeId: RecipeId) = - recipePublicationRequests.insert(_.recipeId -> recipeId) + inline def requestsQ: EntityQuery[DbRecipePublicationRequest] = + query[DbRecipePublicationRequest] + + inline def requestPublicationQ(inline recipeId: RecipeId): + ActionReturning[DbRecipePublicationRequest, UUID] = + requestsQ + .insert(_.recipeId -> recipeId) + .returningGenerated(_.id) + + inline def pendingRequestsQ: EntityQuery[DbRecipePublicationRequest] = + requestsQ + .filter(r => + infix"${r.status} = 'pending'::publication_request_status" + .asCondition + ) + + inline def pendingRequestsByRecipeIdQ(inline recipeId: RecipeId): + EntityQuery[DbRecipePublicationRequest] = + pendingRequestsQ + .filter(_.recipeId == recipeId) + + inline def getQ(inline id: PublicationRequestId): EntityQuery[DbRecipePublicationRequest] = + requestsQ + .filter(_.id == id) + + inline def updateQ( + inline id: PublicationRequestId, + inline status: DbPublicationRequestStatus, + inline reason: Option[String], + ): Update[DbRecipePublicationRequest] = + requestsQ + .filter(_.id == id) + .update( + _.status -> status, + _.reason -> reason, + ) + + inline def getAllByRecipeIdQ(inline recipeId: RecipeId): EntityQuery[DbRecipePublicationRequest] = + requestsQ.filter(_.recipeId == recipeId) + + inline def getAllCreatedByQ(inline userId: UserId): Query[(DbRecipePublicationRequest, DbRecipe)] = + requestsQ + .join(query[DbRecipe]) + .on(_.recipeId == _.id) + .filter(_._2.creatorId.contains(userId)) - inline def allPendingQ = recipePublicationRequests.filter(_.status == lift(Pending)) - inline def pendingRequestsByIdQ(inline recipeId: RecipeId) = allPendingQ.filter(_.recipeId == recipeId) - object RecipePublicationRequestsRepo: def layer: RLayer[DataSource, RecipePublicationRequestsRepo] = ZLayer.fromFunction(RecipePublicationRequestsRepoLive.apply) diff --git a/src/main/scala/db/repositories/RecipesDomainRepo.scala b/src/main/scala/db/repositories/RecipesDomainRepo.scala index f8978cdb..2ecaf8f1 100644 --- a/src/main/scala/db/repositories/RecipesDomainRepo.scala +++ b/src/main/scala/db/repositories/RecipesDomainRepo.scala @@ -7,22 +7,24 @@ import domain.{RecipeId, StorageId} import com.augustnagro.magnum.magzio.* import zio.{ZIO, ZLayer} +import api.Authentication.AuthenticatedUser trait RecipesDomainRepo: protected type RecipeSummary = (RecipeId, String, Int, Int, Int) def getSuggestedIngredients( paginationParams: PaginationParams, storageIds: Vector[StorageId] - ): ZIO[StorageIngredientsRepo, DbError, Vector[RecipeSummary]] + ): ZIO[AuthenticatedUser & StorageIngredientsRepo, DbError, Vector[RecipeSummary]] private final case class RecipesDomainRepoLive(xa: Transactor) extends RecipesDomainRepo: override def getSuggestedIngredients( paginationParams: PaginationParams, storageIds: Vector[StorageId] - ): ZIO[StorageIngredientsRepo, DbError, Vector[RecipeSummary]] = + ): ZIO[AuthenticatedUser & StorageIngredientsRepo, DbError, Vector[RecipeSummary]] = val PaginationParams(size, offset) = paginationParams val table = recipeIngredientsTable for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) allIngredients <- ZIO.collectAll(storageIds.map { storageId => ZIO.serviceWithZIO[StorageIngredientsRepo](_.getAllIngredientsFromStorage(storageId)) }).map(_.flatten) @@ -34,6 +36,8 @@ private final case class RecipesDomainRepoLive(xa: Transactor) extends RecipesDo ${table.recipeId}, r.${recipesTable.name} AS recipe_name, COUNT(*) AS total_ingredients, + r.${recipesTable.isPublished}, + r.${recipesTable.creatorId}, SUM( CASE WHEN ${table.ingredientId} = ANY(${allIngredients.toArray}) THEN 1 @@ -42,7 +46,11 @@ private final case class RecipesDomainRepoLive(xa: Transactor) extends RecipesDo ) AS available_ingredients FROM $table ri JOIN ${recipesTable} r ON r.${recipesTable.id} = ri.${table.recipeId} - GROUP BY ${table.recipeId}, recipe_name + GROUP BY + ${table.recipeId}, + recipe_name, + ${recipesTable.isPublished}, + ${recipesTable.creatorId} ) SELECT ${table.recipeId}, @@ -52,6 +60,7 @@ private final case class RecipesDomainRepoLive(xa: Transactor) extends RecipesDo COUNT(*) OVER() AS recipes_found FROM recipe_stats WHERE total_ingredients > 0 -- Avoid division by zero + AND (${recipesTable.isPublished} OR ${recipesTable.creatorId} = $userId) ORDER BY (available_ingredients::float / total_ingredients) DESC, total_ingredients DESC diff --git a/src/main/scala/db/repositories/RecipesRepo.scala b/src/main/scala/db/repositories/RecipesRepo.scala index a7dc31d0..cdbc1c73 100644 --- a/src/main/scala/db/repositories/RecipesRepo.scala +++ b/src/main/scala/db/repositories/RecipesRepo.scala @@ -11,6 +11,8 @@ import javax.sql.DataSource import zio.{ZIO, IO, RLayer, ZLayer} trait RecipesRepo: + def createPublic(name: String, sourceLink: Option[String], ingredients: List[IngredientId]): + IO[DbError, RecipeId] def addRecipe(name: String, sourceLink: Option[String], ingredients: List[IngredientId]): ZIO[AuthenticatedUser, DbError, RecipeId] @@ -19,6 +21,7 @@ trait RecipesRepo: def getAllCustom: ZIO[AuthenticatedUser, DbError, List[DbRecipe]] def getAllPublic: IO[DbError, List[DbRecipe]] + def isUserOwner(recipeId: RecipeId): ZIO[AuthenticatedUser, DbError, Boolean] def isVisible(recipeId: RecipeId): ZIO[AuthenticatedUser, DbError, Boolean] def isPublic(recipeId: RecipeId): IO[DbError, Boolean] @@ -33,13 +36,29 @@ final case class RecipesRepoLive(dataSource: DataSource) extends RecipesRepo: private given DataSource = dataSource + override def createPublic(name: String, sourceLink: Option[String], ingredientIds: List[IngredientId]): + IO[DbError, RecipeId] = transaction { + for + recipeId <- run( + recipesQ + .insertValue(lift(DbRecipe(id=null, name, None, isPublished=true, sourceLink))) + .returningGenerated(r => r.id) // null is safe here because of returningGenerated + ) + _ <- run( + liftQuery(ingredientIds).foreach( + RecipeIngredientsQueries.addIngredientQ(lift(recipeId), _) + ) + ) + yield recipeId + }.provideDS + override def addRecipe(name: String, sourceLink: Option[String], ingredientIds: List[IngredientId]): ZIO[AuthenticatedUser, DbError, RecipeId] = transaction { for creatorId <- ZIO.serviceWith[AuthenticatedUser](_.userId) recipeId <- run( recipesQ - .insertValue(lift(DbRecipe(id=null, name, creatorId, isPublished=false, sourceLink))) + .insertValue(lift(DbRecipe(id=null, name, Some(creatorId), isPublished=false, sourceLink))) .returningGenerated(r => r.id) // null is safe here because of returningGenerated ) _ <- run( @@ -54,7 +73,7 @@ final case class RecipesRepoLive(dataSource: DataSource) extends RecipesRepo: ZIO[AuthenticatedUser, DbError, Option[Recipe]] = transaction { for userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) - mRecipe <- run(getVisibleRecipeQ(lift(userId), lift(recipeId))).map(_.headOption) + mRecipe <- run(getVisibleRecipeQ(lift(userId), lift(recipeId)).value) mRecipeWithIngredients <- ZIO.foreach(mRecipe) { recipe => val DbRecipe(id, name, creatorId, isPublished, sourceLink) = recipe run( @@ -77,6 +96,16 @@ final case class RecipesRepoLive(dataSource: DataSource) extends RecipesRepo: override def getAllPublic: IO[DbError, List[DbRecipe]] = run(publicRecipesQ).provideDS + override def isUserOwner(recipeId: RecipeId): ZIO[AuthenticatedUser, DbError, Boolean] = + for + userId <- ZIO.serviceWith[AuthenticatedUser](_.userId) + res <- run( + customRecipesQ(lift(userId)) + .filter(_.id == lift(recipeId)) + .nonEmpty + ).provideDS + yield res + override def isVisible(recipeId: RecipeId): ZIO[AuthenticatedUser, DbError, Boolean] = ZIO.serviceWithZIO[AuthenticatedUser](user => run( @@ -113,10 +142,10 @@ object RecipesQueries: recipesQ.filter(_.isPublished) inline def visibleRecipesQ(inline userId: UserId): EntityQuery[DbRecipe] = - recipesQ.filter(r => r.isPublished || r.creatorId == userId) + recipesQ.filter(r => r.isPublished || r.creatorId.contains(userId)) inline def customRecipesQ(inline userId: UserId): EntityQuery[DbRecipe] = - recipesQ.filter(r => r.creatorId == userId) + recipesQ.filter(r => r.creatorId.contains(userId)) inline def getRecipeQ(inline recipeId: RecipeId): EntityQuery[DbRecipe] = recipesQ.filter(r => r.id == recipeId) @@ -124,6 +153,9 @@ object RecipesQueries: inline def getVisibleRecipeQ(inline userId: UserId, inline recipeId: RecipeId): EntityQuery[DbRecipe] = visibleRecipesQ(userId).filter(r => r.id == recipeId) + inline def getPublicRecipeQ(inline recipeId: RecipeId): EntityQuery[DbRecipe] = + publicRecipesQ.filter(r => r.id == recipeId) + object RecipesRepo: def layer: RLayer[DataSource, RecipesRepo] = ZLayer.fromFunction(RecipesRepoLive.apply) diff --git a/src/main/scala/db/repositories/ShoppingListsRepo.scala b/src/main/scala/db/repositories/ShoppingListsRepo.scala index 215cc62f..1ab4c699 100644 --- a/src/main/scala/db/repositories/ShoppingListsRepo.scala +++ b/src/main/scala/db/repositories/ShoppingListsRepo.scala @@ -71,6 +71,7 @@ object ShoppingListsQueries: inline def addIngredientQ(inline userId: UserId, inline ingredientId: IngredientId) = query[DbShoppingList] .insertValue(DbShoppingList(userId, ingredientId)) + .onConflictIgnore inline def deleteIngredientQ(inline userId: UserId, inline ingredientId: IngredientId): Delete[DbShoppingList] = query[DbShoppingList] diff --git a/src/main/scala/db/tables/DbRecipe.scala b/src/main/scala/db/tables/DbRecipe.scala index 9cb85ee0..a02e8d37 100644 --- a/src/main/scala/db/tables/DbRecipe.scala +++ b/src/main/scala/db/tables/DbRecipe.scala @@ -9,7 +9,7 @@ import com.augustnagro.magnum.* final case class DbRecipe( @Id id: RecipeId, name: String, - creatorId: UserId, + creatorId: Option[UserId], isPublished: Boolean, sourceLink: Option[String], ) derives DbCodec diff --git a/src/main/scala/db/tables/publication/DbIngredientPublicationRequest.scala b/src/main/scala/db/tables/publication/DbIngredientPublicationRequest.scala index 715f2d47..0ad17a07 100644 --- a/src/main/scala/db/tables/publication/DbIngredientPublicationRequest.scala +++ b/src/main/scala/db/tables/publication/DbIngredientPublicationRequest.scala @@ -3,33 +3,36 @@ package db.tables.publication import domain.{IngredientId, IngredientPublicationRequest} import java.time.OffsetDateTime +import java.util.UUID final case class DbIngredientPublicationRequest( - ingredientId: IngredientId, - createdAt: OffsetDateTime, - updatedAt: OffsetDateTime, - status: DbPublicationRequestStatus, - reason: Option[String], - ): + id: UUID, + ingredientId: IngredientId, + createdAt: OffsetDateTime, + updatedAt: OffsetDateTime, + status: DbPublicationRequestStatus, + reason: Option[String], +): def toDomain: IngredientPublicationRequest = - IngredientPublicationRequest(ingredientId, createdAt, updatedAt, status.toDomain(reason)) + IngredientPublicationRequest(id, ingredientId, createdAt, updatedAt, status.toDomain(reason)) object DbIngredientPublicationRequest: def fromDomain(req: IngredientPublicationRequest): DbIngredientPublicationRequest = - val (reason, status) = DbPublicationRequestStatus.fromDomain(req.status) - DbIngredientPublicationRequest(req.ingredientId, req.createdAt, req.updatedAt, status, reason) + val (status, reason) = DbPublicationRequestStatus.fromDomain(req.status) + DbIngredientPublicationRequest(req.id, req.ingredientId, req.createdAt, req.updatedAt, status, reason) - val createTable: String = """ + val createTable: String = s""" CREATE TABLE IF NOT EXISTS ingredient_publication_requests( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ingredient_id UUID NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - status publication_request_status NOT NULL DEFAULT 'pending', + status ${DbPublicationRequestStatus.postgresTypeName} NOT NULL DEFAULT 'pending', reason TEXT, - FOREIGN KEY (ingredient_id) REFERENCES recipes(id) ON DELETE CASCADE + FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE ); - CREATE OR REPLACE TRIGGER update_timestamp + CREATE OR REPLACE TRIGGER ingredient_publication_requests_update_timestamp BEFORE UPDATE ON ingredient_publication_requests FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); diff --git a/src/main/scala/db/tables/publication/DbPublicationRequestStatus.scala b/src/main/scala/db/tables/publication/DbPublicationRequestStatus.scala index 8a24775a..decb5dca 100644 --- a/src/main/scala/db/tables/publication/DbPublicationRequestStatus.scala +++ b/src/main/scala/db/tables/publication/DbPublicationRequestStatus.scala @@ -3,7 +3,8 @@ package db.tables.publication import db.QuillConfig.ctx.* import domain.PublicationRequestStatus -import java.sql.{PreparedStatement, Types} +import java.sql.Types +import org.postgresql.util.PGobject enum DbPublicationRequestStatus: case Pending @@ -12,42 +13,49 @@ enum DbPublicationRequestStatus: def toDomain(reason: Option[String]): PublicationRequestStatus = this match case Pending => PublicationRequestStatus.Pending case Accepted => PublicationRequestStatus.Accepted - case Rejected => PublicationRequestStatus.Rejected(reason.get) // <- unsafe code here + case Rejected => PublicationRequestStatus.Rejected(reason) + + def postgresValue: String = this match + case Pending => "pending" + case Accepted => "accepted" + case Rejected => "rejected" object DbPublicationRequestStatus: - val fromDomain: PublicationRequestStatus => (Option[String], DbPublicationRequestStatus) = - case PublicationRequestStatus.Pending => (None, Pending) - case PublicationRequestStatus.Accepted => (None, Accepted) - case PublicationRequestStatus.Rejected(reason) => (Some(reason), Rejected) + val fromDomain: PublicationRequestStatus => (DbPublicationRequestStatus, Option[String]) = + case PublicationRequestStatus.Pending => (Pending, None) + case PublicationRequestStatus.Accepted => (Accepted, None) + case PublicationRequestStatus.Rejected(reason) => (Rejected, reason) + + inline val postgresTypeName: "publication_request_status" = + "publication_request_status" - val createType: String = """ - DO $$ + val createType: String = s""" + DO $$$$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'publication_request_status') THEN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = '$postgresTypeName') THEN CREATE TYPE publication_request_status AS ENUM ( 'pending', 'accepted', 'rejected' ); END IF; - END $$; + END $$$$; """ - given JdbcDecoder[DbPublicationRequestStatus]( - (index, row, _) => - val statusType = row.getString(index) - statusType match + given Decoder[DbPublicationRequestStatus] = + decoder(row => index => + row.getObject(index).toString match case "pending" => DbPublicationRequestStatus.Pending case "accepted" => DbPublicationRequestStatus.Accepted case "rejected" => DbPublicationRequestStatus.Rejected - ) + ) - given JdbcEncoder[DbPublicationRequestStatus] = encoder( + given Encoder[DbPublicationRequestStatus] = encoder( Types.OTHER, - (index: Int, value: DbPublicationRequestStatus, row: PreparedStatement) => - val statusString = value match - case DbPublicationRequestStatus.Pending => "pending" - case DbPublicationRequestStatus.Accepted => "accepted" - case DbPublicationRequestStatus.Rejected => "rejected" - row.setString(index, statusString) + (index, value, row) => { + val pgObj = new PGobject() + pgObj.setType(postgresTypeName) + pgObj.setValue(value.postgresValue) + row.setObject(index, pgObj, Types.OTHER) + } ) diff --git a/src/main/scala/db/tables/publication/DbRecipePublicationRequest.scala b/src/main/scala/db/tables/publication/DbRecipePublicationRequest.scala index 03bcbb14..e7b1934e 100644 --- a/src/main/scala/db/tables/publication/DbRecipePublicationRequest.scala +++ b/src/main/scala/db/tables/publication/DbRecipePublicationRequest.scala @@ -3,8 +3,10 @@ package db.tables.publication import domain.{RecipeId, RecipePublicationRequest} import java.time.OffsetDateTime +import java.util.UUID final case class DbRecipePublicationRequest( + id: UUID, recipeId: RecipeId, createdAt: OffsetDateTime, updatedAt: OffsetDateTime, @@ -12,24 +14,25 @@ final case class DbRecipePublicationRequest( reason: Option[String], ): def toDomain: RecipePublicationRequest = - RecipePublicationRequest(recipeId, createdAt, updatedAt, status.toDomain(reason)) + RecipePublicationRequest(id, recipeId, createdAt, updatedAt, status.toDomain(reason)) object DbRecipePublicationRequest: def fromDomain(req: RecipePublicationRequest): DbRecipePublicationRequest = - val (reason, status) = DbPublicationRequestStatus.fromDomain(req.status) - DbRecipePublicationRequest(req.recipeId, req.createdAt, req.updatedAt, status, reason) + val (status, reason) = DbPublicationRequestStatus.fromDomain(req.status) + DbRecipePublicationRequest(req.id, req.recipeId, req.createdAt, req.updatedAt, status, reason) - val createTable: String = """ + val createTable: String = s""" CREATE TABLE IF NOT EXISTS recipe_publication_requests( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipe_id UUID NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - status publication_request_status NOT NULL DEFAULT 'pending', + status ${DbPublicationRequestStatus.postgresTypeName} NOT NULL DEFAULT 'pending', reason TEXT, FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE ); - CREATE OR REPLACE TRIGGER update_timestamp + CREATE OR REPLACE TRIGGER recipe_publication_requests_update_timestamp BEFORE UPDATE ON recipe_publication_requests FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); diff --git a/src/main/scala/domain/Errors.scala b/src/main/scala/domain/Errors.scala index 06593d5c..cf77e15c 100644 --- a/src/main/scala/domain/Errors.scala +++ b/src/main/scala/domain/Errors.scala @@ -36,3 +36,8 @@ final case class InvalidInvitationHash( hash: String, message: String = "Invalid invitation hash", ) + +final case class PublicationRequestNotFound( + requestId: PublicationRequestId, + message: String = "Publication request not found" +) diff --git a/src/main/scala/domain/Ids.scala b/src/main/scala/domain/Ids.scala index 1e327bb8..0f896a5f 100644 --- a/src/main/scala/domain/Ids.scala +++ b/src/main/scala/domain/Ids.scala @@ -6,3 +6,4 @@ type UserId = Long type StorageId = UUID type IngredientId = UUID type RecipeId = UUID +type PublicationRequestId = UUID diff --git a/src/main/scala/domain/IngredientPublicationRequest.scala b/src/main/scala/domain/IngredientPublicationRequest.scala index 9e7874d5..a8677fb2 100644 --- a/src/main/scala/domain/IngredientPublicationRequest.scala +++ b/src/main/scala/domain/IngredientPublicationRequest.scala @@ -3,6 +3,7 @@ package domain import java.time.OffsetDateTime final case class IngredientPublicationRequest( + id: PublicationRequestId, ingredientId: IngredientId, createdAt: OffsetDateTime, updatedAt: OffsetDateTime, diff --git a/src/main/scala/domain/PublicationRequestStatus.scala b/src/main/scala/domain/PublicationRequestStatus.scala index 82f543a8..ebdf0129 100644 --- a/src/main/scala/domain/PublicationRequestStatus.scala +++ b/src/main/scala/domain/PublicationRequestStatus.scala @@ -3,5 +3,5 @@ package domain enum PublicationRequestStatus: case Pending case Accepted - case Rejected(reason: String) + case Rejected(reason: Option[String]) diff --git a/src/main/scala/domain/Recipe.scala b/src/main/scala/domain/Recipe.scala index 271b5d18..b55e4f70 100644 --- a/src/main/scala/domain/Recipe.scala +++ b/src/main/scala/domain/Recipe.scala @@ -3,7 +3,7 @@ package domain case class Recipe( id: RecipeId, name: String, - creatorId: UserId, + creatorId: Option[UserId], isPublished: Boolean, ingredients: List[IngredientId], sourceLink: Option[String], diff --git a/src/main/scala/domain/RecipePublicationRequest.scala b/src/main/scala/domain/RecipePublicationRequest.scala index 16921aea..3f6cae8b 100644 --- a/src/main/scala/domain/RecipePublicationRequest.scala +++ b/src/main/scala/domain/RecipePublicationRequest.scala @@ -3,6 +3,7 @@ package domain import java.time.OffsetDateTime final case class RecipePublicationRequest( + id: PublicationRequestId, recipeId: RecipeId, createdAt: OffsetDateTime, updatedAt: OffsetDateTime, diff --git a/src/test/scala/integration/api/ingredients/CreateIngredientTests.scala b/src/test/scala/integration/api/ingredients/CreateIngredientTests.scala new file mode 100644 index 00000000..6f382559 --- /dev/null +++ b/src/test/scala/integration/api/ingredients/CreateIngredientTests.scala @@ -0,0 +1,51 @@ +package integration.api.ingredients + +import api.Authentication.AuthenticatedUser +import api.ingredients.CreateIngredientReqBody +import db.repositories.IngredientsRepo +import integration.common.Utils.* +import integration.common.ZIOIntegrationTestSpec + +import io.circe.generic.auto.* +import zio.http.* +import zio.* +import zio.test.* + +object CreateIngredientTests extends ZIOIntegrationTestSpec: + private def endpointPath: URL = + URL(Path.root / "ingredients") + + private def createIngredient(user: AuthenticatedUser, reqBody: CreateIngredientReqBody): + RIO[Client, Response] = + Client.batched( + post(endpointPath) + .withJsonBody(reqBody) + .addAuthorization(user) + ) + + override def spec: Spec[TestEnvironment & Scope, Any] = suite("Create invitation tests")( + test("When unauthorized should get 401") { + for + resp <- Client.batched(post(endpointPath)) + yield assertTrue(resp.status == Status.Unauthorized) + }, + test("When create ingredient should get 204 and custom ingredient should be created"){ + for + user <- registerUser + ingredientName <- randomString + + resp <- createIngredient(user, CreateIngredientReqBody(ingredientName)) + + bodyStr <- resp.body.asString + ingredientId <- ZIO.fromOption(bodyStr.toUUID) + .orElse(failed(Cause.fail(s"Could not parse response ingredientId $bodyStr"))) + + ingredient <- ZIO.serviceWithZIO[IngredientsRepo](_ + .get(ingredientId) + .provideUser(user) + ) + yield assertTrue(resp.status == Status.Created) + && assertTrue(ingredient.is(_.some).ownerId.is(_.some) == user.userId) + && assertTrue(ingredient.is(_.some).name == ingredientName) + }, + ).provideLayer(testLayer) diff --git a/src/test/scala/integration/api/invitations/CreateInvitationTests.scala b/src/test/scala/integration/api/invitations/CreateInvitationTests.scala index 5f436cc2..bd5120cb 100644 --- a/src/test/scala/integration/api/invitations/CreateInvitationTests.scala +++ b/src/test/scala/integration/api/invitations/CreateInvitationTests.scala @@ -1,6 +1,5 @@ package integration.api.invitations - import api.Authentication.AuthenticatedUser import db.repositories.StorageMembersRepo import db.tables.storageInvitationTable diff --git a/src/test/scala/integration/api/recipes/GetRecipeTests.scala b/src/test/scala/integration/api/recipes/GetRecipeTests.scala index da1e4a30..6b4c0483 100644 --- a/src/test/scala/integration/api/recipes/GetRecipeTests.scala +++ b/src/test/scala/integration/api/recipes/GetRecipeTests.scala @@ -46,11 +46,11 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser storageId <- createStorage(user) - ingredientIds <- createNIngredients(defaultIngredientAmount) - _extraIngredientIds <- createNIngredients(defaultIngredientAmount) + ingredientIds <- createNPublicIngredients(defaultIngredientAmount) + _extraIngredientIds <- createNPublicIngredients(defaultIngredientAmount) _ <- addIngredientsToStorage(storageId, ingredientIds) - recipeId <- createRecipe(user, ingredientIds) + recipeId <- createCustomRecipe(user, ingredientIds) resp <- getRecipe(user, recipeId) @@ -67,20 +67,20 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: storage1Id <- createStorage(user) storage2Id <- createStorage(user) - storage1UsedIngredientIds <- createNIngredients(defaultIngredientAmount) - storage2UsedIngredientIds <- createNIngredients(defaultIngredientAmount) + storage1UsedIngredientIds <- createNPublicIngredients(defaultIngredientAmount) + storage2UsedIngredientIds <- createNPublicIngredients(defaultIngredientAmount) recipeIngredientsIds = storage1UsedIngredientIds ++ storage2UsedIngredientIds - recipeId <- createRecipe(user, recipeIngredientsIds) + recipeId <- createCustomRecipe(user, recipeIngredientsIds) _ <- addIngredientsToStorage(storage1Id, storage1UsedIngredientIds) _ <- addIngredientsToStorage(storage2Id, storage2UsedIngredientIds) // create some extra ingredients that are not used in the recipe - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(storage1Id, _)) - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(storage2Id, _)) resp <- getRecipe(user, recipeId) @@ -113,16 +113,16 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: user2StorageId <- createStorage(user2) temp <- for - commonIngredientIds <- createNIngredients(defaultIngredientAmount) - user1OnlyStorageIngredientIds <- createNIngredients(defaultIngredientAmount) - user2OnlyStorageIngredientIds <- createNIngredients(defaultIngredientAmount) + commonIngredientIds <- createNPublicIngredients(defaultIngredientAmount) + user1OnlyStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) + user2OnlyStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) yield (user1OnlyStorageIngredientIds ++ commonIngredientIds, user2OnlyStorageIngredientIds ++ commonIngredientIds) (user1StorageIngredientIds, user2StorageIngredientIds) = temp _ <- addIngredientsToStorage(user1StorageId, user1StorageIngredientIds) _ <- addIngredientsToStorage(user2StorageId, user2StorageIngredientIds) - sharedStorageIngredientIds <- createNIngredients(defaultIngredientAmount) + sharedStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) _ <- addIngredientsToStorage(sharedStorageId, sharedStorageIngredientIds) recipeIngredientsIds = @@ -130,15 +130,15 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: ++ user2StorageIngredientIds ++ sharedStorageIngredientIds ).distinct - recipeId <- createRecipe(user1, recipeIngredientsIds) + recipeId <- createCustomRecipe(user1, recipeIngredientsIds) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) // create some extra ingredients that are not used in the recipe - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(user1StorageId, _)) - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(user2StorageId, _)) - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(sharedStorageId, _)) // case 1: sending request as a 1st user @@ -199,16 +199,16 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: user2StorageId <- createStorage(user2) temp <- for - commonIngredientIds <- createNIngredients(defaultIngredientAmount) - user1OnlyStorageIngredientIds <- createNIngredients(defaultIngredientAmount) - user2OnlyStorageIngredientIds <- createNIngredients(defaultIngredientAmount) + commonIngredientIds <- createNPublicIngredients(defaultIngredientAmount) + user1OnlyStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) + user2OnlyStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) yield (user1OnlyStorageIngredientIds ++ commonIngredientIds, user2OnlyStorageIngredientIds ++ commonIngredientIds) (user1StorageIngredientIds, user2StorageIngredientIds) = temp _ <- addIngredientsToStorage(user1StorageId, user1StorageIngredientIds) _ <- addIngredientsToStorage(user2StorageId, user2StorageIngredientIds) - sharedStorageIngredientIds <- createNIngredients(defaultIngredientAmount) + sharedStorageIngredientIds <- createNPublicIngredients(defaultIngredientAmount) _ <- addIngredientsToStorage(sharedStorageId, sharedStorageIngredientIds) recipeIngredientsIds = @@ -216,14 +216,14 @@ object GetRecipeTests extends ZIOIntegrationTestSpec: ++ user2StorageIngredientIds ++ sharedStorageIngredientIds ).distinct - recipeId <- createRecipe(user1, recipeIngredientsIds) + recipeId <- createCustomRecipe(user1, recipeIngredientsIds) // create some extra ingredients that are not used in the recipe - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(user1StorageId, _)) - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(user2StorageId, _)) - _ <- createNIngredients(defaultIngredientAmount) + _ <- createNPublicIngredients(defaultIngredientAmount) .flatMap(addIngredientsToStorage(sharedStorageId, _)) resp <- getRecipe(user2, recipeId) diff --git a/src/test/scala/integration/api/recipes/GetSuggestedRecipesTests.scala b/src/test/scala/integration/api/recipes/GetSuggestedRecipesTests.scala index 211abda4..cd75cfa9 100644 --- a/src/test/scala/integration/api/recipes/GetSuggestedRecipesTests.scala +++ b/src/test/scala/integration/api/recipes/GetSuggestedRecipesTests.scala @@ -29,9 +29,7 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: override def spec: Spec[TestEnvironment & Scope, Any] = suite("Get suggested recipes tests")( test("When unauthorized should get 401") { for - resp <- Client.batched( - Request.get(endpointPath) - ) + resp <- Client.batched(get(endpointPath)) yield assertTrue(resp.status == Status.Unauthorized) }, test("When authorized should get 200") { @@ -47,8 +45,8 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: n <- Gen.int(2, 10).runHead.some recipeIds <- ZIO.collectAll( (1 to n).map(i => - createNIngredients(i).flatMap( - createRecipe(user, _) + createNPublicIngredients(i).flatMap( + createCustomRecipe(user, _) ) ) ) @@ -72,13 +70,13 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: tmp <- ZIO.foreach(Seq(storage1Id, storage2Id))(storageId => for n <- Gen.int(3, 10).runHead.some - ingredientIds <- createNIngredients(n) + ingredientIds <- createNPublicIngredients(n) _ <- addIngredientsToStorage(storage1Id, ingredientIds) yield ingredientIds) Seq(storage1IngredientIds, storage2IngredientIds) = tmp n <- Gen.int(2, 10).runHead.some - otherIngredientIds <- createNIngredients(n) + otherIngredientIds <- createNPublicIngredients(n) recipe1Storage1Availability = minStorageIngredientsAmount recipe1Storage2Availability = minStorageIngredientsAmount - 1 @@ -86,7 +84,7 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: = storage1IngredientIds.take(recipe1Storage1Availability) ++ storage2IngredientIds.take(recipe1Storage2Availability) ++ otherIngredientIds - recipe1Id <- createRecipe(user, recipe1IngredientIds) + recipe1Id <- createCustomRecipe(user, recipe1IngredientIds) recipe2Storage1Availability = minStorageIngredientsAmount - 2 recipe2Storage2Availability = minStorageIngredientsAmount @@ -94,7 +92,7 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: = storage1IngredientIds.takeRight(recipe2Storage1Availability) ++ storage2IngredientIds.takeRight(recipe2Storage2Availability) ++ otherIngredientIds - recipe2Id <- createRecipe(user, recipe2IngredientIds) + recipe2Id <- createCustomRecipe(user, recipe2IngredientIds) resp <- getSuggestedRecipes(user, Seq(storage1Id, storage2Id)) @@ -129,13 +127,13 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: tmp <- ZIO.foreach(Seq(memberedStorageId, storage1Id, storage2Id))(storageId => for n <- Gen.int(3, 10).runHead.some - ingredientIds <- createNIngredients(n) + ingredientIds <- createNPublicIngredients(n) _ <- addIngredientsToStorage(storage1Id, ingredientIds) yield ingredientIds) Seq(memberedStorageIngredientIds, storage1IngredientIds, storage2IngredientIds) = tmp n <- Gen.int(2, 10).runHead.some - otherIngredientIds <- createNIngredients(n) + otherIngredientIds <- createNPublicIngredients(n) recipe1MemberedStorageAvailability = minStorageIngredientsAmount - 2 recipe1Storage1Availability = minStorageIngredientsAmount @@ -145,7 +143,7 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: ++ storage1IngredientIds.take(recipe1Storage1Availability) ++ storage2IngredientIds.take(recipe1Storage2Availability) ++ otherIngredientIds - recipe1Id <- createRecipe(user, recipe1IngredientIds) + recipe1Id <- createCustomRecipe(user, recipe1IngredientIds) recipe2MemberedStorageAvailability = minStorageIngredientsAmount - 1 recipe2Storage1Availability = minStorageIngredientsAmount - 2 @@ -155,7 +153,7 @@ object GetSuggestedRecipesTests extends ZIOIntegrationTestSpec: ++ storage1IngredientIds.takeRight(recipe2Storage1Availability) ++ storage2IngredientIds.takeRight(recipe2Storage2Availability) ++ otherIngredientIds - recipe2Id <- createRecipe(user, recipe2IngredientIds) + recipe2Id <- createCustomRecipe(user, recipe2IngredientIds) resp <- getSuggestedRecipes(user, Seq(storage1Id, storage2Id)) diff --git a/src/test/scala/integration/api/recipes/RequestRecipePublicationTests.scala b/src/test/scala/integration/api/recipes/RequestRecipePublicationTests.scala new file mode 100644 index 00000000..50bc3e33 --- /dev/null +++ b/src/test/scala/integration/api/recipes/RequestRecipePublicationTests.scala @@ -0,0 +1,129 @@ +package integration.api.recipes + +import api.Authentication.AuthenticatedUser +import db.repositories.RecipePublicationRequestsRepo +import db.tables.publication.DbPublicationRequestStatus +import domain.RecipeId +import integration.common.Utils.* +import integration.common.ZIOIntegrationTestSpec + +import io.circe.generic.auto.* +import io.circe.parser.decode +import zio.* +import zio.http.* +import zio.test.* +import db.repositories.IngredientsRepo +import api.recipes.publicationRequests.CannotPublishRecipeWithPrivateIngredients + +object RequestRecipePublicationTests extends ZIOIntegrationTestSpec: + private def endpointPath(recipeId: RecipeId): URL = + URL(Path.root / "recipes" / recipeId.toString / "publication-requests") + + private def requestRecipePublication(user: AuthenticatedUser, recipeId: RecipeId): + RIO[Client, Response] = + Client.batched( + post(endpointPath(recipeId)) + .addAuthorization(user) + ) + + override def spec: Spec[TestEnvironment & Scope, Any] = suite("Request recipe publication tests")( + test("When unauthorized should get 401") { + for + recipeId <- getRandomUUID + resp <- Client.batched( + post(endpointPath(recipeId)) + ) + yield assertTrue(resp.status == Status.Unauthorized) + }, + test("When authorized should get 201") { + for + user <- registerUser + + recipeId <- createCustomRecipe(user, Vector.empty) + + resp <- requestRecipePublication(user, recipeId) + yield assertTrue(resp.status == Status.Created) + }, + test("When requesting publication of recipe with public ingredients, pending request should be created") { + for + user <- registerUser + + n <- Gen.int(2, 8).runHead.some + ingredientIds <- createNPublicIngredients(n) + recipeId <- createCustomRecipe(user, ingredientIds) + + resp <- requestRecipePublication(user, recipeId) + + bodyStr <- resp.body.asString + requestId <- ZIO.fromOption(bodyStr.toUUID) + request <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.get(requestId)) + yield assertTrue(resp.status == Status.Created) + && assertTrue(request.is(_.some).recipeId == recipeId) + && assertTrue(request.is(_.some).status == DbPublicationRequestStatus.Pending) + }, + test("When requesting publication of recipe with no ingredients, pending request should be created") { + for + user <- registerUser + + recipeId <- createCustomRecipe(user, Vector.empty) + + resp <- requestRecipePublication(user, recipeId) + + bodyStr <- resp.body.asString + requestId <- ZIO.fromOption(bodyStr.toUUID) + request <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.get(requestId)) + yield assertTrue(resp.status == Status.Created) + && assertTrue(request.is(_.some).recipeId == recipeId) + && assertTrue(request.is(_.some).status == DbPublicationRequestStatus.Pending) + }, + test("""When requesting publication of recipe with published custom ingredients, + pending request should be created""") { + for + user <- registerUser + + n <- Gen.int(2, 8).runHead.some + ingredientIds <- ZIO.foreach(1 to n)(_ => for + ingredientId <- createCustomIngredient(user) + _ <- ZIO.serviceWithZIO[IngredientsRepo](_.publish(ingredientId)) + yield ingredientId) + recipeId <- createCustomRecipe(user, ingredientIds.toVector) + + resp <- requestRecipePublication(user, recipeId) + + bodyStr <- resp.body.asString + requestId <- ZIO.fromOption(bodyStr.toUUID) + request <- ZIO.serviceWithZIO[RecipePublicationRequestsRepo](_.get(requestId)) + yield assertTrue(resp.status == Status.Created) + && assertTrue(request.is(_.some).recipeId == recipeId) + && assertTrue(request.is(_.some).status == DbPublicationRequestStatus.Pending) + }, + test("""When requesting publication of recipe with private ingredients, + should get 400 Cannot publish recipe with private ingredients""") { + for + user <- registerUser + + n <- Gen.int(2, 8).runHead.some + publishedCustomIngredients <- ZIO.foreach(1 to n)(_ => for + ingredientId <- createCustomIngredient(user) + _ <- ZIO.serviceWithZIO[IngredientsRepo](_.publish(ingredientId)) + yield ingredientId) + m <- Gen.int(2, 8).runHead.some + publiсIngredients <- createNPublicIngredients(m) + + k <- Gen.int(2, 8).runHead.some + privateIngredients <- ZIO.foreach(1 to n)(_ => createCustomIngredient(user)) + + ingredientIds = publishedCustomIngredients ++ publiсIngredients ++ privateIngredients + + recipeId <- createCustomRecipe(user, ingredientIds.toVector) + + resp <- requestRecipePublication(user, recipeId) + + bodyStr <- resp.body.asString + error = decode[CannotPublishRecipeWithPrivateIngredients](bodyStr) + yield assertTrue(resp.status == Status.BadRequest) + && assertTrue(error.isRight) + && assertTrue(error.forall(_.ingredients hasSameElementsAs privateIngredients)) + }, + ).provideLayer(testLayer) + diff --git a/src/test/scala/integration/api/recipes/ingredients/AddIngredientToRecipeTests.scala b/src/test/scala/integration/api/recipes/ingredients/AddIngredientToRecipeTests.scala index 0cedcb82..63a7e7b2 100644 --- a/src/test/scala/integration/api/recipes/ingredients/AddIngredientToRecipeTests.scala +++ b/src/test/scala/integration/api/recipes/ingredients/AddIngredientToRecipeTests.scala @@ -43,8 +43,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser - recipeId <- createNIngredients(5) - .flatMap(createRecipe(user, _)) + recipeId <- createNPublicIngredients(5) + .flatMap(createCustomRecipe(user, _)) ingredientId <- createPublicIngredient resp <- addIngredientToRecipe(user, recipeId, ingredientId) @@ -56,8 +56,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: user <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) ingredientId <- createPublicIngredient resp <- addIngredientToRecipe(user, recipeId, ingredientId) @@ -76,8 +76,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: user <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) ingredientId <- createCustomIngredient(user) resp <- addIngredientToRecipe(user, recipeId, ingredientId) @@ -98,8 +98,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: user <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) resp <- addIngredientToRecipe(user, recipeId, ingredientId) @@ -118,8 +118,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: for otherUser <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(otherUser, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(otherUser, initialIngredients) ingredientId <- createPublicIngredient @@ -142,8 +142,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: for otherUser <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(otherUser, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(otherUser, initialIngredients) user <- registerUser ingredientId <- createCustomIngredient(user) @@ -164,8 +164,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: should get 403 cannot modify published recipe and ingredient should NOT be added to the recipe""") { for initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- registerUser.flatMap(createRecipe(_, initialIngredients)) + .flatMap(createNPublicIngredients) + recipeId <- registerUser.flatMap(createCustomRecipe(_, initialIngredients)) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) ingredientId <- createPublicIngredient @@ -188,8 +188,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: should get 403 cannot modify published recipe and ingredient should NOT be added to the recipe""") { for initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- registerUser.flatMap(createRecipe(_, initialIngredients)) + .flatMap(createNPublicIngredients) + recipeId <- registerUser.flatMap(createCustomRecipe(_, initialIngredients)) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) user <- registerUser @@ -212,8 +212,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) ingredientId <- createPublicIngredient @@ -235,8 +235,8 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) ingredientId <- createCustomIngredient(user) @@ -260,9 +260,9 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) + .flatMap(createNPublicIngredients) .map(_ :+ ingredientId) - recipeId <- createRecipe(user, initialIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) resp <- addIngredientToRecipe(user, recipeId, ingredientId) @@ -280,9 +280,9 @@ object AddIngredientToRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createCustomIngredient(user) initialIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) + .flatMap(createNPublicIngredients) .map(_ :+ ingredientId) - recipeId <- createRecipe(user, initialIngredients) + recipeId <- createCustomRecipe(user, initialIngredients) resp <- addIngredientToRecipe(user, recipeId, ingredientId) diff --git a/src/test/scala/integration/api/recipes/ingredients/RemoveIngredientFromRecipeTests.scala b/src/test/scala/integration/api/recipes/ingredients/RemoveIngredientFromRecipeTests.scala index 85072f89..98dd8039 100644 --- a/src/test/scala/integration/api/recipes/ingredients/RemoveIngredientFromRecipeTests.scala +++ b/src/test/scala/integration/api/recipes/ingredients/RemoveIngredientFromRecipeTests.scala @@ -46,8 +46,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, otherIngredientIds :+ ingredientId) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) yield assertTrue(resp.status == Status.NoContent) @@ -59,8 +59,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, otherIngredientIds :+ ingredientId) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) @@ -78,8 +78,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createCustomIngredient(user) otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, otherIngredientIds :+ ingredientId) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) @@ -96,8 +96,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient otherUser <- registerUser otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(otherUser, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(otherUser, otherIngredientIds :+ ingredientId) user <- registerUser @@ -118,8 +118,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: otherUser <- registerUser ingredientId <- createCustomIngredient(otherUser) otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(otherUser, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(otherUser, otherIngredientIds :+ ingredientId) user <- registerUser @@ -140,8 +140,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- registerUser.flatMap(createRecipe(_, otherIngredientIds :+ ingredientId)) + .flatMap(createNPublicIngredients) + recipeId <- registerUser.flatMap(createCustomRecipe(_, otherIngredientIds :+ ingredientId)) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) user <- registerUser @@ -163,8 +163,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: ingredientId <- createPublicIngredient user <- registerUser otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, otherIngredientIds :+ ingredientId) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) @@ -184,8 +184,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: user <- registerUser ingredientId <- createCustomIngredient(user) otherIngredientIds <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, otherIngredientIds :+ ingredientId) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, otherIngredientIds :+ ingredientId) _ <- ZIO.serviceWithZIO[RecipesRepo](_.publish(recipeId)) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) @@ -204,8 +204,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser initialRecipeIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialRecipeIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialRecipeIngredients) ingredientId <- getRandomUUID @@ -226,8 +226,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: user <- registerUser initialRecipeIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialRecipeIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialRecipeIngredients) resp <- removeIngredientFromRecipe(user, recipeId, ingredientId) @@ -243,8 +243,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser initialRecipeIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialRecipeIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialRecipeIngredients) ingredientId <- createPublicIngredient @@ -262,8 +262,8 @@ object RemoveIngredientFromRecipeTests extends ZIOIntegrationTestSpec: for user <- registerUser initialRecipeIngredients <- Gen.int(0, 5).runHead.some - .flatMap(createNIngredients) - recipeId <- createRecipe(user, initialRecipeIngredients) + .flatMap(createNPublicIngredients) + recipeId <- createCustomRecipe(user, initialRecipeIngredients) ingredientId <- createCustomIngredient(user) diff --git a/src/test/scala/integration/api/storages/GetAllStoragesTests.scala b/src/test/scala/integration/api/storages/GetAllStoragesTests.scala index 568bb384..12e44cc4 100644 --- a/src/test/scala/integration/api/storages/GetAllStoragesTests.scala +++ b/src/test/scala/integration/api/storages/GetAllStoragesTests.scala @@ -50,7 +50,6 @@ object GetAllStoragesTests extends ZIOIntegrationTestSpec: storages <- ZIO.fromEither(decode[Vector[StorageSummaryResp]](bodyStr)) yield assertTrue(resp.status == Status.Ok) && assertTrue(storages.map(_.name).hasSameElementsAs(storageNames)) - && assertTrue(storages.forall(_.ownerId == user.userId)) }, test("When authorized with membered storages should get 200 and all storages") { for diff --git a/src/test/scala/integration/api/storages/GetStorageSummaryTests.scala b/src/test/scala/integration/api/storages/GetStorageTests.scala similarity index 91% rename from src/test/scala/integration/api/storages/GetStorageSummaryTests.scala rename to src/test/scala/integration/api/storages/GetStorageTests.scala index 55fca6d3..1acd0534 100644 --- a/src/test/scala/integration/api/storages/GetStorageSummaryTests.scala +++ b/src/test/scala/integration/api/storages/GetStorageTests.scala @@ -1,6 +1,6 @@ package integration.api.storages -import api.storages.StorageSummaryResp +import api.storages.GetStorageResp import db.repositories.{StorageMembersRepo, StoragesRepo} import domain.StorageId import integration.common.Utils.* @@ -13,7 +13,7 @@ import zio.http.{Client, Status, URL, Path} import zio.test.{Gen, TestEnvironment, assertTrue, Spec} import zio.{Scope, ZIO, ZLayer} -object GetStorageSummaryTests extends ZIOIntegrationTestSpec: +object GetStorageTests extends ZIOIntegrationTestSpec: private def endpointPath(storageId: StorageId): URL = URL(Path.root / "storages" / storageId.toString) @@ -50,11 +50,10 @@ object GetStorageSummaryTests extends ZIOIntegrationTestSpec: ) bodyStr <- resp.body.asString - storage <- ZIO.fromEither(decode[StorageSummaryResp](bodyStr)) + storage <- ZIO.fromEither(decode[GetStorageResp](bodyStr)) yield assertTrue(resp.status == Status.Ok) - && assertTrue(storage.id == storageId) - && assertTrue(storage.name == storageName) && assertTrue(storage.ownerId == user.userId) + && assertTrue(storage.name == storageName) }, test("When authorized and user is a member of the storage should get 200 and the storage") { for @@ -73,11 +72,10 @@ object GetStorageSummaryTests extends ZIOIntegrationTestSpec: ) bodyStr <- resp.body.asString - storage <- ZIO.fromEither(decode[StorageSummaryResp](bodyStr)) + storage <- ZIO.fromEither(decode[GetStorageResp](bodyStr)) yield assertTrue(resp.status == Status.Ok) - && assertTrue(storage.id == storageId) - && assertTrue(storage.name == storageName) && assertTrue(storage.ownerId == creator.userId) + && assertTrue(storage.name == storageName) }, test("When authorized but user is neither the owner nor a member should get 404") { for diff --git a/src/test/scala/integration/common/Utils.scala b/src/test/scala/integration/common/Utils.scala index 725deb38..22e65d36 100644 --- a/src/test/scala/integration/common/Utils.scala +++ b/src/test/scala/integration/common/Utils.scala @@ -100,12 +100,13 @@ object Utils: ) ) - def createNIngredients(n: Int): ZIO[IngredientsRepo, InternalServerError, Vector[IngredientId]] = + def createNPublicIngredients(n: Int): + ZIO[IngredientsRepo, InternalServerError, Vector[IngredientId]] = ZIO.collectAll( (1 to n).map(_ => createPublicIngredient).toVector ) - def createRecipe(user: AuthenticatedUser, ingredientIds: Vector[IngredientId]): ZIO[ + def createCustomRecipe(user: AuthenticatedUser, ingredientIds: Vector[IngredientId]): ZIO[ RecipesRepo, InternalServerError | DbError, RecipeId