diff --git a/.gitignore b/.gitignore index 69d5619..7a52ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ log.txt .idea -node_modules/ \ No newline at end of file +node_modules/ +/.phpunit.result.cache diff --git a/migrations/08-add-reset-token-expiry.sql b/migrations/08-add-reset-token-expiry.sql index e5c561d..d6cacaf 100644 --- a/migrations/08-add-reset-token-expiry.sql +++ b/migrations/08-add-reset-token-expiry.sql @@ -1,3 +1,2 @@ -- BUG-09 : Ajoute la date d'expiration du token de réinitialisation de mot de passe -ALTER TABLE users ADD COLUMN reset_token_expires_at DATETIME NULL DEFAULT NULL AFTER reset_token; - +ALTER TABLE {prefix}users ADD COLUMN reset_token_expires_at DATETIME NULL DEFAULT NULL AFTER reset_token; \ No newline at end of file diff --git a/migrations/11-move-goal-to-stream-event.sql b/migrations/11-move-goal-to-stream-event.sql new file mode 100644 index 0000000..1ccb11f --- /dev/null +++ b/migrations/11-move-goal-to-stream-event.sql @@ -0,0 +1,15 @@ +-- Ajouter la colonne goal aux tables stream et event +ALTER TABLE {prefix}charity_stream ADD goal INT NOT NULL DEFAULT 1000; +ALTER TABLE {prefix}charity_event ADD goal INT NOT NULL DEFAULT 1000; + +-- Copier les objectifs existants depuis les widgets barre de don +UPDATE {prefix}charity_stream cs + INNER JOIN {prefix}widget_donation_goal_bar w ON w.charity_stream_guid = cs.guid + SET cs.goal = w.goal + WHERE w.goal IS NOT NULL AND w.goal > 0; + +UPDATE {prefix}charity_event ce + INNER JOIN {prefix}widget_donation_goal_bar w ON w.charity_event_guid = ce.guid + SET ce.goal = w.goal + WHERE w.goal IS NOT NULL AND w.goal > 0; + diff --git a/migrations/12-add-email-verified.sql b/migrations/12-add-email-verified.sql new file mode 100644 index 0000000..c6e0c18 --- /dev/null +++ b/migrations/12-add-email-verified.sql @@ -0,0 +1,3 @@ +-- Ajoute le champ de vérification d'email pour l'inscription +ALTER TABLE {prefix}users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password; + diff --git a/public/index.php b/public/index.php index 0fb7e9a..b1aac77 100644 --- a/public/index.php +++ b/public/index.php @@ -190,10 +190,13 @@ function (\Psr\Http\Message\ServerRequestInterface $request, \Throwable $excepti true // handleSubclasses ); $app->get('/', [HomeController::class, 'index'])->setName('app_index'); +$app->get('/register', [HomeController::class, 'register'])->setName('app_register'); $app->get('/forgot_password', [HomeController::class, 'forgotPassword'])->setName('app_forgot_password'); $app->get('/reset_password/{token}', [HomeController::class, 'resetPassword'])->setName('app_reset_password'); $app->post('/login', [LoginController::class, 'login'])->setName('app_login'); +$app->post('/register', [LoginController::class, 'register'])->setName('app_register_post'); +$app->get('/verify-email/{token}', [LoginController::class, 'verifyEmail'])->setName('app_verify_email'); $app->get('/logout', [LoginController::class, 'logout'])->add(new AuthMiddleware())->setName('app_logout'); $app->post('/forgot_password', [LoginController::class, 'forgotPassword'])->setName('app_forgot_password_post'); $app->post('/reset_password', [LoginController::class, 'resetPassword'])->setName('app_reset_password_post'); @@ -201,8 +204,9 @@ function (\Psr\Http\Message\ServerRequestInterface $request, \Throwable $excepti $app->get('/validate_auth_page', [LoginController::class, 'validateAuthPage'])->setName('app_validate_auth_page'); $app->get('/admin', [AdminController::class, 'index'])->add(new AuthMiddleware())->setName('app_admin_index'); -$app->post('/admin/event', [AdminController::class, 'newEvent'])->add(new AuthAdminMiddleware())->setName('app_event_new'); -$app->post('/admin/event/{id}/delete', [AdminController::class, 'deleteEvent'])->add(new AuthAdminMiddleware())->setName('app_event_delete'); +$app->post('/admin/user', [AdminController::class, 'newUser'])->add(new AuthAdminMiddleware())->setName('app_user_new'); +$app->post('/admin/event', [AdminController::class, 'newEvent'])->add(new AuthMiddleware())->setName('app_event_new'); +$app->post('/admin/event/{id}/delete', [AdminController::class, 'deleteEvent'])->add(new AuthMiddleware())->setName('app_event_delete'); $app->get('/admin/event/{id}/edit', [AdminController::class, 'editEvent'])->add(new AuthMiddleware())->setName('app_event_edit'); $app->post('/admin/event/{id}/edit', [AdminController::class, 'editEventPost'])->add(new AuthMiddleware())->setName('app_event_edit_post'); $app->post('/admin/stream', [AdminController::class, 'newStream'])->add(new AuthMiddleware())->setName('app_stream_new'); diff --git a/src/Assets/js/admin.js b/src/Assets/js/admin.js index 3fe9fa6..b02b40c 100644 --- a/src/Assets/js/admin.js +++ b/src/Assets/js/admin.js @@ -17,7 +17,8 @@ function updateDonationGoalPreview() { const front = document.querySelector('.front'); const backTitle = document.getElementById('back-title'); const frontTitle = document.getElementById('front-title'); - const goal = document.getElementById('goal'); + // L'objectif est maintenant dans le formulaire d'infos (stream_goal ou event_goal) + const goalInput = document.getElementById('stream_goal') || document.getElementById('event_goal'); back.style.backgroundColor = document.getElementById('background_color').value; front.style.backgroundColor = document.getElementById('bar_color').value; @@ -29,7 +30,7 @@ function updateDonationGoalPreview() { backTitle.textContent = textContent; frontTitle.textContent = textContent; - const goalValue = parseFloat(goal.value) || 0; + const goalValue = goalInput ? (parseFloat(goalInput.value) || 0) : 0; const currentDonation = goalValue / 2; document.getElementById('back-goal-total').textContent = `${goalValue} €`; @@ -43,7 +44,7 @@ function updateDonationGoalPreview() { const donationBarForm = document.getElementById('donationBarForm'); if (donationBarForm) { bindPreviewInputs( - ['text_color_main', 'text_color_alt', 'text_content', 'bar_color', 'background_color', 'goal'], + ['text_color_main', 'text_color_alt', 'text_content', 'bar_color', 'background_color', 'stream_goal', 'event_goal'], updateDonationGoalPreview ); } @@ -74,7 +75,9 @@ if (cardWidgetForm) { const barBgColor = document.getElementById('card_bar_background_color').value; const tagColor = document.getElementById('card_tag_color').value; const tagBgColor = document.getElementById('card_tag_background_color').value; - const goalValue = parseFloat(document.getElementById('card_goal').value) || 1; + // L'objectif est maintenant dans le formulaire d'infos (stream_goal ou event_goal) + const goalInput = document.getElementById('stream_goal') || document.getElementById('event_goal'); + const goalValue = goalInput ? (parseFloat(goalInput.value) || 1) : 1; if (preview) { preview.style.backgroundColor = bgColor; @@ -101,9 +104,10 @@ if (cardWidgetForm) { bindPreviewInputs( [ - 'card_tag', 'card_title', 'card_description', 'card_goal', + 'card_tag', 'card_title', 'card_description', 'card_background_color', 'card_text_color', 'card_bar_color', 'card_bar_background_color', 'card_tag_color', 'card_tag_background_color', + 'stream_goal', 'event_goal', ], updateCardPreview ); diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index ff7bdc4..e01f89a 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -11,6 +11,7 @@ use App\Repositories\WidgetRepository; use App\Services\ApiWrapper; use Exception; +use MailchimpTransactional\ApiClient; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Flash\Messages; @@ -30,8 +31,31 @@ public function __construct( private ApiWrapper $apiWrapper, private AccessTokenRepository $accessTokenRepository, private AuthorizationCodeRepository $authorizationCodeRepository, + private ApiClient $mailchimp, ) {} + /** + * Retourne les slugs d'organisation dont le token est expiré parmi les streams donnés. + */ + private function getInvalidTokenSlugs(array $streams): array + { + $validSlugs = $this->accessTokenRepository->getValidOrganizationSlugs(); + $invalidSlugs = []; + foreach ($streams as $stream) { + if ($stream->organization_slug && !in_array($stream->organization_slug, $validSlugs)) { + $invalidSlugs[] = $stream->organization_slug; + } + } + return array_unique($invalidSlugs); + } + + private function redirectToRoute(Request $request, Response $response, string $routeName, array $params = []): Response + { + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $url = $routeParser->urlFor($routeName, $params); + return $response->withHeader('Location', $url)->withStatus(302); + } + public function index(Request $request, Response $response): Response { $user = $request->getAttribute('user'); @@ -43,13 +67,6 @@ public function index(Request $request, Response $response): Response $events = $this->eventRepository->selectListByUser($user); } - $validSlugs = $this->accessTokenRepository->getValidOrganizationSlugs(); - $invalidTokenSlugs = []; - foreach ($streams as $stream) { - if ($stream->organization_slug && !in_array($stream->organization_slug, $validSlugs)) { - $invalidTokenSlugs[] = $stream->organization_slug; - } - } $data = [ "streams" => $streams, @@ -58,44 +75,130 @@ public function index(Request $request, Response $response): Response "currentUser" => $user, "selectedEventId" => $request->getQueryParams()['eventId'] ?? null, "openCreateStream" => isset($request->getQueryParams()['createStream']), - "invalidTokenSlugs" => $invalidTokenSlugs, + "openCreateEvent" => isset($request->getQueryParams()['createEvent']), + "invalidTokenSlugs" => $this->getInvalidTokenSlugs($streams), + "ownerEmail" => $user->email, ]; + if ($user->role === "ADMIN") { + $data["users"] = $this->userRepository->selectAll(); + } + $template = $user->role === "ADMIN" ? 'stream/index-admin.html.twig' : 'stream/index.html.twig'; return $this->view->render($response, $template, $data); } + public function newUser(Request $request, Response $response): Response + { + $data = $request->getParsedBody(); + $email = trim($data['user_email'] ?? ''); + + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->messages->addMessage('error', 'Email invalide'); + return $this->redirectToRoute($request, $response, 'app_admin_index'); + } + + $existing = $this->userRepository->select($email); + if ($existing) { + $this->messages->addMessage('error', 'Un utilisateur avec cet email existe déjà'); + return $this->redirectToRoute($request, $response, 'app_admin_index'); + } + + $user = $this->userRepository->insert($email); + $user = $this->userRepository->insertResetToken($user); + + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $resetUrl = $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_reset_password', ["token" => $user->reset_token]); + + try { + $result = $this->mailchimp->messages->send([ + "message" => [ + "from_email" => "contact@helloasso.io", + "from_name" => "HelloAsso", + "subject" => "Bienvenue sur HelloAsso Stream !", + "html" => $this->buildWelcomeEmail($resetUrl), + "to" => [["email" => $user->email, "type" => "to"]], + ], + ]); + + if ($result instanceof \Exception) { + throw $result; + } + } catch (\Exception $e) { + $this->logger->error('Échec de l\'envoi de l\'email de bienvenue', [ + 'email' => $user->email, + 'error' => $e->getMessage(), + ]); + $this->messages->addMessage('error', 'Utilisateur créé mais l\'email de bienvenue n\'a pas pu être envoyé.'); + return $this->redirectToRoute($request, $response, 'app_admin_index'); + } + + $this->messages->addMessage('success', 'Utilisateur créé : ' . $email . ' — un email de bienvenue a été envoyé.'); + return $this->redirectToRoute($request, $response, 'app_admin_index'); + } + + /** + * Génère le contenu HTML de l'email de bienvenue envoyé aux nouveaux utilisateurs. + */ + private function buildWelcomeEmail(string $resetUrl): string + { + return << +

Bienvenue sur HelloAsso Stream ! 🎉

+

Bonjour,

+

Un compte vient d'être créé pour vous sur HelloAsso Stream, l'outil qui vous permet de suivre vos collectes en temps réel.

+

Pour commencer, définissez votre mot de passe en cliquant sur le bouton ci-dessous :

+

+ Définir mon mot de passe +

+

Ou copiez ce lien dans votre navigateur : {$resetUrl}

+
+

Que pourrez-vous faire une fois connecté ?

+

📊 Créer un Stream

+

Un stream vous permet d'avoir un compteur en temps réel lié à un formulaire de don HelloAsso. Idéal pour afficher la progression d'une collecte lors d'un live ou sur votre site.

+

🎯 Créer un Évènement

+

Un évènement vous permet d'avoir un compteur en temps réel qui agrège plusieurs streams (et donc plusieurs formulaires de don). Parfait pour suivre une campagne de collecte globale composée de plusieurs initiatives.

+
+

À très vite sur la plateforme ! 🚀

+

L'équipe HelloAsso

+ + HTML; + } + public function newEvent(Request $request, Response $response): Response { + $user = $request->getAttribute('user'); $data = $request->getParsedBody(); - $user = $this->userRepository->findOrCreate($data['owner_email']); + $ownerEmail = $data['owner_email'] ?? $user->email; + + // Si l'utilisateur n'est pas admin, il crée l'évènement pour lui-même + if ($user->role !== 'ADMIN') { + $ownerEmail = $user->email; + } + + $owner = $this->userRepository->findOrCreate($ownerEmail); $event = $this->eventRepository->insert($data['title']); - $this->userRepository->insertRight($user, null, $event); + $this->userRepository->insertRight($owner, null, $event); $this->messages->addMessage('success', 'Évènement ajouté'); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_admin_index'); - - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } public function deleteEvent(Request $request, Response $response, array $args): Response { $user = $request->getAttribute('user'); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_admin_index'); $event = $this->eventRepository->selectByUserAndGuid($user, $args['id']); if (!$event) { $this->messages->addMessage('error', 'Tu n\'as pas accès cet évènement'); - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } $this->eventRepository->delete($event); $this->messages->addMessage('success', 'Évènement supprimé'); - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } public function editEvent(Request $request, Response $response, array $args): Response @@ -107,19 +210,11 @@ public function editEvent(Request $request, Response $response, array $args): Re $streams = $this->streamRepository->selectListByEvent($event); $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $validSlugs = $this->accessTokenRepository->getValidOrganizationSlugs(); - $invalidTokenSlugs = []; - foreach ($streams as $stream) { - if ($stream->organization_slug && !in_array($stream->organization_slug, $validSlugs)) { - $invalidTokenSlugs[] = $stream->organization_slug; - } - } - $data = [ "logged" => true, "event" => $event, "streams" => $streams, - "invalidTokenSlugs" => $invalidTokenSlugs, + "invalidTokenSlugs" => $this->getInvalidTokenSlugs($streams), "donationGoalWidget" => $donationGoalWidget, "cardWidget" => $cardWidget, "cardWidgetPictureUrl" => ($cardWidget && $cardWidget->image) ? $this->fileManager->getPictureUrl($cardWidget->image) : null, @@ -135,12 +230,22 @@ public function editEventPost(Request $request, Response $response, array $args) $user = $request->getAttribute('user'); $event = $this->eventRepository->selectByUserAndGuid($user, $args['id']); - $this->handleWidgetFormSave($request, null, $event->guid); + $body = $request->getParsedBody(); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_event_edit', ["id" => $event->guid]); + if (isset($body['save_event_info'])) { + $updateData = []; + if (isset($body['event_title'])) { + $updateData['title'] = $body['event_title']; + } + if (isset($body['event_goal'])) { + $updateData['goal'] = (int) $body['event_goal']; + } + $this->eventRepository->update($event, $updateData); + } - return $response->withHeader('Location', $url)->withStatus(302); + $this->handleWidgetFormSave($request, null, $event->guid); + + return $this->redirectToRoute($request, $response, 'app_event_edit', ["id" => $event->guid]); } public function newStream(Request $request, Response $response): Response @@ -151,7 +256,12 @@ public function newStream(Request $request, Response $response): Response $parentEvent = $data['parent_event'] ?? null; $parentStyle = isset($data['parent_style']); - $owner = $this->userRepository->findOrCreate($data['owner_email']); + $ownerEmail = $data['owner_email'] ?? $user->email; + if ($user->role !== 'ADMIN') { + $ownerEmail = $user->email; + } + + $owner = $this->userRepository->findOrCreate($ownerEmail); $event = null; if (!empty($parentEvent)) { @@ -177,28 +287,23 @@ public function newStream(Request $request, Response $response): Response } $this->messages->addMessage('success', 'Stream ajouté'); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_admin_index'); - - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } public function deleteStream(Request $request, Response $response, array $args): Response { $user = $request->getAttribute('user'); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_admin_index'); $stream = $this->streamRepository->selectByUserAndGuid($user, $args['id']); if (!$stream) { $this->messages->addMessage('error', 'Tu n\'as pas accès ce stream'); - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } $this->streamRepository->delete($stream); $this->messages->addMessage('success', 'Stream supprimé'); - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_admin_index'); } public function editStream(Request $request, Response $response, array $args): Response @@ -216,6 +321,13 @@ public function editStream(Request $request, Response $response, array $args): R $parentEvent = $this->eventRepository->selectByUserAndId($user, $charityStream->charity_event_id); } + // Liste des events accessibles pour le lien stream ↔ event + if ($user->role === 'ADMIN') { + $availableEvents = $this->eventRepository->selectList(); + } else { + $availableEvents = $this->eventRepository->selectListByUser($user); + } + $donationUrl = $_SERVER['HA_URL'] . '/associations/' . $charityStream->organization_slug . '/formulaires/' . $charityStream->form_slug; $routeParser = RouteContext::fromRequest($request)->getRouteParser(); @@ -223,6 +335,7 @@ public function editStream(Request $request, Response $response, array $args): R "logged" => true, "charityStream" => $charityStream, "parentEvent" => $parentEvent, + "availableEvents" => $availableEvents, "donationGoalWidget" => $donationGoalWidget, "alertBoxWidget" => $alertBoxWidget, "alertBoxWidgetPictureUrl" => ($alertBoxWidget && $alertBoxWidget->image) ? $this->fileManager->getPictureUrl($alertBoxWidget->image) : null, @@ -233,6 +346,7 @@ public function editStream(Request $request, Response $response, array $args): R "widgetDonationGoalUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_stream_widget_donation', ["id" => $guid]), "widgetAlertBoxUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_stream_widget_alert', ["id" => $guid]), "widgetCardUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_stream_widget_card', ["id" => $guid]), + "messages" => $this->messages->getMessages(), ]; return $this->view->render($response, 'stream/edit.html.twig', $data); @@ -246,6 +360,35 @@ public function editStreamPost(Request $request, Response $response, array $args $body = $request->getParsedBody(); + if (isset($body['save_stream_info'])) { + $updateData = []; + if (isset($body['stream_title'])) { + $updateData['title'] = $body['stream_title']; + } + if (isset($body['stream_goal'])) { + $updateData['goal'] = (int) $body['stream_goal']; + } + $this->streamRepository->update($charityStream, $updateData); + } + + if (isset($body['link_event'])) { + $eventId = !empty($body['event_id']) ? (int) $body['event_id'] : null; + if ($eventId) { + $event = $this->eventRepository->selectByUserAndId($user, $eventId); + if ($event) { + $this->streamRepository->updateEventLink($charityStream, $event->id); + $this->messages->addMessage('success', 'Stream lié à l\'événement « ' . $event->title . ' »'); + } else { + $this->messages->addMessage('error', 'Événement introuvable ou non autorisé'); + } + } + } + + if (isset($body['unlink_event'])) { + $this->streamRepository->updateEventLink($charityStream, null); + $this->messages->addMessage('success', 'Stream délié de son événement'); + } + if (isset($body['save_alert_box'])) { $uploadedFiles = $request->getUploadedFiles(); $image = isset($uploadedFiles['image']) && $uploadedFiles['image']->getSize() > 0 @@ -260,10 +403,7 @@ public function editStreamPost(Request $request, Response $response, array $args $this->handleWidgetFormSave($request, $guid, null); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_stream_edit', ["id" => $guid]); - - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_stream_edit', ["id" => $guid]); } /** diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 47e18ff..8d2bf07 100644 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -2,23 +2,17 @@ namespace App\Controllers; -use App\Repositories\FileManager; use App\Repositories\StreamRepository; use App\Repositories\UserRepository; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Slim\Flash\Messages; use Slim\Routing\RouteContext; -use Slim\Views\Twig; class ApiController { public function __construct( - private Twig $view, - private FileManager $fileManager, private StreamRepository $streamRepository, private UserRepository $userRepository, - private Messages $messages, ) {} public function new(Request $request, Response $response): Response diff --git a/src/Controllers/HomeController.php b/src/Controllers/HomeController.php index 88d5b44..7548a81 100644 --- a/src/Controllers/HomeController.php +++ b/src/Controllers/HomeController.php @@ -20,6 +20,12 @@ public function index(Request $request, Response $response): Response return $this->view->render($response, 'index.html.twig', $messages); } + public function register(Request $request, Response $response): Response + { + $messages = $this->messages->getMessages(); + return $this->view->render($response, 'register.html.twig', $messages); + } + public function forgotPassword(Request $request, Response $response): Response { return $this->view->render($response, 'password-forgot.html.twig'); diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index ac63412..a5051be 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -9,16 +9,15 @@ use App\Services\ApiWrapper; use Exception; use MailchimpTransactional\ApiClient; +use Monolog\Logger; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Flash\Messages; use Slim\Routing\RouteContext; use Slim\Views\Twig; -use Monolog\Logger; class LoginController { - public function __construct( private Twig $view, private ApiWrapper $apiWrapper, @@ -28,19 +27,19 @@ public function __construct( private UserRepository $userRepository, private ApiClient $mailchimp, private Messages $messages, - private Logger $logger - ) { - + private Logger $logger, + ) {} + + private function redirectToRoute(Request $request, Response $response, string $routeName, array $params = []): Response + { + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $url = $routeParser->urlFor($routeName, $params); + return $response->withHeader('Location', $url)->withStatus(302); } - - /** - * Valide la page de connexion après soumission du formulaire. Si les identifiants sont corrects, stocke l'utilisateur en session et redirige vers la page d'administration. Sinon, affiche un message d'erreur et redirige vers la page d'accueil. - * - * @param Request $request - * @param Response $response - * @return Response + * Valide la page de connexion après soumission du formulaire. + * Vérifie aussi que l'email est confirmé. */ public function login(Request $request, Response $response): Response { @@ -49,213 +48,321 @@ public function login(Request $request, Response $response): Response $password = $data['password'] ?? ''; $user = $this->userRepository->select($email); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - if ($user && password_verify($password, $user->password)) { + if (isset($user->email_verified) && !$user->email_verified) { + $this->messages->addMessage('email_not_verified', true); + return $this->redirectToRoute($request, $response, 'app_index'); + } session_regenerate_id(true); $_SESSION['user'] = $user; - $url = $routeParser->urlFor('app_admin_index'); + return $this->redirectToRoute($request, $response, 'app_admin_index'); + } + + $this->messages->addMessage('login_failed', true); + return $this->redirectToRoute($request, $response, 'app_index'); + } + + /** + * Inscrit un nouvel utilisateur avec vérification par email. + */ + public function register(Request $request, Response $response): Response + { + $data = $request->getParsedBody(); + $email = trim($data['email'] ?? ''); + $password = $data['password'] ?? ''; + $passwordRepeat = $data['passwordRepeat'] ?? ''; + + // Validation email + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->messages->addMessage('register_error', 'Adresse email invalide.'); + return $this->redirectToRoute($request, $response, 'app_register'); + } + + // Validation mot de passe + $passwordErrors = $this->validatePassword($password, $passwordRepeat); + if (!empty($passwordErrors)) { + $this->messages->addMessage('register_error', implode(' ', $passwordErrors)); + return $this->redirectToRoute($request, $response, 'app_register'); + } + + // Vérifier si l'utilisateur existe déjà + $existing = $this->userRepository->select($email); + if ($existing) { + $this->messages->addMessage('register_error', 'Un compte avec cet email existe déjà.'); + return $this->redirectToRoute($request, $response, 'app_register'); + } + + // Créer l'utilisateur (email non vérifié) + $user = $this->userRepository->insertWithPassword($email, $password); + $user = $this->userRepository->insertResetToken($user); + + // Envoyer l'email de vérification + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $verifyUrl = $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_verify_email', ["token" => $user->reset_token]); + + try { + $result = $this->mailchimp->messages->send([ + "message" => [ + "from_email" => "contact@helloasso.io", + "from_name" => "HelloAsso", + "subject" => "Confirmez votre adresse email", + "html" => $this->buildVerificationEmail($verifyUrl), + "to" => [["email" => $user->email, "type" => "to"]], + ], + ]); + + // Le client Mandrill retourne l'exception au lieu de la lancer + if ($result instanceof Exception) { + throw $result; + } + + // Vérifier le statut de l'envoi Mandrill + if (is_array($result) && isset($result[0]->status) && in_array($result[0]->status, ['rejected', 'invalid'])) { + throw new Exception('Email rejeté par Mandrill : ' . ($result[0]->reject_reason ?? 'raison inconnue')); + } + } catch (Exception $e) { + $this->logger->error('Échec de l\'envoi de l\'email de vérification', [ + 'email' => $user->email, + 'error' => $e->getMessage(), + ]); + $this->messages->addMessage('register_error', 'Votre compte a été créé mais l\'email de vérification n\'a pas pu être envoyé. Veuillez réessayer plus tard.'); + return $this->redirectToRoute($request, $response, 'app_register'); + } + + $this->messages->addMessage('register_success', true); + return $this->redirectToRoute($request, $response, 'app_index'); + } + + /** + * Vérifie l'email via le token reçu par mail. + */ + public function verifyEmail(Request $request, Response $response, array $args): Response + { + $token = $args['token'] ?? ''; + $user = $this->userRepository->selectByToken($token); + + if ($user) { + $this->userRepository->verifyEmail($user); + $this->messages->addMessage('email_verified', true); } else { - $this->messages->addMessage('login_failed', true); - $url = $routeParser->urlFor('app_index'); + $this->messages->addMessage('email_verify_error', true); } - return $response->withHeader('Location', $url)->withStatus(302); + + return $this->redirectToRoute($request, $response, 'app_index'); + } + + /** + * Valide les règles de mot de passe. + */ + private function validatePassword(string $password, string $passwordRepeat): array + { + $errors = []; + + if ($password !== $passwordRepeat) { + $errors[] = 'Les mots de passe ne correspondent pas.'; + } + if (strlen($password) < 8) { + $errors[] = 'Le mot de passe doit contenir au moins 8 caractères.'; + } + if (!preg_match('/[A-Z]/', $password)) { + $errors[] = 'Le mot de passe doit contenir au moins une majuscule.'; + } + if (!preg_match('/[a-z]/', $password)) { + $errors[] = 'Le mot de passe doit contenir au moins une minuscule.'; + } + if (!preg_match('/[0-9]/', $password)) { + $errors[] = 'Le mot de passe doit contenir au moins un chiffre.'; + } + if (!preg_match('/[^A-Za-z0-9]/', $password)) { + $errors[] = 'Le mot de passe doit contenir au moins un caractère spécial.'; + } + + return $errors; + } + + /** + * Génère le contenu HTML de l'email de vérification. + */ + private function buildVerificationEmail(string $verifyUrl): string + { + return << +

Confirmez votre adresse email 📧

+

Bonjour,

+

Merci de vous être inscrit sur HelloAsso Stream !

+

Pour activer votre compte, veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous :

+

+ Confirmer mon email +

+

Ou copiez ce lien dans votre navigateur : {$verifyUrl}

+

Ce lien est valable 1 heure.

+
+

Si vous n'êtes pas à l'origine de cette inscription, vous pouvez ignorer cet email.

+

L'équipe HelloAsso

+ + HTML; } /** - * Valide la page de mot de passe oublié après soumission du formulaire. Si l'email existe, génère un token de réinitialisation, l'associe à l'utilisateur et envoie un email avec le lien de réinitialisation. Affiche un message indiquant que l'email a été envoyé, même si l'email n'existe pas pour éviter les attaques de type enumeration. - * - * @param Request $request - * @param Response $response - * @return Response + * Envoie un email de réinitialisation de mot de passe si l'adresse existe. */ public function forgotPassword(Request $request, Response $response): Response { $data = $request->getParsedBody(); - $email = $data['email'] ?? ''; $user = $this->userRepository->select($email); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - if ($user) { $user = $this->userRepository->insertResetToken($user); - $url = $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_reset_password', ["token" => $user->reset_token]); + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $resetUrl = $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_reset_password', ["token" => $user->reset_token]); - $this->mailchimp->messages->send([ - "message" => [ - "from_email" => "contact@helloasso.io", - "from_name" => "HelloAsso", - "subject" => "Mot de passe oublié", - "html" => "

Vous avez fait une demande de réinitialisation de mot de passe. Merci de le définir sur cette page
Ou en suivant ce lien " . $url . "

", - "to" => [ - [ - "email" => $user->email - ] + try { + $result = $this->mailchimp->messages->send([ + "message" => [ + "from_email" => "contact@helloasso.io", + "from_name" => "HelloAsso", + "subject" => "Mot de passe oublié", + "html" => "

Vous avez fait une demande de réinitialisation de mot de passe. Merci de le définir sur cette page
Ou en suivant ce lien {$resetUrl}

", + "to" => [["email" => $user->email, "type" => "to"]], ], - ] - ]); + ]); + + if ($result instanceof \Exception) { + throw $result; + } + } catch (Exception $e) { + $this->logger->error('Échec de l\'envoi de l\'email de réinitialisation', [ + 'email' => $user->email, + 'error' => $e->getMessage(), + ]); + } } $this->messages->addMessage('mail_sent', true); - $url = $routeParser->urlFor('app_index'); - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_index'); } /** - * Valide la page de réinitialisation de mot de passe après soumission du formulaire. Si les mots de passe correspondent, met à jour le mot de passe de l'utilisateur et affiche un message de succès. Sinon, affiche un message d'erreur. - * - * @param Request $request - * @param Response $response - * @return Response + * Réinitialise le mot de passe après soumission du formulaire. */ public function resetPassword(Request $request, Response $response): Response { $data = $request->getParsedBody(); - $password = $data['password'] ?? ''; $passwordRepeat = $data['passwordRepeat'] ?? ''; $token = $data['token'] ?? ''; $user = $this->userRepository->selectByToken($token); - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - - if ($user && $password && $passwordRepeat && $password == $passwordRepeat) { + if ($user && $password && $passwordRepeat && $password === $passwordRepeat) { $this->userRepository->updatePassword($user, $password); $this->messages->addMessage('password_reset', true); - $url = $routeParser->urlFor('app_index'); - } else { - $this->messages->addMessage('password_reset_error', true); - $url = $routeParser->urlFor('app_reset_password', ["token" => $token]); + return $this->redirectToRoute($request, $response, 'app_index'); } - return $response->withHeader('Location', $url)->withStatus(302); + $this->messages->addMessage('password_reset_error', true); + return $this->redirectToRoute($request, $response, 'app_reset_password', ["token" => $token]); } + /** * Détruit la session de l'utilisateur et redirige vers la page d'accueil. - * - * @param Request $request - * @param Response $response - * @return Response */ public function logout(Request $request, Response $response): Response { unset($_SESSION['user']); - - $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $url = $routeParser->urlFor('app_index'); - - return $response->withHeader('Location', $url)->withStatus(302); + return $this->redirectToRoute($request, $response, 'app_index'); } - - /** * Redirige vers l'URL d'autorisation pour l'organisation donnée. - * - * @param Response $response - * @param [type] $organizationSlug - * @return Response - */ - private function redirectionToAuthorizationUrl(Response $response, $organizationSlug): Response - { - - // Génération de l'URL d'autorisation (nouvelle authentification OAuth) - $authorizationUrl = $this->apiWrapper->generateAuthorizationUrl($organizationSlug); - - return $response->withHeader('Location', $authorizationUrl)->withStatus(302); - } - - /** - * Redirige vers l'URL d'autorisation pour l'organisation donnée. Si un token existe déjà, tente de le rafraîchir avant de rediriger. - * - * @param Request $request - * @param Response $response - * @return Response + * Si un token existe déjà et est encore valide, le rafraîchit et affiche un message. */ public function redirectAuthPage(Request $request, Response $response): Response { - $organizationSlug = $request->getQueryParams()['organizationSlug']; - if ($organizationSlug == null) { + $organizationSlug = $request->getQueryParams()['organizationSlug'] ?? null; + if (!$organizationSlug) { throw new Exception("Erreur : OrganizationSlug introuvable"); } - $organizationToken = $this->accessTokenRepository->selectBySlug($organizationSlug); + $existingToken = $this->accessTokenRepository->selectBySlug($organizationSlug); - if ($organizationToken != null) { + if ($existingToken) { try { $this->apiWrapper->getOrganizationAccessToken($organizationSlug); - $response->getBody()->write('Nous possédons déjà un token pour le compte ' . $organizationSlug . ' et nous l\'avons rafraichi, vous pouvez fermer cette page.'); - - } catch (Exception $e) { - - return $this->redirectionToAuthorizationUrl($response, $organizationSlug); - } - } - else { - return $this->redirectionToAuthorizationUrl($response, $organizationSlug); + $response->getBody()->write( + 'Nous possédons déjà un token pour le compte ' . $organizationSlug + . ' et nous l\'avons rafraichi, vous pouvez fermer cette page.' + ); + return $response; + } catch (Exception) { + // Token invalide → on redirige vers la mire d'auth + } } - return $response; + $authorizationUrl = $this->apiWrapper->generateAuthorizationUrl($organizationSlug); + return $response->withHeader('Location', $authorizationUrl)->withStatus(302); } /** - * Valide la page d'autorisation après redirection depuis HelloAsso. Si une erreur est présente dans les query params, l'affiche. Sinon, échange le code d'autorisation contre un token d'accès, le stocke et affiche un message de succès. - * - * @param Request $request - * @param Response $response - * @return Response + * Callback OAuth : échange le code d'autorisation, stocke les tokens et notifie par email si c'est un nouveau compte. */ public function validateAuthPage(Request $request, Response $response): Response { - $error = $request->getQueryParams()['error'] ?? null; - $errorDescription = $request->getQueryParams()['error_description'] ?? null; + $queryParams = $request->getQueryParams(); + $error = $queryParams['error'] ?? null; if ($error) { - $response->getBody()->write($errorDescription); + $response->getBody()->write($queryParams['error_description'] ?? 'Erreur inconnue'); return $response; } - $state = $request->getQueryParams()['state']; - $code = $request->getQueryParams()['code']; + $state = $queryParams['state']; + $code = $queryParams['code']; $authorizationCodeData = $this->authorizationCodeRepository->selectById($state); - $redirect_uri = $authorizationCodeData->redirect_uri; - $codeVerifier = $authorizationCodeData->code_verifier; + $tokenData = $this->apiWrapper->exchangeAuthorizationCode( + $code, + $authorizationCodeData->redirect_uri, + $authorizationCodeData->code_verifier, + ); - $tokenDataGrantAuthorization = $this->apiWrapper->exchangeAuthorizationCode($code, $redirect_uri, $codeVerifier); - - if ($authorizationCodeData->organization_slug !== $tokenDataGrantAuthorization['organization_slug']) { + if ($authorizationCodeData->organization_slug !== $tokenData['organization_slug']) { $this->logger->warning('Incohérence de slug lors de l\'échange du code d\'autorisation', [ 'slug_attendu' => $authorizationCodeData->organization_slug, - 'slug_reçu' => $tokenDataGrantAuthorization['organization_slug'], + 'slug_reçu' => $tokenData['organization_slug'], ]); $response->getBody()->write('Erreur : le slug de l\'association ne correspond pas à celui attendu. L\'authentification a été annulée.'); return $response->withStatus(400); } - $isNewToken = $this->accessTokenRepository->selectBySlug($tokenDataGrantAuthorization['organization_slug']) === null; - $this->apiWrapper->storeOrUpdateToken($tokenDataGrantAuthorization); + $isNewToken = $this->accessTokenRepository->selectBySlug($tokenData['organization_slug']) === null; + $this->apiWrapper->storeOrUpdateToken($tokenData); if ($isNewToken) { - $this->mailchimp->messages->send([ - "message" => [ - "from_email" => "contact@helloasso.io", - "from_name" => "HelloAsso", - "subject" => "Une association vient de valider sa mire", - "html" => "

L'association " . $tokenDataGrantAuthorization['organization_slug'] . " vient de valider sa mire d'authorisation sur l'environnement " . $_SERVER['WEBSITE_DOMAIN'] . "

", - "to" => [ - [ - "email" => "helloasso.stream@helloasso.org" - ] + try { + $result = $this->mailchimp->messages->send([ + "message" => [ + "from_email" => "contact@helloasso.io", + "from_name" => "HelloAsso", + "subject" => "Une association vient de valider sa mire", + "html" => "

L'association {$tokenData['organization_slug']} vient de valider sa mire d'authorisation sur l'environnement {$_SERVER['WEBSITE_DOMAIN']}

", + "to" => [["email" => "helloasso.stream@helloasso.org", "type" => "to"]], ], - ] - ]); - } + ]); - $response->getBody()->write('Votre compte ' . $tokenDataGrantAuthorization['organization_slug'] . ' à bien été lié à HelloAssoCharityStream, vous pouvez fermer cette page.'); + if ($result instanceof \Exception) { + throw $result; + } + } catch (Exception $e) { + $this->logger->error('Échec de l\'envoi de la notification de nouvelle association', [ + 'slug' => $tokenData['organization_slug'], + 'error' => $e->getMessage(), + ]); + } + } + $response->getBody()->write('Votre compte ' . $tokenData['organization_slug'] . ' à bien été lié à HelloAssoCharityStream, vous pouvez fermer cette page.'); return $response; } } diff --git a/src/Controllers/WidgetController.php b/src/Controllers/WidgetController.php index 8d62aa2..4bc6743 100644 --- a/src/Controllers/WidgetController.php +++ b/src/Controllers/WidgetController.php @@ -36,9 +36,23 @@ private function jsonError(Response $response, string $message, int $status): Re return $this->jsonResponse($response, ['error' => $message], $status); } + private function calculatePercentage(int $amountInCents, int $goal): int + { + $safeGoal = $goal ?: 1; + return min(100, (int) round(($amountInCents / 100) / $safeGoal * 100)); + } + + private function requireIdArg(array $args, string $label): string + { + $id = $args['id'] ?? ''; + if (!$id) { + throw new Exception("$label ID manquant ou incorrect."); + } + return $id; + } + /** * Agrège les montants de tous les streams d'un event via le cache par stream. - * Retourne le cache mis à jour avec le total. */ private function aggregateEventStreams(array $streams, array $cacheData, bool $trackDonors = false): array { @@ -92,20 +106,40 @@ private function aggregateEventStreams(array $streams, array $cacheData, bool $t return $cacheData; } - // ── Event Donation ─────────────────────────────────────────── + // ── Stream donation: shared logic ──────────────────────────── - public function widgetEventDonation(Request $request, Response $response, array $args): Response + private function fetchStreamDonationData(string $streamGuid): array { - $eventGuid = $args['id'] ?? ''; - if (!$eventGuid) { - throw new Exception("Event ID manquant ou incorrect."); + $charityStream = $this->streamRepository->selectByGuid($streamGuid); + if (!$charityStream) { + throw new Exception("Charity Stream non trouvé."); } - $donationGoalWidget = $this->widgetRepository->selectDonationWidgetByGuid(null, $eventGuid); - if (!$donationGoalWidget) { - throw new Exception("Aucun widget trouvé pour le Event ID fourni."); + $cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream) + ?? ['amount' => 0, 'continuation_token' => '']; + + $result = $this->apiWrapper->getAllOrders( + $charityStream->organization_slug, + $charityStream->form_slug, + $cacheData['amount'], + $cacheData['continuation_token'], + ); + + if ($cacheData['continuation_token'] !== $result['continuation_token'] + || $cacheData['amount'] !== $result['amount']) { + $this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [ + 'amount' => $result['amount'], + 'continuation_token' => $result['continuation_token'], + ]); } + return ['stream' => $charityStream, 'result' => $result]; + } + + // ── Event donation: shared logic ───────────────────────────── + + private function fetchEventDonationData(string $eventGuid): array + { $event = $this->eventRepository->selectByGuid($eventGuid); if (!$event) { throw new Exception("Event non trouvé."); @@ -122,51 +156,112 @@ public function widgetEventDonation(Request $request, Response $response, array $this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData); } - $data = [ - "donationGoalWidget" => $donationGoalWidget, - "currentAmount" => $cacheData['amount'], - "event" => 1 - ]; + return ['event' => $event, 'cacheData' => $cacheData]; + } + + // ── Stream card: shared logic ──────────────────────────────── + + private function fetchStreamCardData(string $streamGuid): array + { + $charityStream = $this->streamRepository->selectByGuid($streamGuid); + if (!$charityStream) { + throw new Exception("Charity Stream non trouvé."); + } + + $cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream) + ?? ['amount' => 0, 'donors' => 0, 'continuation_token' => '']; + + $result = $this->apiWrapper->getAllOrders( + $charityStream->organization_slug, + $charityStream->form_slug, + $cacheData['amount'], + $cacheData['continuation_token'], + ); + + $newDonors = count($result['donations'] ?? []); + $donors = ($cacheData['donors'] ?? 0) + $newDonors; + + if ($cacheData['continuation_token'] !== $result['continuation_token'] + || $cacheData['amount'] !== $result['amount']) { + $this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [ + 'amount' => $result['amount'], + 'donors' => $donors, + 'continuation_token' => $result['continuation_token'], + ]); + } + + return ['stream' => $charityStream, 'amount' => $result['amount'], 'donors' => $donors]; + } + + // ── Event card: shared logic ───────────────────────────────── + + private function fetchEventCardData(string $eventGuid): array + { + $event = $this->eventRepository->selectByGuid($eventGuid); + if (!$event) { + throw new Exception("Event non trouvé."); + } + + $cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event) + ?? ['amount' => 0, 'donors' => 0, 'streams' => []]; + + $streams = $this->streamRepository->selectListByEvent($event); + $oldAmount = $cacheData['amount']; + $cacheData = $this->aggregateEventStreams($streams, $cacheData, true); + + if ($oldAmount !== $cacheData['amount']) { + $this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData); + } - return $this->view->render($response, 'widget/donation.html.twig', $data); + return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors']]; + } + + // ── Event Donation ─────────────────────────────────────────── + + public function widgetEventDonation(Request $request, Response $response, array $args): Response + { + $eventGuid = $this->requireIdArg($args, 'Event'); + + $donationGoalWidget = $this->widgetRepository->selectDonationWidgetByGuid(null, $eventGuid); + if (!$donationGoalWidget) { + throw new Exception("Aucun widget trouvé pour le Event ID fourni."); + } + + $data = $this->fetchEventDonationData($eventGuid); + + return $this->view->render($response, 'widget/donation.html.twig', [ + 'donationGoalWidget' => $donationGoalWidget, + 'currentAmount' => $data['cacheData']['amount'], + 'goal' => $data['event']->goal, + 'event' => 1, + ]); } public function widgetEventDonationFetch(Request $request, Response $response, array $args): Response { - $eventId = $args['id'] ?? ''; - if (!$eventId) { + $eventGuid = $args['id'] ?? ''; + if (!$eventGuid) { return $this->jsonError($response, 'Event ID manquant ou incorrect.', 400); } - $event = $this->eventRepository->selectByGuid($eventId); + $event = $this->eventRepository->selectByGuid($eventGuid); if (!$event) { return $this->jsonError($response, 'Event non trouvé.', 404); } - $cacheData = $this->widgetRepository->selectEventDonationWidgetCacheData($event) - ?? ['amount' => 0, 'streams' => []]; - try { - $streams = $this->streamRepository->selectListByEvent($event); - $oldAmount = $cacheData['amount']; - $cacheData = $this->aggregateEventStreams($streams, $cacheData); - - if ($oldAmount !== $cacheData['amount']) { - $this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData); - } - - return $this->jsonResponse($response, ['amount' => $cacheData['amount']]); + $data = $this->fetchEventDonationData($eventGuid); + return $this->jsonResponse($response, ['amount' => $data['cacheData']['amount']]); } catch (Exception $e) { return $this->jsonError($response, 'Impossible de récupérer le montant', 500); } } + // ── Stream Alert ───────────────────────────────────────────── + public function widgetAlert(Request $request, Response $response, array $args): Response { - $charityStreamId = $args['id'] ?? ''; - if (!$charityStreamId) { - throw new Exception("Charity Stream ID manquant ou incorrect."); - } + $charityStreamId = $this->requireIdArg($args, 'Charity Stream'); $alertBoxWidget = $this->widgetRepository->selectAlertWidgetByGuid($charityStreamId); if (!$alertBoxWidget) { @@ -174,38 +269,31 @@ public function widgetAlert(Request $request, Response $response, array $args): } $charityStream = $this->streamRepository->selectByGuid($charityStreamId); - if (!$charityStream) { throw new Exception("Charity Stream non trouvé."); } - $cacheData = $this->widgetRepository->selectAlertWidgetCacheData($charityStream); - if (!$cacheData) { - $cacheData = [ - 'continuation_token' => "", - ]; - } + $cacheData = $this->widgetRepository->selectAlertWidgetCacheData($charityStream) + ?? ['continuation_token' => '']; $result = $this->apiWrapper->getAllOrders( $charityStream->organization_slug, $charityStream->form_slug, 0, - $cacheData["continuation_token"], + $cacheData['continuation_token'], ); - if ($cacheData["continuation_token"] !== $result['continuation_token']) { + if ($cacheData['continuation_token'] !== $result['continuation_token']) { $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ - "continuation_token" => $result['continuation_token'] + 'continuation_token' => $result['continuation_token'], ]); } - $data = [ - "alertBoxWidget" => $alertBoxWidget, - "alertBoxWidgetPictureUrl" => $this->fileManager->getPictureUrl($alertBoxWidget->image), - "alertBoxWidgetSoundUrl" => $this->fileManager->getSoundUrl($alertBoxWidget->sound), - ]; - - return $this->view->render($response, 'widget/alert.html.twig', $data); + return $this->view->render($response, 'widget/alert.html.twig', [ + 'alertBoxWidget' => $alertBoxWidget, + 'alertBoxWidgetPictureUrl' => $this->fileManager->getPictureUrl($alertBoxWidget->image), + 'alertBoxWidgetSoundUrl' => $this->fileManager->getSoundUrl($alertBoxWidget->sound), + ]); } public function widgetAlertFetch(Request $request, Response $response, array $args): Response @@ -233,7 +321,7 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar if ($cacheData['continuation_token'] !== $result['continuation_token']) { $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ - "continuation_token" => $result['continuation_token'] + 'continuation_token' => $result['continuation_token'], ]); } @@ -243,47 +331,25 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar } } + // ── Stream Donation ────────────────────────────────────────── + public function widgetDonation(Request $request, Response $response, array $args): Response { - $streamGuid = $args['id'] ?? ''; - if (!$streamGuid) { - throw new Exception("Charity Stream ID manquant ou incorrect."); - } + $streamGuid = $this->requireIdArg($args, 'Charity Stream'); $donationGoalWidget = $this->widgetRepository->selectDonationWidgetByGuid($streamGuid, null); if (!$donationGoalWidget) { throw new Exception("Aucun widget trouvé pour le Charity Stream ID fourni."); } - $charityStream = $this->streamRepository->selectByGuid($streamGuid); - if (!$charityStream) { - throw new Exception("Charity Stream non trouvé."); - } - - $cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream) - ?? ['amount' => 0, 'continuation_token' => '']; - - $result = $this->apiWrapper->getAllOrders( - $charityStream->organization_slug, - $charityStream->form_slug, - $cacheData['amount'], - $cacheData['continuation_token'], - ); - - if ($cacheData['continuation_token'] !== $result['continuation_token'] || $cacheData['amount'] !== $result['amount']) { - $this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [ - "amount" => $result['amount'], - "continuation_token" => $result['continuation_token'] - ]); - } - - $data = [ - "donationGoalWidget" => $donationGoalWidget, - "currentAmount" => $result['amount'], - "stream" => 1 - ]; + $data = $this->fetchStreamDonationData($streamGuid); - return $this->view->render($response, 'widget/donation.html.twig', $data); + return $this->view->render($response, 'widget/donation.html.twig', [ + 'donationGoalWidget' => $donationGoalWidget, + 'currentAmount' => $data['result']['amount'], + 'goal' => $data['stream']->goal, + 'stream' => 1, + ]); } public function widgetDonationFetch(Request $request, Response $response, array $args): Response @@ -298,26 +364,9 @@ public function widgetDonationFetch(Request $request, Response $response, array return $this->jsonError($response, 'Charity Stream non trouvé.', 404); } - $cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream) - ?? ['amount' => 0, 'continuation_token' => '']; - try { - $result = $this->apiWrapper->getAllOrders( - $charityStream->organization_slug, - $charityStream->form_slug, - $cacheData['amount'], - $cacheData['continuation_token'] - ); - - if ($cacheData['continuation_token'] !== $result['continuation_token'] - || $cacheData['amount'] !== $result['amount']) { - $this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [ - "amount" => $result['amount'], - "continuation_token" => $result['continuation_token'] - ]); - } - - return $this->jsonResponse($response, $result); + $data = $this->fetchStreamDonationData($charityStreamId); + return $this->jsonResponse($response, $data['result']); } catch (Exception $e) { return $this->jsonError($response, 'Impossible de récupérer les commandes.', 500); } @@ -327,56 +376,24 @@ public function widgetDonationFetch(Request $request, Response $response, array public function widgetStreamCard(Request $request, Response $response, array $args): Response { - $streamGuid = $args['id'] ?? ''; - if (!$streamGuid) { - throw new Exception("Charity Stream ID manquant ou incorrect."); - } + $streamGuid = $this->requireIdArg($args, 'Charity Stream'); $cardWidget = $this->widgetRepository->selectCardWidgetByGuid($streamGuid, null); if (!$cardWidget) { throw new Exception("Aucun widget card trouvé pour le Charity Stream ID fourni."); } - $charityStream = $this->streamRepository->selectByGuid($streamGuid); - if (!$charityStream) { - throw new Exception("Charity Stream non trouvé."); - } + $data = $this->fetchStreamCardData($streamGuid); - $cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream) - ?? ['amount' => 0, 'donors' => 0, 'continuation_token' => '']; - - $result = $this->apiWrapper->getAllOrders( - $charityStream->organization_slug, - $charityStream->form_slug, - $cacheData['amount'], - $cacheData['continuation_token'], - ); - - $newDonors = count($result['donations'] ?? []); - $donors = ($cacheData['donors'] ?? 0) + $newDonors; - - if ($cacheData['continuation_token'] !== $result['continuation_token'] || $cacheData['amount'] !== $result['amount']) { - $this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [ - "amount" => $result['amount'], - "donors" => $donors, - "continuation_token" => $result['continuation_token'] - ]); - } - - $currentAmount = $result['amount']; - $goal = $cardWidget->goal ?: 1; - $percentage = min(100, round(($currentAmount / 100) / $goal * 100)); - - $data = [ - "cardWidget" => $cardWidget, - "cardWidgetPictureUrl" => $cardWidget->image ? $this->fileManager->getPictureUrl($cardWidget->image) : null, - "currentAmount" => $currentAmount, - "donorCount" => $donors, - "percentage" => $percentage, - "stream" => 1 - ]; - - return $this->view->render($response, 'widget/card.html.twig', $data); + return $this->view->render($response, 'widget/card.html.twig', [ + 'cardWidget' => $cardWidget, + 'cardWidgetPictureUrl' => $cardWidget->image ? $this->fileManager->getPictureUrl($cardWidget->image) : null, + 'currentAmount' => $data['amount'], + 'donorCount' => $data['donors'], + 'percentage' => $this->calculatePercentage($data['amount'], $data['stream']->goal), + 'goal' => $data['stream']->goal ?: 1, + 'stream' => 1, + ]); } public function widgetStreamCardFetch(Request $request, Response $response, array $args): Response @@ -386,35 +403,9 @@ public function widgetStreamCardFetch(Request $request, Response $response, arra return $this->jsonError($response, 'Charity Stream ID manquant ou incorrect.', 400); } - $charityStream = $this->streamRepository->selectByGuid($charityStreamId); - if (!$charityStream) { - return $this->jsonError($response, 'Charity Stream non trouvé.', 404); - } - - $cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream) - ?? ['amount' => 0, 'donors' => 0, 'continuation_token' => '']; - try { - $result = $this->apiWrapper->getAllOrders( - $charityStream->organization_slug, - $charityStream->form_slug, - $cacheData['amount'], - $cacheData['continuation_token'] - ); - - $newDonors = count($result['donations'] ?? []); - $donors = ($cacheData['donors'] ?? 0) + $newDonors; - - if ($cacheData['continuation_token'] !== $result['continuation_token'] - || $cacheData['amount'] !== $result['amount']) { - $this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [ - "amount" => $result['amount'], - "donors" => $donors, - "continuation_token" => $result['continuation_token'] - ]); - } - - return $this->jsonResponse($response, ['amount' => $result['amount'], 'donors' => $donors]); + $data = $this->fetchStreamCardData($charityStreamId); + return $this->jsonResponse($response, ['amount' => $data['amount'], 'donors' => $data['donors']]); } catch (Exception $e) { return $this->jsonError($response, 'Impossible de récupérer les données.', 500); } @@ -424,45 +415,24 @@ public function widgetStreamCardFetch(Request $request, Response $response, arra public function widgetEventCard(Request $request, Response $response, array $args): Response { - $eventGuid = $args['id'] ?? ''; - if (!$eventGuid) { - throw new Exception("Event ID manquant ou incorrect."); - } + $eventGuid = $this->requireIdArg($args, 'Event'); $cardWidget = $this->widgetRepository->selectCardWidgetByGuid(null, $eventGuid); if (!$cardWidget) { throw new Exception("Aucun widget card trouvé pour le Event ID fourni."); } - $event = $this->eventRepository->selectByGuid($eventGuid); - if (!$event) { - throw new Exception("Event non trouvé."); - } - - $cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event) - ?? ['amount' => 0, 'donors' => 0, 'streams' => []]; - - $streams = $this->streamRepository->selectListByEvent($event); - $oldAmount = $cacheData['amount']; - $cacheData = $this->aggregateEventStreams($streams, $cacheData, true); - - if ($oldAmount !== $cacheData['amount']) { - $this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData); - } + $data = $this->fetchEventCardData($eventGuid); - $goal = $cardWidget->goal ?: 1; - $percentage = min(100, round(($cacheData['amount'] / 100) / $goal * 100)); - - $data = [ - "cardWidget" => $cardWidget, - "cardWidgetPictureUrl" => $cardWidget->image ? $this->fileManager->getPictureUrl($cardWidget->image) : null, - "currentAmount" => $cacheData['amount'], - "donorCount" => $cacheData['donors'], - "percentage" => $percentage, - "event" => 1 - ]; - - return $this->view->render($response, 'widget/card.html.twig', $data); + return $this->view->render($response, 'widget/card.html.twig', [ + 'cardWidget' => $cardWidget, + 'cardWidgetPictureUrl' => $cardWidget->image ? $this->fileManager->getPictureUrl($cardWidget->image) : null, + 'currentAmount' => $data['amount'], + 'donorCount' => $data['donors'], + 'percentage' => $this->calculatePercentage($data['amount'], $data['event']->goal), + 'goal' => $data['event']->goal ?: 1, + 'event' => 1, + ]); } public function widgetEventCardFetch(Request $request, Response $response, array $args): Response @@ -472,27 +442,9 @@ public function widgetEventCardFetch(Request $request, Response $response, array return $this->jsonError($response, 'Event ID manquant ou incorrect.', 400); } - $event = $this->eventRepository->selectByGuid($eventId); - if (!$event) { - return $this->jsonError($response, 'Event non trouvé.', 404); - } - - $cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event) - ?? ['amount' => 0, 'donors' => 0, 'streams' => []]; - try { - $streams = $this->streamRepository->selectListByEvent($event); - $oldAmount = $cacheData['amount']; - $cacheData = $this->aggregateEventStreams($streams, $cacheData, true); - - if ($oldAmount !== $cacheData['amount']) { - $this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData); - } - - return $this->jsonResponse($response, [ - 'amount' => $cacheData['amount'], - 'donors' => $cacheData['donors'] - ]); + $data = $this->fetchEventCardData($eventId); + return $this->jsonResponse($response, ['amount' => $data['amount'], 'donors' => $data['donors']]); } catch (Exception $e) { return $this->jsonError($response, 'Impossible de récupérer les données.', 500); } diff --git a/src/Models/Event.php b/src/Models/Event.php index eb18f7b..cef136a 100644 --- a/src/Models/Event.php +++ b/src/Models/Event.php @@ -7,6 +7,7 @@ class Event public $id; public $guid; public $title; + public $goal; public $creation_date; public $last_update; public $admin; diff --git a/src/Models/Stream.php b/src/Models/Stream.php index 2b7f322..d13ed3d 100644 --- a/src/Models/Stream.php +++ b/src/Models/Stream.php @@ -8,6 +8,7 @@ class Stream public $charity_event_id; public $guid; public $title; + public $goal; public $form_slug; public $organization_slug; public $creation_date; diff --git a/src/Models/User.php b/src/Models/User.php index d9149e9..dbf14ad 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -7,8 +7,10 @@ class User public $id; public $email; public $password; + public $email_verified; public $role; public $reset_token; + public $reset_token_expires_at; public $creation_date; public $last_update; } diff --git a/src/Repositories/EventRepository.php b/src/Repositories/EventRepository.php index 52e6172..8585c10 100644 --- a/src/Repositories/EventRepository.php +++ b/src/Repositories/EventRepository.php @@ -139,6 +139,28 @@ public function insert(string $title): Event } } + public function update(Event $event, array $data): void + { + $fields = []; + $params = []; + + if (array_key_exists('title', $data)) { + $fields[] = 'title = ?'; + $params[] = $data['title']; + } + if (array_key_exists('goal', $data)) { + $fields[] = 'goal = ?'; + $params[] = $data['goal']; + } + + if (empty($fields)) return; + + $params[] = $event->id; + $sql = 'UPDATE ' . $this->prefix . 'charity_event SET ' . implode(', ', $fields) . ' WHERE id = ?'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + } + public function delete(Event $event) { $this->pdo->beginTransaction(); diff --git a/src/Repositories/StreamRepository.php b/src/Repositories/StreamRepository.php index dfdbe81..d3aba5b 100644 --- a/src/Repositories/StreamRepository.php +++ b/src/Repositories/StreamRepository.php @@ -164,6 +164,39 @@ public function insert(string $form_slug, string $organization_slug, string $tit } } + public function updateEventLink(Stream $stream, ?int $eventId): void + { + $stmt = $this->pdo->prepare(' + UPDATE ' . $this->prefix . 'charity_stream + SET charity_event_id = ? + WHERE id = ? + '); + $stmt->execute([$eventId, $stream->id]); + $stream->charity_event_id = $eventId; + } + + public function update(Stream $stream, array $data): void + { + $fields = []; + $params = []; + + if (array_key_exists('title', $data)) { + $fields[] = 'title = ?'; + $params[] = $data['title']; + } + if (array_key_exists('goal', $data)) { + $fields[] = 'goal = ?'; + $params[] = $data['goal']; + } + + if (empty($fields)) return; + + $params[] = $stream->id; + $sql = 'UPDATE ' . $this->prefix . 'charity_stream SET ' . implode(', ', $fields) . ' WHERE id = ?'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + } + public function delete(Stream $stream): void { $this->pdo->beginTransaction(); diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index 7808ecc..bc8bb29 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -32,6 +32,37 @@ public function insert(string $email): User return $user; } + /** + * Crée un utilisateur avec un mot de passe choisi et l'email non vérifié. + */ + public function insertWithPassword(string $email, string $password): User + { + $stmt = $this->pdo->prepare('INSERT INTO ' . $this->prefix . 'users (email, password, email_verified) VALUES (:email, :password, 0)'); + $stmt->execute([ + ':email' => $email, + ':password' => password_hash($password, PASSWORD_DEFAULT), + ]); + + $user = new User(); + $user->id = $this->pdo->lastInsertId(); + $user->email = $email; + $user->email_verified = 0; + + return $user; + } + + /** + * Marque l'email d'un utilisateur comme vérifié. + */ + public function verifyEmail(User $user): void + { + $stmt = $this->pdo->prepare('UPDATE ' . $this->prefix . 'users SET email_verified = 1, reset_token = NULL, reset_token_expires_at = NULL WHERE id = :id'); + $stmt->execute([':id' => $user->id]); + $user->email_verified = 1; + $user->reset_token = null; + $user->reset_token_expires_at = null; + } + public function insertRight(User $user, ?Stream $stream, ?Event $event): void { $stmt = $this->pdo->prepare('INSERT INTO ' . $this->prefix . 'user_right (id_user, id_charity_event, id_charity_stream) VALUES (:id_user, :id_charity_event, :id_charity_stream)'); @@ -50,6 +81,13 @@ public function select(string $email): ?User return $stmt->fetch() ?: null; } + public function selectAll(): array + { + $stmt = $this->pdo->query('SELECT id, email, role, creation_date FROM ' . $this->prefix . 'users ORDER BY creation_date DESC'); + $stmt->setFetchMode(PDO::FETCH_CLASS, User::class); + return $stmt->fetchAll(); + } + public function findOrCreate(string $email): User { return $this->select($email) ?? $this->insert($email); diff --git a/src/Services/ApiWrapper.php b/src/Services/ApiWrapper.php index 45aae85..c17e52e 100644 --- a/src/Services/ApiWrapper.php +++ b/src/Services/ApiWrapper.php @@ -18,10 +18,9 @@ class ApiWrapper { - private $client; + private Client $client; public function __construct( - private AccessTokenRepository $accessTokenRepository, private AuthorizationCodeRepository $authorizationCodeRepository, private string $haAuthUrl, @@ -30,48 +29,65 @@ public function __construct( private string $clientId, private string $clientSecret, private string $webSiteDomain, - private Logger $apiLogger - + private Logger $apiLogger, ) { $this->client = new Client(); } - /** - * Génère un token d'accès global en utilisant le flux client_credentials, et le stocke en base de données. + * Exécute une requête HTTP via Guzzle avec gestion d'erreur centralisée. * - * @return AccessToken + * @return \Psr\Http\Message\ResponseInterface */ - private function generateGlobalAccessToken(): AccessToken + private function httpRequest(string $method, string $url, array $options, string $errorContext): \Psr\Http\Message\ResponseInterface { try { - $response = $this->client->request('POST', $this->apiAuthUrl, [ - 'form_params' => [ - 'grant_type' => 'client_credentials', - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret - ], - 'headers' => [ - 'content-type' => 'application/x-www-form-urlencoded', - 'accept' => 'application/json', - ], - ]); + return $this->client->request($method, $url, $options); } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors de la génération du token global: ' . $e->getMessage()); + $this->apiLogger->error("Erreur lors de {$errorContext}: " . $e->getMessage()); if ($e->hasResponse()) { $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); } - throw new Exception("Erreur lors de la requête d'authentification : " . $e->getMessage(), 0, $e); + throw new Exception("Erreur lors de {$errorContext} : " . $e->getMessage(), 0, $e); } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors de la génération du token global: ' . $e->getMessage()); + $this->apiLogger->error("Erreur Guzzle lors de {$errorContext}: " . $e->getMessage()); throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); } + } - $responseData = json_decode($response->getBody(), true); - + /** + * Décode la réponse JSON et vérifie sa validité. + */ + private function decodeJsonResponse(\Psr\Http\Message\ResponseInterface $response): array + { + $data = json_decode($response->getBody(), true); if (json_last_error() !== JSON_ERROR_NONE) { throw new Exception("Erreur de décodage JSON : " . json_last_error_msg()); } + return $data; + } + + + /** + * Génère un token d'accès global en utilisant le flux client_credentials, et le stocke en base de données. + * + * @return AccessToken + */ + private function generateGlobalAccessToken(): AccessToken + { + $response = $this->httpRequest('POST', $this->apiAuthUrl, [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ], + 'headers' => [ + 'content-type' => 'application/x-www-form-urlencoded', + 'accept' => 'application/json', + ], + ], 'la génération du token global'); + + $responseData = $this->decodeJsonResponse($response); if (!isset($responseData['access_token']) || !isset($responseData['refresh_token'])) { throw new Exception("Erreur : Les tokens ne sont pas présents dans la réponse."); @@ -107,37 +123,20 @@ private function generateGlobalAccessToken(): AccessToken * @param string $organization_slug * @return AccessToken */ - public function refreshToken($refreshToken, $organization_slug): ?AccessToken - { - try { - $response = $this->client->request('POST', $this->apiAuthUrl, [ - 'form_params' => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refreshToken, - ], - 'headers' => [ - 'content-type' => 'application/x-www-form-urlencoded', - 'accept' => 'application/json', - ], - ]); - } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors du refresh token pour ' . $organization_slug . ': ' . $e->getMessage()); - if ($e->hasResponse()) { - $statusCode = $e->getResponse()->getStatusCode(); - $this->apiLogger->error('Response status: ' . $statusCode); - $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); - } - throw new Exception("Erreur lors du rafraîchissement du token : " . $e->getMessage(), 0, $e); - } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors du refresh token pour ' . $organization_slug . ': ' . $e->getMessage()); - throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); - } - - $responseData = json_decode($response->getBody(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception("Erreur de décodage JSON : " . json_last_error_msg()); - } + public function refreshToken(string $refreshToken, string $organizationSlug): ?AccessToken + { + $response = $this->httpRequest('POST', $this->apiAuthUrl, [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ], + 'headers' => [ + 'content-type' => 'application/x-www-form-urlencoded', + 'accept' => 'application/json', + ], + ], "le refresh token pour {$organizationSlug}"); + + $responseData = $this->decodeJsonResponse($response); if (!isset($responseData['access_token']) || !isset($responseData['refresh_token'])) { throw new Exception("Erreur : Les tokens ne sont pas présents dans la réponse."); @@ -149,13 +148,11 @@ public function refreshToken($refreshToken, $organization_slug): ?AccessToken $obj = new AccessToken(); $obj->access_token = $responseData['access_token']; $obj->refresh_token = $responseData['refresh_token']; - $obj->organization_slug = $organization_slug; + $obj->organization_slug = $organizationSlug; $obj->access_token_expires_at = $accessTokenExpiresAt; $obj->refresh_token_expires_at = $refreshTokenExpiresAt; - $this->apiLogger->info('New organisation access token generated successfully. it will expires at '.$obj->access_token_expires_at->format('Y-m-d H:i:s')); - return $this->accessTokenRepository->update( - $obj - ); + $this->apiLogger->info('New organisation access token generated successfully. it will expires at ' . $obj->access_token_expires_at->format('Y-m-d H:i:s')); + return $this->accessTokenRepository->update($obj); } /** @@ -188,32 +185,29 @@ public function getGlobalAccessToken(): AccessToken * @param string $organization_slug * @return AccessToken|null */ - public function getOrganizationAccessToken(string $organization_slug): AccessToken + public function getOrganizationAccessToken(string $organizationSlug): AccessToken { - $tokenData = $this->accessTokenRepository->selectBySlug($organization_slug); + $tokenData = $this->accessTokenRepository->selectBySlug($organizationSlug); - $expiration_refresh_date = $tokenData->refresh_token_expires_at ?? false; - $expiration_access_date = $tokenData->access_token_expires_at ?? false; + if ($tokenData === null) { + $this->apiLogger->error('Aucun token trouvé pour organization_slug: ' . $organizationSlug); + throw new Exception('Aucun token trouvé pour l\'organisation: ' . $organizationSlug); + } - if ($this->isExpired($expiration_access_date)) { - $this->apiLogger->debug('Access token for organization_slug: ' . $organization_slug . ' is expired. Attempting to refresh token.'); - $tokenData = $this->refreshToken( $tokenData->refresh_token, $organization_slug); - $this->apiLogger->info('Access token refreshed for organization_slug: ' . $organization_slug . '. New expiry time: ' . - ($tokenData->access_token_expires_at instanceof DateTime ? $tokenData->access_token_expires_at->format('Y-m-d H:i:s') : $tokenData->access_token_expires_at)); + $this->apiLogger->info('Check expiration for access token of organization_slug: ' . $organizationSlug); - } - - $this->apiLogger->info('Check expiration for access token of organization_slug: ' . $organization_slug); - if ($this->isExpired($expiration_refresh_date)) { - $this->apiLogger->error('Refresh token is expired for organization_slug: ' . $organization_slug); + if ($this->isExpired($tokenData->refresh_token_expires_at ?? false)) { + $this->apiLogger->error('Refresh token is expired for organization_slug: ' . $organizationSlug); throw new Exception('Invalid token data: refresh_token is expired'); } - if ($tokenData === null) { - $this->apiLogger->error('Aucun token trouvé pour organization_slug: ' . $organization_slug); - throw new Exception('Aucun token trouvé pour l\'organisation: ' . $organization_slug); + if ($this->isExpired($tokenData->access_token_expires_at ?? false)) { + $this->apiLogger->debug('Access token for organization_slug: ' . $organizationSlug . ' is expired. Attempting to refresh token.'); + $tokenData = $this->refreshToken($tokenData->refresh_token, $organizationSlug); + $this->apiLogger->info('Access token refreshed for organization_slug: ' . $organizationSlug . '. New expiry time: ' . + ($tokenData->access_token_expires_at instanceof \DateTime ? $tokenData->access_token_expires_at->format('Y-m-d H:i:s') : $tokenData->access_token_expires_at)); } - + return $tokenData; } @@ -223,15 +217,15 @@ public function getOrganizationAccessToken(string $organization_slug): AccessTok * @param [type] $expirationDate * @return boolean */ - private function isExpired($expirationDate): bool + private function isExpired(string|\DateTime|false $expirationDate): bool { - if(!$expirationDate) { + if (!$expirationDate) { return true; } - $expiration = is_string($expirationDate) ? new DateTime($expirationDate) : $expirationDate; - $now = new DateTime(); + $expiration = is_string($expirationDate) ? new \DateTime($expirationDate) : $expirationDate; + $now = new \DateTime(); $this->apiLogger->debug('Current time: ' . $now->format('Y-m-d H:i:s')); - $this->apiLogger->debug('Refresh token expiry time: ' . $expiration->format('Y-m-d H:i:s')); + $this->apiLogger->debug('Token expiry time: ' . $expiration->format('Y-m-d H:i:s')); return $expiration < $now; } @@ -279,33 +273,18 @@ public function getDonationForms(string $organizationSlug): array { $tokenData = $this->getOrganizationAccessToken($organizationSlug); - try { - $response = $this->client->request('GET', "{$this->apiUrl}/organizations/{$organizationSlug}/forms", [ - 'query' => [ - 'formTypes' => 'Donation', - 'pageSize' => 50, - ], - 'headers' => [ - 'Authorization' => 'Bearer ' . $tokenData->access_token, - 'accept' => 'application/json', - ], - ]); - } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors de la récupération des formulaires de don: ' . $e->getMessage()); - if ($e->hasResponse()) { - $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); - } - throw new Exception("Erreur lors de la récupération des formulaires : " . $e->getMessage(), 0, $e); - } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors de la récupération des formulaires: ' . $e->getMessage()); - throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); - } - - $data = json_decode($response->getBody(), true); + $response = $this->httpRequest('GET', "{$this->apiUrl}/organizations/{$organizationSlug}/forms", [ + 'query' => [ + 'formTypes' => 'Donation', + 'pageSize' => 50, + ], + 'headers' => [ + 'Authorization' => 'Bearer ' . $tokenData->access_token, + 'accept' => 'application/json', + ], + ], "la récupération des formulaires de don pour {$organizationSlug}"); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception("Erreur de décodage JSON : " . json_last_error_msg()); - } + $data = $this->decodeJsonResponse($response); return $data['data'] ?? []; } @@ -316,29 +295,16 @@ public function getDonationForms(string $organizationSlug): array * @param [type] $accessToken * @return void */ - public function setClientDomain($accessToken) + public function setClientDomain(string $accessToken): void { - try { - $this->client->request('PUT', "$this->apiUrl/partners/me/api-clients", [ - 'body' => json_encode([ - "Domain" => $this->webSiteDomain - ]), - 'headers' => [ - 'content-type' => 'application/*+json', - 'accept' => 'application/json', - 'Authorization' => "Bearer $accessToken", - ], - ]); - } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors de la configuration du domaine client: ' . $e->getMessage()); - if ($e->hasResponse()) { - $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); - } - throw new Exception("Erreur lors de la configuration du domaine : " . $e->getMessage(), 0, $e); - } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors de la configuration du domaine client: ' . $e->getMessage()); - throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); - } + $this->httpRequest('PUT', "{$this->apiUrl}/partners/me/api-clients", [ + 'body' => json_encode(["Domain" => $this->webSiteDomain]), + 'headers' => [ + 'content-type' => 'application/*+json', + 'accept' => 'application/json', + 'Authorization' => "Bearer {$accessToken}", + ], + ], 'la configuration du domaine client'); } /** @@ -373,39 +339,24 @@ public function storeOrUpdateToken(array $tokenData): AccessToken * @param [type] $codeVerifier * @return void */ - public function exchangeAuthorizationCode($code, $redirect_uri, $codeVerifier) + public function exchangeAuthorizationCode(string $code, string $redirectUri, string $codeVerifier): array { - try { - $response = $this->client->request('POST', $this->apiAuthUrl, [ - 'form_params' => [ - 'grant_type' => 'authorization_code', - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - 'code' => $code, - 'redirect_uri' => $redirect_uri, - 'code_verifier' => $codeVerifier - ], - 'headers' => [ - 'content-type' => 'application/x-www-form-urlencoded', - 'accept' => 'application/json', - ], - ]); - } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors de l\'échange du code d\'autorisation: ' . $e->getMessage()); - if ($e->hasResponse()) { - $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); - } - throw new Exception("Erreur lors de l'échange du code d'autorisation : " . $e->getMessage(), 0, $e); - } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors de l\'échange du code d\'autorisation: ' . $e->getMessage()); - throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); - } - - $responseData = json_decode($response->getBody(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception("Erreur de décodage JSON : " . json_last_error_msg()); - } + $response = $this->httpRequest('POST', $this->apiAuthUrl, [ + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'code_verifier' => $codeVerifier, + ], + 'headers' => [ + 'content-type' => 'application/x-www-form-urlencoded', + 'accept' => 'application/json', + ], + ], "l'échange du code d'autorisation"); + + $responseData = $this->decodeJsonResponse($response); if ( !isset($responseData['access_token']) || @@ -428,31 +379,25 @@ public function exchangeAuthorizationCode($code, $redirect_uri, $codeVerifier) * @param [type] $continuationToken * @return array */ - private function getDonationFormOrders($organizationSlug, $donationSlug, $accessToken, $continuationToken = null) + private function getDonationFormOrders(string $organizationSlug, string $donationSlug, string $accessToken, ?string $continuationToken = null): array { $query = ['withDetails' => 'true', 'sortOrder' => 'asc']; if ($continuationToken) { $query['continuationToken'] = $continuationToken; } - try { - $response = $this->client->request('GET', $this->apiUrl . '/organizations/' . $organizationSlug . '/forms/donation/' . $donationSlug . '/orders', [ + $response = $this->httpRequest( + 'GET', + "{$this->apiUrl}/organizations/{$organizationSlug}/forms/donation/{$donationSlug}/orders", + [ 'query' => $query, 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'accept' => 'application/json', ], - ]); - } catch (RequestException $e) { - $this->apiLogger->error('Erreur lors de la récupération des commandes: ' . $e->getMessage()); - if ($e->hasResponse()) { - $this->apiLogger->error('Response body: ' . $e->getResponse()->getBody()); - } - throw new Exception("Erreur lors de la récupération des commandes : " . $e->getMessage(), 0, $e); - } catch (GuzzleException $e) { - $this->apiLogger->error('Erreur Guzzle lors de la récupération des commandes: ' . $e->getMessage()); - throw new Exception("Erreur de connexion à l'API : " . $e->getMessage(), 0, $e); - } + ], + "la récupération des commandes pour {$organizationSlug}/{$donationSlug}", + ); return json_decode($response->getBody(), true); } @@ -513,13 +458,11 @@ public function getAllOrders(string $organizationSlug, string $formSlug, int $cu $amount = isset($order['amount']['total']) && is_numeric($order['amount']['total']) ? $order['amount']['total'] : 0; $currentAmount += $amount; - $donation = [ + $donations[] = [ "pseudo" => $pseudo, "message" => $message, - "amount" => $amount + "amount" => $amount, ]; - - array_push($donations, $donation); } $previousToken = $continuationToken; diff --git a/src/views/event/edit.html.twig b/src/views/event/edit.html.twig index 236c112..2845653 100644 --- a/src/views/event/edit.html.twig +++ b/src/views/event/edit.html.twig @@ -2,10 +2,40 @@ {% extends "layout.html.twig" %} {% block content %}
-

Édition

+

Édition de l'évènement

Retour + {# ── Informations de l'événement ──────────────────────────── #} +
+
+
📋 Informations de l'événement
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+
+ {# ── Streams liés à cet événement ────────────────────────── #}
@@ -79,22 +109,18 @@
-
- - -
-

{{ donationGoalWidget.goal / 2 }} €

+

{{ event.goal / 2 }} €

{{ donationGoalWidget.text_content }}

-

{{ donationGoalWidget.goal }} €

+

{{ event.goal }} €

-

{{ donationGoalWidget.goal / 2 }} €

+

{{ event.goal / 2 }} €

{{ donationGoalWidget.text_content }}

-

{{ donationGoalWidget.goal }} €

+

{{ event.goal }} €

@@ -143,10 +169,6 @@
-
- - -
Couleurs
@@ -188,12 +210,12 @@
{{ cardWidget.title }}
{{ cardWidget.description }}
-
{{ (cardWidget.goal / 2) }} €
+
{{ (event.goal / 2) }} €
-
Objectif : {{ cardWidget.goal }} €
+
Objectif : {{ event.goal }} €
50% collectés diff --git a/src/views/index.html.twig b/src/views/index.html.twig index dd38a4d..cea28ff 100644 --- a/src/views/index.html.twig +++ b/src/views/index.html.twig @@ -2,7 +2,7 @@ {% block title %}HelloAsso Widgets{% endblock %} {% extends "layout.html.twig" %} {% block content %} -
+

HelloAsso Widgets

@@ -16,7 +16,10 @@ - + +
{% if login_failed %} @@ -60,6 +63,62 @@
{% endif %} + + {% if register_success %} +
+ +
+ {% endif %} + + {% if email_verified %} +
+ +
+ {% endif %} + + {% if email_verify_error %} +
+ +
+ {% endif %} + + {% if email_not_verified %} +
+ +
+ {% endif %} {% endblock %} {% block scripts %} {% endblock %} diff --git a/src/views/register.html.twig b/src/views/register.html.twig new file mode 100644 index 0000000..70e404d --- /dev/null +++ b/src/views/register.html.twig @@ -0,0 +1,115 @@ + +{% block title %}Créer un compte — HelloAsso Widgets{% endblock %} +{% extends "layout.html.twig" %} +{% block content %} +
+

Créer un compte

+
+
+ + +
+
+ + +
+ ✗ Au moins 8 caractères + ✗ Au moins une majuscule + ✗ Au moins une minuscule + ✗ Au moins un chiffre + ✗ Au moins un caractère spécial +
+
+
+ + + +
+ + +
+
+ + {% if register_error %} +
+ +
+ {% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} + diff --git a/src/views/stream/_modal-create-stream.html.twig b/src/views/stream/_modal-create-stream.html.twig new file mode 100644 index 0000000..7ed26f3 --- /dev/null +++ b/src/views/stream/_modal-create-stream.html.twig @@ -0,0 +1,229 @@ +{# ── Modal création stream ── + Variables attendues : isAdmin, events, ownerEmail, currentUser, + organizationSlug, formSlug, title +#} + + diff --git a/src/views/stream/_scripts-create-stream.html.twig b/src/views/stream/_scripts-create-stream.html.twig new file mode 100644 index 0000000..8ea097b --- /dev/null +++ b/src/views/stream/_scripts-create-stream.html.twig @@ -0,0 +1,138 @@ +{# ── Scripts partagés pour la création de stream ── + Variables attendues : openCreateStream, selectedEventId, openCreateEvent +#} + + diff --git a/src/views/stream/edit.html.twig b/src/views/stream/edit.html.twig index 26aa910..bee1fd3 100644 --- a/src/views/stream/edit.html.twig +++ b/src/views/stream/edit.html.twig @@ -5,20 +5,88 @@ {% block content %}
-

Édition

+

Édition du stream

- Retour + Retour - {% if parentEvent %} -
- 📅 Événement parent : - {{ parentEvent.title }} + {% for key, msgs in messages|default({}) %} + {% for msg in msgs %} + - {% endif %} + {% endfor %} + {% endfor %} -
- Formulaire de don Helloasso - {{ donationUrl }} + {# ── Événement parent (lier / délier) ──────────────────────── #} +
+
+
📅 Événement parent
+
+
+ {% if parentEvent %} +
+
+ {{ parentEvent.title }} + 🔗 Voir l'événement +
+
+ +
+
+ {% else %} +
+
+ + +
+ +
+ {% if availableEvents is empty %} +

Aucun événement disponible. Créer un événement.

+ {% endif %} + {% endif %} +
+
+ + {# ── Informations du stream ────────────────────────────────── #} +
+
+
📋 Informations du stream
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+
+ +
+ Formulaire HelloAsso lié + 🔗 Ouvrir le formulaire
@@ -57,22 +125,18 @@
-
- - -
-

{{ donationGoalWidget.goal / 2 }} €

+

{{ charityStream.goal / 2 }} €

{{ donationGoalWidget.text_content }}

-

{{ donationGoalWidget.goal }} €

+

{{ charityStream.goal }} €

-

{{ donationGoalWidget.goal / 2 }} €

+

{{ charityStream.goal / 2 }} €

{{ donationGoalWidget.text_content }}

-

{{ donationGoalWidget.goal }} €

+

{{ charityStream.goal }} €

@@ -190,15 +254,11 @@
-
- - -
-
- - -
+
+ +
+
Couleurs
@@ -239,12 +299,12 @@
{{ cardWidget.title }}
{{ cardWidget.description }}
-
{{ (cardWidget.goal / 2) }} €
+
{{ (charityStream.goal / 2) }} €
-
Objectif : {{ cardWidget.goal }} €
+
Objectif : {{ charityStream.goal }} €
50% collectés diff --git a/src/views/stream/index-admin.html.twig b/src/views/stream/index-admin.html.twig index f7f6ee7..df5e885 100644 --- a/src/views/stream/index-admin.html.twig +++ b/src/views/stream/index-admin.html.twig @@ -1,4 +1,3 @@ - {% block title %}HelloAsso Widgets - Administration{% endblock %} {% extends "layout.html.twig" %} @@ -8,18 +7,18 @@