├── .editorconfig ├── .env.end_to_end_testing ├── .github └── workflows │ └── code_analysis.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── cleanup ├── composer ├── console ├── fix ├── install ├── load-users ├── php ├── restart └── test ├── composer.json ├── composer.lock ├── config ├── application_testing.php ├── autoload │ ├── dependencies.global.php │ └── mezzio.global.php ├── config.php ├── container.php ├── end_to_end_testing.php ├── pipeline.php └── routes.php ├── console.php ├── docker-compose.yml ├── docker ├── nginx │ ├── Dockerfile │ └── template.conf ├── php-fpm │ ├── Dockerfile │ └── php.ini ├── php │ ├── Dockerfile │ └── php.ini └── web │ ├── Dockerfile │ └── php.ini ├── ecs.php ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist ├── public ├── .htaccess ├── img │ └── logo.png └── index.php ├── rector.php ├── src ├── App │ ├── AddFlashMessage.php │ ├── Application.php │ ├── ApplicationInterface.php │ ├── Cli │ │ ├── ConsoleApplication.php │ │ ├── ConsumeEventsCommand.php │ │ ├── ExportUsersCommand.php │ │ ├── OutboxRelayCommand.php │ │ └── SignUpCommand.php │ ├── ConfigProvider.php │ ├── ConfigurableEventDispatcher.php │ ├── ConnectionFactory.php │ ├── Entity │ │ ├── CouldNotFindUser.php │ │ ├── EventRecordingCapabilities.php │ │ ├── User.php │ │ ├── UserHasSignedUp.php │ │ ├── UserId.php │ │ ├── UserRepository.php │ │ ├── UserRepositoryUsingDbal.php │ │ └── UserType.php │ ├── EventDispatcher.php │ ├── EventDispatcherFactory.php │ ├── ExternalEvents │ │ ├── AsynchronousExternalEventPublisher.php │ │ ├── ConsumerRestarted.php │ │ ├── EventStreamConfigProvider.php │ │ ├── ExternalEventConsumer.php │ │ ├── ExternalEventConsumersFactory.php │ │ ├── ExternalEventPublisher.php │ │ ├── PublishExternalEvent.php │ │ └── SynchronousExternalEventPublisher.php │ ├── Handler │ │ ├── LoginHandler.php │ │ ├── LogoutHandler.php │ │ ├── SignUpHandler.php │ │ └── SwitchUserHandler.php │ ├── Json.php │ ├── Mapping.php │ ├── SchemaManager.php │ ├── Session.php │ └── Twig │ │ └── SessionExtension.php ├── Billing │ ├── Handler │ │ ├── CreateInvoiceHandler.php │ │ ├── DeleteInvoiceHandler.php │ │ ├── ListInvoicesHandler.php │ │ └── ListOrganizersHandler.php │ ├── Projections │ │ └── OrganizerProjection.php │ └── ViewModel │ │ ├── Invoice.php │ │ └── Organizer.php └── MeetupOrganizing │ ├── Application │ ├── RsvpForMeetup.php │ └── SignUp.php │ ├── Entity │ ├── Answer.php │ ├── CouldNotFindMeetup.php │ ├── CouldNotFindRsvp.php │ ├── MeetupId.php │ ├── Rsvp.php │ ├── RsvpId.php │ ├── RsvpRepository.php │ ├── RsvpWasCancelled.php │ └── UserHasRsvpd.php │ ├── Handler │ ├── ApiCountMeetupsHandler.php │ ├── ApiPingHandler.php │ ├── CancelMeetupHandler.php │ ├── CancelRsvpHandler.php │ ├── ListMeetupsHandler.php │ ├── MeetupDetailsHandler.php │ ├── RescheduleMeetupHandler.php │ ├── RsvpForMeetupHandler.php │ └── ScheduleMeetupHandler.php │ ├── Infrastructure │ └── RsvpRepositoryUsingDbal.php │ └── ViewModel │ ├── MeetupDetails.php │ ├── MeetupDetailsRepository.php │ └── Organizer.php ├── templates ├── admin │ └── list-organizers.html.twig ├── app │ ├── list-meetups.html.twig │ ├── login.html.twig │ ├── meetup-details.html.twig │ ├── reschedule-meetup.html.twig │ ├── schedule-meetup.html.twig │ └── sign-up.html.twig ├── billing │ ├── create-invoice.html.twig │ └── list-invoices.html.twig ├── error │ ├── 404.html.twig │ └── error.html.twig └── layout │ ├── _flashes.html.twig │ ├── _navigation.html.twig │ └── default.html.twig ├── test ├── AppTest │ ├── AbstractApplicationTest.php │ ├── AbstractBrowserTest.php │ ├── ApiTest.php │ ├── ApplicationLevelInvoicingTest.php │ ├── CancelMeetupTest.php │ ├── InvoicingTest.php │ ├── PageObject │ │ ├── AbstractPageObject.php │ │ ├── CreateInvoicePage.php │ │ ├── GenericPage.php │ │ ├── ListInvoicesPage.php │ │ ├── ListMeetupsPage.php │ │ ├── ListOrganizersPage.php │ │ ├── LoginPage.php │ │ ├── MeetupDetailsPage.php │ │ ├── MeetupSnippet.php │ │ ├── OrganizerSnippet.php │ │ ├── RescheduleMeetupPage.php │ │ ├── ScheduleMeetupPage.php │ │ └── SignUpPage.php │ ├── RescheduleMeetupTest.php │ ├── RsvpForMeetupTest.php │ ├── ScheduleMeetupTest.php │ ├── SignUpCommandTest.php │ ├── SuccessfulResponse.php │ └── UnsuccessfulResponse.php └── JsonTest.php └── var ├── .gitkeep └── session └── .gitkeep /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.{php,json,yaml}] 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.env.end_to_end_testing: -------------------------------------------------------------------------------- 1 | APPLICATION_ENV=end_to_end_testing 2 | -------------------------------------------------------------------------------- /.github/workflows/code_analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Code Analysis 2 | 3 | on: 4 | pull_request: null 5 | push: null 6 | 7 | jobs: 8 | code_analysis: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | actions: 13 | - 14 | name: "PHPStan and PHPUnit" 15 | run: bin/test 16 | 17 | name: ${{ matrix.actions.name }} 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - run: bin/install 23 | - run: ${{ matrix.actions.run }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.env 3 | /var/ 4 | /.idea 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sandbox project for the "Hexagonal Architecture" training 2 | 3 | You'll find all the available training programs here: 4 | 5 | ## Requirements 6 | 7 | - Docker Engine 8 | - Docker Compose 9 | - Git 10 | - Bash 11 | 12 | ## Getting started 13 | 14 | - Clone this repository (`git clone git@github.com:matthiasnoback/hexagonal-architecture-workshop.git`) and `cd` into it. 15 | - Run `bin/install`. 16 | - Open in a browser. You should see the homepage of the Bunchup application. 17 | 18 | If port 8000 is no longer available on your local machine, modify `docker-compose.yml` to publish to another port: 19 | 20 | ```yaml 21 | ports: 22 | # To try port 8081: 23 | - "8001:8080" 24 | ``` 25 | 26 | ## Running development tools 27 | 28 | - Run `bin/load-users` to create a standard set of users 29 | - Run `bin/composer` to use Composer (e.g. `bin/composer require symfony/var-dumper`) 30 | - Run `bin/test` to run all tests, including PHPStan 31 | - Run `bin/console` to run CLI commands specific to this application (e.g. `bin/console sign-up`) 32 | 33 | ## Cleaning up after the workshop 34 | 35 | - Run `bin/cleanup` to remove all containers for this project, their images, and their volumes. 36 | - Remove the project directory. 37 | -------------------------------------------------------------------------------- /bin/cleanup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | echo "Stopping services and removing their images and volumes" 6 | docker compose down --rmi all -v --remove-orphans 7 | 8 | echo "" 9 | echo "Now delete the project folder" 10 | echo "" 11 | -------------------------------------------------------------------------------- /bin/composer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose run --rm composer "$@" 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose run --rm php php console.php "$@" 4 | -------------------------------------------------------------------------------- /bin/fix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | docker compose run --rm php vendor/bin/rector process --ansi 6 | 7 | docker compose run --rm php vendor/bin/ecs check --fix --ansi 8 | -------------------------------------------------------------------------------- /bin/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | echo "Creating .env file" 6 | printf "HOST_UID=%s\nHOST_GID=%s\n" "$(id -u)" "$(id -g)" > .env 7 | 8 | echo "Pulling Docker images" 9 | docker compose pull 10 | 11 | echo "Installing Composer dependencies" 12 | docker compose run --rm composer install --ignore-platform-reqs 13 | 14 | echo "Starting all services in docker compose.yml" 15 | docker compose up -d 16 | 17 | echo "" 18 | echo "Now open http://localhost:8000/ in your browser" 19 | echo "" 20 | -------------------------------------------------------------------------------- /bin/load-users: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | echo "Sign up: Administrator" 6 | bin/console sign-up Administrator administrator@gmail.com Administrator 7 | 8 | echo "Sign up: Organizer" 9 | bin/console sign-up Organizer organizer@gmail.com Organizer 10 | 11 | echo "Sign up: Regular user" 12 | bin/console sign-up User user@gmail.com RegularUser 13 | -------------------------------------------------------------------------------- /bin/php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose run --rm php php "$@" 4 | -------------------------------------------------------------------------------- /bin/restart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker compose up -d --force-recreate 4 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | docker compose run --rm php vendor/bin/phpstan analyze --ansi --no-progress 6 | 7 | docker compose up -d --force-recreate 8 | 9 | docker compose run --rm php vendor/bin/phpunit --colors 10 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "sort-packages": true, 4 | "allow-plugins": { 5 | "composer/package-versions-deprecated": true, 6 | "laminas/laminas-component-installer": true, 7 | "phpstan/extension-installer": true 8 | } 9 | }, 10 | "require": { 11 | "php": "~8.1.0", 12 | "ext-json": "*", 13 | "beberlei/assert": "^3.3", 14 | "composer/package-versions-deprecated": "^1.10.99", 15 | "doctrine/dbal": "^2.9", 16 | "laminas/laminas-config-aggregator": "^1.6", 17 | "laminas/laminas-diactoros": "^2.7", 18 | "laminas/laminas-pimple-config": "^1.1.1", 19 | "laminas/laminas-stdlib": "^3.6", 20 | "matthiasnoback/tail-event-stream": "^0.1.1", 21 | "mezzio/mezzio": "^3.7", 22 | "mezzio/mezzio-fastroute": "^3.0.3", 23 | "mezzio/mezzio-helpers": "^5.7", 24 | "mezzio/mezzio-twigrenderer": "^2.6", 25 | "php-http/guzzle7-adapter": "^0.1.1", 26 | "ramsey/uuid": "^4.2", 27 | "symfony/console": "^6.0", 28 | "ext-pcntl": "*" 29 | }, 30 | "require-dev": { 31 | "ext-dom": "*", 32 | "filp/whoops": "^2.7.1", 33 | "mezzio/mezzio-tooling": "^2.1", 34 | "phil-nelson/phpstan-container-extension": "^0.1.0", 35 | "phpstan/extension-installer": "^1.1", 36 | "phpstan/phpstan": "^1.3", 37 | "phpstan/phpstan-beberlei-assert": "^1.0", 38 | "phpstan/phpstan-phpunit": "^1.0", 39 | "phpunit/phpunit": "^9.5.11", 40 | "rector/rector": "^0.12.12", 41 | "roave/security-advisories": "dev-master", 42 | "symfony/css-selector": "^6.0", 43 | "symfony/mime": "^6.0", 44 | "symfony/panther": "^2.0", 45 | "symplify/coding-standard": "^10.0", 46 | "symplify/easy-coding-standard": "^10.0" 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "": "src/" 51 | } 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "AppTest\\": "test/AppTest/" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/application_testing.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'factories' => [ 12 | ExternalEventPublisher::class => fn (ContainerInterface $container) => new SynchronousExternalEventPublisher( 13 | $container->get('external_event_consumers') 14 | ), 15 | // TODO define application test-specific factories here, which will override earlier service definitions 16 | ], 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /config/autoload/dependencies.global.php: -------------------------------------------------------------------------------- 1 | [ 16 | // Use 'aliases' to alias a service name to another service. The 17 | // key is the alias name, the value is the service to which it points. 18 | 'aliases' => [ 19 | // Fully\Qualified\ClassOrInterfaceName::class => Fully\Qualified\ClassName::class, 20 | ], 21 | // Use 'invokables' for constructor-less services, or services that do 22 | // not require arguments to the constructor. Map a service name to the 23 | 24 | 'invokables' => [ 25 | // Fully\Qualified\InterfaceName::class => Fully\Qualified\ClassName::class, 26 | ], 27 | // Use 'factories' for services provided by callbacks/factory classes. 28 | 'factories' => [ 29 | ErrorResponseGenerator::class => WhoopsErrorResponseGeneratorFactory::class, 30 | 'Mezzio\Whoops' => WhoopsFactory::class, 31 | 'Mezzio\WhoopsPageHandler' => WhoopsPageHandlerFactory::class, 32 | ], 33 | // Fully\Qualified\ClassName::class => Fully\Qualified\FactoryName::class, 34 | ], 35 | 'whoops' => [ 36 | 'json_exceptions' => [ 37 | 'display' => true, 38 | 'show_trace' => true, 39 | 'ajax_only' => true, 40 | ], 41 | ], 42 | ]; 43 | -------------------------------------------------------------------------------- /config/autoload/mezzio.global.php: -------------------------------------------------------------------------------- 1 | false, 13 | 14 | // Enable debugging; typically used to provide debugging information within templates. 15 | 'debug' => true, 16 | 'environment' => $_ENV['APPLICATION_ENV'] ?? 'development', 17 | 'mezzio' => [ 18 | // Provide templates for the error handling middleware to use when 19 | // generating responses. 20 | 'error_handler' => [ 21 | 'template_404' => 'error::404', 22 | 'template_error' => 'error::error', 23 | ], 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | getMergedConfig(); 35 | -------------------------------------------------------------------------------- /config/container.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'factories' => [ 12 | ExternalEventPublisher::class => fn (ContainerInterface $container) => new SynchronousExternalEventPublisher( 13 | $container->get('external_event_consumers') 14 | ), 15 | ], 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /config/pipeline.php: -------------------------------------------------------------------------------- 1 | pipe(ErrorHandler::class); 26 | $app->pipe(ServerUrlMiddleware::class); 27 | 28 | // Pipe more middleware here that you want to execute on every request: 29 | // - bootstrapping 30 | // - pre-conditions 31 | // - modifications to outgoing responses 32 | // 33 | // Piped Middleware may be either callables or service names. Middleware may 34 | // also be passed as an array; each item in the array must resolve to 35 | // middleware eventually (i.e., callable or service name). 36 | // 37 | // Middleware can be attached to specific paths, allowing you to mix and match 38 | // applications under a common domain. The handlers in each middleware 39 | // attached this way will see a URI with the matched path segment removed. 40 | // 41 | // i.e., path of "/api/member/profile" only passes "/member/profile" to $apiMiddleware 42 | // - $app->pipe('/api', $apiMiddleware); 43 | // - $app->pipe('/docs', $apiDocMiddleware); 44 | // - $app->pipe('/files', $filesMiddleware); 45 | 46 | // Register the routing middleware in the middleware pipeline. 47 | // This middleware registers the Mezzio\Router\RouteResult request attribute. 48 | $app->pipe(RouteMiddleware::class); 49 | 50 | // The following handle routing failures for common conditions: 51 | // - HEAD request but no routes answer that method 52 | // - OPTIONS request but no routes answer that method 53 | // - method not allowed 54 | // Order here matters; the MethodNotAllowedMiddleware should be placed 55 | // after the Implicit*Middleware. 56 | $app->pipe(ImplicitHeadMiddleware::class); 57 | $app->pipe(ImplicitOptionsMiddleware::class); 58 | $app->pipe(MethodNotAllowedMiddleware::class); 59 | 60 | // Seed the UrlHelper with the routing results: 61 | $app->pipe(UrlHelperMiddleware::class); 62 | 63 | // Add more middleware here that needs to introspect the routing results; this 64 | // might include: 65 | // 66 | // - route-based authentication 67 | // - route-based validation 68 | // - etc. 69 | 70 | // Register the dispatch middleware in the middleware pipeline 71 | $app->pipe(DispatchMiddleware::class); 72 | 73 | // At this point, if no Response is returned by any middleware, the 74 | // NotFoundHandler kicks in; alternately, you can provide other fallback 75 | // middleware to execute. 76 | $app->pipe(NotFoundHandler::class); 77 | }; 78 | -------------------------------------------------------------------------------- /config/routes.php: -------------------------------------------------------------------------------- 1 | route('/schedule-meetup', ScheduleMeetupHandler::class, ['GET', 'POST'], 'schedule_meetup'); 28 | $app->route('/meetup-details/{id:.+}', MeetupDetailsHandler::class, ['GET'], 'meetup_details'); 29 | $app->route('/rsvp-for-meetup', RsvpForMeetupHandler::class, ['POST'], 'rsvp_for_meetup'); 30 | $app->route('/cancel-rsvp', CancelRsvpHandler::class, ['POST'], 'cancel_rsvp'); 31 | $app->route('/reschedule-meetup/{id:.+}', RescheduleMeetupHandler::class, ['GET', 'POST'], 'reschedule_meetup'); 32 | $app->route('/cancel-meetup', CancelMeetupHandler::class, ['POST'], 'cancel_meetup'); 33 | $app->route('/', ListMeetupsHandler::class, ['GET'], 'list_meetups'); 34 | $app->route('/sign-up', SignUpHandler::class, ['GET', 'POST'], 'sign_up'); 35 | $app->route('/login', LoginHandler::class, ['GET', 'POST'], 'login'); 36 | $app->route('/logout', LogoutHandler::class, ['POST'], 'logout'); 37 | $app->route('/switch-user', SwitchUserHandler::class, ['POST'], 'switch_user'); 38 | $app->route('/admin/list-organizers', ListOrganizersHandler::class, ['GET'], 'list_organizers'); 39 | $app->route('/billing/list-invoices/{organizerId:.+}', ListInvoicesHandler::class, ['GET'], 'list_invoices'); 40 | $app->route( 41 | '/billing/create-invoice/{organizerId:.+}', 42 | CreateInvoiceHandler::class, 43 | ['GET', 'POST'], 44 | 'create_invoice' 45 | ); 46 | $app->route( 47 | '/billing/delete-invoice/{organizerId:.+}/{invoiceId:.+}', 48 | DeleteInvoiceHandler::class, 49 | ['POST'], 50 | 'delete_invoice' 51 | ); 52 | $app->route('/api/ping', ApiPingHandler::class, ['GET'], 'api_ping'); 53 | $app->route( 54 | '/api/count-meetups/{organizerId:.+}/{year:\d+}/{month:\d+}', 55 | ApiCountMeetupsHandler::class, 56 | ['GET'], 57 | 'api_count_meetups' 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /console.php: -------------------------------------------------------------------------------- 1 | get(ConsoleApplication::class); 15 | 16 | $application->run(); 17 | })(); 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | php: 5 | build: docker/php/ 6 | image: matthiasnoback/hexagonal-architecture-workshop-php 7 | volumes: 8 | - ./:/app 9 | working_dir: /app 10 | user: ${HOST_UID}:${HOST_GID} 11 | env_file: 12 | - .env 13 | init: true 14 | environment: 15 | - API_BASE_URI=http://api_testing 16 | 17 | composer: 18 | image: composer:latest 19 | volumes: 20 | - ./:/app 21 | user: ${HOST_UID}:${HOST_GID} 22 | env_file: 23 | - .env 24 | entrypoint: composer 25 | 26 | web: 27 | build: docker/nginx/ 28 | image: matthiasnoback/hexagonal-architecture-workshop-nginx 29 | volumes: 30 | - ./:/app 31 | ports: 32 | # Change the left number to something else if the port is already in use on your machine 33 | - "8000:80" 34 | environment: 35 | - SERVER_NAME=localhost 36 | - PHP_BACKEND=php_fpm 37 | - ROOT=/app/public 38 | depends_on: 39 | - php_fpm 40 | 41 | php_fpm: 42 | build: docker/php-fpm/ 43 | image: matthiasnoback/hexagonal-architecture-workshop-php-fpm 44 | volumes: 45 | - ./:/app 46 | user: ${HOST_UID}:${HOST_GID} 47 | env_file: 48 | - .env 49 | environment: 50 | - API_BASE_URI=http://api 51 | depends_on: 52 | - api 53 | 54 | api: 55 | build: docker/nginx/ 56 | image: matthiasnoback/hexagonal-architecture-workshop-nginx 57 | volumes: 58 | - ./:/app 59 | environment: 60 | - SERVER_NAME=api 61 | - PHP_BACKEND=api_php_fpm 62 | - ROOT=/app/public 63 | 64 | api_php_fpm: 65 | build: docker/php-fpm/ 66 | image: matthiasnoback/hexagonal-architecture-workshop-php-fpm 67 | volumes: 68 | - ./:/app 69 | user: ${HOST_UID}:${HOST_GID} 70 | env_file: 71 | - .env 72 | environment: 73 | - API_BASE_URI=http://api 74 | 75 | web_testing: 76 | build: docker/nginx/ 77 | image: matthiasnoback/hexagonal-architecture-workshop-nginx 78 | volumes: 79 | - ./:/app 80 | environment: 81 | - SERVER_NAME=web_testing 82 | - PHP_BACKEND=php_fpm_testing 83 | - ROOT=/app/public 84 | depends_on: 85 | - php_fpm_testing 86 | ports: 87 | - "8001:80" 88 | 89 | php_fpm_testing: 90 | build: docker/php-fpm/ 91 | image: matthiasnoback/hexagonal-architecture-workshop-php-fpm 92 | volumes: 93 | - ./:/app 94 | user: ${HOST_UID}:${HOST_GID} 95 | env_file: 96 | - .env 97 | - .env.end_to_end_testing 98 | environment: 99 | - API_BASE_URI=http://api_testing 100 | depends_on: 101 | - api_testing 102 | 103 | api_testing: 104 | build: docker/nginx/ 105 | image: matthiasnoback/hexagonal-architecture-workshop-nginx 106 | volumes: 107 | - ./:/app 108 | environment: 109 | - SERVER_NAME=api_testing 110 | - PHP_BACKEND=api_php_fpm_testing 111 | - ROOT=/app/public 112 | 113 | api_php_fpm_testing: 114 | build: docker/php-fpm/ 115 | image: matthiasnoback/hexagonal-architecture-workshop-php-fpm 116 | volumes: 117 | - ./:/app 118 | user: ${HOST_UID}:${HOST_GID} 119 | env_file: 120 | - .env 121 | - .env.end_to_end_testing 122 | environment: 123 | - API_BASE_URI=http://api_testing 124 | 125 | outbox_relay: 126 | build: docker/php/ 127 | image: matthiasnoback/hexagonal-architecture-workshop-php 128 | volumes: 129 | - ./:/app 130 | working_dir: /app 131 | user: ${HOST_UID}:${HOST_GID} 132 | env_file: 133 | - .env 134 | init: true 135 | command: php console.php outbox:relay 136 | 137 | billing_organizer_projection_consumer: 138 | build: docker/php/ 139 | image: matthiasnoback/hexagonal-architecture-workshop-php 140 | volumes: 141 | - ./:/app 142 | working_dir: /app 143 | user: ${HOST_UID}:${HOST_GID} 144 | env_file: 145 | - .env 146 | init: true 147 | command: 'php console.php consume:events Billing\\Projections\\OrganizerProjection' 148 | -------------------------------------------------------------------------------- /docker/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.19-alpine 2 | COPY template.conf /etc/nginx/templates/default.conf.template 3 | -------------------------------------------------------------------------------- /docker/nginx/template.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | index index.php; 4 | server_name ${SERVER_NAME}; 5 | root ${ROOT}; 6 | 7 | location / { 8 | # try to serve file directly, fallback to index.php 9 | try_files $uri /index.php$is_args$args; 10 | } 11 | 12 | location ~ ^/index\.php(/|$) { 13 | fastcgi_pass ${PHP_BACKEND}:9000; 14 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 15 | include fastcgi_params; 16 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 17 | fastcgi_param DOCUMENT_ROOT $realpath_root; 18 | 19 | # Prevents URIs that include the front controller. This will 404: 20 | # http://domain.tld/index.php/some-path 21 | # Remove the internal directive to allow URIs like this 22 | internal; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docker/php-fpm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-fpm-alpine 2 | COPY php.ini /usr/local/etc/php/ 3 | -------------------------------------------------------------------------------- /docker/php-fpm/php.ini: -------------------------------------------------------------------------------- 1 | display_errors = On 2 | html_errors= Off 3 | error_reporting = E_ALL 4 | date.timezone = "UTC" 5 | session.save_path = "/app/var/session" 6 | -------------------------------------------------------------------------------- /docker/php/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli-alpine 2 | RUN apk add icu-dev \ 3 | && docker-php-ext-install intl 4 | RUN docker-php-ext-install pcntl && php -m | grep pcntl 5 | COPY php.ini ${PHP_INI_DIR} 6 | -------------------------------------------------------------------------------- /docker/php/php.ini: -------------------------------------------------------------------------------- 1 | date.timezone = "UTC" 2 | error_reporting = E_ALL 3 | log_errors = "1" 4 | memory_limit = 2G 5 | -------------------------------------------------------------------------------- /docker/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli-alpine 2 | COPY php.ini ${PHP_INI_DIR} 3 | 4 | # Prepare for mounting the project's code as a volume 5 | VOLUME /app 6 | WORKDIR /app 7 | 8 | # Expose a running instance of PHP's built-in web server 9 | 10 | # The built-in PHP webserver only responds to SIGINT, not to SIGTERM 11 | STOPSIGNAL SIGINT 12 | 13 | EXPOSE 8080 14 | ENTRYPOINT ["php", "-S", "0.0.0.0:8080", "-t", "public/"] 15 | -------------------------------------------------------------------------------- /docker/web/php.ini: -------------------------------------------------------------------------------- 1 | # Convenient for Dutch people 2 | date.timezone = "UTC" 3 | 4 | error_reporting = E_ALL 5 | 6 | # This will print any errors to STDOUT, Docker picks this up as log output 7 | log_errors = "1" 8 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | parameters(); 12 | $parameters->set( 13 | Option::PATHS, 14 | [ 15 | __DIR__ . '/config', 16 | __DIR__ . '/public', 17 | __DIR__ . '/src', 18 | __DIR__ . '/test', 19 | __DIR__ . '/ecs.php', 20 | __DIR__ . '/rector.php', 21 | __DIR__ . '/console.php', 22 | ] 23 | ); 24 | 25 | $parameters->set(Option::SKIP, [PhpUnitStrictFixer::class]); 26 | 27 | $containerConfigurator->import(SetList::CONTROL_STRUCTURES); 28 | $containerConfigurator->import(SetList::PSR_12); 29 | $containerConfigurator->import(SetList::COMMON); 30 | $containerConfigurator->import(SetList::SYMPLIFY); 31 | }; 32 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#1 \\$externalEventConsumers of class App\\\\ExternalEvents\\\\SynchronousExternalEventPublisher constructor expects array\\, mixed given\\.$#" 5 | count: 1 6 | path: config/application_testing.php 7 | 8 | - 9 | message: "#^Parameter \\#1 \\$externalEventConsumers of class App\\\\ExternalEvents\\\\SynchronousExternalEventPublisher constructor expects array\\, mixed given\\.$#" 10 | count: 1 11 | path: config/end_to_end_testing.php 12 | 13 | - 14 | message: "#^Parameter \\#2 \\$externalEventConsumers of class App\\\\Cli\\\\ConsumeEventsCommand constructor expects array\\, mixed given\\.$#" 15 | count: 1 16 | path: src/App/ConfigProvider.php 17 | 18 | - 19 | message: "#^Method App\\\\ExternalEvents\\\\ExternalEventConsumersFactory\\:\\:__invoke\\(\\) should return array\\ but returns array\\.$#" 20 | count: 1 21 | path: src/App/ExternalEvents/ExternalEventConsumersFactory.php 22 | 23 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - config/ 5 | - public/ 6 | - src/ 7 | - test/ 8 | - ecs.php 9 | - rector.php 10 | - console.php 11 | checkMissingIterableValueType: false 12 | 13 | includes: 14 | - phpstan-baseline.neon 15 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | test 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | # The following rule allows authentication to work with fast-cgi 3 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 4 | # The following rule tells Apache that if the requested filename 5 | # exists, simply serve it. 6 | RewriteCond %{REQUEST_FILENAME} -f [OR] 7 | RewriteCond %{REQUEST_FILENAME} -l [OR] 8 | RewriteCond %{REQUEST_FILENAME} -d 9 | RewriteRule ^ - [NC,L] 10 | 11 | # The following rewrites all other queries to index.php. The 12 | # condition ensures that if you are using Apache aliases to do 13 | # mass virtual hosting, the base path will be prepended to 14 | # allow proper resolution of the index.php file; it will work 15 | # in non-aliased environments as well, providing a safe, one-size 16 | # fits all solution. 17 | RewriteCond $0::%{REQUEST_URI} ^([^:]*+(?::[^:]*+)*?)::(/.+?)\1$ 18 | RewriteRule .+ - [E=BASE:%2] 19 | RewriteRule .* %{ENV:BASE}index.php [NC,L] 20 | -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiasnoback/hexagonal-architecture-workshop/40204f6ab90c87adb4618cc1df1081ce50045d33/public/img/logo.png -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | get(Application::class); 26 | $factory = $container->get(MiddlewareFactory::class); 27 | 28 | // Execute programmatic/declarative middleware pipeline and routing 29 | // configuration statements 30 | (require 'config/pipeline.php')($app, $factory, $container); 31 | (require 'config/routes.php')($app, $factory, $container); 32 | 33 | $app->run(); 34 | })(); 35 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | parameters(); 11 | $parameters->set(Option::PATHS, [ 12 | __DIR__ . '/config', 13 | __DIR__ . '/public', 14 | __DIR__ . '/src', 15 | // No /test unfortunately, because Panther declares traits in a quasi-dynamic way 16 | __DIR__ . '/ecs.php', 17 | __DIR__ . '/rector.php', 18 | ]); 19 | 20 | $parameters->set(Option::AUTO_IMPORT_NAMES, true); 21 | 22 | $containerConfigurator->import(LevelSetList::UP_TO_PHP_81); 23 | }; 24 | -------------------------------------------------------------------------------- /src/App/AddFlashMessage.php: -------------------------------------------------------------------------------- 1 | session->addSuccessFlash('You have successfully RSVP-ed to this meetup'); 20 | } 21 | 22 | public function whenRsvpWasCancelled(RsvpWasCancelled $event): void 23 | { 24 | $this->session->addSuccessFlash('You have successfully cancelled your RSVP for this meetup'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/App/Application.php: -------------------------------------------------------------------------------- 1 | userRepository->nextIdentity(), 38 | $command->name(), 39 | $command->emailAddress(), 40 | $command->userType() 41 | ); 42 | 43 | $this->userRepository->save($user); 44 | 45 | $this->eventDispatcher->dispatchAll($user->releaseEvents()); 46 | 47 | return $user->userId() 48 | ->asString(); 49 | } 50 | 51 | public function meetupDetails(string $id): MeetupDetails 52 | { 53 | return $this->meetupDetailsRepository->getById($id); 54 | } 55 | 56 | public function rsvpForMeetup(RsvpForMeetup $command): void 57 | { 58 | try { 59 | $rsvp = $this->rsvpRepository->getByMeetupAndUserId($command->meetupId(), $command->userId()); 60 | 61 | $rsvp->yes(); 62 | } catch (CouldNotFindRsvp) { 63 | $statement = $this->connection 64 | ->createQueryBuilder() 65 | ->select('*') 66 | ->from('meetups') 67 | ->where('meetupId = :meetupId') 68 | ->setParameter('meetupId', $command->meetupId()) 69 | ->execute(); 70 | Assert::that($statement)->isInstanceOf(Statement::class); 71 | 72 | $record = $statement->fetchAssociative(); 73 | 74 | if ($record === false) { 75 | throw CouldNotFindMeetup::withId($command->meetupId()); 76 | } 77 | 78 | $rsvp = Rsvp::forMeetup( 79 | $this->rsvpRepository->nextIdentity(), 80 | $command->meetupId(), 81 | $command->userId() 82 | ); 83 | } 84 | 85 | $this->rsvpRepository->save($rsvp); 86 | 87 | $this->eventDispatcher->dispatchAll($rsvp->releaseEvents()); 88 | } 89 | 90 | public function cancelRsvp(string $meetupId, string $userId): void 91 | { 92 | $userId = UserId::fromString($userId); 93 | 94 | $rsvp = $this->rsvpRepository->getByMeetupAndUserId($meetupId, $userId); 95 | 96 | $rsvp->no(); 97 | 98 | $this->rsvpRepository->save($rsvp); 99 | 100 | $this->eventDispatcher->dispatch(new RsvpWasCancelled($rsvp->rsvpId())); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/App/ApplicationInterface.php: -------------------------------------------------------------------------------- 1 | addCommands([ 18 | $this->container->get(SignUpCommand::class), 19 | $this->container->get(ConsumeEventsCommand::class), 20 | $this->container->get(OutboxRelayCommand::class), 21 | $this->container->get(ExportUsersCommand::class), 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/App/Cli/ConsumeEventsCommand.php: -------------------------------------------------------------------------------- 1 | $externalEventConsumers 19 | */ 20 | public function __construct( 21 | private readonly Consumer $consumer, 22 | private readonly array $externalEventConsumers, 23 | ) { 24 | parent::__construct(); 25 | } 26 | 27 | protected function configure(): void 28 | { 29 | $this->setName('consume:events') 30 | ->addArgument('consumerServiceClass'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $externalEventConsumer = $this->resolveExternalEventConsumer($input); 36 | 37 | $externalEventConsumer->whenConsumerRestarted(); 38 | 39 | $this->consumer->consume( 40 | function (string $eventName, array $eventData) use ($externalEventConsumer, $output) { 41 | $output->writeln('Consuming external event: ' . $eventName); 42 | 43 | $externalEventConsumer->whenExternalEventReceived($eventName, $eventData,); 44 | } 45 | ); 46 | 47 | return 0; 48 | } 49 | 50 | private function resolveExternalEventConsumer(InputInterface $input): ExternalEventConsumer 51 | { 52 | $consumerServiceClass = $input->getArgument('consumerServiceClass'); 53 | Assertion::string($consumerServiceClass); 54 | 55 | foreach ($this->externalEventConsumers as $eventConsumer) { 56 | if ($eventConsumer instanceof $consumerServiceClass) { 57 | return $eventConsumer; 58 | } 59 | } 60 | 61 | throw new RuntimeException( 62 | sprintf( 63 | 'There is no external event consumer with class "%s", first add it to the list of consumers in ConfigProvider under "external_event_consumers"', 64 | $consumerServiceClass 65 | ) 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/App/Cli/ExportUsersCommand.php: -------------------------------------------------------------------------------- 1 | setName('users:export'); 21 | } 22 | 23 | protected function execute(InputInterface $input, OutputInterface $output): int 24 | { 25 | return 0; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/App/Cli/OutboxRelayCommand.php: -------------------------------------------------------------------------------- 1 | setName('outbox:relay'); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int 28 | { 29 | pcntl_signal(SIGTERM, function () { 30 | $this->keepRunning = false; 31 | }); 32 | 33 | while ($this->keepRunning) { 34 | $this->publishNextMessage(); 35 | 36 | usleep(1000); 37 | pcntl_signal_dispatch(); 38 | } 39 | 40 | return 0; 41 | } 42 | 43 | private function publishNextMessage(): void 44 | { 45 | $record = $this->connection->fetchAssociative( 46 | 'SELECT * FROM outbox WHERE wasPublished = 0 ORDER BY messageId LIMIT 1' 47 | ); 48 | if ($record === false) { 49 | return; 50 | } 51 | 52 | // TODO really publish the message this time 53 | 54 | // TODO mark the message as published 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/App/Cli/SignUpCommand.php: -------------------------------------------------------------------------------- 1 | setName('sign-up') 30 | ->setDescription('Sign up as a new user') 31 | ->addArgument('name', InputArgument::REQUIRED, 'Name of the user') 32 | ->addArgument('emailAddress', InputArgument::REQUIRED, 'Email address of the user') 33 | ->addArgument('userType', InputArgument::REQUIRED, 'Type of user'); 34 | } 35 | 36 | protected function interact(InputInterface $input, OutputInterface $output): void 37 | { 38 | /** @var QuestionHelper $questionHelper */ 39 | $questionHelper = $this->getHelper('question'); 40 | 41 | if ($input->getArgument('name') === null) { 42 | $input->setArgument('name', $questionHelper->ask($input, $output, new Question('Name: '))); 43 | } 44 | if ($input->getArgument('emailAddress') === null) { 45 | $input->setArgument('emailAddress', $questionHelper->ask($input, $output, new Question('Email address: '))); 46 | } 47 | if ($input->getArgument('userType') === null) { 48 | $input->setArgument( 49 | 'userType', 50 | $questionHelper->ask($input, $output, new ChoiceQuestion('User type: ', UserType::namesAndLabels())) 51 | ); 52 | } 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int 56 | { 57 | $name = $input->getArgument('name'); 58 | Assert::that($name)->string(); 59 | $emailAddress = $input->getArgument('emailAddress'); 60 | Assert::that($emailAddress)->string(); 61 | $userType = $input->getArgument('userType'); 62 | Assert::that($userType)->string(); 63 | 64 | $this->application->signUp(new SignUp($name, $emailAddress, $userType)); 65 | 66 | $output->writeln('User was signed up successfully'); 67 | 68 | return 0; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/App/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 58 | 'templates' => $this->getTemplates(), 59 | 'twig' => [ 60 | 'extensions' => [SessionExtension::class], 61 | ], 62 | 'project_root_dir' => realpath(__DIR__ . '/../../'), 63 | 'event_listeners' => [ 64 | UserHasRsvpd::class => [[AddFlashMessage::class, 'whenUserHasRsvped']], 65 | UserHasSignedUp::class => [[PublishExternalEvent::class, 'whenUserHasSignedUp']], 66 | ], 67 | ]; 68 | } 69 | 70 | public function getDependencies(): array 71 | { 72 | return [ 73 | 'invokables' => [], 74 | 'factories' => [ 75 | ScheduleMeetupHandler::class => fn (ContainerInterface $container) => new ScheduleMeetupHandler( 76 | $container->get(Session::class), 77 | $container->get(TemplateRendererInterface::class), 78 | $container->get(RouterInterface::class), 79 | $container->get(Connection::class) 80 | ), 81 | MeetupDetailsHandler::class => fn (ContainerInterface $container) => new MeetupDetailsHandler( 82 | $container->get(MeetupDetailsRepository::class), 83 | $container->get(TemplateRendererInterface::class) 84 | ), 85 | CancelMeetupHandler::class => fn (ContainerInterface $container) => new CancelMeetupHandler( 86 | $container->get(Connection::class), 87 | $container->get(Session::class), 88 | $container->get(RouterInterface::class) 89 | ), 90 | RescheduleMeetupHandler::class => fn (ContainerInterface $container) => new RescheduleMeetupHandler( 91 | $container->get(Connection::class), 92 | $container->get(Session::class), 93 | $container->get(RouterInterface::class), 94 | $container->get(ResponseFactory::class), 95 | $container->get(TemplateRendererInterface::class), 96 | ), 97 | ListMeetupsHandler::class => fn (ContainerInterface $container) => new ListMeetupsHandler( 98 | $container->get(Connection::class), 99 | $container->get(TemplateRendererInterface::class) 100 | ), 101 | LoginHandler::class => fn (ContainerInterface $container) => new LoginHandler( 102 | $container->get(UserRepository::class), 103 | $container->get(Session::class), 104 | $container->get(TemplateRendererInterface::class) 105 | ), 106 | LogoutHandler::class => fn (ContainerInterface $container) => new LogoutHandler( 107 | $container->get(Session::class), 108 | $container->get(RouterInterface::class) 109 | ), 110 | SwitchUserHandler::class => fn (ContainerInterface $container) => new SwitchUserHandler( 111 | $container->get(UserRepository::class), 112 | $container->get(Session::class), 113 | $container->get(RouterInterface::class) 114 | ), 115 | SignUpHandler::class => fn (ContainerInterface $container) => new SignUpHandler( 116 | $container->get(TemplateRendererInterface::class), 117 | $container->get(ApplicationInterface::class), 118 | $container->get(RouterInterface::class), 119 | $container->get(Session::class), 120 | ), 121 | RsvpForMeetupHandler::class => fn (ContainerInterface $container) => new RsvpForMeetupHandler( 122 | $container->get(Session::class), 123 | $container->get(RouterInterface::class), 124 | $container->get(ApplicationInterface::class), 125 | ), 126 | CancelRsvpHandler::class => fn (ContainerInterface $container) => new CancelRsvpHandler( 127 | $container->get(Session::class), 128 | $container->get(RouterInterface::class), 129 | $container->get(ApplicationInterface::class), 130 | ), 131 | ListOrganizersHandler::class => fn (ContainerInterface $container) => new ListOrganizersHandler( 132 | $container->get(Connection::class), 133 | $container->get(TemplateRendererInterface::class) 134 | ), 135 | ListInvoicesHandler::class => fn (ContainerInterface $container) => new ListInvoicesHandler( 136 | $container->get(Connection::class), 137 | $container->get(TemplateRendererInterface::class) 138 | ), 139 | CreateInvoiceHandler::class => fn (ContainerInterface $container) => new CreateInvoiceHandler( 140 | $container->get(Connection::class), 141 | $container->get(Session::class), 142 | $container->get(RouterInterface::class), 143 | $container->get(TemplateRendererInterface::class) 144 | ), 145 | DeleteInvoiceHandler::class => fn (ContainerInterface $container) => new DeleteInvoiceHandler( 146 | $container->get(Connection::class), 147 | $container->get(RouterInterface::class), 148 | ), 149 | AddFlashMessage::class => fn (ContainerInterface $container) => new AddFlashMessage($container->get( 150 | Session::class 151 | )), 152 | ApplicationInterface::class => fn (ContainerInterface $container) => new Application( 153 | $container->get(UserRepository::class), 154 | $container->get(MeetupDetailsRepository::class), 155 | $container->get(EventDispatcher::class), 156 | $container->get(Connection::class), 157 | $container->get(RsvpRepository::class), 158 | ), 159 | EventDispatcher::class => EventDispatcherFactory::class, 160 | Session::class => fn (ContainerInterface $container) => new Session($container->get( 161 | UserRepository::class 162 | )), 163 | UserRepository::class => fn (ContainerInterface $container) => new UserRepositoryUsingDbal( 164 | $container->get(Connection::class) 165 | ), 166 | RsvpRepository::class => fn (ContainerInterface $container) => new RsvpRepositoryUsingDbal( 167 | $container->get( 168 | Connection::class 169 | ) 170 | ), 171 | MeetupDetailsRepository::class => fn (ContainerInterface $container) => new MeetupDetailsRepository( 172 | $container->get(Connection::class) 173 | ), 174 | Connection::class => ConnectionFactory::class, 175 | SchemaManager::class => fn (ContainerInterface $container) => new SchemaManager($container->get( 176 | Connection::class 177 | )), 178 | SessionExtension::class => fn (ContainerInterface $container) => new SessionExtension( 179 | $container->get(Session::class), 180 | $container->get(UserRepository::class) 181 | ), 182 | ConsoleApplication::class => fn (ContainerInterface $container) => new ConsoleApplication( 183 | $container 184 | ), 185 | SignUpCommand::class => fn (ContainerInterface $container) => new SignUpCommand($container->get( 186 | ApplicationInterface::class 187 | )), 188 | ExportUsersCommand::class => fn () => new ExportUsersCommand(), 189 | ConsumeEventsCommand::class => fn (ContainerInterface $container) => new ConsumeEventsCommand( 190 | $container->get(Consumer::class), 191 | $container->get('external_event_consumers'), 192 | ), 193 | OutboxRelayCommand::class => fn (ContainerInterface $container) => new OutboxRelayCommand( 194 | $container->get(Connection::class), 195 | ), 196 | OrganizerProjection::class => fn (ContainerInterface $container) => new OrganizerProjection( 197 | $container->get(Connection::class), 198 | ), 199 | RequestFactoryInterface::class => fn () => new HttpFactory(), 200 | ClientInterface::class => fn () => Client::createWithConfig( 201 | [ 202 | 'base_uri' => getenv('API_BASE_URI') ?: null, 203 | ] 204 | ), 205 | PublishExternalEvent::class => fn (ContainerInterface $container) => new PublishExternalEvent( 206 | $container->get(ExternalEventPublisher::class), 207 | ), 208 | ExternalEventPublisher::class => fn (ContainerInterface $container) => new AsynchronousExternalEventPublisher( 209 | $container->get(Producer::class) 210 | ), 211 | ApiCountMeetupsHandler::class => fn () => new ApiCountMeetupsHandler(), 212 | 'external_event_consumers' => fn (ContainerInterface $container) => [ 213 | $container->get(OrganizerProjection::class), 214 | ], 215 | ], 216 | ]; 217 | } 218 | 219 | public function getTemplates(): array 220 | { 221 | return [ 222 | 'paths' => [ 223 | 'app' => ['templates/app'], 224 | 'admin' => ['templates/admin'], 225 | 'billing' => ['templates/billing'], 226 | 'error' => ['templates/error'], 227 | 'layout' => ['templates/layout'], 228 | ], 229 | ]; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/App/ConfigurableEventDispatcher.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $genericListeners = []; 13 | 14 | /** 15 | * @var array> 16 | */ 17 | private array $listenersPerEvent = []; 18 | 19 | public function __construct() 20 | { 21 | } 22 | 23 | /** 24 | * @param array $genericListeners 25 | */ 26 | public function registerGenericListeners(array $genericListeners): void 27 | { 28 | $this->genericListeners = $genericListeners; 29 | } 30 | 31 | /** 32 | * @param class-string $event 33 | */ 34 | public function registerSpecificListener(string $event, callable $listener): void 35 | { 36 | $this->listenersPerEvent[$event][] = $listener; 37 | } 38 | 39 | public function dispatchAll(array $events): void 40 | { 41 | foreach ($events as $event) { 42 | $this->dispatch($event); 43 | } 44 | } 45 | 46 | public function dispatch(object $event): void 47 | { 48 | foreach ($this->genericListeners as $listener) { 49 | $this->notifyListener($listener, $event); 50 | } 51 | 52 | foreach ($this->listenersPerEvent[$event::class] ?? [] as $listener) { 53 | $this->notifyListener($listener, $event); 54 | } 55 | } 56 | 57 | private function notifyListener(callable $listener, object $event): void 58 | { 59 | $result = $listener($event); 60 | if (is_callable($result)) { 61 | $result($event); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/App/ConnectionFactory.php: -------------------------------------------------------------------------------- 1 | get('config'); 17 | Assert::that($config)->isArray(); 18 | 19 | $projectRootDir = $config['project_root_dir']; 20 | Assert::that($projectRootDir)->directory(); 21 | 22 | $sqliteFile = $projectRootDir . '/var/app-' . ($config['environment'] ?? 'development') . '.sqlite'; 23 | 24 | $connection = DriverManager::getConnection([ 25 | 'driver' => 'pdo_sqlite', 26 | 'path' => $sqliteFile, 27 | ]); 28 | (new SchemaManager($connection))->updateSchema(); 29 | 30 | return $connection; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/App/Entity/CouldNotFindUser.php: -------------------------------------------------------------------------------- 1 | asString())); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/App/Entity/EventRecordingCapabilities.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $events = []; 13 | 14 | /** 15 | * @return array 16 | */ 17 | public function releaseEvents(): array 18 | { 19 | $events = $this->events; 20 | 21 | $this->events = []; 22 | 23 | return $events; 24 | } 25 | 26 | private function recordThat(object $event): void 27 | { 28 | $this->events[] = $event; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/App/Entity/User.php: -------------------------------------------------------------------------------- 1 | userId = UserId::fromString($record['userId']); 28 | $user->name = $record['name']; 29 | $user->emailAddress = $record['emailAddress']; 30 | $user->userType = UserType::from($record['userType']); 31 | 32 | return $user; 33 | } 34 | 35 | public static function create(UserId $userId, string $name, string $emailAddress, UserType $userType): self 36 | { 37 | $user = new self(); 38 | 39 | $user->userId = $userId; 40 | $user->name = $name; 41 | $user->emailAddress = $emailAddress; 42 | $user->userType = $userType; 43 | 44 | $user->events[] = new UserHasSignedUp($user->userId, $user->name, $user->emailAddress, $user->userType); 45 | 46 | return $user; 47 | } 48 | 49 | public function userId(): UserId 50 | { 51 | return $this->userId; 52 | } 53 | 54 | public function name(): string 55 | { 56 | return $this->name; 57 | } 58 | 59 | public function emailAddress(): string 60 | { 61 | return $this->emailAddress; 62 | } 63 | 64 | public function userTypeIs(UserType $userType): bool 65 | { 66 | return $this->userType === $userType; 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | public function asDatabaseRecord(): array 73 | { 74 | return [ 75 | 'userId' => $this->userId->asString(), 76 | 'name' => $this->name, 77 | 'emailAddress' => $this->emailAddress, 78 | 'userType' => $this->userType->name, 79 | ]; 80 | } 81 | 82 | public function userType(): UserType 83 | { 84 | return $this->userType; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/App/Entity/UserHasSignedUp.php: -------------------------------------------------------------------------------- 1 | userId; 20 | } 21 | 22 | public function name(): string 23 | { 24 | return $this->name; 25 | } 26 | 27 | public function emailAddress(): string 28 | { 29 | return $this->emailAddress; 30 | } 31 | 32 | public function userType(): UserType 33 | { 34 | return $this->userType; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/App/Entity/UserId.php: -------------------------------------------------------------------------------- 1 | uuid(); 15 | } 16 | 17 | public static function fromString(string $id): self 18 | { 19 | return new self($id); 20 | } 21 | 22 | public function asString(): string 23 | { 24 | return $this->id; 25 | } 26 | 27 | public function equals(self $other): bool 28 | { 29 | return $this->id === $other->id; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/App/Entity/UserRepository.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public function findAll(): array; 22 | } 23 | -------------------------------------------------------------------------------- /src/App/Entity/UserRepositoryUsingDbal.php: -------------------------------------------------------------------------------- 1 | connection->insert('users', $user->asDatabaseRecord()); 22 | } 23 | 24 | public function nextIdentity(): UserId 25 | { 26 | return UserId::fromString(Uuid::uuid4()->toString()); 27 | } 28 | 29 | public function getById(UserId $id): User 30 | { 31 | $result = $this->connection->executeQuery('SELECT * FROM users WHERE userId = ?', [$id->asString()]); 32 | Assert::that($result)->isInstanceOf(DriverResultStatement::class, 'User not found'); 33 | 34 | $record = $result->fetchAssociative(); 35 | if ($record === false) { 36 | throw CouldNotFindUser::withId($id); 37 | } 38 | 39 | return User::fromDatabaseRecord($record); 40 | } 41 | 42 | public function getByEmailAddress(string $emailAddress): User 43 | { 44 | $result = $this->connection->executeQuery('SELECT * FROM users WHERE emailAddress = ?', [$emailAddress]); 45 | Assert::that($result)->isInstanceOf(DriverResultStatement::class, 'User not found'); 46 | 47 | $record = $result->fetchAssociative(); 48 | if ($record === false) { 49 | throw CouldNotFindUser::withEmailAddress($emailAddress); 50 | } 51 | Assert::that($record)->isArray(); 52 | 53 | return User::fromDatabaseRecord($record); 54 | } 55 | 56 | public function findAll(): array 57 | { 58 | $records = $this->connection->fetchAllAssociative('SELECT userId, name FROM users'); 59 | 60 | return array_combine(array_column($records, 'userId'), array_column($records, 'name'),); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/App/Entity/UserType.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public static function namesAndLabels(): array 17 | { 18 | $namesAndLabels = []; 19 | foreach (self::cases() as $case) { 20 | $namesAndLabels[$case->name] = $case->label(); 21 | } 22 | return $namesAndLabels; 23 | } 24 | 25 | public function label(): string 26 | { 27 | return match ($this) { 28 | self::RegularUser => 'Regular user', 29 | self::Organizer => 'Organizer', 30 | self::Administrator => 'Administrator', 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/App/EventDispatcher.php: -------------------------------------------------------------------------------- 1 | $events 11 | */ 12 | public function dispatchAll(array $events): void; 13 | 14 | public function dispatch(object $event): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/App/EventDispatcherFactory.php: -------------------------------------------------------------------------------- 1 | get('config'); 17 | Assert::that($config)->isArray(); 18 | 19 | $eventListeners = $config['event_listeners'] ?? []; 20 | 21 | foreach ($eventListeners as $eventClass => $listeners) { 22 | foreach ($listeners as $listener) { 23 | $eventDispatcher->registerSpecificListener( 24 | $eventClass, 25 | function ($event) use ($container, $listener) { 26 | [$listenerServiceId, $listenerMethod] = $listener; 27 | $listener = $container->get($listenerServiceId); 28 | $listener->{$listenerMethod}($event); 29 | } 30 | ); 31 | } 32 | } 33 | 34 | return $eventDispatcher; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/App/ExternalEvents/AsynchronousExternalEventPublisher.php: -------------------------------------------------------------------------------- 1 | producer->produce($eventType, $eventData); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/App/ExternalEvents/ConsumerRestarted.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 18 | ]; 19 | } 20 | 21 | public function getDependencies(): array 22 | { 23 | return [ 24 | 'factories' => [ 25 | Consumer::class => fn (ContainerInterface $container) => new Consumer($this->getStreamFilePath( 26 | $container 27 | )), 28 | Producer::class => fn (ContainerInterface $container) => new Producer($this->getStreamFilePath( 29 | $container 30 | )), 31 | ], 32 | ]; 33 | } 34 | 35 | private function getStreamFilePath(ContainerInterface $container): string 36 | { 37 | $config = $container->get('config'); 38 | Assert::that($config)->isArray(); 39 | 40 | $rootDir = $config['project_root_dir']; 41 | Assert::that($rootDir)->directory(); 42 | 43 | $streamFilePath = $rootDir . '/var/stream-' . ($config['environment'] ?? 'development') . '.txt'; 44 | 45 | Assert::that(dirname($streamFilePath))->directory(); 46 | 47 | return $streamFilePath; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/App/ExternalEvents/ExternalEventConsumer.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function __invoke(ContainerInterface $container): array 16 | { 17 | $config = $container->get('config'); 18 | Assert::that($config)->isArray(); 19 | 20 | $serviceIds = $config['external_event_consumers'] ?? []; 21 | 22 | return array_map(fn (string $id) => $container->get($id), $serviceIds); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/App/ExternalEvents/ExternalEventPublisher.php: -------------------------------------------------------------------------------- 1 | publisher->publish( 19 | 'user.signed_up', 20 | [ 21 | 'id' => $event->userId() 22 | ->asString(), 23 | 'name' => $event->name(), 24 | 'type' => $event->userType() 25 | ->value, 26 | ] 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/App/ExternalEvents/SynchronousExternalEventPublisher.php: -------------------------------------------------------------------------------- 1 | $externalEventConsumers 11 | */ 12 | public function __construct( 13 | private readonly array $externalEventConsumers, 14 | ) { 15 | } 16 | 17 | public function publish(string $eventType, array $eventData): void 18 | { 19 | foreach ($this->externalEventConsumers as $eventConsumer) { 20 | $eventConsumer->whenExternalEventReceived($eventType, $eventData,); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/App/Handler/LoginHandler.php: -------------------------------------------------------------------------------- 1 | session->isUserLoggedIn()) { 30 | return new RedirectResponse('/'); 31 | } 32 | 33 | $formData = [ 34 | 'emailAddress' => '', 35 | ]; 36 | $formErrors = []; 37 | 38 | if ($request->getMethod() === 'POST') { 39 | $requestData = $request->getParsedBody(); 40 | Assert::that($requestData)->isArray(); 41 | 42 | $formData = array_merge($formData, $requestData); 43 | 44 | if ($formData['emailAddress'] === '') { 45 | $formErrors['emailAddress'][] = 'Please provide an email address'; 46 | } 47 | 48 | try { 49 | $user = $this->userRepository->getByEmailAddress($formData['emailAddress']); 50 | 51 | $this->session->setLoggedInUserId($user->userId()); 52 | 53 | $this->session->addSuccessFlash('You have successfully logged in'); 54 | 55 | return new RedirectResponse('/'); 56 | } catch (CouldNotFindUser) { 57 | $formErrors['emailAddress'][] = 'Unknown email address'; 58 | } 59 | } 60 | 61 | return new HtmlResponse( 62 | $this->renderer->render( 63 | 'app::login.html.twig', 64 | [ 65 | 'formData' => $formData, 66 | 'formErrors' => $formErrors, 67 | ] 68 | ) 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/App/Handler/LogoutHandler.php: -------------------------------------------------------------------------------- 1 | session->logout(); 25 | 26 | $this->session->addSuccessFlash('You are now logged out'); 27 | 28 | return new RedirectResponse($this->router->generateUri('list_meetups')); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/App/Handler/SignUpHandler.php: -------------------------------------------------------------------------------- 1 | session->isUserLoggedIn()) { 33 | return new RedirectResponse('/'); 34 | } 35 | 36 | $formData = [ 37 | 'name' => '', 38 | 'userType' => UserType::RegularUser->name, 39 | 'emailAddress' => '', 40 | ]; 41 | $formErrors = []; 42 | 43 | if ($request->getMethod() === 'POST') { 44 | $requestData = $request->getParsedBody(); 45 | Assert::that($requestData)->isArray(); 46 | 47 | $formData = array_merge($formData, $requestData); 48 | 49 | if ($formData['name'] === '') { 50 | $formErrors['name'][] = 'Please provide a name'; 51 | } 52 | if ($formData['emailAddress'] === '') { 53 | $formErrors['emailAddress'][] = 'Please provide an email address'; 54 | } 55 | 56 | if ($formErrors === []) { 57 | $this->application->signUp( 58 | new SignUp($formData['name'], $formData['emailAddress'], $formData['userType']) 59 | ); 60 | 61 | $this->session->addSuccessFlash('You have been registered as a user'); 62 | 63 | return new RedirectResponse($this->router->generateUri('login')); 64 | } 65 | } 66 | 67 | return new HtmlResponse( 68 | $this->renderer->render('app::sign-up.html.twig', [ 69 | 'formData' => $formData, 70 | 'formErrors' => $formErrors, 71 | 'userTypes' => UserType::cases(), 72 | ]) 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/App/Handler/SwitchUserHandler.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 29 | Assert::that($requestData)->isArray(); 30 | 31 | $userId = $requestData['userId']; 32 | if ($userId === '') { 33 | $this->session->logout(); 34 | } else { 35 | $user = $this->userRepository->getById(UserId::fromString($userId)); 36 | 37 | $this->session->setLoggedInUserId($user->userId()); 38 | } 39 | 40 | return new RedirectResponse($this->router->generateUri('list_meetups')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/App/Json.php: -------------------------------------------------------------------------------- 1 | $data 13 | */ 14 | public static function getString(array $data, string $key): string 15 | { 16 | Assert::that($data)->keyExists($key); 17 | 18 | Assert::that($data[$key])->scalar(); 19 | 20 | return (string) $data[$key]; 21 | } 22 | 23 | /** 24 | * @param array $data 25 | */ 26 | public static function getInt(array $data, string $key): int 27 | { 28 | Assert::that($data)->keyExists($key); 29 | Assert::that($data[$key])->integerish(); 30 | 31 | return (int) $data[$key]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/App/SchemaManager.php: -------------------------------------------------------------------------------- 1 | compare( 21 | $this->connection->getSchemaManager() 22 | ->createSchema(), 23 | $this->provideSchema() 24 | ); 25 | 26 | foreach ($schemaDiff->toSaveSql($this->connection->getDatabasePlatform()) as $sql) { 27 | $this->connection->executeStatement($sql); 28 | } 29 | } 30 | 31 | public function truncateTables(): void 32 | { 33 | foreach ($this->provideSchema()->getTables() as $table) { 34 | $this->connection->executeQuery( 35 | $this->connection->getDatabasePlatform() 36 | ->getTruncateTableSQL($table->getName()) 37 | ); 38 | } 39 | } 40 | 41 | private function provideSchema(): Schema 42 | { 43 | $schema = new Schema(); 44 | 45 | $accountsTable = $schema->createTable('users'); 46 | $accountsTable->addColumn('userId', 'string'); 47 | $accountsTable->addColumn('name', 'string'); 48 | $accountsTable->addColumn('emailAddress', 'string'); 49 | $accountsTable->addColumn('userType', 'string'); 50 | $accountsTable->setPrimaryKey(['userId']); 51 | $accountsTable->addUniqueIndex(['emailAddress']); 52 | 53 | $meetupsTable = $schema->createTable('meetups'); 54 | $meetupsTable->addColumn('meetupId', 'integer', [ 55 | 'autoincrement' => true, 56 | ]); 57 | $meetupsTable->addColumn('organizerId', 'string'); 58 | $meetupsTable->addColumn('name', 'string'); 59 | $meetupsTable->addColumn('description', 'string'); 60 | $meetupsTable->addColumn('scheduledFor', 'string'); 61 | $meetupsTable->addColumn('wasCancelled', 'integer', [ 62 | 'default' => 0, 63 | ]); 64 | $meetupsTable->setPrimaryKey(['meetupId']); 65 | 66 | $invoicesTable = $schema->createTable('invoices'); 67 | $invoicesTable->addColumn('invoiceId', 'integer', [ 68 | 'autoincrement' => true, 69 | ]); 70 | $invoicesTable->addColumn('organizerId', 'string'); 71 | $invoicesTable->addColumn('amount', 'string'); 72 | $invoicesTable->addColumn('year', 'integer'); 73 | $invoicesTable->addColumn('month', 'integer'); 74 | $invoicesTable->setPrimaryKey(['invoiceId']); 75 | $invoicesTable->addUniqueIndex(['organizerId', 'year', 'month']); 76 | 77 | $rsvpsTable = $schema->createTable('rsvps'); 78 | $rsvpsTable->addColumn('rsvpId', 'string'); 79 | $rsvpsTable->addColumn('meetupId', 'string'); 80 | $rsvpsTable->addColumn('userId', 'string'); 81 | $rsvpsTable->addColumn('answer', 'string'); 82 | $rsvpsTable->setPrimaryKey(['rsvpId']); 83 | 84 | $billingOrganizersTable = $schema->createTable('billing_organizers'); 85 | $billingOrganizersTable->addColumn('organizerId', 'string'); 86 | $billingOrganizersTable->addColumn('name', 'string'); 87 | $billingOrganizersTable->setPrimaryKey(['organizerId']); 88 | 89 | $billingMeetupsTable = $schema->createTable('billing_meetups'); 90 | $billingMeetupsTable->addColumn('organizerId', 'string'); 91 | $billingMeetupsTable->addColumn('meetupId', 'string'); 92 | $billingMeetupsTable->addColumn('year', 'integer'); 93 | $billingMeetupsTable->addColumn('month', 'integer'); 94 | $billingMeetupsTable->setPrimaryKey(['meetupId']); 95 | 96 | $outboxTable = $schema->createTable('outbox'); 97 | $outboxTable->addColumn('messageId', 'integer', [ 98 | 'autoincrement' => true, 99 | ]); 100 | $outboxTable->addColumn('messageType', 'string'); 101 | $outboxTable->addColumn('messageData', 'string'); 102 | $outboxTable->addColumn('wasPublished', 'integer', [ 103 | 'default' => 0, 104 | ]); 105 | $outboxTable->setPrimaryKey(['messageId']); 106 | 107 | return $schema; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/App/Session.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | private array $sessionData; 22 | 23 | public function __construct( 24 | private readonly UserRepository $userRepository 25 | ) { 26 | if (PHP_SAPI === 'cli') { 27 | $this->sessionData = []; 28 | } else { 29 | session_start(); 30 | $this->sessionData = &$_SESSION; 31 | } 32 | } 33 | 34 | public function getLoggedInUser(): ?User 35 | { 36 | if (! isset($this->sessionData[self::LOGGED_IN_USER_ID])) { 37 | return null; 38 | } 39 | 40 | $loggedInUserId = $this->sessionData[self::LOGGED_IN_USER_ID]; 41 | Assert::that($loggedInUserId)->string(); 42 | 43 | try { 44 | return $this->userRepository->getById(UserId::fromString($loggedInUserId)); 45 | } catch (CouldNotFindUser) { 46 | $this->logout(); 47 | return null; 48 | } 49 | } 50 | 51 | public function getLoggedInUserId(): ?string 52 | { 53 | $userId = $this->sessionData[self::LOGGED_IN_USER_ID] ?? null; 54 | Assert::that($userId)->nullOr()->string(); 55 | 56 | return $userId; 57 | } 58 | 59 | public function isLoggedInUserAdmin(): bool 60 | { 61 | return $this->isLoggedInUserType(UserType::Administrator); 62 | } 63 | 64 | public function isLoggedInUserOrganizer(): bool 65 | { 66 | return $this->isLoggedInUserType(UserType::Organizer); 67 | } 68 | 69 | public function isLoggedInUserRegular(): bool 70 | { 71 | return $this->isLoggedInUserType(UserType::RegularUser); 72 | } 73 | 74 | public function isLoggedInUserType(UserType $userType): bool 75 | { 76 | $user = $this->getLoggedInUser(); 77 | if ($user === null) { 78 | return false; 79 | } 80 | 81 | return $user->userTypeIs($userType); 82 | } 83 | 84 | public function setLoggedInUserId(UserId $id): void 85 | { 86 | $this->sessionData[self::LOGGED_IN_USER_ID] = $id->asString(); 87 | } 88 | 89 | public function isUserLoggedIn(): bool 90 | { 91 | return isset($this->sessionData[self::LOGGED_IN_USER_ID]); 92 | } 93 | 94 | public function isLoggedInUser(string $userId): bool 95 | { 96 | if (! isset($this->sessionData[self::LOGGED_IN_USER_ID])) { 97 | return false; 98 | } 99 | 100 | $loggedInUserId = $this->sessionData[self::LOGGED_IN_USER_ID]; 101 | 102 | return $loggedInUserId === $userId; 103 | } 104 | 105 | public function logout(): void 106 | { 107 | unset($this->sessionData[self::LOGGED_IN_USER_ID]); 108 | } 109 | 110 | public function addErrorFlash(string $message): void 111 | { 112 | $this->addFlash('danger', $message); 113 | } 114 | 115 | public function addSuccessFlash(string $message): void 116 | { 117 | $this->addFlash('success', $message); 118 | } 119 | 120 | public function getFlashes(): array 121 | { 122 | $flashes = $this->sessionData['flashes'] ?? []; 123 | $flashes = is_array($flashes) ? $flashes : []; 124 | 125 | $this->sessionData['flashes'] = []; 126 | 127 | return $flashes; 128 | } 129 | 130 | private function addFlash(string $type, string $message): void 131 | { 132 | if (! is_array($this->sessionData['flashes'])) { 133 | $this->sessionData['flashes'] = []; 134 | } 135 | 136 | $this->sessionData['flashes'][$type][] = $message; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/App/Twig/SessionExtension.php: -------------------------------------------------------------------------------- 1 | $this->session, 24 | 'allUsers' => $this->userRepository->findAll(), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Billing/Handler/CreateInvoiceHandler.php: -------------------------------------------------------------------------------- 1 | date('Y'), 33 | 'month' => date('m'), 34 | 'organizerId' => $request->getAttribute('organizerId'), 35 | ]; 36 | 37 | if ($request->getMethod() === 'POST') { 38 | $requestData = $request->getParsedBody(); 39 | Assert::that($requestData)->isArray(); 40 | 41 | $formData = array_merge($formData, $requestData); 42 | 43 | $year = $formData['year']; 44 | Assert::that($year)->integerish(); 45 | $month = $formData['month']; 46 | Assert::that($month)->integerish(); 47 | $organizerId = $formData['organizerId']; 48 | Assert::that($organizerId)->string(); 49 | 50 | $firstDayOfMonth = DateTimeImmutable::createFromFormat('Y-m-d', $year . '-' . $month . '-1'); 51 | Assert::that($firstDayOfMonth)->isInstanceOf(DateTimeImmutable::class); 52 | $lastDayOfMonth = $firstDayOfMonth->modify('last day of this month'); 53 | 54 | // Load the data directly from the database 55 | $result = $this->connection->executeQuery( 56 | 'SELECT COUNT(meetupId) as numberOfMeetups FROM meetups WHERE organizerId = :organizerId AND scheduledFor >= :firstDayOfMonth AND scheduledFor <= :lastDayOfMonth', 57 | [ 58 | 'organizerId' => $organizerId, 59 | 'firstDayOfMonth' => $firstDayOfMonth->format('Y-m-d'), 60 | 'lastDayOfMonth' => $lastDayOfMonth->format('Y-m-d'), 61 | ] 62 | ); 63 | 64 | $record = $result->fetchAssociative(); 65 | Assert::that($record)->isArray(); 66 | $numberOfMeetups = $record['numberOfMeetups']; 67 | if ($numberOfMeetups > 0) { 68 | $invoiceAmount = $numberOfMeetups * 5; 69 | 70 | $this->connection->insert('invoices', [ 71 | 'organizerId' => $organizerId, 72 | 'amount' => number_format($invoiceAmount, 2), 73 | 'year' => $year, 74 | 'month' => $month, 75 | ]); 76 | 77 | $this->session->addSuccessFlash('Invoice created'); 78 | } else { 79 | $this->session->addErrorFlash('No need to create an invoice'); 80 | } 81 | 82 | return new RedirectResponse($this->router->generateUri('list_organizers', [ 83 | 'id' => $organizerId, 84 | ])); 85 | } 86 | 87 | return new HtmlResponse($this->renderer->render('billing::create-invoice.html.twig', [ 88 | 'formData' => $formData, 89 | 'years' => range(date('Y') - 1, date('Y')), 90 | 'months' => range(1, 12), 91 | ])); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Billing/Handler/DeleteInvoiceHandler.php: -------------------------------------------------------------------------------- 1 | getAttribute('invoiceId'); 26 | Assert::that($invoiceId)->string(); 27 | 28 | $organizerId = $request->getAttribute('organizerId'); 29 | Assert::that($organizerId)->string(); 30 | 31 | $this->connection->delete('invoices', [ 32 | 'organizerId' => $organizerId, 33 | 'invoiceId' => $invoiceId, 34 | ]); 35 | 36 | return new RedirectResponse($this->router->generateUri('list_invoices', [ 37 | 'organizerId' => $organizerId, 38 | ])); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Billing/Handler/ListInvoicesHandler.php: -------------------------------------------------------------------------------- 1 | getAttribute('organizerId'); 28 | Assert::that($organizerId)->string(); 29 | 30 | $records = $this->connection->fetchAllAssociative( 31 | 'SELECT * FROM invoices WHERE organizerId = ?', 32 | [$organizerId] 33 | ); 34 | $invoices = array_map( 35 | fn (array $record) => new Invoice( 36 | Mapping::getInt($record, 'invoiceId'), 37 | Mapping::getString($record, 'organizerId'), 38 | Mapping::getInt($record, 'month') . '/' . Mapping::getInt($record, 'year'), 39 | Mapping::getString($record, 'amount'), 40 | ), 41 | $records 42 | ); 43 | 44 | return new HtmlResponse($this->renderer->render('billing::list-invoices.html.twig', [ 45 | 'invoices' => $invoices, 46 | 'organizerId' => $organizerId, 47 | ])); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Billing/Handler/ListOrganizersHandler.php: -------------------------------------------------------------------------------- 1 | new Organizer( 28 | Mapping::getString($record, 'organizerId'), 29 | Mapping::getString($record, 'name'), 30 | ), 31 | $this->connection->fetchAllAssociative('SELECT * FROM billing_organizers') 32 | ); 33 | 34 | return new HtmlResponse( 35 | $this->renderer->render('admin::list-organizers.html.twig', [ 36 | 'organizers' => $organizers, 37 | ]) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Billing/Projections/OrganizerProjection.php: -------------------------------------------------------------------------------- 1 | connection->executeQuery('DELETE FROM billing_organizers WHERE 1'); 21 | } 22 | 23 | public function whenExternalEventReceived(string $eventType, array $eventData,): void 24 | { 25 | if ($eventType !== 'user.signed_up') { 26 | return; 27 | } 28 | 29 | if (Mapping::getString($eventData, 'type') !== 'Organizer') { 30 | // Only process organizers 31 | return; 32 | } 33 | 34 | // This is a new organizer 35 | $this->connection->insert('billing_organizers', [ 36 | 'organizerId' => Mapping::getString($eventData, 'id'), 37 | 'name' => Mapping::getString($eventData, 'name'), 38 | ]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Billing/ViewModel/Invoice.php: -------------------------------------------------------------------------------- 1 | invoiceId; 20 | } 21 | 22 | public function organizerId(): string 23 | { 24 | return $this->organizerId; 25 | } 26 | 27 | public function period(): string 28 | { 29 | return $this->period; 30 | } 31 | 32 | public function amount(): string 33 | { 34 | return $this->amount; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Billing/ViewModel/Organizer.php: -------------------------------------------------------------------------------- 1 | id; 18 | } 19 | 20 | public function name(): string 21 | { 22 | return $this->name; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Application/RsvpForMeetup.php: -------------------------------------------------------------------------------- 1 | meetupId; 20 | } 21 | 22 | public function userId(): UserId 23 | { 24 | return UserId::fromString($this->userId); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Application/SignUp.php: -------------------------------------------------------------------------------- 1 | name; 21 | } 22 | 23 | public function emailAddress(): string 24 | { 25 | return $this->emailAddress; 26 | } 27 | 28 | public function userType(): UserType 29 | { 30 | return UserType::from($this->userType); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Entity/Answer.php: -------------------------------------------------------------------------------- 1 | asString())); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Entity/MeetupId.php: -------------------------------------------------------------------------------- 1 | uuid(); 14 | } 15 | 16 | public static function fromString(string $id): self 17 | { 18 | return new self($id); 19 | } 20 | 21 | public function asString(): string 22 | { 23 | return $this->id; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Entity/Rsvp.php: -------------------------------------------------------------------------------- 1 | yes(); 28 | 29 | return $rsvp; 30 | } 31 | 32 | public function yes(): void 33 | { 34 | if ($this->answer !== Answer::Yes) { 35 | $this->answer = Answer::Yes; 36 | 37 | $this->recordThat(new UserHasRsvpd($this->meetupId, $this->userId, $this->rsvpId)); 38 | } 39 | } 40 | 41 | public function no(): void 42 | { 43 | if ($this->answer !== Answer::No) { 44 | $this->answer = Answer::No; 45 | 46 | $this->recordThat(new RsvpWasCancelled($this->rsvpId)); 47 | } 48 | } 49 | 50 | public static function fromDatabaseRecord(array $record): self 51 | { 52 | return new self( 53 | RsvpId::fromString(Mapping::getString($record, 'rsvpId')), 54 | Mapping::getString($record, 'meetupId'), 55 | UserId::fromString(Mapping::getString($record, 'userId')), 56 | Answer::from(Mapping::getString($record, 'answer')), 57 | ); 58 | } 59 | 60 | public function rsvpId(): RsvpId 61 | { 62 | return $this->rsvpId; 63 | } 64 | 65 | public function meetupId(): string 66 | { 67 | return $this->meetupId; 68 | } 69 | 70 | public function userId(): UserId 71 | { 72 | return $this->userId; 73 | } 74 | 75 | /** 76 | * @return array 77 | */ 78 | public function asDatabaseRecord(): array 79 | { 80 | return [ 81 | 'rsvpId' => $this->rsvpId->asString(), 82 | 'meetupId' => $this->meetupId, 83 | 'userId' => $this->userId() 84 | ->asString(), 85 | 'answer' => $this->answer->value, 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Entity/RsvpId.php: -------------------------------------------------------------------------------- 1 | uuid(); 15 | } 16 | 17 | public static function fromString(string $id): self 18 | { 19 | return new self($id); 20 | } 21 | 22 | public function asString(): string 23 | { 24 | return $this->id; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Entity/RsvpRepository.php: -------------------------------------------------------------------------------- 1 | meetupId; 21 | } 22 | 23 | public function rsvpId(): RsvpId 24 | { 25 | return $this->rsvpId; 26 | } 27 | 28 | public function userId(): UserId 29 | { 30 | return $this->userId; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/ApiCountMeetupsHandler.php: -------------------------------------------------------------------------------- 1 | getAttribute('organizerId'); 18 | Assert::that($organizerId)->string(); 19 | 20 | $year = $request->getAttribute('year'); 21 | Assert::that($year)->integerish(); 22 | 23 | $month = $request->getAttribute('month'); 24 | Assert::that($month)->integerish(); 25 | 26 | return new JsonResponse( 27 | [ 28 | 'organizerId' => $organizerId, 29 | 'year' => (int) $year, 30 | 'month' => (int) $month, 31 | 'numberOfMeetups' => 1, 32 | ] 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/ApiPingHandler.php: -------------------------------------------------------------------------------- 1 | time(), 19 | ] 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/CancelMeetupHandler.php: -------------------------------------------------------------------------------- 1 | session->getLoggedInUser(); 29 | Assert::that($loggedInUser)->notNull(); 30 | 31 | $parsedBody = $request->getParsedBody(); 32 | Assert::that($parsedBody)->isArray(); 33 | 34 | if (! isset($parsedBody['meetupId'])) { 35 | throw new RuntimeException('Bad request'); 36 | } 37 | $meetupId = $parsedBody['meetupId']; 38 | 39 | $numberOfAffectedRows = $this->connection->update( 40 | 'meetups', 41 | [ 42 | 'wasCancelled' => 1, 43 | ], 44 | [ 45 | 'meetupId' => $meetupId, 46 | 'organizerId' => $loggedInUser->userId() 47 | ->asString(), 48 | ] 49 | ); 50 | 51 | if ($numberOfAffectedRows > 0) { 52 | $this->session->addSuccessFlash('You have cancelled the meetup'); 53 | } 54 | 55 | return new RedirectResponse($this->router->generateUri('list_meetups')); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/CancelRsvpHandler.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 29 | Assert::that($postData)->isArray(); 30 | 31 | if (! isset($postData['meetupId'])) { 32 | throw new RuntimeException('Bad request'); 33 | } 34 | 35 | $user = $this->session->getLoggedInUser(); 36 | Assert::that($user)->notNull('You need to be logged in'); 37 | 38 | $this->application->cancelRsvp($postData['meetupId'], $user->userId()->asString()); 39 | 40 | return new RedirectResponse( 41 | $this->router->generateUri('meetup_details', [ 42 | 'id' => $postData['meetupId'], 43 | ]) 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/ListMeetupsHandler.php: -------------------------------------------------------------------------------- 1 | getQueryParams()['showPastMeetups'] ?? 'no') === 'yes'; 28 | 29 | $query = 'SELECT m.* FROM meetups m WHERE m.wasCancelled = 0'; 30 | $parameters = []; 31 | 32 | if (!$showPastMeetups) { 33 | $query .= ' AND scheduledFor >= ?'; 34 | $parameters[] = $now->format('Y-m-d H:i'); 35 | } 36 | 37 | $meetups = $this->connection->fetchAllAssociative($query, $parameters); 38 | 39 | return new HtmlResponse( 40 | $this->renderer->render('app::list-meetups.html.twig', [ 41 | 'meetups' => $meetups, 42 | 'showPastMeetups' => $showPastMeetups, 43 | ]) 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/MeetupDetailsHandler.php: -------------------------------------------------------------------------------- 1 | getAttribute('id'); 26 | Assert::that($meetupId)->string(); 27 | 28 | $meetupDetails = $this->meetupDetailsRepository->getById($meetupId); 29 | 30 | return new HtmlResponse( 31 | $this->renderer->render('app::meetup-details.html.twig', [ 32 | 'meetupDetails' => $meetupDetails, 33 | ]) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/RescheduleMeetupHandler.php: -------------------------------------------------------------------------------- 1 | session->getLoggedInUser(); 36 | Assert::that($loggedInUser)->notNull(); 37 | 38 | $record = $this->connection->fetchAssociative( 39 | 'SELECT * FROM meetups WHERE meetupId = ?', 40 | [$request->getAttribute('id')] 41 | ); 42 | 43 | if ($record === false) { 44 | return $this->responseFactory->createResponse(400); 45 | } 46 | 47 | [$date, $time] = explode(' ', Mapping::getString($record, 'scheduledFor')); 48 | 49 | $formErrors = []; 50 | $formData = [ 51 | 'scheduleForDate' => $date, 52 | 'scheduleForTime' => $time, 53 | ]; 54 | 55 | if ($request->getMethod() === 'POST') { 56 | $submittedData = $request->getParsedBody(); 57 | Assertion::isArray($submittedData); 58 | 59 | $formData = array_merge($formData, $submittedData); 60 | 61 | $dateTime = DateTimeImmutable::createFromFormat( 62 | 'Y-m-d H:i', 63 | $formData['scheduleForDate'] . ' ' . $formData['scheduleForTime'] 64 | ); 65 | if ($dateTime === false) { 66 | $formErrors['scheduleFor'][] = 'Invalid date/time'; 67 | } 68 | 69 | if ($formErrors === []) { 70 | $numberOfAffectedRows = $this->connection->update( 71 | 'meetups', 72 | [ 73 | 'scheduledFor' => $formData['scheduleForDate'] . ' ' . $formData['scheduleForTime'], 74 | ], 75 | [ 76 | 'meetupId' => $record['meetupId'], 77 | 'organizerId' => $loggedInUser->userId() 78 | ->asString(), 79 | ] 80 | ); 81 | 82 | if ($numberOfAffectedRows > 0) { 83 | $this->session->addSuccessFlash('You have rescheduled the meetup'); 84 | } 85 | 86 | return new RedirectResponse($this->router->generateUri('list_meetups')); 87 | } 88 | } 89 | 90 | return new HtmlResponse( 91 | $this->renderer->render( 92 | 'app::reschedule-meetup.html.twig', 93 | [ 94 | 'formData' => $formData, 95 | 'formErrors' => $formErrors, 96 | 'meetupId' => $record['meetupId'], 97 | 'meetupName' => $record['name'], 98 | ] 99 | ) 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/RsvpForMeetupHandler.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 30 | Assert::that($postData)->isArray(); 31 | 32 | if (! isset($postData['meetupId'])) { 33 | throw new RuntimeException('Bad request'); 34 | } 35 | 36 | $user = $this->session->getLoggedInUser(); 37 | Assert::that($user)->notNull('You need to be logged in'); 38 | 39 | $this->application->rsvpForMeetup(new RsvpForMeetup($postData['meetupId'], $user->userId()->asString())); 40 | 41 | return new RedirectResponse( 42 | $this->router->generateUri('meetup_details', [ 43 | 'id' => $postData['meetupId'], 44 | ]) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Handler/ScheduleMeetupHandler.php: -------------------------------------------------------------------------------- 1 | '', 34 | 'description' => '', 35 | 'scheduleForDate' => '', 36 | // This is a nice place to set some defaults 37 | 'scheduleForTime' => '20:00', 38 | ]; 39 | 40 | if ($request->getMethod() === 'POST') { 41 | $formData = $request->getParsedBody(); 42 | Assert::that($formData)->isArray(); 43 | 44 | if ($formData['name'] === '') { 45 | $formErrors['name'][] = 'Provide a name'; 46 | } 47 | if ($formData['description'] === '') { 48 | $formErrors['description'][] = 'Provide a description'; 49 | } 50 | 51 | $dateTime = DateTimeImmutable::createFromFormat( 52 | 'Y-m-d H:i', 53 | $formData['scheduleForDate'] . ' ' . $formData['scheduleForTime'] 54 | ); 55 | if ($dateTime === false) { 56 | $formErrors['scheduleFor'][] = 'Invalid date/time'; 57 | } 58 | 59 | if ($formErrors === []) { 60 | $user = $this->session->getLoggedInUser(); 61 | Assert::that($user)->notNull('You need to be logged in'); 62 | 63 | $record = [ 64 | 'organizerId' => $user 65 | ->userId() 66 | ->asString(), 67 | 'name' => $formData['name'], 68 | 'description' => $formData['description'], 69 | 'scheduledFor' => $formData['scheduleForDate'] . ' ' . $formData['scheduleForTime'], 70 | 'wasCancelled' => 0, 71 | ]; 72 | $this->connection->insert('meetups', $record); 73 | 74 | $meetupId = (int) $this->connection->lastInsertId(); 75 | 76 | $this->session->addSuccessFlash('Your meetup was scheduled successfully'); 77 | 78 | return new RedirectResponse( 79 | $this->router->generateUri('meetup_details', [ 80 | 'id' => $meetupId, 81 | ]) 82 | ); 83 | } 84 | } 85 | 86 | return new HtmlResponse( 87 | $this->renderer->render( 88 | 'app::schedule-meetup.html.twig', 89 | [ 90 | 'formData' => $formData, 91 | 'formErrors' => $formErrors, 92 | ] 93 | ), 94 | $formErrors === [] ? 200 : 422 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/Infrastructure/RsvpRepositoryUsingDbal.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | private array $savedRsvpIds = []; 21 | 22 | public function __construct( 23 | private readonly Connection $connection 24 | ) { 25 | } 26 | 27 | public function save(Rsvp $rsvp): void 28 | { 29 | if (isset($this->savedRsvpIds[$rsvp->rsvpId()->asString()])) { 30 | $this->connection->update( 31 | 'rsvps', 32 | $rsvp->asDatabaseRecord(), 33 | [ 34 | 'rsvpId' => $rsvp->rsvpId() 35 | ->asString(), 36 | ] 37 | ); 38 | } else { 39 | $this->connection->insert('rsvps', $rsvp->asDatabaseRecord(),); 40 | $this->savedRsvpIds[$rsvp->rsvpId()->asString()] = true; 41 | } 42 | } 43 | 44 | public function nextIdentity(): RsvpId 45 | { 46 | return RsvpId::fromString(Uuid::uuid4()->toString()); 47 | } 48 | 49 | public function getByMeetupAndUserId(string $meetupId, UserId $userId): Rsvp 50 | { 51 | $record = $this->connection->fetchAssociative( 52 | 'SELECT * FROM rsvps WHERE meetupId = ? AND userId = ?', 53 | [$meetupId, $userId->asString()] 54 | ); 55 | if ($record === false) { 56 | throw CouldNotFindRsvp::withMeetupAndUserId($meetupId, $userId); 57 | } 58 | 59 | $rsvp = Rsvp::fromDatabaseRecord($record); 60 | 61 | $this->savedRsvpIds[$rsvp->rsvpId()->asString()] = true; 62 | 63 | return $rsvp; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/ViewModel/MeetupDetails.php: -------------------------------------------------------------------------------- 1 | $rsvps 11 | */ 12 | public function __construct( 13 | private readonly string $meetupId, 14 | private readonly string $name, 15 | private readonly string $description, 16 | private readonly string $scheduledFor, 17 | private readonly Organizer $organizer, 18 | private readonly array $rsvps 19 | ) { 20 | } 21 | 22 | public function name(): string 23 | { 24 | return $this->name; 25 | } 26 | 27 | public function scheduledFor(): string 28 | { 29 | return $this->scheduledFor; 30 | } 31 | 32 | public function description(): string 33 | { 34 | return $this->description; 35 | } 36 | 37 | public function organizer(): Organizer 38 | { 39 | return $this->organizer; 40 | } 41 | 42 | public function meetupId(): string 43 | { 44 | return $this->meetupId; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function rsvps(): array 51 | { 52 | return $this->rsvps; 53 | } 54 | 55 | public function hasRsvpedForMeetup(string $userId): bool 56 | { 57 | return isset($this->rsvps[$userId]); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/ViewModel/MeetupDetailsRepository.php: -------------------------------------------------------------------------------- 1 | connection->fetchAssociative( 22 | 'SELECT m.*, u.name as organizerName FROM meetups m INNER JOIN users u ON m.organizerId = u.userId WHERE meetupId = ?', 23 | [$meetupId] 24 | ); 25 | if ($record === false) { 26 | throw new RuntimeException('Meetup not found'); 27 | } 28 | 29 | $rsvpRecords = $this->connection->fetchAllAssociative( 30 | 'SELECT r.rsvpId, r.userId, u.name as userName FROM rsvps r INNER JOIN users u ON r.userId = u.userId WHERE r.meetupId = ? AND r.answer = ?', 31 | [$meetupId, Answer::Yes->value] 32 | ); 33 | 34 | return new MeetupDetails( 35 | Mapping::getString($record, 'meetupId'), 36 | Mapping::getString($record, 'name'), 37 | Mapping::getString($record, 'description'), 38 | Mapping::getString($record, 'scheduledFor'), 39 | new Organizer(Mapping::getString($record, 'organizerId'), Mapping::getString($record, 'organizerName')), 40 | array_combine(array_column($rsvpRecords, 'userId'), array_column($rsvpRecords, 'userName'),), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/MeetupOrganizing/ViewModel/Organizer.php: -------------------------------------------------------------------------------- 1 | id; 18 | } 19 | 20 | public function name(): string 21 | { 22 | return $this->name; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /templates/admin/list-organizers.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Organizers{% endblock %} 4 | 5 | {% block content %} 6 | Organizers 7 | 8 | {% if organizers is empty %} 9 | There are no organizers 10 | {% else %} 11 | 12 | 13 | NameActions 14 | 15 | 16 | {% for organizer in organizers %} 17 | 18 | {{ organizer.name }} 19 | 20 | Create invoice 21 | List invoices 22 | 23 | 24 | {% endfor %} 25 | 26 | 27 | {% endif %} 28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /templates/app/list-meetups.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Meetups{% endblock %} 4 | 5 | {% block content %} 6 | {% if session.isLoggedInUserOrganizer() %} 7 | Schedule meetup 8 | {% endif %} 9 | 10 | Meetups 11 | 12 | 13 | 14 | 15 | Show past meetups 16 | 17 | 18 | Update list 19 | 20 | 21 | 22 | 23 | {% for meetup in meetups %} 24 | 25 | 26 | 27 | {{ meetup.name }} 28 | 29 | 30 | 31 | 32 | {{ meetup.scheduledFor|date() }} 33 | {% if session.isLoggedInUser(meetup.organizerId) %} 34 | Organized by you! 35 | {% endif %} 36 | 37 | Read more 38 | 39 | 40 | {% else %} 41 | No meetups found 42 | {% endfor %} 43 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /templates/app/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Login{% endblock %} 4 | 5 | {% block content %} 6 | Log in 7 | 8 | 9 | 10 | Your email address: 11 | 13 | {% for error in formErrors["emailAddress"]|default({}) %} 14 | {{ error }} 15 | {% endfor %} 16 | 17 | Log in 18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /templates/app/meetup-details.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}{{ meetupDetails.name }}{% endblock %} 4 | 5 | {% block content %} 6 | « Back to the list 7 | {{ meetupDetails.organizer.name }} presents: {{ meetupDetails.name }} 8 | 9 | {{ meetupDetails.scheduledFor|date() }} 10 | {{ meetupDetails.description }} 11 | 12 | Attendees 13 | {% if meetupDetails.rsvps|length > 0 %} 14 | 15 | {% for userId, userName in meetupDetails.rsvps %} 16 | 17 | {{ userName }} 18 | {% if session.isLoggedInUser(userId) %} 19 | 20 | 21 | Cancel RSVP 22 | 23 | {% endif %} 24 | 25 | {% endfor %} 26 | 27 | {% else %} 28 | No attendees yet. 29 | {% endif %} 30 | 31 | {% if session.isLoggedInUser(meetupDetails.organizer.organizerId) %} 32 | 33 | 34 | Cancel this meetup 35 | 36 | Reschedule this meetup 37 | {% elseif session.isLoggedInUserRegular() and not meetupDetails.hasRsvpedForMeetup(session.getLoggedInUserId) %} 38 | 39 | 40 | RSVP 41 | 42 | {% endif %} 43 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /templates/app/reschedule-meetup.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Reschedule a meetup{% endblock %} 4 | 5 | {% block content %} 6 | Rechedule meetup {{ meetupName }} 7 | 8 | 9 | 10 | Schedule for date: 11 | 13 | 14 | 15 | Time: 16 | 18 | {% for error in formErrors["scheduleFor"]|default({}) %} 19 | {{ error }} 20 | {% endfor %} 21 | 22 | 23 | Reschedule 24 | 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /templates/app/schedule-meetup.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Schedule a meetup{% endblock %} 4 | 5 | {% block content %} 6 | Schedule a meetup 7 | 8 | 9 | {% for error in formErrors["general"]|default({}) %} 10 | {{ error }} 11 | {% endfor %} 12 | 13 | Name: 14 | 16 | {% for error in formErrors["name"]|default({}) %} 17 | {{ error }} 18 | {% endfor %} 19 | 20 | 21 | Description: 22 | {{ formData["description"]|default("") }} 24 | {% for error in formErrors["description"]|default({}) %} 25 | {{ error }} 26 | {% endfor %} 27 | 28 | 29 | Schedule for date: 30 | 32 | 33 | 34 | Time: 35 | 37 | {% for error in formErrors["scheduleFor"]|default({}) %} 38 | {{ error }} 39 | {% endfor %} 40 | 41 | 42 | Schedule this meetup 43 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /templates/app/sign-up.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Sign up{% endblock %} 4 | 5 | {% block content %} 6 | Sign up 7 | 8 | 9 | 10 | Your name: 11 | 13 | {% for error in formErrors["name"]|default({}) %} 14 | {{ error }} 15 | {% endfor %} 16 | 17 | 18 | Your email address: 19 | 21 | {% for error in formErrors["emailAddress"]|default({}) %} 22 | {{ error }} 23 | {% endfor %} 24 | 25 | 26 | User type: 27 | 28 | {% for userType in userTypes %} 29 | {{ userType.label }} 30 | {% endfor %} 31 | 32 | {% for error in formErrors["userType"]|default({}) %} 33 | {{ error }} 34 | {% endfor %} 35 | 36 | 37 | Sign up 38 | 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /templates/billing/create-invoice.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Create invoice{% endblock %} 4 | 5 | {% block content %} 6 | Create invoice 7 | 8 | 9 | 10 | 11 | Year: 12 | 13 | {% for year in years %} 14 | {{ year }} 15 | {% endfor %} 16 | 17 | {% for error in formErrors["year"]|default({}) %} 18 | {{ error }} 19 | {% endfor %} 20 | 21 | 22 | Month: 23 | 24 | {% for month in months %} 25 | {{ month }} 26 | {% endfor %} 27 | 28 | {% for error in formErrors["month"]|default({}) %} 29 | {{ error }} 30 | {% endfor %} 31 | 32 | 33 | Create invoice 34 | 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /templates/billing/list-invoices.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "@layout/default.html.twig" %} 2 | 3 | {% block title %}Invoices{% endblock %} 4 | 5 | {% block content %} 6 | Invoices 7 | 8 | Create 9 | invoice 10 | 11 | {% if invoices is empty %} 12 | There are no invoices 13 | {% else %} 14 | 15 | 16 | 17 | Period 18 | Amount 19 | Actions 20 | 21 | 22 | 23 | {% for invoice in invoices %} 24 | 25 | {{ invoice.period }} 26 | {{ invoice.amount|number_format(2) }} 27 | 28 | 29 | Delete 30 | 31 | 32 | 33 | {% endfor %} 34 | 35 | 36 | {% endif %} 37 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /templates/error/404.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@layout/default.html.twig' %} 2 | 3 | {% block title %}404 Not Found{% endblock %} 4 | 5 | {% block content %} 6 | Oops! 7 | This is awkward. 8 | We encountered a 404 Not Found error. 9 | 10 | You are looking for something that doesn't exist or may have moved. Check out one of the links on this page 11 | or head back to Home. 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /templates/error/error.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@layout/default.html.twig' %} 2 | 3 | {% block title %}{{ status }} {{ reason }}{% endblock %} 4 | 5 | {% block content %} 6 | Oops! 7 | This is awkward. 8 | We encountered a {{ status }} {{ reason }} error. 9 | {% if status == 404 %} 10 | 11 | You are looking for something that doesn't exist or may have moved. Check out one of the links on this page 12 | or head back to Home. 13 | 14 | {% endif %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/layout/_flashes.html.twig: -------------------------------------------------------------------------------- 1 | {% for type, flashes in session.getFlashes() %} 2 | 3 | {% for message in flashes %} 4 | 5 | {{ message }} 6 | 7 | {% endfor %} 8 | 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /templates/layout/_navigation.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | List meetups 17 | 18 | {% if session.isLoggedInUserOrganizer %} 19 | 20 | Schedule a meetup 21 | 22 | {% endif %} 23 | 24 | {% if session.isLoggedInUserAdmin %} 25 | 26 | Organizers Admin 27 | 28 | {% endif %} 29 | 30 | {% if session.isUserLoggedIn() %} 31 | 32 | 33 | Logout 34 | 35 | 36 | {% else %} 37 | 38 | Sign up 39 | 40 | 41 | Log in 42 | 43 | {% endif %} 44 | 45 | 46 | 47 | Anonymous user 48 | {% for userId, userName in allUsers %} 49 | {{ userName }} 50 | {% endfor %} 51 | 52 | Switch 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /templates/layout/default.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}{% endblock %} - Bunchup; get together! 8 | 9 | 10 | 16 | {% block stylesheets %}{% endblock %} 17 | 18 | 19 | 20 | {% include "@layout/_navigation.html.twig" %} 21 | 22 | 23 | 24 | 25 | {% include "@layout/_flashes.html.twig" %} 26 | 27 | {% block content %}{% endblock %} 28 | 29 | 30 | 31 | 41 | 42 | 43 | 44 | {% block javascript %}{% endblock %} 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/AppTest/AbstractApplicationTest.php: -------------------------------------------------------------------------------- 1 | get(SchemaManager::class); 25 | $schemaManager->updateSchema(); 26 | $schemaManager->truncateTables(); 27 | 28 | $this->application = $container->get(ApplicationInterface::class); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/AppTest/AbstractBrowserTest.php: -------------------------------------------------------------------------------- 1 | get(SchemaManager::class); 36 | $schemaManager->updateSchema(); 37 | $schemaManager->truncateTables(); 38 | 39 | self::$baseUri = 'http://web_testing:80'; 40 | $this->browser = self::createHttpBrowserClient(); 41 | $this->setServerTime('now'); 42 | } 43 | 44 | protected function tearDown(): void 45 | { 46 | $this->logout(); 47 | } 48 | 49 | protected function scheduleMeetup(string $name, string $description, string $date, string $time): void 50 | { 51 | (new ScheduleMeetupPage($this->browser->request('GET', '/schedule-meetup'))) 52 | ->scheduleMeetup($this->browser, $name, $description, $date, $time); 53 | } 54 | 55 | protected function scheduleMeetupProducesFormError(string $name, string $description, string $date, string $time, string $expectedError): void 56 | { 57 | (new ScheduleMeetupPage($this->browser->request('GET', '/schedule-meetup'))) 58 | ->scheduleMeetupUnsuccessfully($this->browser, $name, $description, $date, $time) 59 | ->assertFormErrorsContains($expectedError); 60 | } 61 | 62 | protected function cancelMeetup(string $name): void 63 | { 64 | $this->meetupDetails($name) 65 | ->cancelMeetup($this->browser); 66 | } 67 | 68 | protected function rescheduleMeetup(string $name, string $date, string $time): void 69 | { 70 | $this->meetupDetails($name) 71 | ->rescheduleMeetup($this->browser) 72 | ->reschedule($this->browser, $date, $time); 73 | } 74 | 75 | protected function assertUpcomingMeetupExists(string $expectedName): void 76 | { 77 | self::assertContains( 78 | $expectedName, 79 | array_map(fn (MeetupSnippet $meetup) => $meetup->name(), $this->listMeetupsPage() ->upcomingMeetups()) 80 | ); 81 | } 82 | 83 | protected function assertUpcomingMeetupDoesNotExist(string $expectedName): void 84 | { 85 | self::assertNotContains( 86 | $expectedName, 87 | array_map(fn (MeetupSnippet $meetup) => $meetup->name(), $this->listMeetupsPage() ->upcomingMeetups()) 88 | ); 89 | } 90 | 91 | protected function listMeetupsPage(): ListMeetupsPage 92 | { 93 | return new ListMeetupsPage($this->browser->request('GET', '/')); 94 | } 95 | 96 | protected function flashMessagesShouldContain(string $expectedMessage): void 97 | { 98 | self::assertContains($expectedMessage, (new GenericPage($this->browser->getCrawler()))->getFlashMessages()); 99 | } 100 | 101 | protected function signUp(string $name, string $emailAddress, string $userType): void 102 | { 103 | (new SignUpPage($this->browser->request('GET', '/sign-up'))) 104 | ->signUp($this->browser, $name, $emailAddress, $userType); 105 | } 106 | 107 | protected function login(string $emailAddress): void 108 | { 109 | (new LoginPage($this->browser->request('GET', '/login'))) 110 | ->logIn($this->browser, $emailAddress); 111 | } 112 | 113 | protected function logout(): void 114 | { 115 | $this->browser->request('POST', '/logout'); 116 | } 117 | 118 | protected function rsvpForMeetup(string $name): void 119 | { 120 | $this->meetupDetails($name) 121 | ->rsvpToMeetup($this->browser); 122 | } 123 | 124 | protected function cancelRsvp(string $name): void 125 | { 126 | $this->meetupDetails($name) 127 | ->cancelRsvp($this->browser); 128 | } 129 | 130 | protected function listOfAttendeesShouldContain(string $meetupName, string $attendeeName): void 131 | { 132 | self::assertContains($attendeeName, $this->meetupDetails($meetupName) ->attendees()); 133 | } 134 | 135 | protected function listOfAttendeesShouldNotContain(string $meetupName, string $attendeeName): void 136 | { 137 | self::assertNotContains($attendeeName, $this->meetupDetails($meetupName) ->attendees()); 138 | } 139 | 140 | private function meetupDetails(string $meetupName): PageObject\MeetupDetailsPage 141 | { 142 | return $this->listMeetupsPage() 143 | ->upcomingMeetup($meetupName) 144 | ->readMore($this->browser); 145 | } 146 | 147 | protected function setServerTime(string $dateTime): void 148 | { 149 | self::assertInstanceOf(HttpBrowser::class, self::$httpBrowserClient); 150 | 151 | self::$httpBrowserClient->setServerParameter( 152 | 'HTTP_X_CURRENT_TIME', 153 | (new DateTimeImmutable($dateTime))->format(DateTimeInterface::ATOM) 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /test/AppTest/ApiTest.php: -------------------------------------------------------------------------------- 1 | browser->request('GET', '/api/ping'); 12 | 13 | $jsonData = $this->browser->getInternalResponse() 14 | ->getContent(); 15 | self::assertJson($jsonData); 16 | 17 | $decodedData = json_decode($jsonData, true); 18 | self::assertIsArray($decodedData); 19 | self::assertArrayHasKey('time', $decodedData); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/AppTest/ApplicationLevelInvoicingTest.php: -------------------------------------------------------------------------------- 1 | application->signUp(new SignUp('Organizer', 'organizer@gmail.com', 'Organizer')); 14 | 15 | // @TODO remove useless assertion 16 | self::assertIsString($organizerId); 17 | 18 | // @TODO let the organizer schedule a meetup (see InvoicingTest for sample data) 19 | // @TODO let the organizer schedule another meetup (see InvoicingTest for sample data) 20 | // @TODO create an invoice for the organizer for January 2022 21 | // @TODO list the invoices for the organizer 22 | // @TODO assert that the only invoice is an invoice for January 2022 with an amount of 10.00 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/AppTest/CancelMeetupTest.php: -------------------------------------------------------------------------------- 1 | signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 12 | $this->login('organizer@gmail.com'); 13 | 14 | $this->scheduleMeetup('Coding Dojo', 'Some description', '2024-10-10', '20:00'); 15 | 16 | $this->cancelMeetup('Coding Dojo'); 17 | 18 | $this->assertUpcomingMeetupDoesNotExist('Coding Dojo'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/AppTest/InvoicingTest.php: -------------------------------------------------------------------------------- 1 | signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 14 | $this->signUp('Administrator', 'administrator@gmail.com', 'Administrator'); 15 | 16 | $this->login('organizer@gmail.com'); 17 | $this->scheduleMeetup('Meetup 1', 'Description', '2022-01-10', '20:00'); 18 | $this->scheduleMeetup('Meetup 2', 'Description', '2022-01-17', '20:00'); 19 | $this->logout(); 20 | 21 | $this->login('administrator@gmail.com'); 22 | 23 | $this->listOrganizersPage() 24 | ->firstOrganizer() 25 | ->createInvoice($this->browser) 26 | ->createInvoice($this->browser, '2022', '1'); 27 | $this->flashMessagesShouldContain('Invoice created'); 28 | 29 | $invoicesPage = $this->listOrganizersPage() 30 | ->firstOrganizer() 31 | ->listInvoices($this->browser); 32 | self::assertEquals('10.00', $invoicesPage->invoiceAmountForPeriod('1/2022')); 33 | } 34 | 35 | private function listOrganizersPage(): ListOrganizersPage 36 | { 37 | return new ListOrganizersPage($this->browser->request('GET', '/admin/list-organizers')); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/AbstractPageObject.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public static function createManyFromCrawler(Crawler $filter): array 25 | { 26 | if (count($filter) === 0) { 27 | return []; 28 | } 29 | 30 | return array_map( 31 | fn(DOMNode $node) => new static(new Crawler($node, $filter->getUri())), 32 | iterator_to_array($filter) 33 | ); 34 | } 35 | 36 | protected static function assertSuccessfulResponse(HttpBrowser $browser): void 37 | { 38 | Assert::assertThat($browser->getInternalResponse(), new SuccessfulResponse()); 39 | } 40 | 41 | protected static function assertUnsuccessfulResponse(HttpBrowser $browser): void 42 | { 43 | Assert::assertThat($browser->getInternalResponse(), new UnsuccessfulResponse()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/CreateInvoicePage.php: -------------------------------------------------------------------------------- 1 | submitForm('Create invoice', [ 14 | 'year' => $year, 15 | 'month' => $month, 16 | ]); 17 | 18 | self::assertSuccessfulResponse($browser); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/GenericPage.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getFlashMessages(): array 13 | { 14 | $nodes = $this->crawler->filter('.flash-message'); 15 | if (count($nodes) === 0) { 16 | return []; 17 | } 18 | 19 | return array_map(fn (\DOMNode $node) => trim($node->textContent), iterator_to_array($nodes)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/ListInvoicesPage.php: -------------------------------------------------------------------------------- 1 | crawler->filter('.invoice') as $invoiceNode) { 14 | $invoiceCrawler = new Crawler($invoiceNode, $this->crawler->getUri()); 15 | 16 | if (trim($invoiceCrawler->filter('.period')->text()) === $period) { 17 | return trim($invoiceCrawler->filter('.amount')->text()); 18 | } 19 | } 20 | 21 | throw new \RuntimeException('Could not find an invoice for period ' . $period); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/ListMeetupsPage.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function upcomingMeetups(): array 15 | { 16 | return MeetupSnippet::createManyFromCrawler($this->crawler->filter('.upcoming-meetups .meetup')); 17 | } 18 | 19 | public function upcomingMeetup(string $name): MeetupSnippet 20 | { 21 | foreach ($this->upcomingMeetups() as $upcomingMeetup) { 22 | if ($upcomingMeetup->name() === $name) { 23 | return $upcomingMeetup; 24 | } 25 | } 26 | 27 | throw new RuntimeException('Could not find upcoming meetup ' . $name); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/ListOrganizersPage.php: -------------------------------------------------------------------------------- 1 | crawler->filter('.organizer'); 12 | 13 | if (count($organizers) === 0) { 14 | throw new \RuntimeException('No organizers found'); 15 | } 16 | 17 | return new OrganizerSnippet($organizers->first()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/LoginPage.php: -------------------------------------------------------------------------------- 1 | submitForm('Log in', [ 14 | 'emailAddress' => $emailAddress, 15 | ]); 16 | 17 | self::assertSuccessfulResponse($browser); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/MeetupDetailsPage.php: -------------------------------------------------------------------------------- 1 | submitForm('Cancel this meetup'); 16 | 17 | self::assertSuccessfulResponse($browser); 18 | } 19 | 20 | public function rsvpToMeetup(HttpBrowser $browser): void 21 | { 22 | $browser->submitForm('RSVP'); 23 | 24 | self::assertSuccessfulResponse($browser); 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function attendees(): array 31 | { 32 | return array_map( 33 | fn (DOMNode $node) => trim($node->textContent), 34 | iterator_to_array($this->crawler->filter('.attendees li .name')) 35 | ); 36 | } 37 | 38 | public function rescheduleMeetup(HttpBrowser $browser): RescheduleMeetupPage 39 | { 40 | $page = new RescheduleMeetupPage($browser->clickLink('Reschedule this meetup')); 41 | 42 | self::assertSuccessfulResponse($browser); 43 | 44 | return $page; 45 | } 46 | 47 | public function assertScheduledFor(string $expected): void 48 | { 49 | Assert::assertEquals($expected, trim($this->crawler->filter('.meetup-scheduled-for')->text())); 50 | } 51 | 52 | public function cancelRsvp(HttpBrowser $browser): void 53 | { 54 | $browser->submitForm('Cancel RSVP'); 55 | 56 | self::assertSuccessfulResponse($browser); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/MeetupSnippet.php: -------------------------------------------------------------------------------- 1 | crawler->filter('.name')->text()); 14 | } 15 | 16 | public function readMore(HttpBrowser $browser): MeetupDetailsPage 17 | { 18 | return new MeetupDetailsPage($browser->click($this->crawler->filter('a.read-more')->link())); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/OrganizerSnippet.php: -------------------------------------------------------------------------------- 1 | click($this->crawler->filter('.create-invoice')->link())); 14 | } 15 | 16 | public function listInvoices(HttpBrowser $browser): ListInvoicesPage 17 | { 18 | return new ListInvoicesPage($browser->click($this->crawler->filter('.list-invoices')->link())); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/RescheduleMeetupPage.php: -------------------------------------------------------------------------------- 1 | submitForm('Reschedule', [ 14 | 'scheduleForDate' => $date, 15 | 'scheduleForTime' => $time, 16 | ]); 17 | 18 | self::assertSuccessfulResponse($browser); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/ScheduleMeetupPage.php: -------------------------------------------------------------------------------- 1 | submitForm('Schedule this meetup', [ 21 | 'name' => $name, 22 | 'description' => $description, 23 | 'scheduleForDate' => $date, 24 | 'scheduleForTime' => $time, 25 | ]); 26 | 27 | self::assertSuccessfulResponse($browser); 28 | } 29 | 30 | public function scheduleMeetupUnsuccessfully( 31 | HttpBrowser $browser, 32 | string $name, 33 | string $description, 34 | string $date, 35 | string $time 36 | ): self { 37 | $crawler = $browser->submitForm('Schedule this meetup', [ 38 | 'name' => $name, 39 | 'description' => $description, 40 | 'scheduleForDate' => $date, 41 | 'scheduleForTime' => $time, 42 | ]); 43 | 44 | self::assertUnsuccessfulResponse($browser); 45 | 46 | return new self($crawler); 47 | } 48 | 49 | public function assertFormErrorsContains(string $expectedError): void 50 | { 51 | $feedback = $this->crawler->filter('.form-error'); 52 | if (count($feedback) === 0) { 53 | throw new LogicException('No form errors found'); 54 | } 55 | 56 | Assert::assertStringContainsString( 57 | $expectedError, 58 | $feedback->text() 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/AppTest/PageObject/SignUpPage.php: -------------------------------------------------------------------------------- 1 | submitForm('Sign up', [ 14 | 'name' => $name, 15 | 'emailAddress' => $emailAddress, 16 | 'userType' => $userType, 17 | ]); 18 | 19 | self::assertSuccessfulResponse($browser); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/AppTest/RescheduleMeetupTest.php: -------------------------------------------------------------------------------- 1 | signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 12 | $this->login('organizer@gmail.com'); 13 | 14 | $this->scheduleMeetup('Coding Dojo', 'Some description', '2024-10-10', '20:00'); 15 | 16 | $this->rescheduleMeetup('Coding Dojo', '2026-04-27', '19:00'); 17 | 18 | $this->listMeetupsPage() 19 | ->upcomingMeetup('Coding Dojo') 20 | ->readMore($this->browser) 21 | ->assertScheduledFor('April 27, 2026 19:00'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/AppTest/RsvpForMeetupTest.php: -------------------------------------------------------------------------------- 1 | signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 12 | $this->login('organizer@gmail.com'); 13 | 14 | $this->scheduleMeetup('Coding Dojo', 'Some description', '2024-10-10', '20:00'); 15 | 16 | $this->logout(); 17 | 18 | $this->signUp('Regular user', 'user@gmail.com', 'RegularUser'); 19 | $this->login('user@gmail.com'); 20 | 21 | $this->rsvpForMeetup('Coding Dojo'); 22 | 23 | $this->flashMessagesShouldContain('You have successfully RSVP-ed to this meetup'); 24 | 25 | $this->listOfAttendeesShouldContain('Coding Dojo', 'Regular user'); 26 | } 27 | 28 | public function testCancelRsvp(): void 29 | { 30 | $this->signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 31 | $this->login('organizer@gmail.com'); 32 | 33 | $this->scheduleMeetup('Coding Dojo', 'Some description', '2024-10-10', '20:00'); 34 | 35 | $this->logout(); 36 | 37 | $this->signUp('Regular user', 'user@gmail.com', 'RegularUser'); 38 | $this->login('user@gmail.com'); 39 | 40 | $this->rsvpForMeetup('Coding Dojo'); 41 | 42 | $this->listOfAttendeesShouldContain('Coding Dojo', 'Regular user'); 43 | 44 | $this->cancelRsvp('Coding Dojo'); 45 | 46 | $this->listOfAttendeesShouldNotContain('Coding Dojo', 'Regular user'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/AppTest/ScheduleMeetupTest.php: -------------------------------------------------------------------------------- 1 | signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 12 | $this->login('organizer@gmail.com'); 13 | 14 | $this->scheduleMeetup('Coding Dojo', 'Some description', '2024-10-10', '20:00'); 15 | 16 | $this->flashMessagesShouldContain('Your meetup was scheduled successfully'); 17 | 18 | // Enable this line for Part 2, Assignment 1: 19 | //$this->flashMessagesShouldContain('You have successfully RSVP-ed to this meetup'); 20 | 21 | $this->assertUpcomingMeetupExists('Coding Dojo'); 22 | 23 | // Enable this line for Part 2, Assignment 1: 24 | //$this->listOfAttendeesShouldContain('Coding Dojo', 'Organizer'); 25 | } 26 | 27 | public function testNameShouldNotBeEmpty(): void 28 | { 29 | $this->signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 30 | $this->login('organizer@gmail.com'); 31 | 32 | $this->scheduleMeetupProducesFormError('', 'Some description', '2024-10-10', '20:00', 'Provide a name'); 33 | } 34 | 35 | public function testDescriptionShouldNotBeEmpty(): void 36 | { 37 | $this->signUp('Organizer', 'organizer@gmail.com', 'Organizer'); 38 | $this->login('organizer@gmail.com'); 39 | 40 | $this->scheduleMeetupProducesFormError('Some name', '', '2024-10-10', '20:00', 'Provide a description'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/AppTest/SignUpCommandTest.php: -------------------------------------------------------------------------------- 1 | get(SchemaManager::class); 26 | $schemaManager->updateSchema(); 27 | $schemaManager->truncateTables(); 28 | 29 | $application = $container->get(ConsoleApplication::class); 30 | $application->setAutoExit(false); 31 | $application->setCatchExceptions(false); 32 | 33 | $applicationTester = new ApplicationTester($application); 34 | 35 | $exitCode = $applicationTester->run( 36 | [ 37 | 'command' => 'sign-up', 38 | 'name' => 'Regular user', 39 | 'emailAddress' => 'user@gmail.com', 40 | 'userType' => 'RegularUser', 41 | ] 42 | ); 43 | 44 | self::assertSame(0, $exitCode); 45 | 46 | $this->assertStringContainsString('User was signed up successfully', $applicationTester->getDisplay()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/AppTest/SuccessfulResponse.php: -------------------------------------------------------------------------------- 1 | getStatusCode() >= 400) { 24 | return false; 25 | } 26 | 27 | if ($other->getStatusCode() < 200) { 28 | return false; 29 | } 30 | 31 | return true; 32 | } 33 | 34 | protected function failureDescription($other): string 35 | { 36 | Assertion::isInstanceOf($other, Response::class); 37 | /** @var Response $other */ 38 | 39 | $content = $other->getContent(); 40 | $endOfErrorMessage = strpos($content, '-->'); 41 | if ($endOfErrorMessage === false) { 42 | $showContent = substr($content, 0, 500); 43 | } else { 44 | $showContent = substr($content, 0, $endOfErrorMessage); 45 | } 46 | 47 | return trim($showContent) . ' [...] ' . $this->toString(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/AppTest/UnsuccessfulResponse.php: -------------------------------------------------------------------------------- 1 | getStatusCode() < 400) { 24 | return false; 25 | } 26 | 27 | if ($other->getStatusCode() >= 500) { 28 | return false; 29 | } 30 | 31 | return true; 32 | } 33 | 34 | protected function failureDescription($other): string 35 | { 36 | Assertion::isInstanceOf($other, Response::class); 37 | /** @var Response $other */ 38 | 39 | $content = $other->getContent(); 40 | $endOfErrorMessage = strpos($content, '-->'); 41 | if ($endOfErrorMessage === false) { 42 | $showContent = substr($content, 0, 500); 43 | } else { 44 | $showContent = substr($content, 0, $endOfErrorMessage); 45 | } 46 | 47 | return trim($showContent) . ' [...] ' . $this->toString(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/JsonTest.php: -------------------------------------------------------------------------------- 1 | 'bar', 14 | ]; 15 | 16 | self::assertSame($data, Json::decode(Json::encode($data))); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /var/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiasnoback/hexagonal-architecture-workshop/40204f6ab90c87adb4618cc1df1081ce50045d33/var/.gitkeep -------------------------------------------------------------------------------- /var/session/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiasnoback/hexagonal-architecture-workshop/40204f6ab90c87adb4618cc1df1081ce50045d33/var/session/.gitkeep --------------------------------------------------------------------------------
There are no organizers
Schedule meetup
32 | {{ meetup.scheduledFor|date() }} 33 | {% if session.isLoggedInUser(meetup.organizerId) %} 34 | Organized by you! 35 | {% endif %} 36 |
No meetups found
« Back to the list
{{ meetupDetails.scheduledFor|date() }}
{{ meetupDetails.description }}
No attendees yet.
8 | Create 9 | invoice 10 |
There are no invoices
We encountered a 404 Not Found error.
10 | You are looking for something that doesn't exist or may have moved. Check out one of the links on this page 11 | or head back to Home. 12 |
We encountered a {{ status }} {{ reason }} error.
11 | You are looking for something that doesn't exist or may have moved. Check out one of the links on this page 12 | or head back to Home. 13 |