diff --git a/.changes/unreleased/Feature-20240208-123318.yaml b/.changes/unreleased/Feature-20240208-123318.yaml new file mode 100644 index 000000000..b883306e8 --- /dev/null +++ b/.changes/unreleased/Feature-20240208-123318.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Prevent social work to be saved when another user edited conccurently the social + work +time: 2024-02-08T12:33:18.319442398+01:00 +custom: + Issue: "115" diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php index f3982d0d5..9ee0d9e19 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/AbstractCRUDController.php @@ -15,10 +15,14 @@ use Chill\MainBundle\CRUD\Resolver\Resolver; use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; +use Doctrine\DBAL\LockMode; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\OptimisticLockException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -173,6 +177,21 @@ abstract class AbstractCRUDController extends AbstractController if (null === $e) { throw $this->createNotFoundException(sprintf('The object %s for id %s is not found', $this->getEntityClass(), $id)); } + if ($request->query->has('entity_version')) { + $expectedVersion = $request->query->getInt('entity_version'); + + try { + $manager = $this->getDoctrine()->getManagerForClass($this->getEntityClass()); + + if ($manager instanceof EntityManagerInterface) { + $manager->lock($e, LockMode::OPTIMISTIC, $expectedVersion); + } else { + throw new \LogicException('This manager does not allow locking.'); + } + } catch (OptimisticLockException $e) { + throw new ConflictHttpException('Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again', $e); + } + } return $e; } diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php index 78bdc96d5..a6675424e 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/ApiController.php @@ -135,7 +135,7 @@ class ApiController extends AbstractCRUDController try { $entity = $this->deserialize($action, $request, $_format, $entity); } catch (NotEncodableValueException $e) { - throw new BadRequestHttpException('invalid json', 400, $e); + throw new BadRequestHttpException('invalid json', $e, 400); } $errors = $this->validate($action, $request, $_format, $entity); @@ -153,7 +153,7 @@ class ApiController extends AbstractCRUDController return $response; } - $this->getDoctrine()->getManager()->flush(); + $this->getDoctrine()->getManagerForClass($this->getEntityClass())->flush(); $response = $this->onAfterFlush($action, $request, $_format, $entity, $errors); diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts index 17ac8879e..24f518ca3 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts @@ -55,11 +55,20 @@ export interface ServerExceptionInterface extends TransportExceptionInterface { body: string; } +export interface ConflictHttpExceptionInterface extends TransportExceptionInterface { + name: 'ConflictHttpException'; + violations: string[]; +} /** * Generic api method that can be adapted to any fetch request */ -export const makeFetch = (method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', url: string, body?: body | Input | null, options?: FetchParams): Promise => { +export const makeFetch = ( + method: 'POST'|'GET'|'PUT'|'PATCH'|'DELETE', + url: string, body?: body | Input | null, + options?: FetchParams +): Promise => { + let opts = { method: method, headers: { @@ -67,6 +76,7 @@ export const makeFetch = (method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL }, }; + if (body !== null && typeof body !== 'undefined') { Object.assign(opts, {body: JSON.stringify(body)}) } @@ -90,6 +100,10 @@ export const makeFetch = (method: 'POST'|'GET'|'PUT'|'PATCH'|'DEL throw AccessException(response); } + if (response.status === 409) { + throw ConflictHttpException(response); + } + throw { name: 'Exception', sta: response.status, @@ -220,3 +234,12 @@ const ServerException = (code: number, body: string): ServerExceptionInterface = return error; } + +const ConflictHttpException = (response: Response): ConflictHttpExceptionInterface => { + const error = {} as ConflictHttpExceptionInterface; + + error.name = 'ConflictHttpException'; + error.violations = ['Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again'] + + return error; +} diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWork.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWork.php index 77906b1ba..618918695 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWork.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodWork.php @@ -244,6 +244,15 @@ class AccompanyingPeriodWork implements AccompanyingPeriodLinkedWithSocialIssues */ private ?User $updatedBy = null; + /** + * @ORM\Column(type="integer", nullable=false, options={"default": 1}) + * + * @Serializer\Groups({"read", "accompanying_period_work:edit"}) + * + * @ORM\Version + */ + private int $version = 1; + public function __construct() { $this->goals = new ArrayCollection(); @@ -452,6 +461,18 @@ class AccompanyingPeriodWork implements AccompanyingPeriodLinkedWithSocialIssues return $this->updatedBy; } + public function getVersion(): int + { + return $this->version; + } + + public function setVersion(int $version): self + { + $this->version = $version; + + return $this; + } + public function removeAccompanyingPeriodWorkEvaluation(AccompanyingPeriodWorkEvaluation $evaluation): self { $this->accompanyingPeriodWorkEvaluations diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue index ad386992e..07b735dee 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue @@ -297,7 +297,7 @@ @go-to-generate-workflow="goToGenerateWorkflow" > - +
  • - +