src/Controller/CropController.php line 282

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\AlphabeetException;
  4. use App\DTO\ApplyEditDto;
  5. use App\DTO\CropLikeListDto;
  6. use App\DTO\ExtractSeedpackageInfoDto;
  7. use App\Entity\Crop;
  8. use App\Entity\Repository\CropRepository;
  9. use App\Entity\Repository\FavoriteCropRepository;
  10. use App\Entity\Repository\UserRepository;
  11. use App\Repository\ClimatezoneRepository;
  12. use App\Entity\User;
  13. use App\Enum\ModerationNotificationType;
  14. use App\Helper\AppversionHelper;
  15. use App\Helper\SettingConstants;
  16. use App\Queue\Message\TranslateCropMessage;
  17. use App\Service\CroplistService;
  18. use App\Service\CropService;
  19. use App\Service\CropSuggestionService;
  20. use App\Service\DynamicLinksService;
  21. use App\Service\GardenService;
  22. use App\Service\MauticService;
  23. use App\Service\ModerationNotificationService;
  24. use App\Service\NotificationService;
  25. use App\Service\OpenAiService;
  26. use App\Service\PatchService;
  27. use App\Service\PushNotificationService;
  28. use App\Structures\PushNotification;
  29. use OpenApi\Annotations as OA;
  30. use Psr\Log\LoggerInterface;
  31. use Symfony\Component\Cache\Adapter\FilesystemAdapter;
  32. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  33. use Symfony\Component\HttpFoundation\JsonResponse;
  34. use Symfony\Component\HttpFoundation\Request;
  35. use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
  36. use Symfony\Component\Messenger\MessageBusInterface;
  37. use Symfony\Component\RateLimiter\RateLimiterFactory;
  38. use Symfony\Contracts\Cache\ItemInterface;
  39. /**
  40.  * @OA\Tag(name="Crop")
  41.  */
  42. class CropController extends BaseController
  43. {
  44.     public function __construct(
  45.         private readonly CropRepository $cropRepository,
  46.         private readonly FavoriteCropRepository $favoriteCropRepository,
  47.         private readonly ClimatezoneRepository $climatezoneRepository,
  48.         private readonly CropService $cropService,
  49.         private readonly CroplistService $croplistService,
  50.         private readonly PatchService $patchService,
  51.         private readonly PushNotificationService $pushNotificationService,
  52.         private readonly ModerationNotificationService $moderationNotificationService,
  53.         private readonly LoggerInterface $logger,
  54.         private readonly DynamicLinksService $dynamicLinksService,
  55.         private readonly GardenService $gardenService,
  56.         private readonly MauticService $mauticService,
  57.         private readonly CropSuggestionService $cropSuggestionService,
  58.         private readonly UserRepository $userRepository,
  59.         private readonly OpenAiService $openAiService,
  60.         private readonly MessageBusInterface $bus
  61.     ) {
  62.     }
  63.     /**
  64.      * @OA\Get(
  65.      *     path="/api/v3/crops",
  66.      *     summary="Get a list of crops",
  67.      *     description="Retrieves a list of crops based on the provided filters and search query.",
  68.      *     @OA\Parameter(
  69.      *         name="filters[]",
  70.      *         in="query",
  71.      *         description="Filters to be applied on crops data. Multiple filters can be applied by repeating the `filters[]` parameter with different values. Possible filters: cropcategories, light, water, nutrients, parentCrop.",
  72.      *         required=false,
  73.      *         @OA\Schema(
  74.      *             type="array",
  75.      *             @OA\Items(type="string")
  76.      *         ),
  77.      *         style="form"
  78.      *     ),
  79.      *     @OA\Parameter(
  80.      *         name="order",
  81.      *         in="query",
  82.      *         description="The order in which the crops should be returned. Possible values: 'alpha', 'date', 'rating'.",
  83.      *         required=false,
  84.      *         @OA\Schema(
  85.      *             type="string"
  86.      *         ),
  87.      *         style="form"
  88.      *     ),
  89.      *     @OA\Parameter(
  90.      *         name="q",
  91.      *         in="query",
  92.      *         description="The search query to filter crops.",
  93.      *         required=false,
  94.      *         @OA\Schema(
  95.      *             type="string"
  96.      *         ),
  97.      *         style="form"
  98.      *     ),
  99.      *     @OA\Parameter(
  100.      *         name="fields",
  101.      *         in="query",
  102.      *         description="The fields to be returned for each crop. Possible values are all fields of the crop",
  103.      *         required=false,
  104.      *         @OA\Schema(
  105.      *             type="string"
  106.      *         ),
  107.      *         style="form"
  108.      *     ),
  109.      *     @OA\Response(
  110.      *         response=200,
  111.      *         description="Successful operation",
  112.      *         @OA\JsonContent(
  113.      *             @OA\Property(
  114.      *                 property="crops",
  115.      *                 type="array",
  116.      *                 description="The list of crops",
  117.      *                 @OA\Items(
  118.      *                     type="object",
  119.      *                     @OA\Property(
  120.      *                         property="id",
  121.      *                         type="integer",
  122.      *                         description="The crop ID"
  123.      *                     ),
  124.      *                 )
  125.      *             )
  126.      *         )
  127.      *     ),
  128.      *     @OA\Response(
  129.      *         response=403,
  130.      *         description="Forbidden",
  131.      *         @OA\JsonContent(
  132.      *             @OA\Property(
  133.      *                 property="message",
  134.      *                 type="string",
  135.      *                 description="The error message"
  136.      *             )
  137.      *         )
  138.      *     ),
  139.      *   )
  140.      * @param Request $request
  141.      * @return JsonResponse
  142.      */
  143.     public function listAction(Request $request): JsonResponse
  144.     {
  145.         $user $this->getUser();
  146.         $fields $request->get('fields');
  147.         $exclude $request->get('exclude');
  148.         $filters $request->get('filters');
  149.         $query $request->get('q');
  150.         // $order = $request->get('order');
  151.         if (!empty($fields)) {
  152.             $fields explode(','$fields);
  153.         }
  154.         // todo: remove flag handling after a few versions
  155.         $appVersion $request->headers->get('app-version');
  156.         if ($user === null || (!empty($appVersion) && AppversionHelper::hasRequiredVersion($appVersion,
  157.                     '3.6.10') === true)) {
  158.             $this->cropService->setFlag(CropService::FLAG_CACHED_CROPLIST);
  159.             $cacheStr '';
  160.             if ($query !== null) {
  161.                 $cacheStr .= '#' $query;
  162.             }
  163.             if ($filters !== null) {
  164.                 $cacheStr .= '#' implode(','$filters);
  165.             }
  166.             if ($fields !== null) {
  167.                 $cacheStr .= '#in#' implode(','$fields);
  168.             }
  169.             if($exclude !== null) {
  170.                 $cacheStr .= '#ex#' implode(','$exclude);
  171.             }
  172.             $locale = !empty($user) ? $user->getLocale() : $request->headers->get('Locale');
  173.             $cacheKey 'crops-list-' $locale '-' md5($cacheStr);
  174.             if ($user !== null && $user->getSetting(SettingConstants::SOUTHERN_HEMISPHERE) === true) {
  175.                 $cacheKey .= '-sh';
  176.             }
  177.             if ($user !== null && isset($user->getClimateSettings()['betaEnabled']) && $user->getClimateSettings()['betaEnabled'] === true
  178.                 && isset($user->getClimateSettings()['zoneId']) && !empty($user->getClimateSettings()['zoneId']))
  179.             {
  180.                 $cacheKey .= '-zone' $user->getClimateSettings()['zoneId'];
  181.             }
  182.             // cache result for 10 mins with filesystem adapter;
  183.             // apcu is too small for huge crop lists
  184.             $cacheAdapter = new FilesystemAdapter('crops');
  185.             $arrData $cacheAdapter->get($cacheKey,
  186.                 function (ItemInterface $item) use ($user$query$filters$fields$exclude) {
  187.                     $item->expiresAfter(600);
  188.                     return $this->cropService->fetchList($user$query$filters$fields$exclude);
  189.                 });
  190.         } else {
  191.             $arrData $this->cropService->fetchList($user$query$filters$fields$exclude);
  192.         }
  193.         return new JsonResponse(['crops' => $arrData]);
  194.     }
  195.     /**
  196.      * This call delivers user specific crop data, so we can cache the common crops call
  197.      * @return JsonResponse
  198.      */
  199.     public function listUserDataAction(): JsonResponse
  200.     {
  201.         $user $this->getUser();
  202.         if (empty($user)) {
  203.             return new JsonResponse('forbidden'403);
  204.         }
  205.         $data = [
  206.             'ownShared' => $this->cropService->getSharedCropsForUser($user),
  207.             'ownPrivate' => $this->cropService->getPrivateCropsForUser($user),
  208.             'rated' => $this->cropService->getRatedCropsForUser($user),
  209.             'changes' => $this->cropService->getCropChangesForUser($user),
  210.         ];
  211.         return new JsonResponse($data);
  212.     }
  213.     public function getUsageAction(Request $request)
  214.     {
  215.         /** @var User $user */
  216.         $user $this->getUser();
  217.         if (empty($user) || !$user->isSuperModerator()) {
  218.             return new JsonResponse('not authorized'403);
  219.         }
  220.         $cropId $request->get('cropId');
  221.         $usersUsingCrop $this->patchService->getCropUsage($cropId);
  222.         $response = new JsonResponse(['users' => $usersUsingCrop]);
  223.         return $response;
  224.     }
  225.     public function lockAction(Request $request)
  226.     {
  227.         /** @var User $user */
  228.         $user $this->getUser();
  229.         if (empty($user) || !$user->isSuperModerator()) {
  230.             return new JsonResponse('not authorized'403);
  231.         }
  232.         $cropId $request->get('cropId');
  233.         $usersUsingCrop $this->patchService->getCropUsage($cropId);
  234.         if ($usersUsingCrop 0) {
  235.             // require alternative crop
  236.             $newCropId $request->get('newCropId');
  237.             if (empty($newCropId)) {
  238.                 return new JsonResponse('new crop id missing'400);
  239.             }
  240.             $this->patchService->replaceCropInPatches($cropId$newCropId);
  241.             $gardenIds1 $this->patchService->replaceCropInPatchesWithPlants($cropId$newCropId);
  242.             $gardenIds2 $this->patchService->replaceVarietyInPatchesWithPlants($cropId$newCropId);
  243.             $gardenIds array_unique(array_merge($gardenIds1$gardenIds2));
  244.             foreach ($gardenIds as $gardenId) {
  245.                 $this->gardenService->markGardenAsUpdated($gardenId);
  246.             }
  247.         }
  248.         $crop $this->cropService->lockCrop($user$cropId);
  249.         $pn $this->pushNotificationService->createPushNotification(
  250.             $crop->getUser(),
  251.             NotificationService::NOTIFICATION_TYPE_RELEASE_REVERTED,
  252.             ['%variety%' => $crop->getName()]
  253.         );
  254.         $pn->setType(PushNotification::TYPE_VARIETY_EDIT);
  255.         $pn->setAdditionalData(['cropId' => $cropId]);
  256.         $this->pushNotificationService->sendPushNotification($crop->getUser(), $pn);
  257.         $response = new JsonResponse(['success' => true]);
  258.         return $response;
  259.     }
  260.     public function getAction(Request $request): JsonResponse
  261.     {
  262.         $user $this->getUser();
  263.         $cropId $request->get('cropId');
  264.         $arrData $this->cropService->fetchOne($user$cropId);
  265.         if (empty($arrData)) {
  266.             return new JsonResponse(null404);
  267.         }
  268.         return new JsonResponse(['crops' => $arrData]);
  269.     }
  270.     public function getVarietiesAction(Request $request): JsonResponse
  271.     {
  272.         $user $this->getUser();
  273.         $cropId $request->get('cropId');
  274.         $arrData $this->cropService->fetchOneWithVarieties($user$cropId);
  275.         if (empty($arrData)) {
  276.             return new JsonResponse(null404);
  277.         }
  278.         return new JsonResponse(['crops' => $arrData]);
  279.     }
  280.     public function getFavoritesAction(): JsonResponse
  281.     {
  282.         $user $this->getUser();
  283.         if (empty($user)) {
  284.             return new JsonResponse('forbidden'403);
  285.         }
  286.         $arrData $this->cropService->fetchFavorites($user);
  287.         return new JsonResponse(['favorites' => $arrData]);
  288.     }
  289.     public function likeAction(Request $request): JsonResponse
  290.     {
  291.         $user $this->getUser();
  292.         if (empty($user)) {
  293.             return new JsonResponse('not authorized'403);
  294.         }
  295.         $cropId $request->get('cropId');
  296.         $data json_decode($request->getContent(), true);
  297.         $listId $data['listId'] ?? null;
  298.         $success $this->croplistService->addCropToList($user$cropId$listId);
  299.         if ($success === false) {
  300.             return new JsonResponse('bad request'400);
  301.         }
  302.         return new JsonResponse(['success' => true]);
  303.     }
  304.     /**
  305.      * @OA\Post(
  306.      *     description="Update the list of favorites of a user. All currently favorized
  307.     crop ids that are not sent will be deleted if delete param is set.",
  308.      *     @OA\Response(
  309.      *         response=200,
  310.      *         description="List updated",
  311.      *     ),
  312.      *     @OA\Parameter(
  313.      *         name="body",
  314.      *         in="path",
  315.      *         required=true,
  316.      *         @OA\JsonContent(
  317.      *             type="object",
  318.      *             @OA\Property(property="cropIds", type="array", @OA\Items(type="number")),
  319.      *             @OA\Property(property="deleteExisting", type="boolean")
  320.      *         ),
  321.      *     )
  322.      *  )
  323.      * @param CropLikeListDto $dto
  324.      * @return JsonResponse
  325.      */
  326.     public function likeListAction(CropLikeListDto $dto): JsonResponse
  327.     {
  328.         if (empty($dto->user)) {
  329.             return new JsonResponse('forbidden'403);
  330.         }
  331.         if (empty($dto->cropIds) && !$dto->deleteExisting) {
  332.             return new JsonResponse('bad request'400);
  333.         }
  334.         // delete existing favorites
  335.         if ($dto->deleteExisting) {
  336.             $this->favoriteCropRepository->deleteExceptCropIds($dto->user->getId(), $dto->cropIds);
  337.         }
  338.         foreach ($dto->cropIds as $cropId) {
  339.             $crop $this->cropRepository->find($cropId);
  340.             if (!empty($crop)) {
  341.                 $this->favoriteCropRepository->addFavorite($dto->user$crop);
  342.             }
  343.         }
  344.         return new JsonResponse(['success' => true]);
  345.     }
  346.     public function dislikeAction(Request $request)
  347.     {
  348.         $user $this->getUser();
  349.         if (empty($user)) {
  350.             return new JsonResponse('not authorized'403);
  351.         }
  352.         $cropId $request->get('cropId');
  353.         $crop $this->cropRepository->find($cropId);
  354.         $fav $this->favoriteCropRepository->findOneBy(['user' => $user'crop' => $crop]);
  355.         if (!empty($fav)) {
  356.             $this->favoriteCropRepository->delete($fav);
  357.         }
  358.         return new JsonResponse(['success' => true]);
  359.     }
  360.     public function saveAction(Request $request): JsonResponse
  361.     {
  362.         $user $this->getUser();
  363.         if (empty($user)) {
  364.             return new JsonResponse('not authorized'403);
  365.         }
  366.         $json $request->getContent();
  367.         $data json_decode($jsontrue);
  368.         $isNewVariety null;
  369.         $isNewCrop null;
  370.         /** @var ?Crop $newCrop */
  371.         $newCrop null;
  372.         try {
  373.             $overrideLocale $request->headers->get('Locale');
  374.             $saveResult $this->cropService->saveCrop($data$user$overrideLocale);
  375.             $newCrop $saveResult['variety'];
  376.             if (empty($newCrop->getDynamicLink())) {
  377.                 $link $this->dynamicLinksService->createCropLink($newCrop);
  378.                 $newCrop->setDynamicLink($link);
  379.                 $this->cropRepository->save($newCrop);
  380.             }
  381.             if(empty($newCrop->getClimateZone())) {
  382.                 $parentCrop $newCrop->getParentCrop();
  383.                 if ($parentCrop !== null && !empty($parentCrop->getClimateZone())) {
  384.                     $newCrop->setClimateZone($parentCrop->getClimateZone());
  385.                 } else {
  386.                     $defaultZone $this->climatezoneRepository->findOneBy(['name' => '8a']);
  387.                     $newCrop->setClimateZone($defaultZone);
  388.                     $this->cropRepository->save($newCrop);
  389.                 }
  390.             }
  391.             $updatedByModerator $saveResult['updatedByModerator'];
  392.             $isNewVariety $saveResult['isNewVariety'];
  393.             $isNewCrop $saveResult['isNewCrop'];
  394.         } catch (AlphabeetException $e) {
  395.             if ($e->getCode()) {
  396.                 $this->logger->error($e->getMessage(), ['data' => $data]);
  397.                 return new JsonResponse(
  398.                     [
  399.                         'error' => [
  400.                             'code' => $e->getCode(),
  401.                             'description' => $e->getMessage()
  402.                         ]
  403.                     ], 400
  404.                 );
  405.             }
  406.         }
  407.         if ($newCrop->getUser() !== null) {
  408.             if ($updatedByModerator && !$isNewCrop) {
  409.                 $pn $this->pushNotificationService->createPushNotification(
  410.                     $newCrop->getUser(),
  411.                     NotificationService::NOTIFICATION_TYPE_VARIETY_EDITED,
  412.                     ['%variety%' => $newCrop->getName()]
  413.                 );
  414.                 $pn->setType(PushNotification::TYPE_VARIETY_EDIT);
  415.                 $pn->setAdditionalData(['cropId' => $newCrop->getId()]);
  416.                 $this->pushNotificationService->sendPushNotification($newCrop->getUser(), $pn);
  417.             } // check if crop was edited -> if yes, create push notification
  418.             else {
  419.                 if ($newCrop->getIsShared() && $user->getId() !== $newCrop->getUser()->getId()) {
  420.                     $pn $this->pushNotificationService->createPushNotification(
  421.                         $newCrop->getUser(),
  422.                         NotificationService::NOTIFICATION_TYPE_EDIT_SUGGESTED,
  423.                         ['%variety%' => $newCrop->getName()]
  424.                     );
  425.                     $pn->setType(PushNotification::TYPE_VARIETY_EDIT);
  426.                     $pn->setAdditionalData(['cropId' => $newCrop->getId()]);
  427.                     $this->pushNotificationService->sendPushNotification($newCrop->getUser(), $pn);
  428.                 }
  429.             }
  430.         }
  431.         if ($isNewVariety) {
  432.             $this->moderationNotificationService->createNotification(
  433.                 ModerationNotificationType::VarietyCreated->value,
  434.                 ['cropId' => $newCrop->getId(), 'userId' => $user->getId()]
  435.             );
  436.         } elseif ($isNewCrop) {
  437.             $notificationMetadata = [
  438.                 'cropId' => $newCrop->getId(),
  439.                 'userId' => $user->getId(),
  440.             ];
  441.             // check if crop was on suggestion list and if so, create moderation notification +
  442.             // push notifications + emails for users that wished for that crop
  443.             if (isset($data['cropSuggestionId'])) {
  444.                 $wishedByUserIds $this->cropSuggestionService->deleteAndGetUserIds($data['cropSuggestionId']);
  445.                 $notificationMetadata['wishedByUserIds'] = $wishedByUserIds;
  446.                 foreach ($wishedByUserIds as $userId) {
  447.                     $wishingUser $this->userRepository->find($userId);
  448.                     $pn $this->pushNotificationService->createPushNotification(
  449.                         $wishingUser,
  450.                         NotificationService::NOTIFICATION_TYPE_CROP_CREATED,
  451.                         [
  452.                             '%crop_name%' => $newCrop->getName(),
  453.                             '%moderator_name%' => $newCrop->getUser()?->getDisplayName()
  454.                         ]);
  455.                     $this->pushNotificationService->sendPushNotification($wishingUser$pn);
  456.                     $this->mauticService->sendCropCreatedEmail($wishingUser$newCrop);
  457.                 }
  458.             }
  459.             $this->moderationNotificationService->createNotification(
  460.                 ModerationNotificationType::CropCreated->value,
  461.                 $notificationMetadata
  462.             );
  463.         }
  464.         if ($isNewCrop || $isNewVariety) {
  465.             // todo: add more locales if needed
  466.             if ($user->getLocale() === 'en') {
  467.                 $this->bus->dispatch(new TranslateCropMessage($newCrop->getId(), 'de'));
  468.             } else {
  469.                 $this->bus->dispatch(new TranslateCropMessage($newCrop->getId(), 'en'));
  470.             }
  471.         }
  472.         $crops = [$newCrop];
  473.         $arrData $this->cropService->prepareData($crops$user);
  474.         foreach ($arrData as &$crop) {
  475.             $parentCrop $newCrop->getParentCrop();
  476.             if (!empty($parentCrop)) {
  477.                 $crop['parentCrop'] = $this->cropService->prepareData([$parentCrop], $user)[0];
  478.             }
  479.         }
  480.         return new JsonResponse(['success' => true'crops' => $arrData]);
  481.     }
  482.     public function deleteAction(Request $request)
  483.     {
  484.         $user $this->getUser();
  485.         if (empty($user)) {
  486.             return new JsonResponse('not authorized'403);
  487.         }
  488.         $cropId $request->get('cropId');
  489.         $success $this->cropService->deleteCrop($user$cropId);
  490.         if (!$success) {
  491.             return new JsonResponse(['success' => false], 400);
  492.         }
  493.         return new JsonResponse(['id' => $cropId]);
  494.     }
  495.     public function imageAction(Request $request)
  496.     {
  497.         $user $this->getUser();
  498.         if (empty($user)) {
  499.             return new JsonResponse('not authorized'403);
  500.         }
  501.         $cropId $request->get('cropId');
  502.         $filePath $this->cropService->getImagePath($user$cropId);
  503.         if ($filePath === false) {
  504.             return new JsonResponse('not found'404);
  505.         }
  506.         return new BinaryFileResponse($filePath200);;
  507.     }
  508.     public function rateAction(Request $request)
  509.     {
  510.         $user $this->getUser();
  511.         if (empty($user)) {
  512.             return new JsonResponse('forbidden'403);
  513.         }
  514.         $cropId $request->get('cropId');
  515.         $data json_decode($request->getContent(), true);
  516.         $comment = isset($data['comment']) ? $data['comment'] : '';
  517.         $rating $data['rating'];
  518.         $result $this->cropService->rateCrop($user$cropId$rating$comment);
  519.         if ($result === false) {
  520.             return new JsonResponse('bad_request'400);
  521.         }
  522.         return new JsonResponse(
  523.             [
  524.                 'success' => true,
  525.                 'cropId' => $cropId,
  526.                 'rating' => $result['rating'],
  527.                 'ratingCount' => $result['count']
  528.             ]
  529.         );
  530.     }
  531.     public function applyEditAction(ApplyEditDto $dto): JsonResponse
  532.     {
  533.         $user $this->getUser();
  534.         if (empty($user) || !$user->isModerator()) {
  535.             return new JsonResponse('forbidden'403);
  536.         }
  537.         $result $this->cropService->applyEdit($user$dto->editId$dto->changeset);
  538.         if ($result === false) {
  539.             return new JsonResponse('bad_request'400);
  540.         }
  541.         /** @var Crop $crop */
  542.         $crop $result['crop'];
  543.         /** @var User $editUser */
  544.         $editUser $result['editUser'];
  545.         if ($result['isApplied'] === false) {
  546.             return new JsonResponse(["success" => true]);
  547.         }
  548.         if ($result['isApproval'] === true) {
  549.             $pn $this->pushNotificationService->createPushNotification(
  550.                 $crop->getUser(),
  551.                 NotificationService::NOTIFICATION_TYPE_VARIETY_RELEASED,
  552.                 ['%variety%' => $crop->getName()]
  553.             );
  554.             $pn->setType(PushNotification::TYPE_VARIETY);
  555.             $pn->setAdditionalData(['cropId' => $crop->getId()]);
  556.             $this->pushNotificationService->sendPushNotification($crop->getUser(), $pn);
  557.             $this->moderationNotificationService->createNotification(
  558.                 ModerationNotificationType::VarietyApproved->value,
  559.                 ['cropId' => $crop->getId(), 'userId' => $user->getId()]
  560.             );
  561.         } else {
  562.             // notify creator of edit that her edit was applied via push
  563.             $pn $this->pushNotificationService->createPushNotification(
  564.                 $editUser,
  565.                 NotificationService::NOTIFICATION_TYPE_EDIT_APPROVED,
  566.                 ['%variety%' => $crop->getName()]
  567.             );
  568.             $pn->setType(PushNotification::TYPE_VARIETY);
  569.             $pn->setAdditionalData(['cropId' => $crop->getId()]);
  570.             $this->pushNotificationService->sendPushNotification($editUser$pn);
  571.         }
  572.         // check if edit-user should be granted moderation status
  573.         if (!$editUser->isModerator()) {
  574.             $varieties $this->cropRepository->getCreatedAndApprovedCountForUser($editUser);
  575.             $edits $this->cropRepository->getApprovedEditsForUser($editUser);
  576.             if ($varieties $edits >= User::MODERATION_TARGET) {
  577.                 $alreadyInvited $editUser->getSetting(SettingConstants::INVITED_TO_MODERATION);
  578.                 if (!$alreadyInvited) {
  579.                     $this->moderationNotificationService->createNotification(
  580.                         ModerationNotificationType::ModerationInvite->value,
  581.                         [],
  582.                         $editUser
  583.                     );
  584.                     $pn $this->pushNotificationService->createPushNotification(
  585.                         $crop->getUser(),
  586.                         NotificationService::NOTIFICATION_TYPE_MODERATION_INVITE
  587.                     );
  588.                     $pn->setType(PushNotification::TYPE_SCREEN);
  589.                     $pn->setView(PushNotification::VIEW_PLANTS_STACK);
  590.                     $pn->setViewParams(['screen' => PushNotification::SCREEN_MODERATION_NOTIFICATIONS]);
  591.                     $pn->setAdditionalData(['cropId' => $crop->getId()]);
  592.                     $this->pushNotificationService->sendPushNotification($editUser$pn,
  593.                         600); // pn is delayed 10 minutes
  594.                     $this->mauticService->sendModerationInviteMail($editUser);
  595.                 }
  596.             }
  597.         }
  598.         return new JsonResponse(["success" => true]);
  599.     }
  600.     public function declineEditAction(Request $request): JsonResponse
  601.     {
  602.         $user $this->getUser();
  603.         if (empty($user)) {
  604.             return new JsonResponse('forbidden'403);
  605.         }
  606.         $editId $request->get('editId');
  607.         $data json_decode($request->getContent(), true);
  608.         $feedback = isset($data['feedback']) ? $data['feedback'] : null;
  609.         $result $this->cropService->declineEdit($user$editId$feedback);
  610.         if ($result === false) {
  611.             return new JsonResponse('bad_request'400);
  612.         }
  613.         if ($result['isApplied'] === false) {
  614.             return new JsonResponse(['success' => true]);
  615.         }
  616.         /** @var Crop $crop */
  617.         $crop $result['crop'];
  618.         /** @var User $editUser */
  619.         $editUser $result['editUser'];
  620.         // if sharing of a crop was declined, send push notification
  621.         if ($result['isApproval'] === true) {
  622.             if ($crop->getParentCrop() === null) {
  623.                 $this->moderationNotificationService->createNotification(
  624.                     ModerationNotificationType::CropDeclined->value,
  625.                     ['cropId' => $crop->getId(), 'userId' => $user->getId()],
  626.                     $crop->getUser()
  627.                 );
  628.             } else {
  629.                 $this->moderationNotificationService->createNotification(
  630.                     ModerationNotificationType::VarietyDeclined->value,
  631.                     ['cropId' => $crop->getId(), 'userId' => $user->getId()],
  632.                     $crop->getUser()
  633.                 );
  634.             }
  635.             $pn $this->pushNotificationService->createPushNotification(
  636.                 $crop->getUser(),
  637.                 NotificationService::NOTIFICATION_TYPE_VARIETY_DECLINED,
  638.                 ['%variety%' => $crop->getName()]
  639.             );
  640.             $pn->setType(PushNotification::TYPE_SCREEN);
  641.             $pn->setView(PushNotification::VIEW_PLANTS_STACK);
  642.             $pn->setViewParams(['screen' => PushNotification::SCREEN_MODERATION_NOTIFICATIONS]);
  643.             $pn->setAdditionalData(['cropId' => $crop->getId()]);
  644.             $this->pushNotificationService->sendPushNotification($crop->getUser(), $pn);
  645.         } else {
  646.             $this->moderationNotificationService->createNotification(
  647.                 ModerationNotificationType::EditDeclined->value,
  648.                 ['cropId' => $crop->getId(), 'userId' => $user->getId()],
  649.                 $editUser
  650.             );
  651.             // notify creator of edit that her edit was declined via push
  652.             $pn $this->pushNotificationService->createPushNotification(
  653.                 $editUser,
  654.                 NotificationService::NOTIFICATION_TYPE_SUGGESTION_DECLINED,
  655.                 ['%variety%' => $crop->getName()]
  656.             );
  657.             $pn->setType(PushNotification::TYPE_SCREEN);
  658.             $pn->setView(PushNotification::VIEW_PLANTS_STACK);
  659.             $pn->setViewParams(['screen' => PushNotification::SCREEN_MODERATION_NOTIFICATIONS]);
  660.             $pn->setAdditionalData(['cropId' => $crop->getId()]);
  661.             $this->pushNotificationService->sendPushNotification($editUser$pn);
  662.         }
  663.         return new JsonResponse(['success' => true]);
  664.     }
  665.     public function listEditsAction(Request $request): JsonResponse
  666.     {
  667.         /** @var User $user */
  668.         $user $this->getUser();
  669.         if (empty($user)) {
  670.             return new JsonResponse('forbidden'403);
  671.         }
  672.         $cropId $request->get('cropId');
  673.         $applied $request->get('applied');
  674.         if (!$applied) {
  675.             $edits $this->cropService->fetchOpenEdits($user$cropId);
  676.         } else {
  677.             $edits $this->cropService->fetchAppliedEdits($user$cropId);
  678.         }
  679.         return new JsonResponse(['cropedits' => $edits]);
  680.     }
  681.     public function listChangesetsAction(Request $request): JsonResponse
  682.     {
  683.         $user $this->getUser();
  684.         if (empty($user)) {
  685.             return new JsonResponse('forbidden'403);
  686.         }
  687.         if (!$user->isModerator()) {
  688.             return new JsonResponse(['changesets' => []]);
  689.         }
  690.         $applied $request->get('applied');
  691.         if (!$applied) {
  692.             $changesets $this->cropService->fetchOpenChangesets($user);
  693.         } else {
  694.             $changesets $this->cropService->fetchAppliedChangesets($user);
  695.         }
  696.         return new JsonResponse(['changesets' => $changesets]);
  697.     }
  698.     public function listApprovalsAction(Request $request): JsonResponse
  699.     {
  700.         /** @var User $user */
  701.         $user $this->getUser();
  702.         if (empty($user) || !$user->isModerator()) {
  703.             return new JsonResponse('forbidden'403);
  704.         }
  705.         $page $request->get('p') ?? 1;
  706.         $edits $this->cropService->fetchOpenApprovals($user$page);
  707.         return new JsonResponse(['approvals' => $edits]);
  708.     }
  709.     public function listBestRatedCropsAction(): JsonResponse
  710.     {
  711.         $user $this->getUser();
  712.         if (empty($user)) {
  713.             return new JsonResponse('forbidden'403);
  714.         }
  715.         $crops $this->cropService->fetchBestRatedCrops($user);
  716.         return new JsonResponse(['crops' => $crops]);
  717.     }
  718.     public function listNewCropsAction(): JsonResponse
  719.     {
  720.         $user $this->getUser();
  721.         if (empty($user)) {
  722.             return new JsonResponse('forbidden'403);
  723.         }
  724.         $crops $this->cropService->fetchNewCrops($user);
  725.         return new JsonResponse(['crops' => $crops]);
  726.     }
  727.     public function listIncompleteCropsAction(Request $request): JsonResponse
  728.     {
  729.         $user $this->getUser();
  730.         if (empty($user)) {
  731.             return new JsonResponse('forbidden'403);
  732.         }
  733.         $page $request->get('p') ?? 1;
  734.         $crops $this->cropService->fetchIncompleteCrops($user$page);
  735.         return new JsonResponse(['crops' => $crops]);
  736.     }
  737.     public function getUpdateDateAction(): JsonResponse
  738.     {
  739.         $user $this->getUser();
  740.         if (empty($user)) {
  741.             $lastUpdateDate = new \DateTime();
  742.         } else {
  743.             $lastUpdateDate $this->cropService->getUpdateDateForUser($user);
  744.         }
  745.         return new JsonResponse(['last_update' => $lastUpdateDate->format(DATE_ATOM)]);
  746.     }
  747.     /**
  748.      * @OA\Get(
  749.      *     summary="Get all crops that have been changed since a given date",
  750.      *     @OA\Parameter(
  751.      *     name="since",
  752.      *     in="query",
  753.      *     description="Date in ISO 8601 format",
  754.      *     required=true,
  755.      *     @OA\Schema(
  756.      *     type="string",
  757.      *     format="date-time"
  758.      *    )
  759.      *  ),
  760.      * )
  761.      * @param Request $request
  762.      * @return JsonResponse
  763.      */
  764.     public function getChangedCropsSinceAction(Request $request): JsonResponse
  765.     {
  766.         $user $this->getUser();
  767.         $lastUpdate $request->get('since');
  768.         if (empty($lastUpdate)) {
  769.             return new JsonResponse('bad_request'400);
  770.         }
  771.         // ensure that the + sign in the timezone is not lost due to url encoding issues
  772.         $lastUpdate str_replace(' ''+'$lastUpdate);
  773.         $datetime = new \DateTime($lastUpdate);
  774.         $datetime->setTimezone(new \DateTimeZone(date_default_timezone_get()));
  775.         $crops $this->cropService->getChangedCropsSince($user$datetime);
  776.         return new JsonResponse(['crops' => $crops]);
  777.     }
  778.     public function extractSeedpackageDataAction(
  779.         ExtractSeedpackageInfoDto $dto,
  780.         RateLimiterFactory $openaiLimiter
  781.     ): JsonResponse {
  782.         if (empty($dto->user)) {
  783.             return new JsonResponse('forbidden'403);
  784.         }
  785.         $limiter $openaiLimiter->create('user-' $dto->user->getId());
  786.         if (false === $limiter->consume()->isAccepted()) {
  787.             throw new TooManyRequestsHttpException();
  788.         }
  789.         $images = [
  790.             $dto->frontImageBase64,
  791.             $dto->backImageBase64
  792.         ];
  793.         $result $this->openAiService->extractDataFromSeedPackageImages(
  794.             $images,
  795.             $dto->user->getLocale() ?? User::DEFAULT_LOCALE
  796.         );
  797.         return new JsonResponse(['crop' => $result]);
  798.     }
  799. }