From c0a8a9ab821010fcc1065f734676cab12a2b8bc3 Mon Sep 17 00:00:00 2001 From: Charles Date: Wed, 18 Feb 2026 15:38:52 +0100 Subject: [PATCH] set up edit for project in api --- assets/controllers/project_controller.js | 40 +++++--- assets/icons/bi/calendar.svg | 1 + assets/icons/bi/input-cursor-text.svg | 1 + assets/icons/bi/trash3.svg | 1 + .../picture-in-picture-outline-rounded.svg | 1 + migrations/Version20260218111608.php | 32 +++++++ migrations/Version20260218111821.php | 36 ++++++++ src/Controller/ProjectController.php | 91 ++++++++++++++----- src/Entity/Project.php | 62 ++++++++++++- src/Service/ProjectService.php | 45 ++++++++- src/Service/SSO/ProjectService.php | 55 ++++++++--- templates/organization/show.html.twig | 85 +++++++++++++---- 12 files changed, 383 insertions(+), 67 deletions(-) create mode 100644 assets/icons/bi/calendar.svg create mode 100644 assets/icons/bi/input-cursor-text.svg create mode 100644 assets/icons/bi/trash3.svg create mode 100644 assets/icons/material-symbols/picture-in-picture-outline-rounded.svg create mode 100644 migrations/Version20260218111608.php create mode 100644 migrations/Version20260218111821.php diff --git a/assets/controllers/project_controller.js b/assets/controllers/project_controller.js index bcf2e12..6b8cc40 100644 --- a/assets/controllers/project_controller.js +++ b/assets/controllers/project_controller.js @@ -10,7 +10,7 @@ export default class extends Controller { orgId: Number, admin: Boolean } - static targets = ["modal", "appList", "nameInput", "formTitle"]; + static targets = ["modal", "appList", "nameInput", "formTitle", "timestampSelect", "deletionSelect"]; connect(){ if(this.listProjectValue){ this.table(); @@ -137,35 +137,44 @@ export default class extends Controller { async submitForm(event) { event.preventDefault(); - const formData = new FormData(event.target); + const form = event.target; + const formData = new FormData(form); // This automatically picks up the 'logo' file - const payload = { - organizationId: this.orgIdValue, - applications: formData.getAll('applications[]') - }; - - // Only include name if it wasn't disabled (new projects) - if (!this.nameInputTarget.disabled) { - payload.name = formData.get('name'); + // 1. Validate File Format + const logoFile = formData.get('logo'); + if (logoFile && logoFile.size > 0) { + const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg']; + if (!allowedTypes.includes(logoFile.type)) { + alert("Format invalide. Veuillez utiliser uniquement des fichiers PNG ou JPG."); + return; // Stop submission + } } + // 2. Prepare for Multipart sending + // Since we are using FormData, we don't need JSON.stringify or 'Content-Type': 'application/json' + // We add the extra fields to the formData object + formData.append('organizationId', this.orgIdValue); + const url = this.currentProjectId ? `/project/edit/${this.currentProjectId}/ajax` : `/project/new/ajax`; const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + // IMPORTANT: Do NOT set Content-Type header when sending FormData with files + // The browser will set 'multipart/form-data' and the boundary automatically + body: formData }); if (response.ok) { this.modal.hide(); - // Use Tabulator's setData() instead of reload() for better UX if possible location.reload(); } else { + const result = await response.json(); if (response.status === 409) { - alert("Un projet avec ce nom existe déjà. Veuillez choisir un nom différent."); + alert("Un projet avec ce nom existe déjà."); + } else { + alert(result.error || "Une erreur est survenue."); } } } @@ -188,6 +197,9 @@ export default class extends Controller { // 3. Set the name this.nameInputTarget.value = project.name; + console.log(project); + this.timestampSelectTarget.value = project.timestampPrecision; + this.deletionSelectTarget.value = project.deletionAllowed; // 4. Check the boxes // We look for all checkboxes inside our appList target diff --git a/assets/icons/bi/calendar.svg b/assets/icons/bi/calendar.svg new file mode 100644 index 0000000..01b8c56 --- /dev/null +++ b/assets/icons/bi/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/bi/input-cursor-text.svg b/assets/icons/bi/input-cursor-text.svg new file mode 100644 index 0000000..f2e716b --- /dev/null +++ b/assets/icons/bi/input-cursor-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/bi/trash3.svg b/assets/icons/bi/trash3.svg new file mode 100644 index 0000000..de8e5aa --- /dev/null +++ b/assets/icons/bi/trash3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg b/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg new file mode 100644 index 0000000..d89ffef --- /dev/null +++ b/assets/icons/material-symbols/picture-in-picture-outline-rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/migrations/Version20260218111608.php b/migrations/Version20260218111608.php new file mode 100644 index 0000000..9796e97 --- /dev/null +++ b/migrations/Version20260218111608.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE project ADD logo VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP logo'); + } +} diff --git a/migrations/Version20260218111821.php b/migrations/Version20260218111821.php new file mode 100644 index 0000000..04631cd --- /dev/null +++ b/migrations/Version20260218111821.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE project ADD timestamp_precision VARCHAR(10) DEFAULT NULL'); + $this->addSql('ALTER TABLE project ADD deletion_allowed BOOLEAN DEFAULT NULL'); + $this->addSql('ALTER TABLE project ADD audits_enabled BOOLEAN DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE project DROP timestamp_precision'); + $this->addSql('ALTER TABLE project DROP deletion_allowed'); + $this->addSql('ALTER TABLE project DROP audits_enabled'); + } +} diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index f1a9fa8..fd98427 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -44,39 +44,66 @@ final class ProjectController extends AbstractController public function new(Request $request): JsonResponse { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); - if (!$data) { - return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); - } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); - if(!$org) { - return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); + + $orgId = $request->request->get('organizationId'); + $name = $request->request->get('name'); + $applications = $request->request->all('applications') ?? []; // Expects applications[] from JS + + $org = $this->organizationsRepository->find($orgId); + if (!$org) { + return new JsonResponse(['error' => 'Organization not found'], 404); } - $sanitizedDbName = $this->projectService->getProjectDbName($data['name'], $org->getProjectPrefix()); - if($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) { - return new JsonResponse(['error' => 'A project with the same name already exists'], Response::HTTP_CONFLICT); + + // 2. Handle File Upload + $logoFile = $request->files->get('logo'); + $logoPath = null; + + + + // 3. Project Creation + $sanitizedDbName = $this->projectService->getProjectDbName($name, $org->getProjectPrefix()); + if ($this->projectRepository->findOneBy(['bddName' => $sanitizedDbName])) { + return new JsonResponse(['error' => 'A project with the same name already exists'], 409); } - if(!$this->projectService->isApplicationArrayValid($data['applications'])) { - return new JsonResponse(['error' => 'Invalid applications array'], Response::HTTP_BAD_REQUEST); + if ($logoFile) { + $logoPath = $this->projectService->handleLogoUpload($logoFile, $sanitizedDbName); } $project = new Project(); - $project->setName($data['name']); + $project->setName($name); $project->setBddName($sanitizedDbName); $project->setOrganization($org); - $project->setApplications($data['applications']); + $project->setApplications($applications); + + $project->setTimestampPrecision($request->request->get('timestamp')); + $project->setDeletionAllowed($request->request->getBoolean('deletion')); + + if ($logoPath) { + $project->setLogo($logoPath); + } + $this->entityManager->persist($project); - $this->SSOProjectService->createRemoteProject('http://api.solutions-easy.moi', $project); - $this->entityManager->flush(); - return new JsonResponse(['message' => 'Project created successfully'], Response::HTTP_CREATED); + $this->entityManager->flush(); //On met le flush avant parce qu'on a besoin de l'ID du projet pour la création distante. + //Oui ducoup c'est chiant parce que le projet est crée même s'il y a une erreur API, mais OH ffs at that point. + // Remote creation logic + try { + $this->SSOProjectService->createRemoteProject('http://api.solutions-easy.moi', $project); + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500); + } + + + + return new JsonResponse(['message' => 'Project created successfully'], 201); } #[Route(path:'/edit/{id}/ajax', name: '_edit', methods: ['POST'])] public function edit(Request $request, int $id): JsonResponse { $this->denyAccessUnlessGranted('ROLE_SUPER_ADMIN'); - $data = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); - if (!$data) { - return new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); - } $org = $this->organizationsRepository->findOneBy(['id' => $data['organizationId']]); + $orgId = $request->request->get('organizationId'); + $applications = $request->request->all('applications') ?? []; // Expects applications[] from JS + + $org = $this->organizationsRepository->findOneBy(['id' => $orgId]); if(!$org) { return new JsonResponse(['error' => 'Organization not found'], Response::HTTP_NOT_FOUND); } @@ -84,9 +111,29 @@ final class ProjectController extends AbstractController if(!$project) { return new JsonResponse(['error' => 'Project not found'], Response::HTTP_NOT_FOUND); } - $project->setApplications($data['applications']); + $logoFile = $request->files->get('logo'); + $logoPath = null; + + if ($logoFile) { + $logoPath = $this->projectService->handleLogoUpload($logoFile, $project->getBddName()); + } + + $project->setApplications($applications); $project->setModifiedAt(new \DateTimeImmutable()); + $project->setTimestampPrecision($request->request->get('timestamp')); + $project->setDeletionAllowed($request->request->getBoolean('deletion')); + if ($logoPath) { + $project->setLogo($logoPath); + } + $this->entityManager->persist($project); + // Remote editing logic + try { + $this->SSOProjectService->editRemoteProject('http://api.solutions-easy.moi', $project); + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Remote creation failed: ' . $e->getMessage()], 500); + } + $this->entityManager->flush(); return new JsonResponse(['message' => 'Project updated successfully'], Response::HTTP_OK); } @@ -148,6 +195,8 @@ final class ProjectController extends AbstractController 'id' => $project->getId(), 'name' => ucfirst($project->getName()), 'applications' => $project->getApplications(), + 'timestampPrecision'=> $project->getTimestampPrecision(), + 'deletionAllowed' => $project->isDeletionAllowed(), ]); } diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 1512564..d19607a 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -35,9 +35,21 @@ class Project #[ORM\Column] private ?bool $isDeleted = null; - #[ORM\Column(length: 255, nullable: true)] + #[ORM\Column(length: 255)] private ?string $bddName = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $logo = null; + + #[ORM\Column(length: 10)] + private ?string $timestampPrecision = null; + + #[ORM\Column()] + private ?bool $deletionAllowed = null; + + #[ORM\Column(nullable: true)] + private ?bool $auditsEnabled = null; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -146,4 +158,52 @@ class Project return $this; } + + public function getLogo(): ?string + { + return $this->logo; + } + + public function setLogo(?string $logo): static + { + $this->logo = $logo; + + return $this; + } + + public function getTimestampPrecision(): ?string + { + return $this->timestampPrecision; + } + + public function setTimestampPrecision(?string $timestampPrecision): static + { + $this->timestampPrecision = $timestampPrecision; + + return $this; + } + + public function isDeletionAllowed(): ?bool + { + return $this->deletionAllowed; + } + + public function setDeletionAllowed(?bool $deletionAllowed): static + { + $this->deletionAllowed = $deletionAllowed; + + return $this; + } + + public function isAuditsEnabled(): ?bool + { + return $this->auditsEnabled; + } + + public function setAuditsEnabled(?bool $auditsEnabled): static + { + $this->auditsEnabled = $auditsEnabled; + + return $this; + } } diff --git a/src/Service/ProjectService.php b/src/Service/ProjectService.php index 11e8d4f..9663c15 100644 --- a/src/Service/ProjectService.php +++ b/src/Service/ProjectService.php @@ -3,12 +3,16 @@ namespace App\Service; use App\Repository\AppsRepository; +use App\Service\LoggerService; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\String\Slugger\AsciiSlugger; class ProjectService{ - public function __construct(private readonly AppsRepository $appsRepository) + + public function __construct(private readonly AppsRepository $appsRepository, private readonly Security $security, private readonly LoggerService $loggerService) { } @@ -40,4 +44,43 @@ class ProjectService{ } return true; } + + public function handleLogoUpload($logoFile, $projectBddName): ?string + { + // 1. Define the destination directory (adjust path as needed, e.g., 'public/uploads/profile_pictures') + $destinationDir = 'uploads/project_logos'; + + // 2. Create the directory if it doesn't exist + if (!file_exists($destinationDir)) { + // 0755 is the standard permission (Owner: read/write/exec, Others: read/exec) + if (!mkdir($destinationDir, 0755, true) && !is_dir($destinationDir)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $destinationDir)); + } + } + + $extension = $logoFile->guessExtension(); + + // Sanitize the filename to remove special characters/spaces to prevent filesystem errors + $customFilename = $projectBddName . '.' . $extension; + + try { + // 4. Move the file to the destination directory + $logoFile->move($destinationDir, $customFilename); + + // 5. Update the user entity with the relative path + // Ensure you store the path relative to your public folder usually + return $destinationDir . '/' . $customFilename; + + } catch (\Exception $e) { + // 6. Log the critical error as requested + $this->loggerService->logError('File upload failed',[ + 'target_user_id' => $this->security->getUser()->getId(), + 'message' => $e->getMessage(), + 'file_name' => $customFilename, + ]); + + // Optional: Re-throw the exception if you want the controller/user to know the upload failed + throw new FileException('File upload failed.'); + } + } } diff --git a/src/Service/SSO/ProjectService.php b/src/Service/SSO/ProjectService.php index 106f8ca..77df55d 100644 --- a/src/Service/SSO/ProjectService.php +++ b/src/Service/SSO/ProjectService.php @@ -7,6 +7,7 @@ use App\Entity\Project; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\Client; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; class ProjectService { @@ -20,23 +21,12 @@ class ProjectService public function createRemoteProject(string $clientAppUrl, Project $project): void { // 1. Get a token for "ourselves" -> on en a besoin parce que c'est du M2M. - $portalClient = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]); - $tokenResponse = $this->httpClient->request('POST', $this->appUrl . 'token', [ - 'auth_basic' => [$portalClient->getIdentifier(),$portalClient->getSecret()], // ID and Secret go here - 'body' => [ - 'grant_type' => 'client_credentials', - ], - ]); + $tokenResponse = $this->getTokenResponse(); $accessToken = $tokenResponse->toArray()['access_token']; // data must match easy check database - $projectJson = [ - 'id' => $project->getId(), - 'projet' => $project->getName(), - 'entity_id' => 3, - 'bdd' => $project->getBddName(), - 'isactive' => $project->isActive(), - ]; + $projectJson = $this->getTokenResponse($project); + // 2. Call the Client Application's Webhook/API $this->httpClient->request('POST', $clientAppUrl . '/api/v1/project/create', [ @@ -45,4 +35,41 @@ class ProjectService ]); } + public function editRemoteProject(string $clientAppUrl, Project $project): void + { + $tokenResponse = $this->getTokenResponse(); + + $accessToken = $tokenResponse->toArray()['access_token']; +// data must match easy check database + $projectJson = $this->getProjectToJson($project); + // 2. Call the Client Application's Webhook/API + $this->httpClient->request('PUT', $clientAppUrl . '/api/v1/project/edit/'. $project->getId(), [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + 'json' => $projectJson + ]); + } + + + public function getTokenResponse(): ResponseInterface{ + $portalClient = $this->entityManager->getRepository(Client::class)->findOneBy(['identifier' => $this->clientIdentifier]); + return $this->httpClient->request('POST', $this->appUrl . 'token', [ + 'auth_basic' => [$portalClient->getIdentifier(),$portalClient->getSecret()], // ID and Secret go here + 'body' => [ + 'grant_type' => 'client_credentials', + ], + ]); + } + + public function getProjectToJson(Project $project): array { + return [ + 'id' => $project->getId(), + 'projet' => $project->getName(), + 'entity_id' => 3, + 'bdd' => $project->getBddName(), + 'isactive' => $project->isActive(), + 'logo' => $project->getLogo(), + 'timestamp'=> $project->getTimestampPrecision(), + 'deletion' => $project->isDeletionAllowed() + ]; + } } \ No newline at end of file diff --git a/templates/organization/show.html.twig b/templates/organization/show.html.twig index 450190f..b83fc9f 100644 --- a/templates/organization/show.html.twig +++ b/templates/organization/show.html.twig @@ -1,7 +1,7 @@ {% extends 'base.html.twig' %} {% block body %} - {% set isSA = is_granted('ROLE_SUPER_ADMIN')%} + {% set isSA = is_granted('ROLE_SUPER_ADMIN') %}
{% for type, messages in app.flashes %} {% for message in messages %} @@ -70,7 +70,8 @@
{# New User Modal #} -