From 151f174a474508226c9453167120c5e426fe68dd Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 21 Feb 2026 21:12:54 +0100 Subject: [PATCH 01/81] add: laravel configuration --- config/permission.php | 202 ++++++++++++++++++ ..._02_21_200557_create_permission_tables.php | 137 ++++++++++++ docker-compose.yml | 80 +++++++ package.json | 17 -- resources/css/app.css | 11 - resources/js/app.js | 1 - resources/js/bootstrap.js | 4 - tests/Pest.php | 47 ++++ vite.config.js | 18 -- 9 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 config/permission.php create mode 100644 database/migrations/2026_02_21_200557_create_permission_tables.php create mode 100644 docker-compose.yml delete mode 100644 package.json delete mode 100644 resources/css/app.css delete mode 100644 resources/js/app.js delete mode 100644 resources/js/bootstrap.js create mode 100644 tests/Pest.php delete mode 100644 vite.config.js diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..f39f6b5 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,202 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Spatie\Permission\Models\Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Spatie\Permission\Models\Role::class, + + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttached + * \Spatie\Permission\Events\RoleDetached + * \Spatie\Permission\Events\PermissionAttached + * \Spatie\Permission\Events\PermissionDetached + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/migrations/2026_02_21_200557_create_permission_tables.php b/database/migrations/2026_02_21_200557_create_permission_tables.php new file mode 100644 index 0000000..8986275 --- /dev/null +++ b/database/migrations/2026_02_21_200557_create_permission_tables.php @@ -0,0 +1,137 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..465901b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + laravel.test: + build: + context: . + dockerfile: Dockerfile + args: + WWWGROUP: "${WWWGROUP:-1000}" + image: sail-8.3/app + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "${APP_PORT:-80}:80" + environment: + WWWUSER: "${WWWUSER:-1000}" + LARAVEL_SAIL: 1 + XDEBUG_MODE: "${SAIL_XDEBUG_MODE:-off}" + XDEBUG_CONFIG: "${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}" + IGNITION_LOCAL_SITES_PATH: "${PWD}" + volumes: + - ".:/var/www/html" + networks: + - sail + depends_on: + - pgsql + - redis + - mailpit + pgsql: + image: "postgres:17" + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + environment: + POSTGRES_DB: "${DB_DATABASE}" + POSTGRES_USER: "${DB_USERNAME}" + POSTGRES_PASSWORD: "${DB_PASSWORD}" + volumes: + - "sail-pgsql:/var/lib/postgresql/data" + - "./vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql" + networks: + - sail + healthcheck: + test: + - CMD + - pg_isready + - "-q" + - "-d" + - "${DB_DATABASE}" + - "-U" + - "${DB_USERNAME}" + retries: 3 + timeout: 5s + redis: + image: "redis:alpine" + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + volumes: + - "sail-redis:/data" + networks: + - sail + healthcheck: + test: + - CMD + - redis-cli + - ping + retries: 3 + timeout: 5s + mailpit: + image: "axllent/mailpit:latest" + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - sail +networks: + sail: + driver: bridge +volumes: + sail-pgsql: + driver: local + sail-redis: + driver: local diff --git a/package.json b/package.json deleted file mode 100644 index 7686b29..0000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://www.schemastore.org/package.json", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "axios": "^1.11.0", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", - "vite": "^7.0.7" - } -} diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index 3e6abea..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1,11 +0,0 @@ -@import 'tailwindcss'; - -@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; -@source '../../storage/framework/views/*.php'; -@source '../**/*.blade.php'; -@source '../**/*.js'; - -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; -} diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 5f1390b..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..60f04a4 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,47 @@ +extend(Tests\TestCase::class) + // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index f35b4e7..0000000 --- a/vite.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - tailwindcss(), - ], - server: { - watch: { - ignored: ['**/storage/framework/views/**'], - }, - }, -}); From 3a7365cfe09ba02e8a30f3e150aa1e3936e7aefc Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 21 Feb 2026 21:33:46 +0100 Subject: [PATCH 02/81] add: core MVp migrations (users, profiles, venues, events, ticketing, analytics) --- app/Models/AnalyticsEvent.php | 10 +++++ app/Models/Category.php | 10 +++++ app/Models/Event.php | 10 +++++ app/Models/GuestRegistration.php | 10 +++++ app/Models/Order.php | 10 +++++ app/Models/OrganizerProfile.php | 10 +++++ app/Models/Ticket.php | 10 +++++ app/Models/TicketType.php | 10 +++++ app/Models/Venue.php | 10 +++++ .../0001_01_01_000000_create_users_table.php | 1 + ...6_02_21_202151_create_categories_table.php | 32 +++++++++++++++ ...202151_create_organizer_profiles_table.php | 33 +++++++++++++++ .../2026_02_21_202151_create_venues_table.php | 34 ++++++++++++++++ .../2026_02_21_202153_create_events_table.php | 40 +++++++++++++++++++ ...02_21_202154_create_ticket_types_table.php | 31 ++++++++++++++ .../2026_02_21_202155_create_orders_table.php | 30 ++++++++++++++ ...2026_02_21_202156_create_tickets_table.php | 33 +++++++++++++++ ...02157_create_guest_registrations_table.php | 35 ++++++++++++++++ ...1_202158_create_analytics_events_table.php | 31 ++++++++++++++ database/seeders/CategorySeeder.php | 37 +++++++++++++++++ database/seeders/DatabaseSeeder.php | 7 +--- 21 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Event.php create mode 100644 app/Models/GuestRegistration.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrganizerProfile.php create mode 100644 app/Models/Ticket.php create mode 100644 app/Models/TicketType.php create mode 100644 app/Models/Venue.php create mode 100644 database/migrations/2026_02_21_202151_create_categories_table.php create mode 100644 database/migrations/2026_02_21_202151_create_organizer_profiles_table.php create mode 100644 database/migrations/2026_02_21_202151_create_venues_table.php create mode 100644 database/migrations/2026_02_21_202153_create_events_table.php create mode 100644 database/migrations/2026_02_21_202154_create_ticket_types_table.php create mode 100644 database/migrations/2026_02_21_202155_create_orders_table.php create mode 100644 database/migrations/2026_02_21_202156_create_tickets_table.php create mode 100644 database/migrations/2026_02_21_202157_create_guest_registrations_table.php create mode 100644 database/migrations/2026_02_21_202158_create_analytics_events_table.php create mode 100644 database/seeders/CategorySeeder.php diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 0000000..e00374e --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,10 @@ +string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->string('locale', 2)->default('pl'); $table->rememberToken(); $table->timestamps(); }); diff --git a/database/migrations/2026_02_21_202151_create_categories_table.php b/database/migrations/2026_02_21_202151_create_categories_table.php new file mode 100644 index 0000000..1e7a918 --- /dev/null +++ b/database/migrations/2026_02_21_202151_create_categories_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name_pl'); + $table->string('name_en')->nullable(); + $table->string('slug')->unique(); + $table->string('icon')->nullable(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_02_21_202151_create_organizer_profiles_table.php b/database/migrations/2026_02_21_202151_create_organizer_profiles_table.php new file mode 100644 index 0000000..847687c --- /dev/null +++ b/database/migrations/2026_02_21_202151_create_organizer_profiles_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('organization_name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizer_profiles'); + } +}; diff --git a/database/migrations/2026_02_21_202151_create_venues_table.php b/database/migrations/2026_02_21_202151_create_venues_table.php new file mode 100644 index 0000000..8183630 --- /dev/null +++ b/database/migrations/2026_02_21_202151_create_venues_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->string('city'); + $table->string('street_address'); + $table->string('postal_code')->nullable(); + $table->string('district')->nullable(); + $table->decimal('lat', 10, 7)->nullable(); + $table->decimal('lng', 10, 7)->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('venues'); + } +}; diff --git a/database/migrations/2026_02_21_202153_create_events_table.php b/database/migrations/2026_02_21_202153_create_events_table.php new file mode 100644 index 0000000..732e81d --- /dev/null +++ b/database/migrations/2026_02_21_202153_create_events_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('organizer_profile_id')->constrained()->cascadeOnDelete(); + $table->foreignId('venue_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('description_pl'); + $table->text('description_en')->nullable(); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->integer('capacity'); + $table->enum('status', ['draft', 'published', 'cancelled'])->default('draft'); + $table->string('cover_image_path')->nullable(); + $table->unsignedInteger('view_count')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/database/migrations/2026_02_21_202154_create_ticket_types_table.php b/database/migrations/2026_02_21_202154_create_ticket_types_table.php new file mode 100644 index 0000000..7bfe383 --- /dev/null +++ b/database/migrations/2026_02_21_202154_create_ticket_types_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('quantity'); + $table->boolean('is_visible')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_types'); + } +}; diff --git a/database/migrations/2026_02_21_202155_create_orders_table.php b/database/migrations/2026_02_21_202155_create_orders_table.php new file mode 100644 index 0000000..28399a2 --- /dev/null +++ b/database/migrations/2026_02_21_202155_create_orders_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'completed', 'cancelled', 'refunded'])->default('completed'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_02_21_202156_create_tickets_table.php b/database/migrations/2026_02_21_202156_create_tickets_table.php new file mode 100644 index 0000000..3221a27 --- /dev/null +++ b/database/migrations/2026_02_21_202156_create_tickets_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->enum('status', ['valid', 'cancelled', 'checked_in'])->default('valid'); + $table->timestamp('checked_in_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/migrations/2026_02_21_202157_create_guest_registrations_table.php b/database/migrations/2026_02_21_202157_create_guest_registrations_table.php new file mode 100644 index 0000000..b6e719e --- /dev/null +++ b/database/migrations/2026_02_21_202157_create_guest_registrations_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('ticket_type_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email'); + $table->string('name'); + $table->uuid('uuid')->unique(); + $table->string('confirmation_token')->nullable()->unique(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('guest_registrations'); + } +}; diff --git a/database/migrations/2026_02_21_202158_create_analytics_events_table.php b/database/migrations/2026_02_21_202158_create_analytics_events_table.php new file mode 100644 index 0000000..31b4224 --- /dev/null +++ b/database/migrations/2026_02_21_202158_create_analytics_events_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('event_type'); + $table->json('properties')->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('session_id')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..280ff66 --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,37 @@ + 'Muzyka', 'name_en' => 'Music', 'icon' => 'music', 'sort_order' => 1], + ['name_pl' => 'Życie nocne', 'name_en' => 'Nightlife', 'icon' => 'moon', 'sort_order' => 2], + ['name_pl' => 'Kultura i Sztuka', 'name_en' => 'Arts & Culture', 'icon' => 'palette', 'sort_order' => 3], + ['name_pl' => 'Warsztaty', 'name_en' => 'Workshops', 'icon' => 'hammer', 'sort_order' => 4], + ['name_pl' => 'Komedia', 'name_en' => 'Comedy', 'icon' => 'smile', 'sort_order' => 5], + ['name_pl' => 'Społeczność', 'name_en' => 'Community', 'icon' => 'users', 'sort_order' => 6], + ]; + + foreach ($categories as $category) { + DB::table('categories')->insert([ + 'name_pl' => $category['name_pl'], + 'name_en' => $category['name_en'], + 'slug' => Str::slug($category['name_en']), + 'icon' => $category['icon'], + 'sort_order' => $category['sort_order'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..e9112e3 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,11 +15,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + CategorySeeder::class, ]); } } From 2c31134f201b7a76f04d5c3b1389c25a9a97774d Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 21 Feb 2026 21:44:56 +0100 Subject: [PATCH 03/81] refactor: restructure app into Domain-Driven Design modules --- app/{ => Domains/Analytics}/Models/AnalyticsEvent.php | 2 +- app/{ => Domains/Auth}/Models/User.php | 2 +- app/{ => Domains/Events}/Models/Category.php | 2 +- app/{ => Domains/Events}/Models/Event.php | 2 +- .../Organizer}/Models/OrganizerProfile.php | 2 +- .../Ticketing}/Models/GuestRegistration.php | 2 +- app/{ => Domains/Ticketing}/Models/Order.php | 2 +- app/{ => Domains/Ticketing}/Models/Ticket.php | 2 +- app/{ => Domains/Ticketing}/Models/TicketType.php | 2 +- app/{ => Domains/Venues}/Models/Venue.php | 2 +- config/auth.php | 2 +- database/factories/UserFactory.php | 11 +++++++++-- database/seeders/CategorySeeder.php | 1 + database/seeders/DatabaseSeeder.php | 2 +- 14 files changed, 22 insertions(+), 14 deletions(-) rename app/{ => Domains/Analytics}/Models/AnalyticsEvent.php (70%) rename app/{ => Domains/Auth}/Models/User.php (96%) rename app/{ => Domains/Events}/Models/Category.php (70%) rename app/{ => Domains/Events}/Models/Event.php (69%) rename app/{ => Domains/Organizer}/Models/OrganizerProfile.php (70%) rename app/{ => Domains/Ticketing}/Models/GuestRegistration.php (71%) rename app/{ => Domains/Ticketing}/Models/Order.php (68%) rename app/{ => Domains/Ticketing}/Models/Ticket.php (68%) rename app/{ => Domains/Ticketing}/Models/TicketType.php (69%) rename app/{ => Domains/Venues}/Models/Venue.php (69%) diff --git a/app/Models/AnalyticsEvent.php b/app/Domains/Analytics/Models/AnalyticsEvent.php similarity index 70% rename from app/Models/AnalyticsEvent.php rename to app/Domains/Analytics/Models/AnalyticsEvent.php index e00374e..55409fa 100644 --- a/app/Models/AnalyticsEvent.php +++ b/app/Domains/Analytics/Models/AnalyticsEvent.php @@ -1,6 +1,6 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', App\Domains\Auth\Models\User::class), ], // 'users' => [ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..0de078e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -7,10 +7,17 @@ use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Domains\Auth\Models\User> */ class UserFactory extends Factory { + /** + * The name of the factory's corresponding model. + * + * @var class-string<\Illuminate\Database\Eloquent\Model> + */ + protected $model = \App\Domains\Auth\Models\User::class; + /** * The current password being used by the factory. */ @@ -37,7 +44,7 @@ public function definition(): array */ public function unverified(): static { - return $this->state(fn (array $attributes) => [ + return $this->state(fn(array $attributes) => [ 'email_verified_at' => null, ]); } diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php index 280ff66..0f3293a 100644 --- a/database/seeders/CategorySeeder.php +++ b/database/seeders/CategorySeeder.php @@ -35,3 +35,4 @@ public function run(): void } } } +feat(db): add core MVP database migrations (users, profiles, venues, events, ticketing, analytics) \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e9112e3..3383ec0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,7 @@ namespace Database\Seeders; -use App\Models\User; +use App\Domains\Auth\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; From f207ba2dc8ed19658c6326dba53b66d7f159bd73 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 21 Feb 2026 23:06:48 +0100 Subject: [PATCH 04/81] feat(auth): implement domain-driven auth api with services, invokable controllers, and versioning --- .../Auth/Controllers/LoginController.php | 40 +++++++++ .../Auth/Controllers/LogoutController.php | 38 +++++++++ app/Domains/Auth/Controllers/MeController.php | 34 ++++++++ .../Auth/Controllers/RegisterController.php | 38 +++++++++ .../ResendVerificationEmailController.php | 45 ++++++++++ .../Controllers/ResetPasswordController.php | 45 ++++++++++ .../SendPasswordResetLinkController.php | 38 +++++++++ .../Controllers/VerifyEmailController.php | 36 ++++++++ .../Auth/Enums/Http/UserRegisterHttpEnum.php | 27 ++++++ app/Domains/Auth/Models/User.php | 14 +++- .../Auth/Repositories/UserRepository.php | 23 +++++ app/Domains/Auth/Requests/LoginRequest.php | 21 +++++ app/Domains/Auth/Requests/RegisterRequest.php | 23 +++++ app/Domains/Auth/Resources/UserResource.php | 24 ++++++ app/Domains/Auth/Services/AuthService.php | 51 +++++++++++ .../Auth/Services/PasswordResetService.php | 43 ++++++++++ .../Auth/Services/VerificationService.php | 28 +++++++ app/Domains/Shared/Http/BaseController.php | 38 +++++++++ .../Transformers/ApiResponseResource.php | 66 +++++++++++++++ bootstrap/app.php | 8 +- composer.json | 2 +- composer.lock | 2 +- config/sanctum.php | 84 +++++++++++++++++++ ...21_create_personal_access_tokens_table.php | 33 ++++++++ routes/api.php | 33 ++++++++ tests/Feature/Auth/AuthTest.php | 68 +++++++++++++++ tests/Feature/Auth/EmailVerificationTest.php | 42 ++++++++++ 27 files changed, 936 insertions(+), 8 deletions(-) create mode 100644 app/Domains/Auth/Controllers/LoginController.php create mode 100644 app/Domains/Auth/Controllers/LogoutController.php create mode 100644 app/Domains/Auth/Controllers/MeController.php create mode 100644 app/Domains/Auth/Controllers/RegisterController.php create mode 100644 app/Domains/Auth/Controllers/ResendVerificationEmailController.php create mode 100644 app/Domains/Auth/Controllers/ResetPasswordController.php create mode 100644 app/Domains/Auth/Controllers/SendPasswordResetLinkController.php create mode 100644 app/Domains/Auth/Controllers/VerifyEmailController.php create mode 100644 app/Domains/Auth/Enums/Http/UserRegisterHttpEnum.php create mode 100644 app/Domains/Auth/Repositories/UserRepository.php create mode 100644 app/Domains/Auth/Requests/LoginRequest.php create mode 100644 app/Domains/Auth/Requests/RegisterRequest.php create mode 100644 app/Domains/Auth/Resources/UserResource.php create mode 100644 app/Domains/Auth/Services/AuthService.php create mode 100644 app/Domains/Auth/Services/PasswordResetService.php create mode 100644 app/Domains/Auth/Services/VerificationService.php create mode 100644 app/Domains/Shared/Http/BaseController.php create mode 100644 app/Domains/Shared/Transformers/ApiResponseResource.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_02_21_204621_create_personal_access_tokens_table.php create mode 100644 routes/api.php create mode 100644 tests/Feature/Auth/AuthTest.php create mode 100644 tests/Feature/Auth/EmailVerificationTest.php diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php new file mode 100644 index 0000000..c536caa --- /dev/null +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -0,0 +1,40 @@ +authService->loginUser($request->validated()); + $request->session()->regenerate(); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::LOGIN_ERROR->value, + $e instanceof ValidationException ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::LOGIN_SUCCESS->value, + Response::HTTP_OK, + new UserResource($user) + ); + } +} diff --git a/app/Domains/Auth/Controllers/LogoutController.php b/app/Domains/Auth/Controllers/LogoutController.php new file mode 100644 index 0000000..ebbe1b9 --- /dev/null +++ b/app/Domains/Auth/Controllers/LogoutController.php @@ -0,0 +1,38 @@ +authService->logoutUser(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::LOGOUT_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::LOGOUT_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Auth/Controllers/MeController.php b/app/Domains/Auth/Controllers/MeController.php new file mode 100644 index 0000000..fef637f --- /dev/null +++ b/app/Domains/Auth/Controllers/MeController.php @@ -0,0 +1,34 @@ +user(); + $user->loadMissing(['roles', 'permissions']); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::ME_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::ME_SUCCESS->value, + Response::HTTP_OK, + new UserResource($user) + ); + } +} diff --git a/app/Domains/Auth/Controllers/RegisterController.php b/app/Domains/Auth/Controllers/RegisterController.php new file mode 100644 index 0000000..444d584 --- /dev/null +++ b/app/Domains/Auth/Controllers/RegisterController.php @@ -0,0 +1,38 @@ +authService->registerUser($request->validated()); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::REGISTER_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::REGISTER_SUCCESS->value, + Response::HTTP_CREATED, + new UserResource($user) + ); + } +} diff --git a/app/Domains/Auth/Controllers/ResendVerificationEmailController.php b/app/Domains/Auth/Controllers/ResendVerificationEmailController.php new file mode 100644 index 0000000..068e7f9 --- /dev/null +++ b/app/Domains/Auth/Controllers/ResendVerificationEmailController.php @@ -0,0 +1,45 @@ +user(); + + $sent = $this->verificationService->sendVerificationEmail($user); + + if (! $sent) { + return $this->apiResponseSuccess( + UserRegisterHttpEnum::VERIFY_ALREADY->value, + Response::HTTP_BAD_REQUEST + ); + } + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::VERIFY_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::VERIFY_LINK_SENT->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Auth/Controllers/ResetPasswordController.php b/app/Domains/Auth/Controllers/ResetPasswordController.php new file mode 100644 index 0000000..42302be --- /dev/null +++ b/app/Domains/Auth/Controllers/ResetPasswordController.php @@ -0,0 +1,45 @@ +validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|confirmed|min:8', + ]); + + $this->passwordResetService->resetPassword( + $request->only('email', 'password', 'password_confirmation', 'token') + ); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::RESET_ERROR->value, + $e instanceof ValidationException ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::RESET_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Auth/Controllers/SendPasswordResetLinkController.php b/app/Domains/Auth/Controllers/SendPasswordResetLinkController.php new file mode 100644 index 0000000..e2b7107 --- /dev/null +++ b/app/Domains/Auth/Controllers/SendPasswordResetLinkController.php @@ -0,0 +1,38 @@ +validate(['email' => 'required|email']); + $this->passwordResetService->sendResetLink($request->only('email')); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::RESET_ERROR->value, + $e instanceof ValidationException ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::RESET_LINK_SENT->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Auth/Controllers/VerifyEmailController.php b/app/Domains/Auth/Controllers/VerifyEmailController.php new file mode 100644 index 0000000..89e6ce3 --- /dev/null +++ b/app/Domains/Auth/Controllers/VerifyEmailController.php @@ -0,0 +1,36 @@ +verificationService->verifyEmail($request->user()); + } catch (Throwable $e) { + return $this->apiResponseError( + $e, + UserRegisterHttpEnum::VERIFY_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::VERIFY_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Auth/Enums/Http/UserRegisterHttpEnum.php b/app/Domains/Auth/Enums/Http/UserRegisterHttpEnum.php new file mode 100644 index 0000000..7cbf808 --- /dev/null +++ b/app/Domains/Auth/Enums/Http/UserRegisterHttpEnum.php @@ -0,0 +1,27 @@ + */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, HasRoles; + + protected static function newFactory() + { + return \Database\Factories\UserFactory::new(); + } /** * The attributes that are mass assignable. @@ -21,6 +28,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'locale', ]; /** diff --git a/app/Domains/Auth/Repositories/UserRepository.php b/app/Domains/Auth/Repositories/UserRepository.php new file mode 100644 index 0000000..1bb5726 --- /dev/null +++ b/app/Domains/Auth/Repositories/UserRepository.php @@ -0,0 +1,23 @@ +update($data); + } + + public function findByEmail(string $email): ?User + { + return User::where('email', $email)->first(); + } +} diff --git a/app/Domains/Auth/Requests/LoginRequest.php b/app/Domains/Auth/Requests/LoginRequest.php new file mode 100644 index 0000000..1507b28 --- /dev/null +++ b/app/Domains/Auth/Requests/LoginRequest.php @@ -0,0 +1,21 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/app/Domains/Auth/Requests/RegisterRequest.php b/app/Domains/Auth/Requests/RegisterRequest.php new file mode 100644 index 0000000..00bbc44 --- /dev/null +++ b/app/Domains/Auth/Requests/RegisterRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]; + } +} diff --git a/app/Domains/Auth/Resources/UserResource.php b/app/Domains/Auth/Resources/UserResource.php new file mode 100644 index 0000000..c6f6c36 --- /dev/null +++ b/app/Domains/Auth/Resources/UserResource.php @@ -0,0 +1,24 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'locale' => $this->locale ?? 'pl', + 'email_verified_at' => $this->email_verified_at, + 'roles' => $this->whenLoaded('roles', fn() => $this->roles->pluck('name')), + 'permissions' => $this->whenLoaded('permissions', fn() => $this->getAllPermissions()->pluck('name')), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Auth/Services/AuthService.php b/app/Domains/Auth/Services/AuthService.php new file mode 100644 index 0000000..e416fc4 --- /dev/null +++ b/app/Domains/Auth/Services/AuthService.php @@ -0,0 +1,51 @@ +userRepository->create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'locale' => $data['locale'] ?? 'pl', + ]); + + event(new Registered($user)); + + Auth::login($user); + + return $user; + } + + public function loginUser(array $credentials): User + { + if (! Auth::attempt($credentials)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + /** @var User $user */ + $user = Auth::user(); + return $user; + } + + public function logoutUser(): void + { + Auth::guard('web')->logout(); + } +} diff --git a/app/Domains/Auth/Services/PasswordResetService.php b/app/Domains/Auth/Services/PasswordResetService.php new file mode 100644 index 0000000..9fc3843 --- /dev/null +++ b/app/Domains/Auth/Services/PasswordResetService.php @@ -0,0 +1,43 @@ +sendResetLink($data); + + if ($status !== Password::RESET_LINK_SENT) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return $status; + } + + public function resetPassword(array $data): string + { + $status = Password::broker()->reset( + $data, + function ($user, $password) { + $user->forceFill([ + 'password' => Hash::make($password) + ])->save(); + } + ); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return $status; + } +} diff --git a/app/Domains/Auth/Services/VerificationService.php b/app/Domains/Auth/Services/VerificationService.php new file mode 100644 index 0000000..64f122d --- /dev/null +++ b/app/Domains/Auth/Services/VerificationService.php @@ -0,0 +1,28 @@ +hasVerifiedEmail()) { + return false; + } + + return $user->markEmailAsVerified(); + } + + public function sendVerificationEmail(User $user): bool + { + if ($user->hasVerifiedEmail()) { + return false; + } + + $user->sendEmailVerificationNotification(); + + return true; + } +} diff --git a/app/Domains/Shared/Http/BaseController.php b/app/Domains/Shared/Http/BaseController.php new file mode 100644 index 0000000..dbb6cbb --- /dev/null +++ b/app/Domains/Shared/Http/BaseController.php @@ -0,0 +1,38 @@ +response() + ->setStatusCode($statusCode); + } + + protected function apiResponseSuccess(string $message = 'success', int $statusCode = 200, $data = []): JsonResponse + { + return $this->apiResponse($message, $statusCode, $data); + } + + protected function apiResponseError(Throwable $error, ?string $message = null, ?int $statusCode = null): JsonResponse + { + // todo: add when we have sentry logging system + // report($error); + Log::error($error); + + $message = $message ?? 'error.server'; + $statusCode = $statusCode ?? ($error->getCode() ?: 500); + + return ApiResponseResource::error($message) + ->response() + ->setStatusCode($statusCode); + } +} diff --git a/app/Domains/Shared/Transformers/ApiResponseResource.php b/app/Domains/Shared/Transformers/ApiResponseResource.php new file mode 100644 index 0000000..1ee5198 --- /dev/null +++ b/app/Domains/Shared/Transformers/ApiResponseResource.php @@ -0,0 +1,66 @@ + (bool) ($this->resource['success'] ?? true), + 'message' => $this->message ?? ($this->resource['message'] ?? null), + 'data' => $this->resource['data'] ?? null, + 'meta' => !empty($this->meta) ? $this->meta : null, + ]; + } + + /** + * Static helper to create a consistent success response. + */ + public static function success($data = [], ?string $message = null): self + { + return (new self(['data' => $data, 'success' => true]))->setMessage($message ?? 'Success'); + } + + /** + * Static helper to create a consistent error response. + */ + public static function error(string $message, $data = []): self + { + return (new self(['data' => $data, 'success' => false]))->setMessage($message); + } + + public function setMessage(?string $message): self + { + $this->message = $message; + return $this; + } + + public function setMeta(array $meta): self + { + $this->meta = $meta; + return $this; + } + + public function setData($data): self + { + $this->resource['data'] = $data; + return $this; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..31e4185 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,12 +6,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__.'/../routes/web.php', - commands: __DIR__.'/../routes/console.php', + web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', + commands: __DIR__ . '/../routes/console.php', health: '/up', + apiPrefix: 'api/' . env('API_VERSION', 'v1'), ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->statefulApi(); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 5a0f88d..e7f6e5d 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "php": "^8.2", "bacon/bacon-qr-code": "^3.0", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.3", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "spatie/laravel-permission": "^7.2" }, diff --git a/composer.lock b/composer.lock index aae6a22..5077523 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2db66c16d883db2f4a44d522a7f278fa", + "content-hash": "44a3750d4f81f48768a18aedf9811d7c", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_02_21_204621_create_personal_access_tokens_table.php b/database/migrations/2026_02_21_204621_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_02_21_204621_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..34290b5 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,33 @@ +group(function () { + Route::post('/register', RegisterController::class); + Route::post('/login', LoginController::class); + + Route::post('/forgot-password', SendPasswordResetLinkController::class)->name('password.email'); + Route::post('/reset-password', ResetPasswordController::class)->name('password.store'); + + Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', LogoutController::class); + Route::get('/me', MeController::class); + + Route::get('/email/verify/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed']) + ->name('verification.verify'); + + Route::post('/email/verification-notification', ResendVerificationEmailController::class) + ->middleware(['throttle:6,1']) + ->name('verification.send'); + }); +}); diff --git a/tests/Feature/Auth/AuthTest.php b/tests/Feature/Auth/AuthTest.php new file mode 100644 index 0000000..86a914b --- /dev/null +++ b/tests/Feature/Auth/AuthTest.php @@ -0,0 +1,68 @@ +postJson('/api/v1/auth/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->dump(); + $response->assertStatus(201) + ->assertJsonStructure(['message', 'data' => ['id', 'name', 'email']]); + + $this->assertDatabaseHas('users', ['email' => 'john@example.com']); + $this->assertAuthenticated(); +}); + +test('a user can login', function () { + $user = User::factory()->create([ + 'email' => 'jane@example.com', + 'password' => bcrypt('password123'), + ]); + + $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/login', [ + 'email' => 'jane@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['message', 'data' => ['id', 'name', 'email']]); + + $this->assertAuthenticatedAs($user); +}); + +test('a user can logout', function () { + $user = User::factory()->create([ + 'password' => bcrypt('password123'), + ]); + + $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password123', + ]); + + $this->assertAuthenticatedAs($user); + + $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/logout'); + + $response->assertStatus(200); +}); + +test('a user can fetch their profile', function () { + $user = User::factory()->create(['name' => 'Test User']); + + $this->actingAs($user); + $response = $this->withHeader('Referer', 'localhost')->getJson('/api/v1/auth/me'); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Test User'); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..f9ef37c --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,42 @@ +postJson('/api/v1/auth/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + Event::assertDispatched(Registered::class); +}); + +test('unverified user can request a verification email', function () { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user); + $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/email/verification-notification'); + + $response->assertStatus(200); +}); + +test('verified user cannot request another verification email', function () { + $user = User::factory()->create(); + + $this->actingAs($user); + $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/email/verification-notification'); + + $response->dump(); + $response->assertStatus(400); +}); From 46c2bf802181206577231484f98b9a98c9b504cf Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 21 Feb 2026 23:12:59 +0100 Subject: [PATCH 05/81] docs(bruno): add bruno api collection for auth testing --- bruno/Auth/LoginController.bru | 18 +++++++++++++++++ bruno/Auth/LogoutController.bru | 11 ++++++++++ bruno/Auth/MeController.bru | 11 ++++++++++ bruno/Auth/RegisterController.bru | 20 +++++++++++++++++++ .../ResendVerificationEmailController.bru | 11 ++++++++++ bruno/Auth/ResetPasswordController.bru | 20 +++++++++++++++++++ .../Auth/SendPasswordResetLinkController.bru | 17 ++++++++++++++++ bruno/bruno.json | 9 +++++++++ bruno/environments/Local.bru | 4 ++++ 9 files changed, 121 insertions(+) create mode 100644 bruno/Auth/LoginController.bru create mode 100644 bruno/Auth/LogoutController.bru create mode 100644 bruno/Auth/MeController.bru create mode 100644 bruno/Auth/RegisterController.bru create mode 100644 bruno/Auth/ResendVerificationEmailController.bru create mode 100644 bruno/Auth/ResetPasswordController.bru create mode 100644 bruno/Auth/SendPasswordResetLinkController.bru create mode 100644 bruno/bruno.json create mode 100644 bruno/environments/Local.bru diff --git a/bruno/Auth/LoginController.bru b/bruno/Auth/LoginController.bru new file mode 100644 index 0000000..eef9602 --- /dev/null +++ b/bruno/Auth/LoginController.bru @@ -0,0 +1,18 @@ +meta { + name: LoginController + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/login + body: json + auth: none +} + +body:json { + { + "email": "john@example.com", + "password": "password" + } +} diff --git a/bruno/Auth/LogoutController.bru b/bruno/Auth/LogoutController.bru new file mode 100644 index 0000000..a5d24a5 --- /dev/null +++ b/bruno/Auth/LogoutController.bru @@ -0,0 +1,11 @@ +meta { + name: LogoutController + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/logout + body: none + auth: none +} diff --git a/bruno/Auth/MeController.bru b/bruno/Auth/MeController.bru new file mode 100644 index 0000000..5930061 --- /dev/null +++ b/bruno/Auth/MeController.bru @@ -0,0 +1,11 @@ +meta { + name: MeController + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/auth/me + body: none + auth: none +} diff --git a/bruno/Auth/RegisterController.bru b/bruno/Auth/RegisterController.bru new file mode 100644 index 0000000..6d0a199 --- /dev/null +++ b/bruno/Auth/RegisterController.bru @@ -0,0 +1,20 @@ +meta { + name: RegisterController + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/register + body: json + auth: none +} + +body:json { + { + "name": "John Doe", + "email": "john@example.com", + "password": "password", + "password_confirmation": "password" + } +} diff --git a/bruno/Auth/ResendVerificationEmailController.bru b/bruno/Auth/ResendVerificationEmailController.bru new file mode 100644 index 0000000..bb66707 --- /dev/null +++ b/bruno/Auth/ResendVerificationEmailController.bru @@ -0,0 +1,11 @@ +meta { + name: ResendVerificationEmailController + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/email/verification-notification + body: none + auth: none +} diff --git a/bruno/Auth/ResetPasswordController.bru b/bruno/Auth/ResetPasswordController.bru new file mode 100644 index 0000000..2488987 --- /dev/null +++ b/bruno/Auth/ResetPasswordController.bru @@ -0,0 +1,20 @@ +meta { + name: ResetPasswordController + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/reset-password + body: json + auth: none +} + +body:json { + { + "token": "YOUR_TOKEN_HERE", + "email": "john@example.com", + "password": "newpassword", + "password_confirmation": "newpassword" + } +} diff --git a/bruno/Auth/SendPasswordResetLinkController.bru b/bruno/Auth/SendPasswordResetLinkController.bru new file mode 100644 index 0000000..3095742 --- /dev/null +++ b/bruno/Auth/SendPasswordResetLinkController.bru @@ -0,0 +1,17 @@ +meta { + name: SendPasswordResetLinkController + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/forgot-password + body: json + auth: none +} + +body:json { + { + "email": "john@example.com" + } +} diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..0ce40c0 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "lokalnie_api", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru new file mode 100644 index 0000000..90d1a86 --- /dev/null +++ b/bruno/environments/Local.bru @@ -0,0 +1,4 @@ +vars { + baseUrl: http://localhost + apiVersion: v1 +} From be52a2b1231dca244664be070cc8c053127f44e2 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 01:06:24 +0100 Subject: [PATCH 06/81] feat(admin): add organizer approval endpoint and notification email --- .../ApproveOrganizerController.php | 43 +++++++++++++++++++ bootstrap/app.php | 7 +++ routes/api.php | 19 +++++++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Admin/Controllers/ApproveOrganizerController.php diff --git a/app/Domains/Admin/Controllers/ApproveOrganizerController.php b/app/Domains/Admin/Controllers/ApproveOrganizerController.php new file mode 100644 index 0000000..e211c1f --- /dev/null +++ b/app/Domains/Admin/Controllers/ApproveOrganizerController.php @@ -0,0 +1,43 @@ +organizerService->approveOrganizer($user); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_BAD_REQUEST + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + OrganizerHttpEnum::APPROVAL_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 31e4185..006bf65 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,8 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Spatie\Permission\Middleware\RoleMiddleware; +use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -14,6 +16,11 @@ ) ->withMiddleware(function (Middleware $middleware): void { $middleware->statefulApi(); + $middleware->alias([ + 'role' => RoleMiddleware::class, + 'permission' => RoleOrPermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/routes/api.php b/routes/api.php index 34290b5..36ac817 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,7 +8,10 @@ use App\Domains\Auth\Controllers\ResetPasswordController; use App\Domains\Auth\Controllers\SendPasswordResetLinkController; use App\Domains\Auth\Controllers\VerifyEmailController; -use Illuminate\Http\Request; +use App\Domains\Organizer\Controllers\StoreOrganizerProfileController; +use App\Domains\Organizer\Controllers\ShowOrganizerProfileController; +use App\Domains\Organizer\Controllers\UpdateOrganizerProfileController; +use App\Domains\Admin\Controllers\ApproveOrganizerController; use Illuminate\Support\Facades\Route; Route::prefix('auth')->group(function () { @@ -31,3 +34,17 @@ ->name('verification.send'); }); }); + +Route::middleware('auth:sanctum')->group(function () { + // Organizer Routes + Route::prefix('organizer')->group(function () { + Route::post('/profile', StoreOrganizerProfileController::class); + Route::get('/profile', ShowOrganizerProfileController::class); + Route::put('/profile', UpdateOrganizerProfileController::class); + }); + + // Admin Routes + Route::prefix('admin')->middleware(['role:admin'])->group(function () { + Route::post('/organizers/{user}/approve', ApproveOrganizerController::class); + }); +}); From 37bd5d0d6fcc96fe066e71537c69504c574e3407 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 01:07:01 +0100 Subject: [PATCH 07/81] feat(organizer): add organizer profile creation api --- app/Domains/Auth/Models/User.php | 12 +- .../ShowOrganizerProfileController.php | 44 ++++ .../StoreOrganizerProfileController.php | 47 ++++ .../UpdateOrganizerProfileController.php | 47 ++++ .../Organizer/Data/OrganizerProfileData.php | 33 +++ .../Enums/Http/OrganizerHttpEnum.php | 17 ++ .../Organizer/Models/OrganizerProfile.php | 20 +- .../OrganizerApprovedNotification.php | 41 ++++ .../Repositories/OrganizerRepository.php | 27 +++ .../Requests/OrganizerOnboardingRequest.php | 30 +++ .../Organizer/Services/OrganizerService.php | 71 +++++++ .../Transformers/OrganizerProfileResource.php | 31 +++ .../Shared/Exceptions/DomainException.php | 9 + lang/en/auth.php | 20 ++ lang/en/organizer.php | 18 ++ lang/en/pagination.php | 19 ++ lang/en/passwords.php | 22 ++ lang/en/validation.php | 200 ++++++++++++++++++ lang/pl/organizer.php | 18 ++ 19 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Organizer/Controllers/ShowOrganizerProfileController.php create mode 100644 app/Domains/Organizer/Controllers/StoreOrganizerProfileController.php create mode 100644 app/Domains/Organizer/Controllers/UpdateOrganizerProfileController.php create mode 100644 app/Domains/Organizer/Data/OrganizerProfileData.php create mode 100644 app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php create mode 100644 app/Domains/Organizer/Notifications/OrganizerApprovedNotification.php create mode 100644 app/Domains/Organizer/Repositories/OrganizerRepository.php create mode 100644 app/Domains/Organizer/Requests/OrganizerOnboardingRequest.php create mode 100644 app/Domains/Organizer/Services/OrganizerService.php create mode 100644 app/Domains/Organizer/Transformers/OrganizerProfileResource.php create mode 100644 app/Domains/Shared/Exceptions/DomainException.php create mode 100644 lang/en/auth.php create mode 100644 lang/en/organizer.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php create mode 100644 lang/pl/organizer.php diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index f765a7a..59e5cce 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -2,8 +2,11 @@ namespace App\Domains\Auth\Models; +use App\Domains\Organizer\Models\OrganizerProfile; +use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -14,9 +17,11 @@ class User extends Authenticatable implements MustVerifyEmail /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable, HasRoles; + protected string $guard_name = 'api'; + protected static function newFactory() { - return \Database\Factories\UserFactory::new(); + return UserFactory::new(); } /** @@ -53,4 +58,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function organizerProfile(): HasOne + { + return $this->hasOne(OrganizerProfile::class); + } } diff --git a/app/Domains/Organizer/Controllers/ShowOrganizerProfileController.php b/app/Domains/Organizer/Controllers/ShowOrganizerProfileController.php new file mode 100644 index 0000000..de33fb7 --- /dev/null +++ b/app/Domains/Organizer/Controllers/ShowOrganizerProfileController.php @@ -0,0 +1,44 @@ +user()->organizerProfile; + + if (!$profile) { + throw new DomainException(OrganizerHttpEnum::PROFILE_NOT_FOUND->value); + } + + return $this->apiResponseSuccess( + OrganizerHttpEnum::PROFILE_RETRIEVED->value, + Response::HTTP_OK, + ['profile' => new OrganizerProfileResource($profile)] + ); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_NOT_FOUND + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/app/Domains/Organizer/Controllers/StoreOrganizerProfileController.php b/app/Domains/Organizer/Controllers/StoreOrganizerProfileController.php new file mode 100644 index 0000000..6adab10 --- /dev/null +++ b/app/Domains/Organizer/Controllers/StoreOrganizerProfileController.php @@ -0,0 +1,47 @@ +validated()); + $profile = $this->organizerService->createProfile($request->user(), $data); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_CONFLICT + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + OrganizerHttpEnum::ONBOARDING_SUCCESS->value, + Response::HTTP_CREATED, + ['profile' => new OrganizerProfileResource($profile)] + ); + } +} diff --git a/app/Domains/Organizer/Controllers/UpdateOrganizerProfileController.php b/app/Domains/Organizer/Controllers/UpdateOrganizerProfileController.php new file mode 100644 index 0000000..788587d --- /dev/null +++ b/app/Domains/Organizer/Controllers/UpdateOrganizerProfileController.php @@ -0,0 +1,47 @@ +validated()); + $profile = $this->organizerService->updateProfile($request->user(), $data); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_NOT_FOUND + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + OrganizerHttpEnum::PROFILE_UPDATED->value, + Response::HTTP_OK, + ['profile' => new OrganizerProfileResource($profile)] + ); + } +} diff --git a/app/Domains/Organizer/Data/OrganizerProfileData.php b/app/Domains/Organizer/Data/OrganizerProfileData.php new file mode 100644 index 0000000..e06e755 --- /dev/null +++ b/app/Domains/Organizer/Data/OrganizerProfileData.php @@ -0,0 +1,33 @@ + $this->organization_name, + 'description' => $this->description, + 'phone' => $this->phone, + 'slug' => $this->slug, + ], fn($value) => $value !== null); + } +} diff --git a/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php new file mode 100644 index 0000000..e6ed8a5 --- /dev/null +++ b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php @@ -0,0 +1,17 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/app/Domains/Organizer/Notifications/OrganizerApprovedNotification.php b/app/Domains/Organizer/Notifications/OrganizerApprovedNotification.php new file mode 100644 index 0000000..3c1e9ce --- /dev/null +++ b/app/Domains/Organizer/Notifications/OrganizerApprovedNotification.php @@ -0,0 +1,41 @@ +subject('Your Organizer Account is Approved!') + ->greeting('Hello ' . $notifiable->name . ',') + ->line('Great news! Your organizer application for Localevents has been approved.') + ->line('You can now log in and start creating your events.') + ->action('Go to Dashboard', url(config('app.frontend_url') . '/organizer/dashboard')) + ->line('Thank you for helping grow the local event scene!'); + } + + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/app/Domains/Organizer/Repositories/OrganizerRepository.php b/app/Domains/Organizer/Repositories/OrganizerRepository.php new file mode 100644 index 0000000..400ce66 --- /dev/null +++ b/app/Domains/Organizer/Repositories/OrganizerRepository.php @@ -0,0 +1,27 @@ +organizerProfile()->create($data->toArray()); + } + + public function update(OrganizerProfile $profile, OrganizerProfileData $data): bool + { + return $profile->update($data->toArray()); + } + + public function verify(OrganizerProfile $profile): bool + { + return $profile->update([ + 'verified_at' => now(), + ]); + } +} diff --git a/app/Domains/Organizer/Requests/OrganizerOnboardingRequest.php b/app/Domains/Organizer/Requests/OrganizerOnboardingRequest.php new file mode 100644 index 0000000..f2feda5 --- /dev/null +++ b/app/Domains/Organizer/Requests/OrganizerOnboardingRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'organization_name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:1000'], + 'phone' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Domains/Organizer/Services/OrganizerService.php b/app/Domains/Organizer/Services/OrganizerService.php new file mode 100644 index 0000000..5354653 --- /dev/null +++ b/app/Domains/Organizer/Services/OrganizerService.php @@ -0,0 +1,71 @@ +organizerProfile()->exists()) { + throw new DomainException(OrganizerHttpEnum::ONBOARDING_ALREADY_EXISTS->value); + } + + $slug = Str::slug($data->organization_name) . '-' . Str::random(5); + + $dtoWithSlug = new OrganizerProfileData( + organization_name: $data->organization_name, + description: $data->description, + phone: $data->phone, + slug: $slug, + ); + + return $this->organizerRepository->createForUser($user, $dtoWithSlug); + } + + public function updateProfile(User $user, OrganizerProfileData $data): OrganizerProfile + { + $profile = $user->organizerProfile; + + if (!$profile) { + throw new DomainException(OrganizerHttpEnum::PROFILE_NOT_FOUND->value); + } + + $this->organizerRepository->update($profile, $data); + + return $profile->fresh(); + } + + public function approveOrganizer(User $user): void + { + $profile = $user->organizerProfile; + + if (!$profile) { + throw new DomainException(OrganizerHttpEnum::APPROVAL_NO_APPLICATION->value); + } + + if ($profile->verified_at !== null) { + throw new DomainException(OrganizerHttpEnum::APPROVAL_ALREADY_APPROVED->value); + } + + $this->organizerRepository->verify($profile); + + Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + $user->assignRole('organizer'); + + $user->notify(new OrganizerApprovedNotification()); + } +} diff --git a/app/Domains/Organizer/Transformers/OrganizerProfileResource.php b/app/Domains/Organizer/Transformers/OrganizerProfileResource.php new file mode 100644 index 0000000..102856f --- /dev/null +++ b/app/Domains/Organizer/Transformers/OrganizerProfileResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organization_name' => $this->organization_name, + 'slug' => $this->slug, + 'description' => $this->description, + 'phone' => $this->phone, + 'verified_at' => $this->verified_at ? $this->verified_at->toIso8601String() : null, + 'is_verified' => $this->verified_at !== null, + ]; + } +} diff --git a/app/Domains/Shared/Exceptions/DomainException.php b/app/Domains/Shared/Exceptions/DomainException.php new file mode 100644 index 0000000..5db005a --- /dev/null +++ b/app/Domains/Shared/Exceptions/DomainException.php @@ -0,0 +1,9 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/organizer.php b/lang/en/organizer.php new file mode 100644 index 0000000..359b574 --- /dev/null +++ b/lang/en/organizer.php @@ -0,0 +1,18 @@ + [ + 'success' => 'Organizer application submitted successfully. Pending admin approval.', + 'already_exists' => 'User already has an organizer profile.', + ], + 'profile' => [ + 'updated' => 'Organizer profile updated successfully.', + 'retrieved' => 'Organizer profile retrieved.', + 'not_found' => 'Profile not found.', + ], + 'approval' => [ + 'success' => 'Organizer approved successfully.', + 'no_application' => 'User has not submitted an organizer application.', + 'already_approved' => 'Organizer is already approved.', + ], +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 0000000..d481411 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 0000000..fad3a7d --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..63ec29a --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,200 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'any_of' => 'The :attribute field is invalid.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', + 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/lang/pl/organizer.php b/lang/pl/organizer.php new file mode 100644 index 0000000..1e7458c --- /dev/null +++ b/lang/pl/organizer.php @@ -0,0 +1,18 @@ + [ + 'success' => 'Wniosek organizatora został pomyślnie złożony. Oczekiwanie na zatwierdzenie przez administratora.', + 'already_exists' => 'Użytkownik posiada już profil organizatora.', + ], + 'profile' => [ + 'updated' => 'Profil organizatora został pomyślnie zaktualizowany.', + 'retrieved' => 'Profil organizatora został pobrany.', + 'not_found' => 'Nie znaleziono profilu.', + ], + 'approval' => [ + 'success' => 'Organizator został pomyślnie zatwierdzony.', + 'no_application' => 'Użytkownik nie złożył wniosku o profil organizatora.', + 'already_approved' => 'Organizator jest już zatwierdzony.', + ], +]; From 6a1326c383f6f13943363c8df991710b48feac1b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 01:07:36 +0100 Subject: [PATCH 08/81] test(organizer): organizer onboarding and approval feature test --- .../Organizer/OrganizerOnboardingTest.php | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/Feature/Organizer/OrganizerOnboardingTest.php diff --git a/tests/Feature/Organizer/OrganizerOnboardingTest.php b/tests/Feature/Organizer/OrganizerOnboardingTest.php new file mode 100644 index 0000000..3edc687 --- /dev/null +++ b/tests/Feature/Organizer/OrganizerOnboardingTest.php @@ -0,0 +1,128 @@ +forgetCachedPermissions(); + + $user = User::factory()->create(); + $this->user = $user; +}); + +test('a user can submit an organizer application', function () { + $response = $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + 'description' => 'Best jazz in town', + 'phone' => '+48 123 456 789', + ]); + + $response->assertStatus(201) + ->assertJsonPath('success', true) + ->assertJsonPath('message', OrganizerHttpEnum::ONBOARDING_SUCCESS->value) + ->assertJsonPath('data.profile.organization_name', 'Wroclaw Jazz Club') + ->assertJsonPath('data.profile.is_verified', false); + + $this->assertDatabaseHas('organizer_profiles', [ + 'user_id' => $this->user->id, + 'organization_name' => 'Wroclaw Jazz Club', + ]); +}); + +test('a user cannot submit multiple applications', function () { + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + ]); + + $response = $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Another Club', + ]); + + $response->assertStatus(409) + ->assertJsonPath('success', false) + ->assertJsonPath('message', OrganizerHttpEnum::ONBOARDING_ALREADY_EXISTS->value); +}); + +test('a user can view their organizer profile', function () { + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + ]); + + $response = $this->actingAs($this->user)->getJson('/api/v1/organizer/profile'); + + $response->assertStatus(200) + ->assertJsonPath('message', OrganizerHttpEnum::PROFILE_RETRIEVED->value) + ->assertJsonPath('data.profile.organization_name', 'Wroclaw Jazz Club'); +}); + +test('a user can update their organizer profile', function () { + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Legacy Name', + ]); + + $response = $this->actingAs($this->user)->putJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Updated Name', + 'description' => 'New description', + ]); + + $response->assertStatus(200) + ->assertJsonPath('message', OrganizerHttpEnum::PROFILE_UPDATED->value) + ->assertJsonPath('data.profile.organization_name', 'Updated Name') + ->assertJsonPath('data.profile.description', 'New description'); + + $this->assertDatabaseHas('organizer_profiles', [ + 'user_id' => $this->user->id, + 'organization_name' => 'Updated Name', + 'description' => 'New description', + ]); +}); + +test('regular users cannot approve an organizer application', function () { + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + ]); + + $otherUser = User::factory()->create(); + + $response = $this->actingAs($otherUser)->postJson("/api/v1/admin/organizers/{$this->user->id}/approve"); + + $response->assertStatus(403); +}); + +test('admin can approve an organizer application and it assigns role and sends email', function () { + Notification::fake(); + + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + ]); + + $admin = User::factory()->create(); + + $role = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $admin->assignRole($role); + + $response = $this->actingAs($admin)->postJson("/api/v1/admin/organizers/{$this->user->id}/approve"); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', OrganizerHttpEnum::APPROVAL_SUCCESS->value); + + $this->user->refresh(); + + expect($this->user->organizerProfile->verified_at)->not->toBeNull() + ->and($this->user->hasRole('organizer'))->toBeTrue(); + + Notification::assertSentTo( + [$this->user], + OrganizerApprovedNotification::class + ); +}); From fdd86dda1964ad04d8755e25a1a325750536e11b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 14:00:49 +0100 Subject: [PATCH 09/81] feat(venues): add venues index, and typeahead search endpoints / laravel scout and Meilisearch on venue model --- app/Domains/Shared/Enums/AppHttpEnum.php | 9 +++++ .../Controllers/ListVenuesController.php | 38 ++++++++++++++++++ .../Controllers/SearchVenuesController.php | 39 +++++++++++++++++++ app/Domains/Venues/Enums/VenueHttpEnum.php | 11 ++++++ .../Venues/Repositories/VenueRepository.php | 23 +++++++++++ .../Repositories/VenueRepositoryInterface.php | 13 +++++++ .../Venues/Requests/SearchVenuesRequest.php | 28 +++++++++++++ app/Domains/Venues/Services/VenueService.php | 24 ++++++++++++ .../Venues/Transformers/VenueResource.php | 28 +++++++++++++ app/Providers/AppServiceProvider.php | 4 +- routes/api.php | 5 +++ 11 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Shared/Enums/AppHttpEnum.php create mode 100644 app/Domains/Venues/Controllers/ListVenuesController.php create mode 100644 app/Domains/Venues/Controllers/SearchVenuesController.php create mode 100644 app/Domains/Venues/Enums/VenueHttpEnum.php create mode 100644 app/Domains/Venues/Repositories/VenueRepository.php create mode 100644 app/Domains/Venues/Repositories/VenueRepositoryInterface.php create mode 100644 app/Domains/Venues/Requests/SearchVenuesRequest.php create mode 100644 app/Domains/Venues/Services/VenueService.php create mode 100644 app/Domains/Venues/Transformers/VenueResource.php diff --git a/app/Domains/Shared/Enums/AppHttpEnum.php b/app/Domains/Shared/Enums/AppHttpEnum.php new file mode 100644 index 0000000..631fb16 --- /dev/null +++ b/app/Domains/Shared/Enums/AppHttpEnum.php @@ -0,0 +1,9 @@ +venueService->getPaginatedVenues((int) $request->input('per_page', 15)); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + VenueHttpEnum::LIST_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + VenueHttpEnum::LIST_SUCCESS->value, + Response::HTTP_OK, + ['venues' => VenueResource::collection($venues)->response()->getData(true)] + ); + } +} diff --git a/app/Domains/Venues/Controllers/SearchVenuesController.php b/app/Domains/Venues/Controllers/SearchVenuesController.php new file mode 100644 index 0000000..73782fa --- /dev/null +++ b/app/Domains/Venues/Controllers/SearchVenuesController.php @@ -0,0 +1,39 @@ +input('q'); + $venues = $this->venueService->searchVenues($query); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + VenueHttpEnum::SEARCH_ERROR->value, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + VenueHttpEnum::SEARCH_SUCCESS->value, + Response::HTTP_OK, + ['venues' => VenueResource::collection($venues)] + ); + } +} diff --git a/app/Domains/Venues/Enums/VenueHttpEnum.php b/app/Domains/Venues/Enums/VenueHttpEnum.php new file mode 100644 index 0000000..1b5f079 --- /dev/null +++ b/app/Domains/Venues/Enums/VenueHttpEnum.php @@ -0,0 +1,11 @@ +limit($limit) + ->get(); + } +} diff --git a/app/Domains/Venues/Repositories/VenueRepositoryInterface.php b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php new file mode 100644 index 0000000..9631c25 --- /dev/null +++ b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php @@ -0,0 +1,13 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'min:2'], + ]; + } +} diff --git a/app/Domains/Venues/Services/VenueService.php b/app/Domains/Venues/Services/VenueService.php new file mode 100644 index 0000000..62d5def --- /dev/null +++ b/app/Domains/Venues/Services/VenueService.php @@ -0,0 +1,24 @@ +venueRepository->getPaginated($perPage); + } + + public function searchVenues(string $query, int $limit = 10): Collection + { + return $this->venueRepository->searchByName(strtolower($query), $limit); + } +} diff --git a/app/Domains/Venues/Transformers/VenueResource.php b/app/Domains/Venues/Transformers/VenueResource.php new file mode 100644 index 0000000..7913d1b --- /dev/null +++ b/app/Domains/Venues/Transformers/VenueResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'city' => $this->city, + 'street_address' => $this->street_address, + 'postal_code' => $this->postal_code, + 'district' => $this->district, + 'lat' => $this->lat, + 'lng' => $this->lng, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..b4aa622 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Domains\Venues\Repositories\VenueRepository; +use App\Domains\Venues\Repositories\VenueRepositoryInterface; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(VenueRepositoryInterface::class, VenueRepository::class); } /** diff --git a/routes/api.php b/routes/api.php index 36ac817..f759581 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,8 +12,13 @@ use App\Domains\Organizer\Controllers\ShowOrganizerProfileController; use App\Domains\Organizer\Controllers\UpdateOrganizerProfileController; use App\Domains\Admin\Controllers\ApproveOrganizerController; +use App\Domains\Venues\Controllers\ListVenuesController; +use App\Domains\Venues\Controllers\SearchVenuesController; use Illuminate\Support\Facades\Route; +Route::get('/venues', ListVenuesController::class); +Route::get('/venues/search', SearchVenuesController::class); + Route::prefix('auth')->group(function () { Route::post('/register', RegisterController::class); Route::post('/login', LoginController::class); From f2df94b9df31c33044460b01059e390196f9ac0e Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 14:01:10 +0100 Subject: [PATCH 10/81] test(venues): add venue search feature tests --- tests/Feature/Venues/VenueApiTest.php | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Feature/Venues/VenueApiTest.php diff --git a/tests/Feature/Venues/VenueApiTest.php b/tests/Feature/Venues/VenueApiTest.php new file mode 100644 index 0000000..bf1e2ac --- /dev/null +++ b/tests/Feature/Venues/VenueApiTest.php @@ -0,0 +1,79 @@ + 'Wroclaw Stadium', + 'city' => 'Wroclaw', + 'street_address' => 'Slaska 1', + ]); + Venue::forceCreate([ + 'name' => 'Hala Stulecia', + 'city' => 'Wroclaw', + 'street_address' => 'Wystawowa 1', + ]); + + $response = $this->getJson('/api/v1/venues'); + + $response->assertOk() + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'venues' => [ + 'data' => [ + ['id', 'name', 'city', 'street_address'] + ], + 'links', + 'meta' + ] + ] + ]) + ->assertJsonCount(2, 'data.venues.data'); +}); + +test('search venues requires q parameter', function () { + $response = $this->getJson('/api/v1/venues/search'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['q']); +}); + +test('search venues requires string with min 2 chars limit', function () { + $response = $this->getJson('/api/v1/venues/search?q=a'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['q']); +}); + +test('search venues filters by name', function () { + Venue::forceCreate([ + 'name' => 'Wroclaw Stadium', + 'city' => 'Wroclaw', + 'street_address' => 'Slaska 1', + ]); + Venue::forceCreate([ + 'name' => 'Hala Stulecia', + 'city' => 'Wroclaw', + 'street_address' => 'Wystawowa 1', + ]); + Venue::forceCreate([ + 'name' => 'Stary Klasztor', + 'city' => 'Wroclaw', + 'street_address' => 'Purkyniego 1', + ]); + + $response = $this->getJson('/api/v1/venues/search?q=sta'); + + $response->assertOk() + ->assertJsonCount(2, 'data.venues') + ->assertJsonFragment(['name' => 'Wroclaw Stadium']) + ->assertJsonFragment(['name' => 'Stary Klasztor']) + ->assertJsonMissing(['name' => 'Hala Stulecia']); +}); From 00bfda173745d056b7c4574df68d52815634380c Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 18:30:54 +0100 Subject: [PATCH 11/81] feat(events): add organizer Crud with eventPolicy, requests, resources, routes --- app/Domains/Events/Enums/EventHttpEnum.php | 19 +++++ app/Domains/Events/Enums/EventStatusEnum.php | 10 +++ app/Domains/Events/Models/Event.php | 30 +++++++- .../Events/Requests/StoreEventRequest.php | 30 ++++++++ .../Events/Requests/UpdateEventRequest.php | 30 ++++++++ .../Events/Transformers/EventResource.php | 33 +++++++++ .../Organizer/Models/OrganizerProfile.php | 14 ++++ app/Domains/Venues/Models/Venue.php | 19 ++++- app/Policies/EventPolicy.php | 70 +++++++++++++++++++ database/factories/EventFactory.php | 35 ++++++++++ .../factories/OrganizerProfileFactory.php | 26 +++++++ database/factories/VenueFactory.php | 24 +++++++ routes/api.php | 9 +++ 13 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Events/Enums/EventHttpEnum.php create mode 100644 app/Domains/Events/Enums/EventStatusEnum.php create mode 100644 app/Domains/Events/Requests/StoreEventRequest.php create mode 100644 app/Domains/Events/Requests/UpdateEventRequest.php create mode 100644 app/Domains/Events/Transformers/EventResource.php create mode 100644 app/Policies/EventPolicy.php create mode 100644 database/factories/EventFactory.php create mode 100644 database/factories/OrganizerProfileFactory.php create mode 100644 database/factories/VenueFactory.php diff --git a/app/Domains/Events/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php new file mode 100644 index 0000000..93dcc25 --- /dev/null +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -0,0 +1,19 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; } diff --git a/app/Domains/Events/Requests/StoreEventRequest.php b/app/Domains/Events/Requests/StoreEventRequest.php new file mode 100644 index 0000000..15e03ba --- /dev/null +++ b/app/Domains/Events/Requests/StoreEventRequest.php @@ -0,0 +1,30 @@ + ['nullable', 'exists:venues,id'], + 'category_id' => ['nullable', 'exists:categories,id'], + 'title' => ['required', 'string', 'max:255'], + 'description_pl' => ['required', 'string'], + 'description_en' => ['nullable', 'string'], + 'starts_at' => ['required', 'date', 'after:now'], + 'ends_at' => ['required', 'date', 'after:starts_at'], + 'capacity' => ['required', 'integer', 'min:1'], + 'status' => ['nullable', 'in:' . implode(',', array_column(EventStatusEnum::cases(), 'value'))], + 'cover_image_path' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Events/Requests/UpdateEventRequest.php b/app/Domains/Events/Requests/UpdateEventRequest.php new file mode 100644 index 0000000..6d76534 --- /dev/null +++ b/app/Domains/Events/Requests/UpdateEventRequest.php @@ -0,0 +1,30 @@ + ['nullable', 'exists:venues,id'], + 'category_id' => ['nullable', 'exists:categories,id'], + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'description_pl' => ['sometimes', 'required', 'string'], + 'description_en' => ['nullable', 'string'], + 'starts_at' => ['sometimes', 'required', 'date'], + 'ends_at' => ['sometimes', 'required', 'date', 'after:starts_at'], + 'capacity' => ['sometimes', 'required', 'integer', 'min:1'], + 'status' => ['nullable', 'in:' . implode(',', array_column(EventStatusEnum::cases(), 'value'))], + 'cover_image_path' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Domains/Events/Transformers/EventResource.php b/app/Domains/Events/Transformers/EventResource.php new file mode 100644 index 0000000..fd95c27 --- /dev/null +++ b/app/Domains/Events/Transformers/EventResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'slug' => $this->slug, + 'description_pl' => $this->description_pl, + 'description_en' => $this->description_en, + 'starts_at' => $this->starts_at?->toIso8601String(), + 'ends_at' => $this->ends_at?->toIso8601String(), + 'capacity' => $this->capacity, + 'status' => $this->status, + 'cover_image_path' => $this->cover_image_path, + 'view_count' => $this->view_count, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Domains/Organizer/Models/OrganizerProfile.php b/app/Domains/Organizer/Models/OrganizerProfile.php index e211ad4..e716ab5 100644 --- a/app/Domains/Organizer/Models/OrganizerProfile.php +++ b/app/Domains/Organizer/Models/OrganizerProfile.php @@ -3,11 +3,20 @@ namespace App\Domains\Organizer\Models; use App\Domains\Auth\Models\User; +use Database\Factories\OrganizerProfileFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class OrganizerProfile extends Model { + use HasFactory; + + protected static function newFactory() + { + return OrganizerProfileFactory::new(); + } + protected $fillable = [ 'user_id', 'organization_name', @@ -25,4 +34,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function isApproved(): bool + { + return $this->verified_at !== null; + } } diff --git a/app/Domains/Venues/Models/Venue.php b/app/Domains/Venues/Models/Venue.php index 6535d12..47b92ce 100644 --- a/app/Domains/Venues/Models/Venue.php +++ b/app/Domains/Venues/Models/Venue.php @@ -2,9 +2,26 @@ namespace App\Domains\Venues\Models; +use Database\Factories\VenueFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Venue extends Model { - // + use HasFactory; + + protected static function newFactory() + { + return VenueFactory::new(); + } + + protected $fillable = [ + 'name', + 'city', + 'street_address', + 'postal_code', + 'district', + 'lat', + 'lng', + ]; } diff --git a/app/Policies/EventPolicy.php b/app/Policies/EventPolicy.php new file mode 100644 index 0000000..5f09da6 --- /dev/null +++ b/app/Policies/EventPolicy.php @@ -0,0 +1,70 @@ +hasRole('organizer') && $user->organizerProfile && $user->organizerProfile->isApproved(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Event $event): bool + { + if (!$user->hasRole('organizer') || !$user->organizerProfile || !$user->organizerProfile->isApproved()) { + return false; + } + + return $user->organizerProfile->id === $event->organizer_profile_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->hasRole('organizer') && $user->organizerProfile && $user->organizerProfile->isApproved(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Event $event): bool + { + return $this->view($user, $event); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Event $event): bool + { + return $this->view($user, $event); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Event $event): bool + { + return $this->view($user, $event); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Event $event): bool + { + return $this->view($user, $event); + } +} diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000..f1cada5 --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,35 @@ +faker->words(3, true); + $startsAt = $this->faker->dateTimeBetween('+1 week', '+2 weeks'); + $endsAt = (clone $startsAt)->modify('+4 hours'); + + return [ + 'organizer_profile_id' => 1, + 'venue_id' => null, + 'category_id' => null, + 'title' => $title, + 'slug' => Str::slug($title), + 'description_pl' => $this->faker->paragraph(), + 'description_en' => $this->faker->paragraph(), + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + 'capacity' => $this->faker->numberBetween(50, 500), + 'status' => 'draft', + 'cover_image_path' => null, + 'view_count' => 0, + ]; + } +} diff --git a/database/factories/OrganizerProfileFactory.php b/database/factories/OrganizerProfileFactory.php new file mode 100644 index 0000000..02ba6f9 --- /dev/null +++ b/database/factories/OrganizerProfileFactory.php @@ -0,0 +1,26 @@ +faker->company(); + + return [ + 'user_id' => null, + 'organization_name' => $name, + 'slug' => Str::slug($name), + 'description' => $this->faker->paragraph(), + 'phone' => $this->faker->phoneNumber(), + 'verified_at' => null, + ]; + } +} diff --git a/database/factories/VenueFactory.php b/database/factories/VenueFactory.php new file mode 100644 index 0000000..f84d3eb --- /dev/null +++ b/database/factories/VenueFactory.php @@ -0,0 +1,24 @@ + $this->faker->company() . ' Venue', + 'city' => $this->faker->city(), + 'street_address' => $this->faker->streetAddress(), + 'postal_code' => $this->faker->postcode(), + 'district' => $this->faker->word(), + 'lat' => $this->faker->latitude(), + 'lng' => $this->faker->longitude(), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index f759581..bcc1876 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,9 @@ use App\Domains\Admin\Controllers\ApproveOrganizerController; use App\Domains\Venues\Controllers\ListVenuesController; use App\Domains\Venues\Controllers\SearchVenuesController; +use App\Domains\Events\Controllers\Organizer\OrganizerEventController; +use App\Domains\Events\Controllers\Organizer\PublishEventController; +use App\Domains\Events\Controllers\Organizer\CancelEventController; use Illuminate\Support\Facades\Route; Route::get('/venues', ListVenuesController::class); @@ -46,6 +49,12 @@ Route::post('/profile', StoreOrganizerProfileController::class); Route::get('/profile', ShowOrganizerProfileController::class); Route::put('/profile', UpdateOrganizerProfileController::class); + + Route::middleware(['role:organizer'])->group(function () { + Route::apiResource('events', OrganizerEventController::class); + Route::post('events/{event}/publish', PublishEventController::class); + Route::post('events/{event}/cancel', CancelEventController::class); + }); }); // Admin Routes From fc5088312144a28f5b0d6f2c16bdc3839d2d2082 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 18:32:32 +0100 Subject: [PATCH 12/81] feat(events): auto-slug generation on event create, service /repository crud layers --- .../Organizer/CancelEventController.php | 47 +++++++ .../Organizer/OrganizerEventController.php | 124 ++++++++++++++++++ .../Organizer/PublishEventController.php | 47 +++++++ .../Events/Repositories/EventRepository.php | 36 +++++ .../Repositories/EventRepositoryInterface.php | 15 +++ app/Domains/Events/Services/EventService.php | 75 +++++++++++ app/Providers/AppServiceProvider.php | 3 + 7 files changed, 347 insertions(+) create mode 100644 app/Domains/Events/Controllers/Organizer/CancelEventController.php create mode 100644 app/Domains/Events/Controllers/Organizer/OrganizerEventController.php create mode 100644 app/Domains/Events/Controllers/Organizer/PublishEventController.php create mode 100644 app/Domains/Events/Repositories/EventRepository.php create mode 100644 app/Domains/Events/Repositories/EventRepositoryInterface.php create mode 100644 app/Domains/Events/Services/EventService.php diff --git a/app/Domains/Events/Controllers/Organizer/CancelEventController.php b/app/Domains/Events/Controllers/Organizer/CancelEventController.php new file mode 100644 index 0000000..690f876 --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/CancelEventController.php @@ -0,0 +1,47 @@ +authorize('update', $event); + + try { + $cancelledEvent = $this->eventService->cancelEvent($event); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::CANCEL_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($cancelledEvent)] + ); + } +} diff --git a/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php new file mode 100644 index 0000000..20a40fa --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php @@ -0,0 +1,124 @@ +authorize('viewAny', Event::class); + + try { + $organizerProfileId = $request->user()->organizerProfile->id; + + $events = $this->eventService->getPaginatedEventsForOrganizer( + $organizerProfileId, + (int) $request->input('per_page', 15) + ); + } catch (Exception $e) { + return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'events' => EventResource::collection($events)->response()->getData(true) + ] + ); + } + + public function store(StoreEventRequest $request): JsonResponse + { + $this->authorize('create', Event::class); + + try { + $organizerProfileId = $request->user()->organizerProfile->id; + $event = $this->eventService->createEventForOrganizer($organizerProfileId, $request->validated()); + } catch (Exception $e) { + return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::STORE_SUCCESS->value, + Response::HTTP_CREATED, + ['event' => new EventResource($event)] + ); + } + + public function show(Event $event): JsonResponse + { + $this->authorize('view', $event); + + try { + } catch (Exception $e) { + return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($event)] + ); + } + + public function update(UpdateEventRequest $request, Event $event): JsonResponse + { + $this->authorize('update', $event); + + try { + $updatedEvent = $this->eventService->updateEvent($event, $request->validated()); + } catch (Exception $e) { + return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::UPDATE_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($updatedEvent)] + ); + } + + public function destroy(Event $event): JsonResponse + { + $this->authorize('delete', $event); + + try { + $this->eventService->deleteEvent($event); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } catch (Exception $e) { + return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::DESTROY_SUCCESS->value, + Response::HTTP_OK, + [] + ); + } +} diff --git a/app/Domains/Events/Controllers/Organizer/PublishEventController.php b/app/Domains/Events/Controllers/Organizer/PublishEventController.php new file mode 100644 index 0000000..6370e72 --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/PublishEventController.php @@ -0,0 +1,47 @@ +authorize('update', $event); + + try { + $publishedEvent = $this->eventService->publishEvent($event); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::PUBLISH_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($publishedEvent)] + ); + } +} diff --git a/app/Domains/Events/Repositories/EventRepository.php b/app/Domains/Events/Repositories/EventRepository.php new file mode 100644 index 0000000..1770bee --- /dev/null +++ b/app/Domains/Events/Repositories/EventRepository.php @@ -0,0 +1,36 @@ +latest() + ->paginate($perPage); + } + + public function findById(int $id): ?Event + { + return Event::find($id); + } + + public function create(array $data): Event + { + return Event::create($data); + } + + public function update(Event $event, array $data): bool + { + return $event->update($data); + } + + public function delete(Event $event): bool + { + return $event->delete(); + } +} diff --git a/app/Domains/Events/Repositories/EventRepositoryInterface.php b/app/Domains/Events/Repositories/EventRepositoryInterface.php new file mode 100644 index 0000000..8003141 --- /dev/null +++ b/app/Domains/Events/Repositories/EventRepositoryInterface.php @@ -0,0 +1,15 @@ +eventRepository->getPaginatedForOrganizer($organizerProfileId, $perPage); + } + + public function findEventById(int $id): ?Event + { + return $this->eventRepository->findById($id); + } + + public function createEventForOrganizer(int $organizerProfileId, array $data): Event + { + $data['organizer_profile_id'] = $organizerProfileId; + + if (empty($data['slug'])) { + $data['slug'] = Str::slug($data['title']) . '-' . uniqid(); + } + + return $this->eventRepository->create($data); + } + + public function updateEvent(Event $event, array $data): Event + { + // todo: post MVP: If title changes,update slug + $this->eventRepository->update($event, $data); + return $event->refresh(); + } + + public function deleteEvent(Event $event): void + { + if ($event->status !== EventStatusEnum::DRAFT->value && $event->status !== EventStatusEnum::CANCELLED->value) { + throw new DomainException('events.destroy.invalid_status'); + } + + $this->eventRepository->delete($event); + } + + public function publishEvent(Event $event): Event + { + if ($event->status === EventStatusEnum::PUBLISHED->value) { + throw new DomainException('events.publish.already_published'); + } + + $this->eventRepository->update($event, ['status' => EventStatusEnum::PUBLISHED->value]); + return $event->refresh(); + } + + public function cancelEvent(Event $event): Event + { + if ($event->status === EventStatusEnum::CANCELLED->value) { + throw new DomainException('events.cancel.already_cancelled'); + } + + $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); + + return $event->refresh(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b4aa622..859bf65 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Domains\Events\Repositories\EventRepository; +use App\Domains\Events\Repositories\EventRepositoryInterface; use App\Domains\Venues\Repositories\VenueRepository; use App\Domains\Venues\Repositories\VenueRepositoryInterface; use Illuminate\Support\ServiceProvider; @@ -14,6 +16,7 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(VenueRepositoryInterface::class, VenueRepository::class); + $this->app->bind(EventRepositoryInterface::class, EventRepository::class); } /** From c217cb2f65b7a649e62f0a0d2931290a327aa45d Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 18:32:49 +0100 Subject: [PATCH 13/81] test(events): feature tests --- .../Feature/Events/OrganizerEventCrudTest.php | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/Feature/Events/OrganizerEventCrudTest.php diff --git a/tests/Feature/Events/OrganizerEventCrudTest.php b/tests/Feature/Events/OrganizerEventCrudTest.php new file mode 100644 index 0000000..0fe3acf --- /dev/null +++ b/tests/Feature/Events/OrganizerEventCrudTest.php @@ -0,0 +1,195 @@ +forgetCachedPermissions(); + + Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + Role::firstOrCreate(['name' => 'attendee', 'guard_name' => 'api']); + Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + } + + private function createApprovedOrganizer(): User + { + $user = User::factory()->create(); + $user->assignRole('organizer'); + + OrganizerProfile::factory()->create([ + 'user_id' => $user->id, + 'verified_at' => now(), // Approved + ]); + + return $user; + } + + private function createUnapprovedOrganizer(): User + { + $user = User::factory()->create(); + $user->assignRole('organizer'); + + OrganizerProfile::factory()->create([ + 'user_id' => $user->id, + 'verified_at' => null, // Pending + ]); + + return $user; + } + + public function test_approved_organizer_can_create_event(): void + { + $user = $this->createApprovedOrganizer(); + $venue = Venue::factory()->create(); + + $payload = [ + 'venue_id' => $venue->id, + 'title' => 'My First Event', + 'description_pl' => 'Lorem ipsum dolor', + 'starts_at' => Carbon::now()->addDays(5)->toIso8601String(), + 'ends_at' => Carbon::now()->addDays(5)->addHours(4)->toIso8601String(), + 'capacity' => 100, + 'status' => 'draft', + ]; + + $response = $this->actingAs($user)->postJson('/api/v1/organizer/events', $payload); + + $response->assertStatus(Response::HTTP_CREATED) + ->assertJsonPath('data.event.title', 'My First Event') + ->assertJsonPath('data.event.status', 'draft') + ->assertJsonPath('data.event.capacity', 100); + + $this->assertDatabaseHas('events', [ + 'title' => 'My First Event', + 'organizer_profile_id' => $user->organizerProfile->id, + 'capacity' => 100, + ]); + } + + public function test_unapproved_organizer_cannot_create_event(): void + { + $user = $this->createUnapprovedOrganizer(); + + $payload = [ + 'title' => 'My First Event', + 'description_pl' => 'Lorem ipsum', + 'starts_at' => Carbon::now()->addDays(5)->toIso8601String(), + 'ends_at' => Carbon::now()->addDays(6)->toIso8601String(), + 'capacity' => 100, + ]; + + $response = $this->actingAs($user)->postJson('/api/v1/organizer/events', $payload); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + } + + public function test_organizer_can_update_own_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'title' => 'Old Title', + 'status' => 'draft', + ]); + + $payload = ['title' => 'New Title', 'capacity' => 200]; + + $response = $this->actingAs($user)->putJson("/api/v1/organizer/events/{$event->id}", $payload); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.title', 'New Title') + ->assertJsonPath('data.event.capacity', 200); + + $this->assertDatabaseHas('events', ['id' => $event->id, 'title' => 'New Title']); + } + + public function test_organizer_cannot_update_others_event(): void + { + $user1 = $this->createApprovedOrganizer(); + $user2 = $this->createApprovedOrganizer(); + + $event = Event::factory()->create([ + 'organizer_profile_id' => $user2->organizerProfile->id, + ]); + + $response = $this->actingAs($user1)->putJson("/api/v1/organizer/events/{$event->id}", ['title' => 'Hacked']); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + } + + public function test_organizer_can_publish_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => 'draft', + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/publish"); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.status', 'published'); + + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'published']); + } + + public function test_organizer_can_cancel_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => 'published', + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/cancel"); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.status', 'cancelled'); + } + + public function test_organizer_can_delete_draft_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => 'draft', + ]); + + $response = $this->actingAs($user)->deleteJson("/api/v1/organizer/events/{$event->id}"); + + $response->assertStatus(Response::HTTP_OK); + $this->assertDatabaseMissing('events', ['id' => $event->id]); + } + + public function test_organizer_cannot_delete_published_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => 'published', + ]); + + $response = $this->actingAs($user)->deleteJson("/api/v1/organizer/events/{$event->id}"); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertDatabaseHas('events', ['id' => $event->id]); + } +} From 90d7b967dac138c20d3f3f90d2d36b7ab608e650 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 18:39:14 +0100 Subject: [PATCH 14/81] feat(events): add EventCancelledNotification draft for p4 --- .../EventCancelledNotification.php | 42 +++++++++++++++++++ app/Domains/Events/Services/EventService.php | 1 + 2 files changed, 43 insertions(+) create mode 100644 app/Domains/Events/Notifications/EventCancelledNotification.php diff --git a/app/Domains/Events/Notifications/EventCancelledNotification.php b/app/Domains/Events/Notifications/EventCancelledNotification.php new file mode 100644 index 0000000..bba1b93 --- /dev/null +++ b/app/Domains/Events/Notifications/EventCancelledNotification.php @@ -0,0 +1,42 @@ +subject("Event Cancelled: {$this->event->title}") + ->line("We regret to inform you that the event '{$this->event->title}' has been cancelled by the organizer.") + ->line('If you had purchased a ticket, you will be receiving a refund shortly.') + ->action('View Events', url('/events')) + ->line('Thank you for using our application!'); + } + + public function toArray(object $notifiable): array + { + return [ + 'event_id' => $this->event->id, + 'title' => $this->event->title, + ]; + } +} diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index d0d3e2c..d9b3117 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -69,6 +69,7 @@ public function cancelEvent(Event $event): Event } $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); + // TODO: in Phase 4 dispatch EventCancelledNotification to attendees here return $event->refresh(); } From c828ead41981039ebdbea143ad9865ff5ef73198 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 19:13:04 +0100 Subject: [PATCH 15/81] feat(categories): add categories index with event counts --- .../Controllers/CategoryController.php | 28 +++++++++++++++++++ app/Domains/Events/Models/Category.php | 16 ++++++++++- database/factories/CategoryFactory.php | 22 +++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Categories/Controllers/CategoryController.php create mode 100644 database/factories/CategoryFactory.php diff --git a/app/Domains/Categories/Controllers/CategoryController.php b/app/Domains/Categories/Controllers/CategoryController.php new file mode 100644 index 0000000..15f9cdb --- /dev/null +++ b/app/Domains/Categories/Controllers/CategoryController.php @@ -0,0 +1,28 @@ +get(); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + 'categories.index.success', + Response::HTTP_OK, + ['categories' => $categories] + ); + } +} diff --git a/app/Domains/Events/Models/Category.php b/app/Domains/Events/Models/Category.php index 043d19c..a4887dd 100644 --- a/app/Domains/Events/Models/Category.php +++ b/app/Domains/Events/Models/Category.php @@ -2,9 +2,23 @@ namespace App\Domains\Events\Models; +use Database\Factories\CategoryFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Category extends Model { - // + use HasFactory; + + protected $fillable = ['name_pl', 'name_en', 'slug', 'icon', 'sort_order']; + + protected static function newFactory() + { + return CategoryFactory::new(); + } + + public function events() + { + return $this->hasMany(Event::class); + } } diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..16a47b4 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,22 @@ +faker->unique()->word(); + return [ + 'name_pl' => ucfirst($name), + 'name_en' => ucfirst($name) . ' EN', + 'slug' => Str::slug($name), + ]; + } +} From eba71f13963c69e807572ab533a1c62595242e8f Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 19:14:22 +0100 Subject: [PATCH 16/81] feat(events): add public events listing with filters --- .../Controllers/PublicEventController.php | 77 +++++++++++++++++++ .../Jobs/IncrementEventViewCountJob.php | 24 ++++++ app/Domains/Events/Models/Event.php | 35 ++++++++- .../Requests/IndexPublicEventsRequest.php | 25 ++++++ app/Domains/Events/Services/EventService.php | 6 +- database/factories/EventFactory.php | 6 +- .../factories/OrganizerProfileFactory.php | 3 +- database/factories/UserFactory.php | 3 +- routes/api.php | 6 ++ 9 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 app/Domains/Events/Controllers/PublicEventController.php create mode 100644 app/Domains/Events/Jobs/IncrementEventViewCountJob.php create mode 100644 app/Domains/Events/Requests/IndexPublicEventsRequest.php diff --git a/app/Domains/Events/Controllers/PublicEventController.php b/app/Domains/Events/Controllers/PublicEventController.php new file mode 100644 index 0000000..75f2fc0 --- /dev/null +++ b/app/Domains/Events/Controllers/PublicEventController.php @@ -0,0 +1,77 @@ +where('status', EventStatusEnum::PUBLISHED->value); + + if ($request->filled('search')) { + $query = Event::search($request->input('search'))->query(function ($builder) { + $builder->where('status', EventStatusEnum::PUBLISHED->value); + }); + } + + if ($request->filled('category')) { + $query->whereHas('category', function ($q) use ($request) { + $q->where('slug', $request->input('category')); + }); + } + + if ($request->filled('date_from')) { + $query->where('starts_at', '>=', $request->input('date_from')); + } + + if ($request->filled('date_to')) { + $query->where('starts_at', '<=', $request->input('date_to')); + } + + $perPage = (int) $request->input('per_page', 15); + $events = $query->paginate($perPage); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'events' => EventResource::collection($events)->response()->getData(true) + ] + ); + } + + public function show(Event $event): JsonResponse + { + if ($event->status !== EventStatusEnum::PUBLISHED) { + return response()->json(['message' => 'Event not found.'], Response::HTTP_NOT_FOUND); + } + + try { + IncrementEventViewCountJob::dispatch($event->id); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + EventHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($event)] + ); + } +} diff --git a/app/Domains/Events/Jobs/IncrementEventViewCountJob.php b/app/Domains/Events/Jobs/IncrementEventViewCountJob.php new file mode 100644 index 0000000..d7c51d3 --- /dev/null +++ b/app/Domains/Events/Jobs/IncrementEventViewCountJob.php @@ -0,0 +1,24 @@ +eventId)->increment('view_count'); + } +} diff --git a/app/Domains/Events/Models/Event.php b/app/Domains/Events/Models/Event.php index fb20c7d..2b40a0c 100644 --- a/app/Domains/Events/Models/Event.php +++ b/app/Domains/Events/Models/Event.php @@ -2,13 +2,17 @@ namespace App\Domains\Events\Models; +use App\Domains\Events\Enums\EventStatusEnum; +use App\Domains\Organizer\Models\OrganizerProfile; +use App\Domains\Venues\Models\Venue; use Database\Factories\EventFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Laravel\Scout\Searchable; class Event extends Model { - use HasFactory; + use HasFactory, Searchable; protected static function newFactory() { @@ -34,5 +38,34 @@ protected static function newFactory() protected $casts = [ 'starts_at' => 'datetime', 'ends_at' => 'datetime', + 'status' => EventStatusEnum::class, ]; + + public function toSearchableArray() + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'description_pl' => $this->description_pl, + 'description_en' => $this->description_en, + 'category_id' => $this->category_id, + 'venue_id' => $this->venue_id, + 'status' => $this->status, + ]; + } + + public function category() + { + return $this->belongsTo(Category::class); + } + + public function venue() + { + return $this->belongsTo(Venue::class); + } + + public function organizerProfile() + { + return $this->belongsTo(OrganizerProfile::class); + } } diff --git a/app/Domains/Events/Requests/IndexPublicEventsRequest.php b/app/Domains/Events/Requests/IndexPublicEventsRequest.php new file mode 100644 index 0000000..9c032b7 --- /dev/null +++ b/app/Domains/Events/Requests/IndexPublicEventsRequest.php @@ -0,0 +1,25 @@ + ['nullable', 'string', 'exists:categories,slug'], + 'district' => ['nullable', 'string'], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], + 'search' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index d9b3117..a345c4f 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -45,7 +45,7 @@ public function updateEvent(Event $event, array $data): Event public function deleteEvent(Event $event): void { - if ($event->status !== EventStatusEnum::DRAFT->value && $event->status !== EventStatusEnum::CANCELLED->value) { + if ($event->status !== EventStatusEnum::DRAFT && $event->status !== EventStatusEnum::CANCELLED) { throw new DomainException('events.destroy.invalid_status'); } @@ -54,7 +54,7 @@ public function deleteEvent(Event $event): void public function publishEvent(Event $event): Event { - if ($event->status === EventStatusEnum::PUBLISHED->value) { + if ($event->status === EventStatusEnum::PUBLISHED) { throw new DomainException('events.publish.already_published'); } @@ -64,7 +64,7 @@ public function publishEvent(Event $event): Event public function cancelEvent(Event $event): Event { - if ($event->status === EventStatusEnum::CANCELLED->value) { + if ($event->status === EventStatusEnum::CANCELLED) { throw new DomainException('events.cancel.already_cancelled'); } diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php index f1cada5..5915a55 100644 --- a/database/factories/EventFactory.php +++ b/database/factories/EventFactory.php @@ -2,7 +2,9 @@ namespace Database\Factories; +use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Models\Event; +use App\Domains\Organizer\Models\OrganizerProfile; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -17,7 +19,7 @@ public function definition(): array $endsAt = (clone $startsAt)->modify('+4 hours'); return [ - 'organizer_profile_id' => 1, + 'organizer_profile_id' => OrganizerProfile::factory(), 'venue_id' => null, 'category_id' => null, 'title' => $title, @@ -27,7 +29,7 @@ public function definition(): array 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'capacity' => $this->faker->numberBetween(50, 500), - 'status' => 'draft', + 'status' => EventStatusEnum::DRAFT, 'cover_image_path' => null, 'view_count' => 0, ]; diff --git a/database/factories/OrganizerProfileFactory.php b/database/factories/OrganizerProfileFactory.php index 02ba6f9..ec256a7 100644 --- a/database/factories/OrganizerProfileFactory.php +++ b/database/factories/OrganizerProfileFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Domains\Auth\Models\User; use App\Domains\Organizer\Models\OrganizerProfile; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -15,7 +16,7 @@ public function definition(): array $name = $this->faker->company(); return [ - 'user_id' => null, + 'user_id' => User::factory(), 'organization_name' => $name, 'slug' => Str::slug($name), 'description' => $this->faker->paragraph(), diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 0de078e..db41745 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Domains\Auth\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -16,7 +17,7 @@ class UserFactory extends Factory * * @var class-string<\Illuminate\Database\Eloquent\Model> */ - protected $model = \App\Domains\Auth\Models\User::class; + protected $model = User::class; /** * The current password being used by the factory. diff --git a/routes/api.php b/routes/api.php index bcc1876..e6a36f8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,11 +17,17 @@ use App\Domains\Events\Controllers\Organizer\OrganizerEventController; use App\Domains\Events\Controllers\Organizer\PublishEventController; use App\Domains\Events\Controllers\Organizer\CancelEventController; +use App\Domains\Categories\Controllers\CategoryController; +use App\Domains\Events\Controllers\PublicEventController; use Illuminate\Support\Facades\Route; Route::get('/venues', ListVenuesController::class); Route::get('/venues/search', SearchVenuesController::class); +Route::get('/categories', [CategoryController::class, 'index']); +Route::get('/events', [PublicEventController::class, 'index']); +Route::get('/events/{event}', [PublicEventController::class, 'show']); + Route::prefix('auth')->group(function () { Route::post('/register', RegisterController::class); Route::post('/login', LoginController::class); From 4982c8fd40f690af4729766e8abfb74a5248de89 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 19:15:08 +0100 Subject: [PATCH 17/81] feat(events): add Meilisearhch full-text search on events --- composer.json | 2 + composer.lock | 321 ++++++++++++++++++++++++++++++++++++++++++++++- config/scout.php | 210 +++++++++++++++++++++++++++++++ phpunit.xml | 1 + 4 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 config/scout.php diff --git a/composer.json b/composer.json index e7f6e5d..794e007 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "bacon/bacon-qr-code": "^3.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", + "laravel/scout": "^10.24", "laravel/tinker": "^2.10.1", + "meilisearch/meilisearch-php": "^1.16", "spatie/laravel-permission": "^7.2" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 5077523..37fafc4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "44a3750d4f81f48768a18aedf9811d7c", + "content-hash": "dd0b97d8940e071dce70cf7c5f96d735", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,86 @@ }, "time": "2026-02-07T17:19:31+00:00" }, + { + "name": "laravel/scout", + "version": "v10.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c", + "reference": "f9864d9a727a0c0d6b95e08ed92df8c301ae6d2c", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8|^11.0", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2026-02-10T18:44:39+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -2187,6 +2267,86 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.16.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/discovery": "^1.7", + "psr/http-client": "^1.0", + "symfony/polyfill-php81": "^1.33" + }, + "require-dev": { + "http-interop/http-factory-guzzle": "^1.2.0", + "php-cs-fixer/shim": "^3.59.3", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.5", + "symfony/http-client": "^5.4|^6.0|^7.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle", + "symfony/http-client": "Use Symfony Http client" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clémentine Urquizar", + "email": "clementine@meilisearch.com" + }, + { + "name": "Bruno Casali", + "email": "bruno@meilisearch.com" + }, + { + "name": "Laurent Cazanove", + "email": "lau.cazanove@gmail.com" + }, + { + "name": "Tomas Norkūnas", + "email": "norkunas.tom@gmail.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1" + }, + "time": "2025-09-18T10:15:45+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -2696,6 +2856,85 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -5111,6 +5350,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.33.0", diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000..57bc34b --- /dev/null +++ b/config/scout.php @@ -0,0 +1,210 @@ + env('SCOUT_DRIVER', 'collection'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + 'index-settings' => [ + // 'users' => [ + // 'searchableAttributes' => ['id', 'name', 'email'], + // 'attributesForFaceting'=> ['filterOnly(email)'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + // 'users' => [ + // 'filterableAttributes'=> ['id', 'name', 'email'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is an open + | source search engine using minimal configuration. Below, you will + | state the host, key, and schema configuration for the instance. + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), + 'model-settings' => [ + // User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // [ + // 'name' => 'id', + // 'type' => 'string', + // ], + // [ + // 'name' => 'name', + // 'type' => 'string', + // ], + // [ + // 'name' => 'created_at', + // 'type' => 'int64', + // ], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name' + // ], + // ], + ], + 'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'), + ], + +]; diff --git a/phpunit.xml b/phpunit.xml index d703241..e28d57e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + From c9bb2525f948f6c8818484f4c8da80eab7e4e8e5 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 19:15:22 +0100 Subject: [PATCH 18/81] test(events): add feature tests --- .../Feature/Events/OrganizerEventCrudTest.php | 20 +++---- tests/Feature/Events/PublicEventApiTest.php | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/Events/PublicEventApiTest.php diff --git a/tests/Feature/Events/OrganizerEventCrudTest.php b/tests/Feature/Events/OrganizerEventCrudTest.php index 0fe3acf..bcdb575 100644 --- a/tests/Feature/Events/OrganizerEventCrudTest.php +++ b/tests/Feature/Events/OrganizerEventCrudTest.php @@ -67,14 +67,14 @@ public function test_approved_organizer_can_create_event(): void 'starts_at' => Carbon::now()->addDays(5)->toIso8601String(), 'ends_at' => Carbon::now()->addDays(5)->addHours(4)->toIso8601String(), 'capacity' => 100, - 'status' => 'draft', + 'status' => EventStatusEnum::DRAFT, ]; $response = $this->actingAs($user)->postJson('/api/v1/organizer/events', $payload); $response->assertStatus(Response::HTTP_CREATED) ->assertJsonPath('data.event.title', 'My First Event') - ->assertJsonPath('data.event.status', 'draft') + ->assertJsonPath('data.event.status', EventStatusEnum::DRAFT->value) ->assertJsonPath('data.event.capacity', 100); $this->assertDatabaseHas('events', [ @@ -107,7 +107,7 @@ public function test_organizer_can_update_own_event(): void $event = Event::factory()->create([ 'organizer_profile_id' => $user->organizerProfile->id, 'title' => 'Old Title', - 'status' => 'draft', + 'status' => EventStatusEnum::DRAFT, ]); $payload = ['title' => 'New Title', 'capacity' => 200]; @@ -140,15 +140,15 @@ public function test_organizer_can_publish_event(): void $user = $this->createApprovedOrganizer(); $event = Event::factory()->create([ 'organizer_profile_id' => $user->organizerProfile->id, - 'status' => 'draft', + 'status' => EventStatusEnum::DRAFT, ]); $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/publish"); $response->assertStatus(Response::HTTP_OK) - ->assertJsonPath('data.event.status', 'published'); + ->assertJsonPath('data.event.status', EventStatusEnum::PUBLISHED->value); - $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'published']); + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => EventStatusEnum::PUBLISHED]); } public function test_organizer_can_cancel_event(): void @@ -156,13 +156,13 @@ public function test_organizer_can_cancel_event(): void $user = $this->createApprovedOrganizer(); $event = Event::factory()->create([ 'organizer_profile_id' => $user->organizerProfile->id, - 'status' => 'published', + 'status' => EventStatusEnum::PUBLISHED, ]); $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/cancel"); $response->assertStatus(Response::HTTP_OK) - ->assertJsonPath('data.event.status', 'cancelled'); + ->assertJsonPath('data.event.status', EventStatusEnum::CANCELLED->value); } public function test_organizer_can_delete_draft_event(): void @@ -170,7 +170,7 @@ public function test_organizer_can_delete_draft_event(): void $user = $this->createApprovedOrganizer(); $event = Event::factory()->create([ 'organizer_profile_id' => $user->organizerProfile->id, - 'status' => 'draft', + 'status' => EventStatusEnum::DRAFT, ]); $response = $this->actingAs($user)->deleteJson("/api/v1/organizer/events/{$event->id}"); @@ -184,7 +184,7 @@ public function test_organizer_cannot_delete_published_event(): void $user = $this->createApprovedOrganizer(); $event = Event::factory()->create([ 'organizer_profile_id' => $user->organizerProfile->id, - 'status' => 'published', + 'status' => EventStatusEnum::PUBLISHED, ]); $response = $this->actingAs($user)->deleteJson("/api/v1/organizer/events/{$event->id}"); diff --git a/tests/Feature/Events/PublicEventApiTest.php b/tests/Feature/Events/PublicEventApiTest.php new file mode 100644 index 0000000..59c5d1b --- /dev/null +++ b/tests/Feature/Events/PublicEventApiTest.php @@ -0,0 +1,59 @@ +count(3)->create(['status' => EventStatusEnum::PUBLISHED]); + Event::factory()->count(2)->create(['status' => EventStatusEnum::DRAFT]); + + $response = $this->getJson('/api/v1/events'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonCount(3, 'data.events.data'); + } + + public function test_can_filter_events_by_category() + { + $category1 = Category::factory()->create(['slug' => 'music']); + $category2 = Category::factory()->create(['slug' => 'tech']); + + Event::factory()->count(2)->create(['status' => EventStatusEnum::PUBLISHED, 'category_id' => $category1->id]); + Event::factory()->count(1)->create(['status' => EventStatusEnum::PUBLISHED, 'category_id' => $category2->id]); + + $response = $this->getJson('/api/v1/events?category=music'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonCount(2, 'data.events.data'); + } + + public function test_can_show_published_event() + { + $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED]); + + $response = $this->getJson("/api/v1/events/{$event->id}"); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.id', $event->id); + } + + public function test_cannot_show_draft_event() + { + $event = Event::factory()->create(['status' => EventStatusEnum::DRAFT]); + + $response = $this->getJson("/api/v1/events/{$event->id}"); + + $response->assertStatus(Response::HTTP_NOT_FOUND); + } +} From 3e5aadfa339d14497b7537458ebe28122bf5f6b9 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 20:28:50 +0100 Subject: [PATCH 19/81] feat(venues): veneu submission migration, models, routes --- app/Domains/Venues/Models/VenueSubmission.php | 35 +++++++++++++++++++ ..._183919_create_venue_submissions_table.php | 33 +++++++++++++++++ routes/api.php | 5 +++ 3 files changed, 73 insertions(+) create mode 100644 app/Domains/Venues/Models/VenueSubmission.php create mode 100644 database/migrations/2026_02_22_183919_create_venue_submissions_table.php diff --git a/app/Domains/Venues/Models/VenueSubmission.php b/app/Domains/Venues/Models/VenueSubmission.php new file mode 100644 index 0000000..2e575ae --- /dev/null +++ b/app/Domains/Venues/Models/VenueSubmission.php @@ -0,0 +1,35 @@ + VenueSubmissionStatusEnum::class, + 'places_result' => 'array', + ]; + + public function organizerProfile() + { + return $this->belongsTo(OrganizerProfile::class); + } +} diff --git a/database/migrations/2026_02_22_183919_create_venue_submissions_table.php b/database/migrations/2026_02_22_183919_create_venue_submissions_table.php new file mode 100644 index 0000000..b8b7567 --- /dev/null +++ b/database/migrations/2026_02_22_183919_create_venue_submissions_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('organizer_profile_id')->constrained('organizer_profiles')->cascadeOnDelete(); + $table->string('name'); + $table->string('city'); + $table->string('address_line_1'); + $table->string('address_line_2')->nullable(); + $table->string('postal_code')->nullable(); + + $table->string('status')->default(VenueSubmissionStatusEnum::PENDING->value); + $table->jsonb('places_result')->nullable(); + $table->text('admin_notes')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('venue_submissions'); + } +}; diff --git a/routes/api.php b/routes/api.php index e6a36f8..57d0c8e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ use App\Domains\Admin\Controllers\ApproveOrganizerController; use App\Domains\Venues\Controllers\ListVenuesController; use App\Domains\Venues\Controllers\SearchVenuesController; +use App\Domains\Venues\Controllers\VenueSubmissionController; use App\Domains\Events\Controllers\Organizer\OrganizerEventController; use App\Domains\Events\Controllers\Organizer\PublishEventController; use App\Domains\Events\Controllers\Organizer\CancelEventController; @@ -60,11 +61,15 @@ Route::apiResource('events', OrganizerEventController::class); Route::post('events/{event}/publish', PublishEventController::class); Route::post('events/{event}/cancel', CancelEventController::class); + + Route::post('venue-submissions', [VenueSubmissionController::class, 'store']); + Route::get('venue-submissions/{venueSubmission}', [VenueSubmissionController::class, 'show']); }); }); // Admin Routes Route::prefix('admin')->middleware(['role:admin'])->group(function () { Route::post('/organizers/{user}/approve', ApproveOrganizerController::class); + Route::get('/venue-submissions', [VenueSubmissionController::class, 'index']); }); }); From a84acfb3289b9b50438b8c785d64b2567729adac Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 20:29:50 +0100 Subject: [PATCH 20/81] feat(venues): venue status consts, controller , http enum --- .../Controllers/VenueSubmissionController.php | 96 +++++++++++++++++++ .../Venues/Enums/VenueSubmissionHttpEnum.php | 15 +++ .../Enums/VenueSubmissionStatusEnum.php | 12 +++ .../Resources/VenueSubmissionResource.php | 26 +++++ 4 files changed, 149 insertions(+) create mode 100644 app/Domains/Venues/Controllers/VenueSubmissionController.php create mode 100644 app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php create mode 100644 app/Domains/Venues/Enums/VenueSubmissionStatusEnum.php create mode 100644 app/Domains/Venues/Resources/VenueSubmissionResource.php diff --git a/app/Domains/Venues/Controllers/VenueSubmissionController.php b/app/Domains/Venues/Controllers/VenueSubmissionController.php new file mode 100644 index 0000000..6440d8c --- /dev/null +++ b/app/Domains/Venues/Controllers/VenueSubmissionController.php @@ -0,0 +1,96 @@ +input('status') === VenueSubmissionStatusEnum::FLAGGED->value) { + $filters['status'] = VenueSubmissionStatusEnum::FLAGGED->value; + } + + $submissions = $this->venueSubmissionService->getPaginatedSubmissions($filters, 15); + } catch (Exception $e) { + return $this->apiResponseError($e, VenueSubmissionHttpEnum::INDEX_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'venue_submissions' => VenueSubmissionResource::collection($submissions)->response()->getData(true) + ] + ); + } + + public function store(StoreVenueSubmissionRequest $request): JsonResponse + { + try { + $organizerProfile = $request->user()->organizerProfile; + + if (!$organizerProfile) { + return $this->apiResponseError( + new Exception('Organizer profile not found'), + VenueSubmissionHttpEnum::NOT_FOUND_ERROR->value, + Response::HTTP_FORBIDDEN + ); + } + + $submission = $this->venueSubmissionService->submitVenue( + $organizerProfile->id, + $request->validated() + ); + } catch (Exception $e) { + return $this->apiResponseError($e, VenueSubmissionHttpEnum::STORE_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::STORE_SUCCESS->value, + Response::HTTP_CREATED, + [ + 'venue_submission' => new VenueSubmissionResource($submission) + ] + ); + } + + public function show(Request $request, VenueSubmission $venueSubmission): JsonResponse + { + try { + if ($venueSubmission->organizer_profile_id !== $request->user()->organizerProfile?->id) { + return $this->apiResponseError( + new Exception('Forbidden'), + VenueSubmissionHttpEnum::FORBIDDEN_ERROR->value, + Response::HTTP_FORBIDDEN + ); + } + } catch (Exception $e) { + return $this->apiResponseError($e, VenueSubmissionHttpEnum::SHOW_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + [ + 'venue_submission' => new VenueSubmissionResource($venueSubmission) + ] + ); + } +} diff --git a/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php new file mode 100644 index 0000000..cb1d0fe --- /dev/null +++ b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php @@ -0,0 +1,15 @@ + $this->id, + 'organizer_profile_id' => $this->organizer_profile_id, + 'name' => $this->name, + 'city' => $this->city, + 'address_line_1' => $this->address_line_1, + 'address_line_2' => $this->address_line_2, + 'postal_code' => $this->postal_code, + 'status' => $this->status, + 'admin_notes' => $this->admin_notes, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} From bfde623e9b47d5acedef089fc7c2addaa8b6d8cc Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 20:31:30 +0100 Subject: [PATCH 21/81] feat(venues): venue submission service and repository logicc, Job skelteton --- .../Venues/Jobs/ProcessVenueSubmissionJob.php | 18 ++++++++++ .../VenueSubmissionRepository.php | 35 +++++++++++++++++++ .../VenueSubmissionRepositoryInterface.php | 17 +++++++++ .../Requests/StoreVenueSubmissionRequest.php | 24 +++++++++++++ .../Services/VenueSubmissionService.php | 33 +++++++++++++++++ app/Providers/AppServiceProvider.php | 3 ++ 6 files changed, 130 insertions(+) create mode 100644 app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php create mode 100644 app/Domains/Venues/Repositories/VenueSubmissionRepository.php create mode 100644 app/Domains/Venues/Repositories/VenueSubmissionRepositoryInterface.php create mode 100644 app/Domains/Venues/Requests/StoreVenueSubmissionRequest.php create mode 100644 app/Domains/Venues/Services/VenueSubmissionService.php diff --git a/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php new file mode 100644 index 0000000..73adcfe --- /dev/null +++ b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php @@ -0,0 +1,18 @@ +where('status', $filters['status']); + } + + return $query->latest()->paginate($perPage); + } + + public function findById(int $id): ?VenueSubmission + { + return VenueSubmission::find($id); + } + + public function create(array $data): VenueSubmission + { + return VenueSubmission::create($data); + } + + public function update(VenueSubmission $submission, array $data): bool + { + return $submission->update($data); + } +} diff --git a/app/Domains/Venues/Repositories/VenueSubmissionRepositoryInterface.php b/app/Domains/Venues/Repositories/VenueSubmissionRepositoryInterface.php new file mode 100644 index 0000000..acd3f0a --- /dev/null +++ b/app/Domains/Venues/Repositories/VenueSubmissionRepositoryInterface.php @@ -0,0 +1,17 @@ + ['required', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:255'], + 'address_line_1' => ['required', 'string', 'max:255'], + 'address_line_2' => ['nullable', 'string', 'max:255'], + 'postal_code' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Domains/Venues/Services/VenueSubmissionService.php b/app/Domains/Venues/Services/VenueSubmissionService.php new file mode 100644 index 0000000..b897095 --- /dev/null +++ b/app/Domains/Venues/Services/VenueSubmissionService.php @@ -0,0 +1,33 @@ +venueSubmissionRepository->getPaginatedForAdminHelper($filters, $perPage); + } + + public function submitVenue(int $organizerProfileId, array $data): VenueSubmission + { + $data['organizer_profile_id'] = $organizerProfileId; + $data['status'] = VenueSubmissionStatusEnum::PENDING->value; + + $submission = $this->venueSubmissionRepository->create($data); + + ProcessVenueSubmissionJob::dispatch($submission); + + return $submission; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 859bf65..30ce17f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use App\Domains\Events\Repositories\EventRepository; use App\Domains\Events\Repositories\EventRepositoryInterface; +use App\Domains\Venues\Repositories\VenueSubmissionRepository; +use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; use App\Domains\Venues\Repositories\VenueRepository; use App\Domains\Venues\Repositories\VenueRepositoryInterface; use Illuminate\Support\ServiceProvider; @@ -17,6 +19,7 @@ public function register(): void { $this->app->bind(VenueRepositoryInterface::class, VenueRepository::class); $this->app->bind(EventRepositoryInterface::class, EventRepository::class); + $this->app->bind(VenueSubmissionRepositoryInterface::class, VenueSubmissionRepository::class); } /** From 461f4e64c20cd95c1dd32565e262d18d2e12a3fc Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sun, 22 Feb 2026 20:31:48 +0100 Subject: [PATCH 22/81] test(venues): venue submission feature testts --- database/factories/VenueSubmissionFactory.php | 28 +++++ .../Feature/Venues/VenueSubmissionApiTest.php | 117 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 database/factories/VenueSubmissionFactory.php create mode 100644 tests/Feature/Venues/VenueSubmissionApiTest.php diff --git a/database/factories/VenueSubmissionFactory.php b/database/factories/VenueSubmissionFactory.php new file mode 100644 index 0000000..0032310 --- /dev/null +++ b/database/factories/VenueSubmissionFactory.php @@ -0,0 +1,28 @@ + OrganizerProfile::factory(), + 'name' => fake()->company(), + 'city' => fake()->city(), + 'address_line_1' => fake()->streetAddress(), + 'address_line_2' => fake()->optional()->secondaryAddress(), + 'postal_code' => fake()->postcode(), + 'status' => VenueSubmissionStatusEnum::PENDING, + 'places_result' => null, + 'admin_notes' => null, + ]; + } +} diff --git a/tests/Feature/Venues/VenueSubmissionApiTest.php b/tests/Feature/Venues/VenueSubmissionApiTest.php new file mode 100644 index 0000000..4932d39 --- /dev/null +++ b/tests/Feature/Venues/VenueSubmissionApiTest.php @@ -0,0 +1,117 @@ +create(); + $user->organizerProfile()->create([ + 'organization_name' => "Test Organizer {$counter}", + 'slug' => "test-organizer-{$counter}", + 'contact_email' => "test{$counter}@test.com", + 'phone' => '123456789' + ]); + Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + $user->assignRole('organizer'); + return $user; +} + +function createAdmin(): User +{ + $user = User::factory()->create(); + Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $user->assignRole('admin'); + return $user; +} + +beforeEach(function () { + $this->artisan('migrate'); +}); + +test('organizer can submit a venue and it dispatches job', function () { + Queue::fake(); + + $organizer = createOrganizer(); + + $payload = [ + 'name' => 'Stary Klasztor', + 'city' => 'Wrocław', + 'address_line_1' => 'Purkyniego 1', + 'postal_code' => '50-155', + ]; + + $response = $this->actingAs($organizer) + ->postJson('/api/v1/organizer/venue-submissions', $payload); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'data' => [ + 'venue_submission' => [ + 'name' => 'Stary Klasztor', + 'status' => VenueSubmissionStatusEnum::PENDING->value, + ] + ] + ]); + + Queue::assertPushed(ProcessVenueSubmissionJob::class, function ($job) use ($response) { + return $job->venueSubmission->id === $response->json('data.venue_submission.id'); + }); +}); + +test('organizer can view their own venue submission', function () { + $organizer = createOrganizer(); + + $submission = VenueSubmission::factory()->create([ + 'organizer_profile_id' => $organizer->organizerProfile->id, + 'status' => VenueSubmissionStatusEnum::FLAGGED, + ]); + + $response = $this->actingAs($organizer) + ->getJson("/api/v1/organizer/venue-submissions/{$submission->id}"); + + $response->assertStatus(200) + ->assertJsonPath('data.venue_submission.id', $submission->id) + ->assertJsonPath('data.venue_submission.status', VenueSubmissionStatusEnum::FLAGGED->value); +}); + +test('organizer cannot view another organizers venue submission', function () { + $organizer1 = createOrganizer(); + $organizer2 = createOrganizer(); + + $submission = VenueSubmission::factory()->create([ + 'organizer_profile_id' => $organizer1->organizerProfile->id, + ]); + + $response = $this->actingAs($organizer2) + ->getJson("/api/v1/organizer/venue-submissions/{$submission->id}"); + + $response->assertStatus(403); +}); + +test('admin can list flagged venue submissions', function () { + $admin = createAdmin(); + + VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::PENDING]); + $flaggedSubmission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED]); + + $response = $this->actingAs($admin) + ->getJson('/api/v1/admin/venue-submissions?status=flagged'); + + $response->assertStatus(200); + + $this->assertCount(1, $response->json('data.venue_submissions.data')); + $this->assertEquals($flaggedSubmission->id, $response->json('data.venue_submissions.data.0.id')); +}); + +test('unauthenticated user cannot access endpoints', function () { + $this->postJson('/api/v1/organizer/venue-submissions', [])->assertStatus(401); + $this->getJson('/api/v1/admin/venue-submissions')->assertStatus(401); +}); From 62f0575d021cc6298947e40dec5596d15e1edf51 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 01:41:08 +0100 Subject: [PATCH 23/81] feat(venues): integrate Google Places API --- .env.example | 2 + .../Venues/Services/GooglePlacesService.php | 62 +++++++++++++++++++ config/app.php | 2 + config/services.php | 4 ++ 4 files changed, 70 insertions(+) create mode 100644 app/Domains/Venues/Services/GooglePlacesService.php diff --git a/.env.example b/.env.example index c0660ea..238ca69 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +GOOGLE_PLACES_API_KEY= diff --git a/app/Domains/Venues/Services/GooglePlacesService.php b/app/Domains/Venues/Services/GooglePlacesService.php new file mode 100644 index 0000000..2acb644 --- /dev/null +++ b/app/Domains/Venues/Services/GooglePlacesService.php @@ -0,0 +1,62 @@ +apiKey = config('services.google_places.key'); + } + + /** + * Search for a place using text query. + * Returns the first match including place_id, name, address, and coordinates. + */ + public function findPlace(string $name, string $city): ?array + { + if (empty($this->apiKey)) { + Log::error('Google Places API key is missing.'); + return null; + } + + $query = "{$name}, {$city}"; + + try { + $response = Http::withHeaders([ + 'X-Goog-Api-Key' => $this->apiKey, + 'X-Goog-FieldMask' => 'places.id,places.displayName,places.formattedAddress,places.location', + ])->post('https://places.googleapis.com/v1/places:searchText', [ + 'textQuery' => $query, + 'maxResultCount' => 1, + ]); + + if ($response->failed()) { + Log::error('Google Places API call failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + $places = $response->json('places'); + + if (empty($places)) { + return null; + } + + return $places[0]; + } catch (Exception $e) { + Log::error('Exception during Google Places API call', [ + 'message' => $e->getMessage(), + ]); + return null; + } + } +} diff --git a/config/app.php b/config/app.php index 423eed5..f916da4 100644 --- a/config/app.php +++ b/config/app.php @@ -54,6 +54,8 @@ 'url' => env('APP_URL', 'http://localhost'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/config/services.php b/config/services.php index 6a90eb8..2c5c896 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ ], ], + 'google_places' => [ + 'key' => env('GOOGLE_PLACES_API_KEY'), + ], + ]; From dad3e04208287a5755e52c05528e6b6181aa52eb Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 01:42:30 +0100 Subject: [PATCH 24/81] feat(venues): GooglePlaces api unit and integration tests --- .../Venues/Jobs/ProcessVenueSubmissionJob.php | 27 ++++++- app/Domains/Venues/Models/VenueSubmission.php | 6 ++ .../Venues/ProcessVenueSubmissionJobTest.php | 74 +++++++++++++++++++ tests/Unit/Venues/GooglePlacesServiceTest.php | 74 +++++++++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Venues/ProcessVenueSubmissionJobTest.php create mode 100644 tests/Unit/Venues/GooglePlacesServiceTest.php diff --git a/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php index 73adcfe..0022955 100644 --- a/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php +++ b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php @@ -2,9 +2,12 @@ namespace App\Domains\Venues\Jobs; +use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; use App\Domains\Venues\Models\VenueSubmission; +use App\Domains\Venues\Services\GooglePlacesService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\Log; class ProcessVenueSubmissionJob implements ShouldQueue { @@ -14,5 +17,27 @@ public function __construct( public VenueSubmission $venueSubmission ) {} - public function handle(): void {} + public function handle(GooglePlacesService $googlePlacesService): void + { + $placeData = $googlePlacesService->findPlace( + $this->venueSubmission->name, + $this->venueSubmission->city + ); + + if ($placeData) { + $this->venueSubmission->update([ + 'places_result' => $placeData, + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + ]); + + Log::info('Venue submission matched with Google Places', [ + 'submission_id' => $this->venueSubmission->id, + 'place_id' => $placeData['id'] ?? 'unknown', + ]); + } else { + Log::info('No Google Places match found for venue submission', [ + 'submission_id' => $this->venueSubmission->id, + ]); + } + } } diff --git a/app/Domains/Venues/Models/VenueSubmission.php b/app/Domains/Venues/Models/VenueSubmission.php index 2e575ae..5cc5180 100644 --- a/app/Domains/Venues/Models/VenueSubmission.php +++ b/app/Domains/Venues/Models/VenueSubmission.php @@ -4,6 +4,7 @@ use App\Domains\Organizer\Models\OrganizerProfile; use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; +use Database\Factories\VenueSubmissionFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,6 +12,11 @@ class VenueSubmission extends Model { use HasFactory; + protected static function newFactory() + { + return VenueSubmissionFactory::new(); + } + protected $fillable = [ 'organizer_profile_id', 'name', diff --git a/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php new file mode 100644 index 0000000..3ae89ec --- /dev/null +++ b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php @@ -0,0 +1,74 @@ + 'test-api-key']); + } + + public function test_it_updates_submission_with_google_places_data(): void + { + Http::fake([ + 'places.googleapis.com/*' => Http::response([ + 'places' => [ + [ + 'id' => 'ChIJN1t_tDeuEmsRUsoyG83frY4', + 'displayName' => ['text' => 'Stary Klasztor'], + 'formattedAddress' => 'Purkyniego 1, 50-155 Wrocław, Poland', + ] + ] + ], 200), + ]); + + $submission = VenueSubmission::factory()->create([ + 'name' => 'Stary Klasztor', + 'city' => 'Wrocław', + 'status' => VenueSubmissionStatusEnum::PENDING, + ]); + + $job = new ProcessVenueSubmissionJob($submission); + $job->handle(new \App\Domains\Venues\Services\GooglePlacesService()); + + $submission->refresh(); + + $this->assertEquals(VenueSubmissionStatusEnum::FLAGGED, $submission->status); + $this->assertNotNull($submission->places_result); + $this->assertEquals('ChIJN1t_tDeuEmsRUsoyG83frY4', $submission->places_result['id']); + } + + public function test_it_keeps_submission_pending_if_no_match_found(): void + { + Http::fake([ + 'places.googleapis.com/*' => Http::response([ + 'places' => [] + ], 200), + ]); + + $submission = VenueSubmission::factory()->create([ + 'name' => 'Non Existent Place', + 'city' => 'Wrocław', + 'status' => VenueSubmissionStatusEnum::PENDING, + ]); + + $job = new ProcessVenueSubmissionJob($submission); + $job->handle(new \App\Domains\Venues\Services\GooglePlacesService()); + + $submission->refresh(); + + $this->assertEquals(VenueSubmissionStatusEnum::PENDING, $submission->status); + $this->assertNull($submission->places_result); + } +} diff --git a/tests/Unit/Venues/GooglePlacesServiceTest.php b/tests/Unit/Venues/GooglePlacesServiceTest.php new file mode 100644 index 0000000..3686d49 --- /dev/null +++ b/tests/Unit/Venues/GooglePlacesServiceTest.php @@ -0,0 +1,74 @@ + 'test-api-key']); + $this->service = new GooglePlacesService(); + } + + public function test_it_returns_place_data_on_successful_match(): void + { + Http::fake([ + 'places.googleapis.com/*' => Http::response([ + 'places' => [ + [ + 'id' => 'ChIJN1t_tDeuEmsRUsoyG83frY4', + 'displayName' => ['text' => 'Stary Klasztor'], + 'formattedAddress' => 'Purkyniego 1, 50-155 Wrocław, Poland', + ] + ] + ], 200), + ]); + + $result = $this->service->findPlace('Stary Klasztor', 'Wrocław'); + + $this->assertNotNull($result); + $this->assertEquals('ChIJN1t_tDeuEmsRUsoyG83frY4', $result['id']); + $this->assertEquals('Stary Klasztor', $result['displayName']['text']); + } + + public function test_it_returns_null_when_no_places_found(): void + { + Http::fake([ + 'places.googleapis.com/*' => Http::response([ + 'places' => [] + ], 200), + ]); + + $result = $this->service->findPlace('Non Existent Place', 'Mars'); + + $this->assertNull($result); + } + + public function test_it_returns_null_on_api_error(): void + { + Http::fake([ + 'places.googleapis.com/*' => Http::response([], 500), + ]); + + $result = $this->service->findPlace('Error Place', 'Wrocław'); + + $this->assertNull($result); + } + + public function test_it_returns_null_if_api_key_is_missing(): void + { + config(['services.google_places.key' => null]); + $service = new GooglePlacesService(); + + $result = $service->findPlace('Test', 'Test'); + + $this->assertNull($result); + } +} From 641934bec77a600b8f0d3a586cb5dbab3843942b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 01:48:00 +0100 Subject: [PATCH 25/81] refactor: add bruno endpoints --- bruno/Admin/ApproveOrganizerController.bru | 11 +++++++++ .../Admin/VenueSubmissionController@index.bru | 11 +++++++++ bruno/Auth/LogoutController.bru | 2 +- bruno/Auth/MeController.bru | 2 +- .../Events/CancelEventController.bru | 11 +++++++++ .../OrganizerEventController@destroy.bru | 11 +++++++++ .../Events/OrganizerEventController@index.bru | 11 +++++++++ .../Events/OrganizerEventController@show.bru | 11 +++++++++ .../Events/OrganizerEventController@store.bru | 23 +++++++++++++++++++ .../OrganizerEventController@update.bru | 23 +++++++++++++++++++ .../Events/PublishEventController.bru | 11 +++++++++ .../ShowOrganizerProfileController.bru | 11 +++++++++ .../StoreOrganizerProfileController.bru | 19 +++++++++++++++ .../UpdateOrganizerProfileController.bru | 19 +++++++++++++++ .../Categories/CategoryController@index.bru | 11 +++++++++ .../Events/PublicEventController@index.bru | 11 +++++++++ .../Events/PublicEventController@show.bru | 11 +++++++++ bruno/Public/Venues/ListVenuesController.bru | 11 +++++++++ .../Public/Venues/SearchVenuesController.bru | 11 +++++++++ .../VenueSubmissionController@index.bru | 11 +++++++++ .../Venues/VenueSubmissionController@show.bru | 11 +++++++++ .../VenueSubmissionController@store.bru | 20 ++++++++++++++++ bruno/collection.bru | 7 ++++++ bruno/environments/Local.bru | 2 +- 24 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 bruno/Admin/ApproveOrganizerController.bru create mode 100644 bruno/Admin/VenueSubmissionController@index.bru create mode 100644 bruno/Organizer/Events/CancelEventController.bru create mode 100644 bruno/Organizer/Events/OrganizerEventController@destroy.bru create mode 100644 bruno/Organizer/Events/OrganizerEventController@index.bru create mode 100644 bruno/Organizer/Events/OrganizerEventController@show.bru create mode 100644 bruno/Organizer/Events/OrganizerEventController@store.bru create mode 100644 bruno/Organizer/Events/OrganizerEventController@update.bru create mode 100644 bruno/Organizer/Events/PublishEventController.bru create mode 100644 bruno/Organizer/ShowOrganizerProfileController.bru create mode 100644 bruno/Organizer/StoreOrganizerProfileController.bru create mode 100644 bruno/Organizer/UpdateOrganizerProfileController.bru create mode 100644 bruno/Public/Categories/CategoryController@index.bru create mode 100644 bruno/Public/Events/PublicEventController@index.bru create mode 100644 bruno/Public/Events/PublicEventController@show.bru create mode 100644 bruno/Public/Venues/ListVenuesController.bru create mode 100644 bruno/Public/Venues/SearchVenuesController.bru create mode 100644 bruno/Venues/VenueSubmissionController@index.bru create mode 100644 bruno/Venues/VenueSubmissionController@show.bru create mode 100644 bruno/Venues/VenueSubmissionController@store.bru create mode 100644 bruno/collection.bru diff --git a/bruno/Admin/ApproveOrganizerController.bru b/bruno/Admin/ApproveOrganizerController.bru new file mode 100644 index 0000000..832109e --- /dev/null +++ b/bruno/Admin/ApproveOrganizerController.bru @@ -0,0 +1,11 @@ +meta { + name: ApproveOrganizerController + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/admin/organizers/2/approve + body: none + auth: inherit +} diff --git a/bruno/Admin/VenueSubmissionController@index.bru b/bruno/Admin/VenueSubmissionController@index.bru new file mode 100644 index 0000000..f98d17b --- /dev/null +++ b/bruno/Admin/VenueSubmissionController@index.bru @@ -0,0 +1,11 @@ +meta { + name: VenueSubmissionController@index + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions?status=flagged + body: none + auth: inherit +} diff --git a/bruno/Auth/LogoutController.bru b/bruno/Auth/LogoutController.bru index a5d24a5..4ab3c00 100644 --- a/bruno/Auth/LogoutController.bru +++ b/bruno/Auth/LogoutController.bru @@ -7,5 +7,5 @@ meta { post { url: {{baseUrl}}/api/{{apiVersion}}/auth/logout body: none - auth: none + auth: inherit } diff --git a/bruno/Auth/MeController.bru b/bruno/Auth/MeController.bru index 5930061..205b162 100644 --- a/bruno/Auth/MeController.bru +++ b/bruno/Auth/MeController.bru @@ -7,5 +7,5 @@ meta { get { url: {{baseUrl}}/api/{{apiVersion}}/auth/me body: none - auth: none + auth: inherit } diff --git a/bruno/Organizer/Events/CancelEventController.bru b/bruno/Organizer/Events/CancelEventController.bru new file mode 100644 index 0000000..289c678 --- /dev/null +++ b/bruno/Organizer/Events/CancelEventController.bru @@ -0,0 +1,11 @@ +meta { + name: CancelEventController + type: http + seq: 7 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1/cancel + body: none + auth: inherit +} diff --git a/bruno/Organizer/Events/OrganizerEventController@destroy.bru b/bruno/Organizer/Events/OrganizerEventController@destroy.bru new file mode 100644 index 0000000..87ba210 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@destroy.bru @@ -0,0 +1,11 @@ +meta { + name: OrganizerEventController@destroy + type: http + seq: 5 +} + +delete { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1 + body: none + auth: inherit +} diff --git a/bruno/Organizer/Events/OrganizerEventController@index.bru b/bruno/Organizer/Events/OrganizerEventController@index.bru new file mode 100644 index 0000000..b055ae5 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@index.bru @@ -0,0 +1,11 @@ +meta { + name: OrganizerEventController@index + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events + body: none + auth: inherit +} diff --git a/bruno/Organizer/Events/OrganizerEventController@show.bru b/bruno/Organizer/Events/OrganizerEventController@show.bru new file mode 100644 index 0000000..361b089 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@show.bru @@ -0,0 +1,11 @@ +meta { + name: OrganizerEventController@show + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1 + body: none + auth: inherit +} diff --git a/bruno/Organizer/Events/OrganizerEventController@store.bru b/bruno/Organizer/Events/OrganizerEventController@store.bru new file mode 100644 index 0000000..3e8886e --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@store.bru @@ -0,0 +1,23 @@ +meta { + name: OrganizerEventController@store + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events + body: json + auth: inherit +} + +body:json { + { + "venue_id": 1, + "title": "Summer Music Festival", + "description": "An amazing outdoor music festival featuring local and international artists.", + "start_time": "2026-07-15T18:00:00Z", + "end_time": "2026-07-17T23:59:59Z", + "timezone": "Europe/Warsaw", + "category_ids": [1, 2] + } +} diff --git a/bruno/Organizer/Events/OrganizerEventController@update.bru b/bruno/Organizer/Events/OrganizerEventController@update.bru new file mode 100644 index 0000000..2a89921 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@update.bru @@ -0,0 +1,23 @@ +meta { + name: OrganizerEventController@update + type: http + seq: 4 +} + +put { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1 + body: json + auth: inherit +} + +body:json { + { + "venue_id": 1, + "title": "Updated Music Festival", + "description": "An updated music festival featuring local and international artists.", + "start_time": "2026-07-15T18:00:00Z", + "end_time": "2026-07-17T23:59:59Z", + "timezone": "Europe/Warsaw", + "category_ids": [1] + } +} diff --git a/bruno/Organizer/Events/PublishEventController.bru b/bruno/Organizer/Events/PublishEventController.bru new file mode 100644 index 0000000..b78a6a7 --- /dev/null +++ b/bruno/Organizer/Events/PublishEventController.bru @@ -0,0 +1,11 @@ +meta { + name: PublishEventController + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1/publish + body: none + auth: inherit +} diff --git a/bruno/Organizer/ShowOrganizerProfileController.bru b/bruno/Organizer/ShowOrganizerProfileController.bru new file mode 100644 index 0000000..d7215eb --- /dev/null +++ b/bruno/Organizer/ShowOrganizerProfileController.bru @@ -0,0 +1,11 @@ +meta { + name: ShowOrganizerProfileController + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/profile + body: none + auth: inherit +} diff --git a/bruno/Organizer/StoreOrganizerProfileController.bru b/bruno/Organizer/StoreOrganizerProfileController.bru new file mode 100644 index 0000000..f4a7961 --- /dev/null +++ b/bruno/Organizer/StoreOrganizerProfileController.bru @@ -0,0 +1,19 @@ +meta { + name: StoreOrganizerProfileController + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/profile + body: json + auth: inherit +} + +body:json { + { + "organization_name": "My Concert Venue", + "description": "Premium concert hall in the heart of the city", + "phone": "123-456-789" + } +} diff --git a/bruno/Organizer/UpdateOrganizerProfileController.bru b/bruno/Organizer/UpdateOrganizerProfileController.bru new file mode 100644 index 0000000..4efd982 --- /dev/null +++ b/bruno/Organizer/UpdateOrganizerProfileController.bru @@ -0,0 +1,19 @@ +meta { + name: UpdateOrganizerProfileController + type: http + seq: 3 +} + +put { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/profile + body: json + auth: inherit +} + +body:json { + { + "organization_name": "Updated Concert Venue", + "description": "Updated premium concert hall in the heart of the city", + "phone": "987-654-321" + } +} diff --git a/bruno/Public/Categories/CategoryController@index.bru b/bruno/Public/Categories/CategoryController@index.bru new file mode 100644 index 0000000..ba690b5 --- /dev/null +++ b/bruno/Public/Categories/CategoryController@index.bru @@ -0,0 +1,11 @@ +meta { + name: CategoryController@index + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/categories + body: none + auth: none +} diff --git a/bruno/Public/Events/PublicEventController@index.bru b/bruno/Public/Events/PublicEventController@index.bru new file mode 100644 index 0000000..7f90b9a --- /dev/null +++ b/bruno/Public/Events/PublicEventController@index.bru @@ -0,0 +1,11 @@ +meta { + name: PublicEventController@index + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/events + body: none + auth: none +} diff --git a/bruno/Public/Events/PublicEventController@show.bru b/bruno/Public/Events/PublicEventController@show.bru new file mode 100644 index 0000000..4180487 --- /dev/null +++ b/bruno/Public/Events/PublicEventController@show.bru @@ -0,0 +1,11 @@ +meta { + name: PublicEventController@show + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/events/1 + body: none + auth: none +} diff --git a/bruno/Public/Venues/ListVenuesController.bru b/bruno/Public/Venues/ListVenuesController.bru new file mode 100644 index 0000000..8319e71 --- /dev/null +++ b/bruno/Public/Venues/ListVenuesController.bru @@ -0,0 +1,11 @@ +meta { + name: ListVenuesController + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/venues + body: none + auth: none +} diff --git a/bruno/Public/Venues/SearchVenuesController.bru b/bruno/Public/Venues/SearchVenuesController.bru new file mode 100644 index 0000000..dc75216 --- /dev/null +++ b/bruno/Public/Venues/SearchVenuesController.bru @@ -0,0 +1,11 @@ +meta { + name: SearchVenuesController + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/venues/search?query=klasztor + body: none + auth: none +} diff --git a/bruno/Venues/VenueSubmissionController@index.bru b/bruno/Venues/VenueSubmissionController@index.bru new file mode 100644 index 0000000..e3da255 --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@index.bru @@ -0,0 +1,11 @@ +meta { + name: VenueSubmissionController@index + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions?status=flagged + body: none + auth: inherit +} diff --git a/bruno/Venues/VenueSubmissionController@show.bru b/bruno/Venues/VenueSubmissionController@show.bru new file mode 100644 index 0000000..a063284 --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@show.bru @@ -0,0 +1,11 @@ +meta { + name: VenueSubmissionController@show + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/venue-submissions/1 + body: none + auth: inherit +} diff --git a/bruno/Venues/VenueSubmissionController@store.bru b/bruno/Venues/VenueSubmissionController@store.bru new file mode 100644 index 0000000..1db05bd --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@store.bru @@ -0,0 +1,20 @@ +meta { + name: VenueSubmissionController@store + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/venue-submissions + body: json + auth: inherit +} + +body:json { + { + "name": "Stary Klasztor", + "city": "Wrocław", + "address_line_1": "Purkyniego 1", + "postal_code": "50-155" + } +} diff --git a/bruno/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..75dde62 --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,7 @@ +auth { + mode: bearer +} + +auth:bearer { + token: 2|G5cR9ChqgIbsZxANSCdhFtMDE9Y8Btx9qovIvrkv91f6025d +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru index 90d1a86..bfcc500 100644 --- a/bruno/environments/Local.bru +++ b/bruno/environments/Local.bru @@ -1,4 +1,4 @@ vars { - baseUrl: http://localhost + baseUrl: http://127.0.0.1:8002 apiVersion: v1 } From 103c5bcea5ca59db79479b72da4e50518a05af84 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 01:48:28 +0100 Subject: [PATCH 26/81] refactor: fix auth endpoints --- .../Auth/Controllers/LoginController.php | 8 ++-- .../Auth/Controllers/LogoutController.php | 4 +- .../PasswordResetRedirectController.php | 18 ++++++++ .../Auth/Controllers/RegisterController.php | 7 ++- app/Domains/Auth/Services/AuthService.php | 28 +++++++----- bootstrap/app.php | 8 +++- routes/api.php | 4 +- tests/Feature/Auth/AuthTest.php | 43 ++++++++++--------- 8 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 app/Domains/Auth/Controllers/PasswordResetRedirectController.php diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php index c536caa..7aab942 100644 --- a/app/Domains/Auth/Controllers/LoginController.php +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -21,8 +21,7 @@ public function __construct( public function __invoke(LoginRequest $request): JsonResponse { try { - $user = $this->authService->loginUser($request->validated()); - $request->session()->regenerate(); + $result = $this->authService->loginUser($request->validated()); } catch (Throwable $e) { return $this->apiResponseError( $e, @@ -34,7 +33,10 @@ public function __invoke(LoginRequest $request): JsonResponse return $this->apiResponseSuccess( UserRegisterHttpEnum::LOGIN_SUCCESS->value, Response::HTTP_OK, - new UserResource($user) + [ + 'user' => new UserResource($result['user']), + 'token' => $result['token'], + ] ); } } diff --git a/app/Domains/Auth/Controllers/LogoutController.php b/app/Domains/Auth/Controllers/LogoutController.php index ebbe1b9..a2c9f4b 100644 --- a/app/Domains/Auth/Controllers/LogoutController.php +++ b/app/Domains/Auth/Controllers/LogoutController.php @@ -19,9 +19,7 @@ public function __construct( public function __invoke(Request $request): JsonResponse { try { - $this->authService->logoutUser(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); + $this->authService->logoutUser($request->user()); } catch (Throwable $e) { return $this->apiResponseError( $e, diff --git a/app/Domains/Auth/Controllers/PasswordResetRedirectController.php b/app/Domains/Auth/Controllers/PasswordResetRedirectController.php new file mode 100644 index 0000000..c17e39e --- /dev/null +++ b/app/Domains/Auth/Controllers/PasswordResetRedirectController.php @@ -0,0 +1,18 @@ +input('email'); + + return redirect("{$frontendUrl}/reset-password?token={$token}&email={$email}"); + } +} diff --git a/app/Domains/Auth/Controllers/RegisterController.php b/app/Domains/Auth/Controllers/RegisterController.php index 444d584..300bd7f 100644 --- a/app/Domains/Auth/Controllers/RegisterController.php +++ b/app/Domains/Auth/Controllers/RegisterController.php @@ -20,7 +20,7 @@ public function __construct( public function __invoke(RegisterRequest $request): JsonResponse { try { - $user = $this->authService->registerUser($request->validated()); + $result = $this->authService->registerUser($request->validated()); } catch (Throwable $e) { return $this->apiResponseError( $e, @@ -32,7 +32,10 @@ public function __invoke(RegisterRequest $request): JsonResponse return $this->apiResponseSuccess( UserRegisterHttpEnum::REGISTER_SUCCESS->value, Response::HTTP_CREATED, - new UserResource($user) + [ + 'user' => new UserResource($result['user']), + 'token' => $result['token'], + ] ); } } diff --git a/app/Domains/Auth/Services/AuthService.php b/app/Domains/Auth/Services/AuthService.php index e416fc4..5d300de 100644 --- a/app/Domains/Auth/Services/AuthService.php +++ b/app/Domains/Auth/Services/AuthService.php @@ -15,7 +15,7 @@ public function __construct( protected UserRepository $userRepository ) {} - public function registerUser(array $data): User + public function registerUser(array $data): array { $user = $this->userRepository->create([ 'name' => $data['name'], @@ -26,26 +26,34 @@ public function registerUser(array $data): User event(new Registered($user)); - Auth::login($user); + $token = $user->createToken('auth_token')->plainTextToken; - return $user; + return [ + 'user' => $user, + 'token' => $token, + ]; } - public function loginUser(array $credentials): User + public function loginUser(array $credentials): array { - if (! Auth::attempt($credentials)) { + $user = $this->userRepository->findByEmail($credentials['email']); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); } - /** @var User $user */ - $user = Auth::user(); - return $user; + $token = $user->createToken('auth_token')->plainTextToken; + + return [ + 'user' => $user, + 'token' => $token, + ]; } - public function logoutUser(): void + public function logoutUser(User $user): void { - Auth::guard('web')->logout(); + $user->tokens()->delete(); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 006bf65..2b740c1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,7 +15,13 @@ apiPrefix: 'api/' . env('API_VERSION', 'v1'), ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->statefulApi(); + // $middleware->statefulApi(); + $middleware->redirectTo(function ($request) { + if ($request->is('api/*')) { + return null; + } + return route('login'); + }); $middleware->alias([ 'role' => RoleMiddleware::class, 'permission' => RoleOrPermissionMiddleware::class, diff --git a/routes/api.php b/routes/api.php index 57d0c8e..00f0794 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Domains\Auth\Controllers\RegisterController; use App\Domains\Auth\Controllers\ResendVerificationEmailController; use App\Domains\Auth\Controllers\ResetPasswordController; +use App\Domains\Auth\Controllers\PasswordResetRedirectController; use App\Domains\Auth\Controllers\SendPasswordResetLinkController; use App\Domains\Auth\Controllers\VerifyEmailController; use App\Domains\Organizer\Controllers\StoreOrganizerProfileController; @@ -31,9 +32,10 @@ Route::prefix('auth')->group(function () { Route::post('/register', RegisterController::class); - Route::post('/login', LoginController::class); + Route::post('/login', LoginController::class)->name('login'); Route::post('/forgot-password', SendPasswordResetLinkController::class)->name('password.email'); + Route::get('/reset-password/{token}', PasswordResetRedirectController::class)->name('password.reset'); Route::post('/reset-password', ResetPasswordController::class)->name('password.store'); Route::middleware('auth:sanctum')->group(function () { diff --git a/tests/Feature/Auth/AuthTest.php b/tests/Feature/Auth/AuthTest.php index 86a914b..ca5bb07 100644 --- a/tests/Feature/Auth/AuthTest.php +++ b/tests/Feature/Auth/AuthTest.php @@ -15,53 +15,56 @@ 'password_confirmation' => 'password', ]); - $response->dump(); $response->assertStatus(201) - ->assertJsonStructure(['message', 'data' => ['id', 'name', 'email']]); + ->assertJsonStructure([ + 'message', + 'data' => [ + 'user' => ['id', 'name', 'email'], + 'token' + ] + ]); $this->assertDatabaseHas('users', ['email' => 'john@example.com']); - $this->assertAuthenticated(); }); test('a user can login', function () { $user = User::factory()->create([ 'email' => 'jane@example.com', 'password' => bcrypt('password123'), + 'email_verified_at' => now(), ]); - $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/login', [ + $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'jane@example.com', 'password' => 'password123', ]); $response->assertStatus(200) - ->assertJsonStructure(['message', 'data' => ['id', 'name', 'email']]); - - $this->assertAuthenticatedAs($user); + ->assertJsonStructure([ + 'message', + 'data' => [ + 'user' => ['id', 'name', 'email'], + 'token' + ] + ]); }); test('a user can logout', function () { - $user = User::factory()->create([ - 'password' => bcrypt('password123'), - ]); - - $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/login', [ - 'email' => $user->email, - 'password' => 'password123', - ]); - - $this->assertAuthenticatedAs($user); + $user = User::factory()->create(); + $token = $user->createToken('test_token')->plainTextToken; - $response = $this->withHeader('Referer', 'localhost')->postJson('/api/v1/auth/logout'); + $response = $this->withHeader('Authorization', 'Bearer ' . $token) + ->postJson('/api/v1/auth/logout'); $response->assertStatus(200); + $this->assertDatabaseCount('personal_access_tokens', 0); }); test('a user can fetch their profile', function () { $user = User::factory()->create(['name' => 'Test User']); - $this->actingAs($user); - $response = $this->withHeader('Referer', 'localhost')->getJson('/api/v1/auth/me'); + $response = $this->actingAs($user, 'sanctum') + ->getJson('/api/v1/auth/me'); $response->assertStatus(200) ->assertJsonPath('data.name', 'Test User'); From 32f3e841895bed69601cf9b1ba1b4ed3f8afc42f Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 02:27:39 +0100 Subject: [PATCH 27/81] feat(venues): add Embedding Service for Openai integration --- .../Venues/Services/EmbeddingService.php | 61 +++++++++++++++++++ ...3_005720_add_embedding_to_venues_table.php | 28 +++++++++ 2 files changed, 89 insertions(+) create mode 100644 app/Domains/Venues/Services/EmbeddingService.php create mode 100644 database/migrations/2026_02_23_005720_add_embedding_to_venues_table.php diff --git a/app/Domains/Venues/Services/EmbeddingService.php b/app/Domains/Venues/Services/EmbeddingService.php new file mode 100644 index 0000000..f9428ff --- /dev/null +++ b/app/Domains/Venues/Services/EmbeddingService.php @@ -0,0 +1,61 @@ +apiKey = config('services.openai.key'); + $this->apiUrl = config('services.openai.embeddings_url'); + } + + /** + * Generate an embedding for a given text using OpenAI API. + * Returns an array of floats or null on failure. + */ + public function generateEmbedding(string $text): ?array + { + if (empty($this->apiKey)) { + Log::error('[EmbeddingService] - generateEmbedding - OpenAI API key is missing.'); + return null; + } + + try { + $response = Http::withToken($this->apiKey) + ->post($this->apiUrl, [ + 'model' => 'text-embedding-3-small', + 'input' => $text, + ]); + + if ($response->failed()) { + Log::error('[EmbeddingService] - generateEmbedding - api call error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + $data = $response->json('data'); + + if (empty($data) || !isset($data[0]['embedding'])) { + return null; + } + + return $data[0]['embedding']; + } catch (Exception $e) { + Log::error('[EmbeddingService] - generateEmbedding - Exception during OpenAI API call', [ + 'message' => $e->getMessage(), + ]); + return null; + } + } +} diff --git a/database/migrations/2026_02_23_005720_add_embedding_to_venues_table.php b/database/migrations/2026_02_23_005720_add_embedding_to_venues_table.php new file mode 100644 index 0000000..cf1e66d --- /dev/null +++ b/database/migrations/2026_02_23_005720_add_embedding_to_venues_table.php @@ -0,0 +1,28 @@ +json('embedding')->nullable()->after('lng'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('venues', function (Blueprint $table) { + $table->dropColumn('embedding'); + }); + } +}; From 51ce1df67b0e099ade1f003d25959328d3b7d560 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 02:59:19 +0100 Subject: [PATCH 28/81] feat(venues): add ProcessVenueSubmissionJob and logic --- .env.example | 4 + .../Exceptions/VenueProcessingException.php | 10 ++ .../Venues/Jobs/ProcessVenueSubmissionJob.php | 38 +++--- app/Domains/Venues/Models/Venue.php | 7 + .../Venues/Repositories/VenueRepository.php | 10 ++ .../Repositories/VenueRepositoryInterface.php | 4 + .../Venues/Services/GooglePlacesService.php | 11 +- .../ProcessVenueSubmissionService.php | 127 ++++++++++++++++++ config/services.php | 6 + 9 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 app/Domains/Venues/Exceptions/VenueProcessingException.php create mode 100644 app/Domains/Venues/Services/ProcessVenueSubmissionService.php diff --git a/.env.example b/.env.example index 238ca69..165489e 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" GOOGLE_PLACES_API_KEY= +GOOGLE_PLACES_API_URL=https://places.googleapis.com/v1/places:searchText + +OPEN_AI_API_KEY= +OPENAI_EMBEDDINGS_URL=https://api.openai.com/v1/embeddings \ No newline at end of file diff --git a/app/Domains/Venues/Exceptions/VenueProcessingException.php b/app/Domains/Venues/Exceptions/VenueProcessingException.php new file mode 100644 index 0000000..f3a1ade --- /dev/null +++ b/app/Domains/Venues/Exceptions/VenueProcessingException.php @@ -0,0 +1,10 @@ +findPlace( - $this->venueSubmission->name, - $this->venueSubmission->city - ); - - if ($placeData) { - $this->venueSubmission->update([ - 'places_result' => $placeData, - 'status' => VenueSubmissionStatusEnum::FLAGGED->value, - ]); - - Log::info('Venue submission matched with Google Places', [ - 'submission_id' => $this->venueSubmission->id, - 'place_id' => $placeData['id'] ?? 'unknown', - ]); - } else { - Log::info('No Google Places match found for venue submission', [ + try { + $processService->process($this->venueSubmission); + } catch (Exception $e) { + Log::error('[ProcessVenueSubmissionJob] - handle - Unexpected error processing venue submission', [ 'submission_id' => $this->venueSubmission->id, + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); + + throw new VenueProcessingException('Failed to process venue submission: ' . $e->getMessage(), 0, $e); } } } diff --git a/app/Domains/Venues/Models/Venue.php b/app/Domains/Venues/Models/Venue.php index 47b92ce..2c24ee1 100644 --- a/app/Domains/Venues/Models/Venue.php +++ b/app/Domains/Venues/Models/Venue.php @@ -23,5 +23,12 @@ protected static function newFactory() 'district', 'lat', 'lng', + 'embedding', + ]; + + protected $casts = [ + 'lat' => 'decimal:7', + 'lng' => 'decimal:7', + 'embedding' => 'array', ]; } diff --git a/app/Domains/Venues/Repositories/VenueRepository.php b/app/Domains/Venues/Repositories/VenueRepository.php index c4a5cb1..cf0bfc1 100644 --- a/app/Domains/Venues/Repositories/VenueRepository.php +++ b/app/Domains/Venues/Repositories/VenueRepository.php @@ -20,4 +20,14 @@ public function searchByName(string $query, int $limit = 10): Collection ->limit($limit) ->get(); } + + public function create(array $data): Venue + { + return Venue::create($data); + } + + public function getWithEmbeddings(): Collection + { + return Venue::whereNotNull('embedding')->get(); + } } diff --git a/app/Domains/Venues/Repositories/VenueRepositoryInterface.php b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php index 9631c25..4acfe60 100644 --- a/app/Domains/Venues/Repositories/VenueRepositoryInterface.php +++ b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php @@ -10,4 +10,8 @@ interface VenueRepositoryInterface public function getPaginated(int $perPage): LengthAwarePaginator; public function searchByName(string $query, int $limit = 10): Collection; + + public function create(array $data): \App\Domains\Venues\Models\Venue; + + public function getWithEmbeddings(): Collection; } diff --git a/app/Domains/Venues/Services/GooglePlacesService.php b/app/Domains/Venues/Services/GooglePlacesService.php index 2acb644..8d152cf 100644 --- a/app/Domains/Venues/Services/GooglePlacesService.php +++ b/app/Domains/Venues/Services/GooglePlacesService.php @@ -10,9 +10,12 @@ class GooglePlacesService { protected ?string $apiKey; + protected string $apiUrl; + public function __construct() { $this->apiKey = config('services.google_places.key'); + $this->apiUrl = config('services.google_places.url'); } /** @@ -22,7 +25,7 @@ public function __construct() public function findPlace(string $name, string $city): ?array { if (empty($this->apiKey)) { - Log::error('Google Places API key is missing.'); + Log::error('[GooglePlacesService] - findPlace - Google Places API key is missing.'); return null; } @@ -32,13 +35,13 @@ public function findPlace(string $name, string $city): ?array $response = Http::withHeaders([ 'X-Goog-Api-Key' => $this->apiKey, 'X-Goog-FieldMask' => 'places.id,places.displayName,places.formattedAddress,places.location', - ])->post('https://places.googleapis.com/v1/places:searchText', [ + ])->post($this->apiUrl, [ 'textQuery' => $query, 'maxResultCount' => 1, ]); if ($response->failed()) { - Log::error('Google Places API call failed', [ + Log::error('[GooglePlacesService] - findPlace - api call error', [ 'status' => $response->status(), 'body' => $response->body(), ]); @@ -53,7 +56,7 @@ public function findPlace(string $name, string $city): ?array return $places[0]; } catch (Exception $e) { - Log::error('Exception during Google Places API call', [ + Log::error('[GooglePlacesService] - findPlace - Exception during Google Places API call', [ 'message' => $e->getMessage(), ]); return null; diff --git a/app/Domains/Venues/Services/ProcessVenueSubmissionService.php b/app/Domains/Venues/Services/ProcessVenueSubmissionService.php new file mode 100644 index 0000000..4acaf7e --- /dev/null +++ b/app/Domains/Venues/Services/ProcessVenueSubmissionService.php @@ -0,0 +1,127 @@ +performGooglePlacesLookup($venueSubmission); + + $embedding = $this->generateEmbeddingForSubmission($venueSubmission); + if (!$embedding) { + return; + } + + if ($this->handleSimilarityChecks($venueSubmission, $embedding)) { + return; + } + + $this->approveAndCreateVenue($venueSubmission, $embedding, $placeData); + } + + private function performGooglePlacesLookup(VenueSubmission $submission): ?array + { + $placeData = $this->googlePlacesService->findPlace( + $submission->name, + $submission->city + ); + + if ($placeData) { + $this->venueSubmissionRepository->update($submission, [ + 'places_result' => $placeData, + ]); + Log::info('[ProcessVenueSubmissionService] - performGooglePlacesLookup - Venue submission matched with Google Places', [ + 'submission_id' => $submission->id, + 'place_id' => $placeData['id'] ?? 'unknown', + ]); + } + + return $placeData; + } + + private function generateEmbeddingForSubmission(VenueSubmission $submission): ?array + { + $text = "{$submission->name}, {$submission->city}, {$submission->address_line_1}"; + $embedding = $this->embeddingService->generateEmbedding($text); + + if (!$embedding) { + Log::error('[ProcessVenueSubmissionService] - generateEmbeddingForSubmission - Failed to generate embedding for venue submission', ['id' => $submission->id]); + $this->venueSubmissionRepository->update($submission, ['status' => VenueSubmissionStatusEnum::FLAGGED->value]); + } + + return $embedding; + } + + private function handleSimilarityChecks(VenueSubmission $submission, array $embedding): bool + { + $match = $this->similarityChecker->findMostSimilar($embedding); + + if (!$match) { + return false; + } + + $score = $match['score']; + $similarVenue = $match['venue']; + + if ($score > 0.95) { + $this->venueSubmissionRepository->update($submission, [ + 'status' => VenueSubmissionStatusEnum::REJECTED->value, + 'admin_notes' => 'Auto-rejected due to high similarity (' . round($score, 2) . ') with venue ID: ' . $similarVenue->id, + ]); + Log::info('[ProcessVenueSubmissionService] - handleSimilarityChecks - Venue submission auto-rejected', ['id' => $submission->id, 'score' => $score]); + + return true; + } + + if ($score >= 0.85 && $score <= 0.95) { + $this->venueSubmissionRepository->update($submission, [ + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + 'admin_notes' => 'Flagged due to moderate similarity (' . round($score, 2) . ') with venue ID: ' . $similarVenue->id, + ]); + Log::info('[ProcessVenueSubmissionService] - handleSimilarityChecks - Venue submission flagged', ['id' => $submission->id, 'score' => $score]); + + return true; + } + + return false; + } + + private function approveAndCreateVenue(VenueSubmission $submission, array $embedding, ?array $placeData): void + { + $venue = $this->venueRepository->create([ + 'name' => $submission->name, + 'city' => $submission->city, + 'street_address' => $submission->address_line_1, + 'postal_code' => $submission->postal_code, + 'lat' => $placeData['location']['latitude'] ?? null, + 'lng' => $placeData['location']['longitude'] ?? null, + 'embedding' => $embedding, + ]); + + $this->venueSubmissionRepository->update($submission, [ + 'status' => VenueSubmissionStatusEnum::APPROVED->value, + 'admin_notes' => 'Auto-approved, similarity check clear.', + ]); + + Log::info('[ProcessVenueSubmissionService] - approveAndCreateVenue - Venue submission auto-approved', ['id' => $submission->id, 'new_venue_id' => $venue->id]); + + $this->promoteAction->execute($submission->organizer_profile_id, $venue); + } +} diff --git a/config/services.php b/config/services.php index 2c5c896..c6d6cf2 100644 --- a/config/services.php +++ b/config/services.php @@ -37,6 +37,12 @@ 'google_places' => [ 'key' => env('GOOGLE_PLACES_API_KEY'), + 'url' => env('GOOGLE_PLACES_API_URL', 'https://places.googleapis.com/v1/places:searchText'), + ], + + 'openai' => [ + 'key' => env('OPENAI_API_KEY', env('OPEN_AI_API_KEY')), + 'embeddings_url' => env('OPENAI_EMBEDDINGS_URL', 'https://api.openai.com/v1/embeddings'), ], ]; From 2aad9150d12a4bbe14d68ad2d36e1ed647f5e71a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 02:59:44 +0100 Subject: [PATCH 29/81] feat(venues): add Vanue similarity checker --- .../Services/VenueSimilarityChecker.php | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/Domains/Venues/Services/VenueSimilarityChecker.php diff --git a/app/Domains/Venues/Services/VenueSimilarityChecker.php b/app/Domains/Venues/Services/VenueSimilarityChecker.php new file mode 100644 index 0000000..625e6e4 --- /dev/null +++ b/app/Domains/Venues/Services/VenueSimilarityChecker.php @@ -0,0 +1,71 @@ +get(); + + if ($venues->isEmpty()) { + return null; + } + + $bestMatch = null; + $highestScore = -1.0; + + foreach ($venues as $venue) { + $score = $this->cosineSimilarity($targetEmbedding, $venue->embedding); + + if ($score > $highestScore) { + $highestScore = $score; + $bestMatch = $venue; + } + } + + if ($bestMatch === null) { + return null; + } + + return [ + 'venue' => $bestMatch, + 'score' => $highestScore, + ]; + } + + /** + * Calculates the cosine similarity between two vectors. + */ + protected function cosineSimilarity(array $vecA, array $vecB): float + { + $dotProduct = 0.0; + $normA = 0.0; + $normB = 0.0; + + $count = min(count($vecA), count($vecB)); + + if ($count === 0) { + return 0.0; + } + + for ($i = 0; $i < $count; $i++) { + $dotProduct += $vecA[$i] * $vecB[$i]; + $normA += pow($vecA[$i], 2); + $normB += pow($vecB[$i], 2); + } + + if ($normA == 0 || $normB == 0) { + return 0.0; + } + + return $dotProduct / (sqrt($normA) * sqrt($normB)); + } +} From b9caa5e4cc47fb948eafa1ab84b05413d59b58f6 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 03:00:02 +0100 Subject: [PATCH 30/81] test(venues): venue similarity checker tests --- .../Actions/PromoteDraftEventAction.php | 41 ++++++ .../Venues/ProcessVenueSubmissionJobTest.php | 117 +++++++++++++----- 2 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 app/Domains/Venues/Actions/PromoteDraftEventAction.php diff --git a/app/Domains/Venues/Actions/PromoteDraftEventAction.php b/app/Domains/Venues/Actions/PromoteDraftEventAction.php new file mode 100644 index 0000000..29eee20 --- /dev/null +++ b/app/Domains/Venues/Actions/PromoteDraftEventAction.php @@ -0,0 +1,41 @@ +whereNull('venue_id') + ->where('status', EventStatusEnum::DRAFT->value) + ->latest() + ->first(); + + if (!$draftEvent) { + Log::info('[PromoteDraftEventAction] - execute - No draft event found to promote for organizer', [ + 'organizer_profile_id' => $organizerProfileId, + 'venue_id' => $venue->id, + ]); + return; + } + + $draftEvent->update([ + 'venue_id' => $venue->id, + 'status' => EventStatusEnum::PUBLISHED->value, + ]); + + Log::info('[PromoteDraftEventAction] - execute - Draft event promoted and venue assigned', [ + 'event_id' => $draftEvent->id, + 'venue_id' => $venue->id, + 'new_status' => EventStatusEnum::PUBLISHED->value, + ]); + + // TODO: Trigger EventPublishedNotification + } +} diff --git a/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php index 3ae89ec..2cf8a82 100644 --- a/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php +++ b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php @@ -2,73 +2,130 @@ namespace Tests\Feature\Venues; +use App\Domains\Venues\Actions\PromoteDraftEventAction; use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; use App\Domains\Venues\Jobs\ProcessVenueSubmissionJob; +use App\Domains\Venues\Models\Venue; use App\Domains\Venues\Models\VenueSubmission; +use App\Domains\Venues\Services\EmbeddingService; +use App\Domains\Venues\Services\GooglePlacesService; +use App\Domains\Venues\Services\VenueSimilarityChecker; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; +use Mockery\MockInterface; use Tests\TestCase; class ProcessVenueSubmissionJobTest extends TestCase { use RefreshDatabase; + private MockInterface $googlePlacesService; + private MockInterface $embeddingService; + private MockInterface $similarityChecker; + private MockInterface $promoteAction; + protected function setUp(): void { parent::setUp(); - config(['services.google_places.key' => 'test-api-key']); + + $this->googlePlacesService = $this->mock(GooglePlacesService::class); + $this->embeddingService = $this->mock(EmbeddingService::class); + $this->similarityChecker = $this->mock(VenueSimilarityChecker::class); + $this->promoteAction = $this->mock(PromoteDraftEventAction::class); } - public function test_it_updates_submission_with_google_places_data(): void + public function test_it_flags_submission_if_embedding_fails(): void { - Http::fake([ - 'places.googleapis.com/*' => Http::response([ - 'places' => [ - [ - 'id' => 'ChIJN1t_tDeuEmsRUsoyG83frY4', - 'displayName' => ['text' => 'Stary Klasztor'], - 'formattedAddress' => 'Purkyniego 1, 50-155 Wrocław, Poland', - ] - ] - ], 200), + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::PENDING, ]); + $this->googlePlacesService->shouldReceive('findPlace')->once()->andReturn(null); + $this->embeddingService->shouldReceive('generateEmbedding')->once()->andReturn(null); + + $job = new ProcessVenueSubmissionJob($submission); + app()->call([$job, 'handle']); + + $submission->refresh(); + $this->assertEquals(VenueSubmissionStatusEnum::FLAGGED, $submission->status); + } + + public function test_it_rejects_submission_if_high_similarity(): void + { $submission = VenueSubmission::factory()->create([ - 'name' => 'Stary Klasztor', - 'city' => 'Wrocław', 'status' => VenueSubmissionStatusEnum::PENDING, ]); + $existingVenue = Venue::factory()->create(); + + $this->googlePlacesService->shouldReceive('findPlace')->once()->andReturn(null); + $this->embeddingService->shouldReceive('generateEmbedding')->once()->andReturn([0.1, 0.2]); + $this->similarityChecker->shouldReceive('findMostSimilar')->once()->andReturn([ + 'venue' => $existingVenue, + 'score' => 0.98, + ]); + $job = new ProcessVenueSubmissionJob($submission); - $job->handle(new \App\Domains\Venues\Services\GooglePlacesService()); + app()->call([$job, 'handle']); $submission->refresh(); - - $this->assertEquals(VenueSubmissionStatusEnum::FLAGGED, $submission->status); - $this->assertNotNull($submission->places_result); - $this->assertEquals('ChIJN1t_tDeuEmsRUsoyG83frY4', $submission->places_result['id']); + $this->assertEquals(VenueSubmissionStatusEnum::REJECTED, $submission->status); + $this->assertStringContainsString('Auto-rejected due to high similarity', $submission->admin_notes); } - public function test_it_keeps_submission_pending_if_no_match_found(): void + public function test_it_flags_submission_if_moderate_similarity(): void { - Http::fake([ - 'places.googleapis.com/*' => Http::response([ - 'places' => [] - ], 200), + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::PENDING, ]); + $existingVenue = Venue::factory()->create(); + + $this->googlePlacesService->shouldReceive('findPlace')->once()->andReturn(null); + $this->embeddingService->shouldReceive('generateEmbedding')->once()->andReturn([0.1, 0.2]); + $this->similarityChecker->shouldReceive('findMostSimilar')->once()->andReturn([ + 'venue' => $existingVenue, + 'score' => 0.90, + ]); + + $job = new ProcessVenueSubmissionJob($submission); + app()->call([$job, 'handle']); + + $submission->refresh(); + $this->assertEquals(VenueSubmissionStatusEnum::FLAGGED, $submission->status); + $this->assertStringContainsString('Flagged due to moderate similarity', $submission->admin_notes); + } + + public function test_it_auto_approves_if_low_similarity_or_no_match(): void + { $submission = VenueSubmission::factory()->create([ - 'name' => 'Non Existent Place', - 'city' => 'Wrocław', 'status' => VenueSubmissionStatusEnum::PENDING, ]); + $this->googlePlacesService->shouldReceive('findPlace')->once()->andReturn([ + 'id' => 'google_test_id', + 'location' => ['latitude' => 51.0, 'longitude' => 17.0], + ]); + $this->embeddingService->shouldReceive('generateEmbedding')->once()->andReturn([0.1, 0.2]); + + $existingVenue = Venue::factory()->create(); + $this->similarityChecker->shouldReceive('findMostSimilar')->once()->andReturn([ + 'venue' => $existingVenue, + 'score' => 0.50, + ]); + + $this->promoteAction->shouldReceive('execute')->once(); + $job = new ProcessVenueSubmissionJob($submission); - $job->handle(new \App\Domains\Venues\Services\GooglePlacesService()); + app()->call([$job, 'handle']); $submission->refresh(); - $this->assertEquals(VenueSubmissionStatusEnum::PENDING, $submission->status); - $this->assertNull($submission->places_result); + $this->assertEquals(VenueSubmissionStatusEnum::APPROVED, $submission->status); + $this->assertEquals('google_test_id', $submission->places_result['id']); + + $this->assertDatabaseHas('venues', [ + 'name' => $submission->name, + 'city' => $submission->city, + ]); } } From d8ff5806c4072568e4bc9d58f65d96a136d6b48a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 13:22:25 +0100 Subject: [PATCH 31/81] feat(admin): admin venu esubmission approve/reject/merge/ endpoints --- .../Admin/AdminVenueSubmissionController.php | 97 +++++++++++++++++++ app/Domains/Venues/Enums/VenueHttpEnum.php | 1 + .../Venues/Enums/VenueSubmissionHttpEnum.php | 3 + .../Requests/MergeVenueSubmissionRequest.php | 20 ++++ .../Requests/RejectVenueSubmissionRequest.php | 20 ++++ .../Admin/VenueSubmissionController@index.bru | 11 --- routes/api.php | 7 +- 7 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 app/Domains/Venues/Controllers/Admin/AdminVenueSubmissionController.php create mode 100644 app/Domains/Venues/Requests/MergeVenueSubmissionRequest.php create mode 100644 app/Domains/Venues/Requests/RejectVenueSubmissionRequest.php delete mode 100644 bruno/Admin/VenueSubmissionController@index.bru diff --git a/app/Domains/Venues/Controllers/Admin/AdminVenueSubmissionController.php b/app/Domains/Venues/Controllers/Admin/AdminVenueSubmissionController.php new file mode 100644 index 0000000..30b47f9 --- /dev/null +++ b/app/Domains/Venues/Controllers/Admin/AdminVenueSubmissionController.php @@ -0,0 +1,97 @@ +venueSubmissionService->getPaginatedSubmissions( + ['status' => VenueSubmissionStatusEnum::FLAGGED->value] + ); + } catch (DomainException $e) { + return $this->apiResponseError($e, $e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'venue_submissions' => VenueSubmissionResource::collection($submissions)->response()->getData(true), + ] + ); + } + + public function approve(VenueSubmission $venueSubmission): JsonResponse + { + try { + $this->adminVenueSubmissionService->approve($venueSubmission); + } catch (DomainException $e) { + return $this->apiResponseError($e, $e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::APPROVE_SUCCESS->value, + Response::HTTP_OK + ); + } + + public function reject(RejectVenueSubmissionRequest $request, VenueSubmission $venueSubmission): JsonResponse + { + try { + $reason = $request->validated('reason'); + $this->adminVenueSubmissionService->reject($venueSubmission, $reason); + } catch (DomainException $e) { + return $this->apiResponseError($e, $e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::REJECT_SUCCESS->value, + Response::HTTP_OK + ); + } + + public function merge(MergeVenueSubmissionRequest $request, VenueSubmission $venueSubmission): JsonResponse + { + try { + $venueId = $request->validated('venue_id'); + $this->adminVenueSubmissionService->merge($venueSubmission, $venueId); + } catch (DomainException $e) { + return $this->apiResponseError($e, $e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (Exception $e) { + return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::MERGE_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Venues/Enums/VenueHttpEnum.php b/app/Domains/Venues/Enums/VenueHttpEnum.php index 1b5f079..2f4ceed 100644 --- a/app/Domains/Venues/Enums/VenueHttpEnum.php +++ b/app/Domains/Venues/Enums/VenueHttpEnum.php @@ -8,4 +8,5 @@ enum VenueHttpEnum: string case LIST_ERROR = 'venues.list.error'; case SEARCH_SUCCESS = 'venues.search.success'; case SEARCH_ERROR = 'venues.search.error'; + case NOT_FOUND = 'venues.not_found'; } diff --git a/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php index cb1d0fe..9588fcf 100644 --- a/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php +++ b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php @@ -12,4 +12,7 @@ enum VenueSubmissionHttpEnum: string case SHOW_ERROR = 'venue_submissions.show.error'; case FORBIDDEN_ERROR = 'venue_submissions.forbidden'; case NOT_FOUND_ERROR = 'organizer_profile.not_found'; + case APPROVE_SUCCESS = 'venue_submissions.approve.success'; + case REJECT_SUCCESS = 'venue_submissions.reject.success'; + case MERGE_SUCCESS = 'venue_submissions.merge.success'; } diff --git a/app/Domains/Venues/Requests/MergeVenueSubmissionRequest.php b/app/Domains/Venues/Requests/MergeVenueSubmissionRequest.php new file mode 100644 index 0000000..3a79b59 --- /dev/null +++ b/app/Domains/Venues/Requests/MergeVenueSubmissionRequest.php @@ -0,0 +1,20 @@ + ['required', 'integer', 'exists:venues,id'], + ]; + } +} diff --git a/app/Domains/Venues/Requests/RejectVenueSubmissionRequest.php b/app/Domains/Venues/Requests/RejectVenueSubmissionRequest.php new file mode 100644 index 0000000..ef44e01 --- /dev/null +++ b/app/Domains/Venues/Requests/RejectVenueSubmissionRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'max:1000'], + ]; + } +} diff --git a/bruno/Admin/VenueSubmissionController@index.bru b/bruno/Admin/VenueSubmissionController@index.bru deleted file mode 100644 index f98d17b..0000000 --- a/bruno/Admin/VenueSubmissionController@index.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: VenueSubmissionController@index - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions?status=flagged - body: none - auth: inherit -} diff --git a/routes/api.php b/routes/api.php index 00f0794..29ac295 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Domains\Admin\Controllers\ApproveOrganizerController; use App\Domains\Venues\Controllers\ListVenuesController; use App\Domains\Venues\Controllers\SearchVenuesController; +use App\Domains\Venues\Controllers\Admin\AdminVenueSubmissionController; use App\Domains\Venues\Controllers\VenueSubmissionController; use App\Domains\Events\Controllers\Organizer\OrganizerEventController; use App\Domains\Events\Controllers\Organizer\PublishEventController; @@ -72,6 +73,10 @@ // Admin Routes Route::prefix('admin')->middleware(['role:admin'])->group(function () { Route::post('/organizers/{user}/approve', ApproveOrganizerController::class); - Route::get('/venue-submissions', [VenueSubmissionController::class, 'index']); + + Route::get('/venue-submissions/flagged', [AdminVenueSubmissionController::class, 'index']); + Route::post('/venue-submissions/{venueSubmission}/approve', [AdminVenueSubmissionController::class, 'approve']); + Route::post('/venue-submissions/{venueSubmission}/reject', [AdminVenueSubmissionController::class, 'reject']); + Route::post('/venue-submissions/{venueSubmission}/merge', [AdminVenueSubmissionController::class, 'merge']); }); }); From 1ad71c4a5d5017a5bba0e31aa1aa8d19b610914a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 13:23:22 +0100 Subject: [PATCH 32/81] feat(admin): admin submissions logic and notifications --- .../VenueRejectedNotification.php | 40 ++++++++++ .../Venues/Repositories/VenueRepository.php | 5 ++ .../Repositories/VenueRepositoryInterface.php | 5 +- .../Services/AdminVenueSubmissionService.php | 76 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Venues/Notifications/VenueRejectedNotification.php create mode 100644 app/Domains/Venues/Services/AdminVenueSubmissionService.php diff --git a/app/Domains/Venues/Notifications/VenueRejectedNotification.php b/app/Domains/Venues/Notifications/VenueRejectedNotification.php new file mode 100644 index 0000000..8822a1e --- /dev/null +++ b/app/Domains/Venues/Notifications/VenueRejectedNotification.php @@ -0,0 +1,40 @@ +subject('Venue Submission Rejected') + ->line("Your submission for the venue '{$this->venueName}' could not be approved.") + ->line("Reason: {$this->reason}") + ->line('Please review our venue submission guidelines and try again.'); + } + + public function toArray(object $notifiable): array + { + return [ + 'venue_name' => $this->venueName, + 'reason' => $this->reason, + ]; + } +} diff --git a/app/Domains/Venues/Repositories/VenueRepository.php b/app/Domains/Venues/Repositories/VenueRepository.php index cf0bfc1..9577385 100644 --- a/app/Domains/Venues/Repositories/VenueRepository.php +++ b/app/Domains/Venues/Repositories/VenueRepository.php @@ -30,4 +30,9 @@ public function getWithEmbeddings(): Collection { return Venue::whereNotNull('embedding')->get(); } + + public function findById(int $id): ?Venue + { + return Venue::find($id); + } } diff --git a/app/Domains/Venues/Repositories/VenueRepositoryInterface.php b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php index 4acfe60..1fe2da2 100644 --- a/app/Domains/Venues/Repositories/VenueRepositoryInterface.php +++ b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php @@ -2,6 +2,7 @@ namespace App\Domains\Venues\Repositories; +use App\Domains\Venues\Models\Venue; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; @@ -11,7 +12,9 @@ public function getPaginated(int $perPage): LengthAwarePaginator; public function searchByName(string $query, int $limit = 10): Collection; - public function create(array $data): \App\Domains\Venues\Models\Venue; + public function create(array $data): Venue; + + public function findById(int $id): ?Venue; public function getWithEmbeddings(): Collection; } diff --git a/app/Domains/Venues/Services/AdminVenueSubmissionService.php b/app/Domains/Venues/Services/AdminVenueSubmissionService.php new file mode 100644 index 0000000..e83aa4a --- /dev/null +++ b/app/Domains/Venues/Services/AdminVenueSubmissionService.php @@ -0,0 +1,76 @@ +places_result) ? $venueSubmission->places_result : []; + + $text = "{$venueSubmission->name}, {$venueSubmission->city}, {$venueSubmission->address_line_1}"; + $embedding = $this->embeddingService->generateEmbedding($text); + + $venue = $this->venueRepository->create([ + 'name' => $venueSubmission->name, + 'city' => $venueSubmission->city, + 'street_address' => $venueSubmission->address_line_1, + 'postal_code' => $venueSubmission->postal_code, + 'lat' => $placeData['location']['latitude'] ?? null, + 'lng' => $placeData['location']['longitude'] ?? null, + 'embedding' => $embedding, + ]); + + $this->venueSubmissionRepository->update($venueSubmission, [ + 'status' => VenueSubmissionStatusEnum::APPROVED->value, + 'admin_notes' => 'Manually approved by admin.', + ]); + + $this->promoteAction->execute($venueSubmission->organizer_profile_id, $venue); + } + + public function reject(VenueSubmission $venueSubmission, string $reason): void + { + $this->venueSubmissionRepository->update($venueSubmission, [ + 'status' => VenueSubmissionStatusEnum::REJECTED->value, + 'admin_notes' => "Manually rejected: {$reason}", + ]); + + $user = $venueSubmission->organizerProfile->user; + if ($user) { + $user->notify(new VenueRejectedNotification($venueSubmission->name, $reason)); + } + } + + public function merge(VenueSubmission $venueSubmission, int $venueId): void + { + $venue = $this->venueRepository->findById($venueId); + + if (!$venue) { + throw new DomainException(VenueHttpEnum::NOT_FOUND->value); + } + + $this->venueSubmissionRepository->update($venueSubmission, [ + 'status' => VenueSubmissionStatusEnum::MERGED->value, + 'admin_notes' => "Manually merged with existing venue ID: {$venueId}", + ]); + + $this->promoteAction->execute($venueSubmission->organizer_profile_id, $venue); + } +} From 50145a446efc872d885d61e6264ebf90f0b9fdbf Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 13:24:00 +0100 Subject: [PATCH 33/81] test(admin): venue submission admin review feature tests --- ...AdminVenueSubmissionController@approve.bru | 15 ++ .../AdminVenueSubmissionController@index.bru | 11 ++ .../AdminVenueSubmissionController@merge.bru | 21 +++ .../AdminVenueSubmissionController@reject.bru | 21 +++ tests/Feature/Admin/VenueReviewTest.php | 171 ++++++++++++++++++ .../Feature/Venues/VenueSubmissionApiTest.php | 4 +- 6 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru create mode 100644 bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru create mode 100644 bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru create mode 100644 bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru create mode 100644 tests/Feature/Admin/VenueReviewTest.php diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru new file mode 100644 index 0000000..8527ab7 --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru @@ -0,0 +1,15 @@ +meta { + name: AdminVenueSubmissionController@approve + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions/:id/approve + body: none + auth: inherit +} + +params:path { + id: +} diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru new file mode 100644 index 0000000..e0fba4d --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru @@ -0,0 +1,11 @@ +meta { + name: AdminVenueSubmissionController@index + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions/flagged + body: none + auth: inherit +} diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru new file mode 100644 index 0000000..ebd3306 --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru @@ -0,0 +1,21 @@ +meta { + name: AdminVenueSubmissionController@merge + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions/:id/merge + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + { + "venue_id": 1 + } +} diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru new file mode 100644 index 0000000..bda7906 --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru @@ -0,0 +1,21 @@ +meta { + name: AdminVenueSubmissionController@reject + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/admin/venue-submissions/:id/reject + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + { + "reason": "This venue does not meet our minimum safety requirements." + } +} diff --git a/tests/Feature/Admin/VenueReviewTest.php b/tests/Feature/Admin/VenueReviewTest.php new file mode 100644 index 0000000..86fe660 --- /dev/null +++ b/tests/Feature/Admin/VenueReviewTest.php @@ -0,0 +1,171 @@ + 'admin', 'guard_name' => 'api']); + Role::create(['name' => 'organizer', 'guard_name' => 'api']); + + $this->admin = User::factory()->create(); + $this->admin->assignRole('admin'); + + $this->organizer = User::factory()->create(); + $this->organizer->assignRole('organizer'); + + $this->organizerProfile = OrganizerProfile::factory()->create([ + 'user_id' => $this->organizer->id + ]); +}); + +test('admin can view flagged submissions', function () { + VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::PENDING->value]); + VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED->value]); + VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED->value]); + + $response = $this->actingAs($this->admin)->getJson('/api/v1/admin/venue-submissions/flagged'); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', VenueSubmissionHttpEnum::INDEX_SUCCESS->value) + ->assertJsonCount(2, 'data.venue_submissions.data'); +}); + +test('admin can approve a flagged submission', function () { + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + 'organizer_profile_id' => $this->organizerProfile->id, + 'name' => 'Test Venue Name', + 'city' => 'Wroclaw', + 'address_line_1' => 'Street 1', + 'postal_code' => '00-000' + ]); + + $draftEvent = Event::factory()->create([ + 'organizer_profile_id' => $this->organizerProfile->id, + 'status' => EventStatusEnum::DRAFT->value, + 'venue_id' => null, + ]); + + $mockEmbeddingService = Mockery::mock(EmbeddingService::class); + $mockEmbeddingService->shouldReceive('generateEmbedding')->andReturn(array_fill(0, 1536, 0.01)); + $this->app->instance(EmbeddingService::class, $mockEmbeddingService); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/approve"); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', VenueSubmissionHttpEnum::APPROVE_SUCCESS->value); + + $this->assertDatabaseHas('venue_submissions', [ + 'id' => $submission->id, + 'status' => VenueSubmissionStatusEnum::APPROVED->value, + ]); + + $this->assertDatabaseHas('venues', [ + 'name' => 'Test Venue Name', + 'city' => 'Wroclaw', + ]); + + $venue = Venue::where('name', 'Test Venue Name')->first(); + + $this->assertDatabaseHas('events', [ + 'id' => $draftEvent->id, + 'venue_id' => $venue->id, + 'status' => EventStatusEnum::PUBLISHED->value, + ]); +}); + +test('admin can reject a flagged submission', function () { + Notification::fake(); + + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + 'organizer_profile_id' => $this->organizerProfile->id, + 'name' => 'Bad Venue', + ]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/reject", [ + 'reason' => 'Does not meet our criteria.' + ]); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', VenueSubmissionHttpEnum::REJECT_SUCCESS->value); + + $this->assertDatabaseHas('venue_submissions', [ + 'id' => $submission->id, + 'status' => VenueSubmissionStatusEnum::REJECTED->value, + 'admin_notes' => 'Manually rejected: Does not meet our criteria.', + ]); + + Notification::assertSentTo( + $this->organizer, + VenueRejectedNotification::class, + function ($notification) { + return $notification->venueName === 'Bad Venue' && $notification->reason === 'Does not meet our criteria.'; + } + ); +}); + +test('admin can merge a flagged submission', function () { + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + 'organizer_profile_id' => $this->organizerProfile->id, + ]); + + $existingVenue = Venue::factory()->create(); + + $draftEvent = Event::factory()->create([ + 'organizer_profile_id' => $this->organizerProfile->id, + 'status' => EventStatusEnum::DRAFT->value, + 'venue_id' => null, + ]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/merge", [ + 'venue_id' => $existingVenue->id + ]); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', VenueSubmissionHttpEnum::MERGE_SUCCESS->value); + + $this->assertDatabaseHas('venue_submissions', [ + 'id' => $submission->id, + 'status' => VenueSubmissionStatusEnum::MERGED->value, + ]); + + $this->assertDatabaseHas('events', [ + 'id' => $draftEvent->id, + 'venue_id' => $existingVenue->id, + 'status' => EventStatusEnum::PUBLISHED->value, + ]); +}); + +test('non-admins cannot access admin endpoints', function () { + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::FLAGGED->value, + ]); + + $this->actingAs($this->organizer)->getJson('/api/v1/admin/venue-submissions/flagged') + ->assertStatus(403); + + $this->actingAs($this->organizer)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/approve") + ->assertStatus(403); + + $this->postJson("/api/v1/admin/venue-submissions/{$submission->id}/reject", ['reason' => 'Testing']) + ->assertStatus(403); +}); diff --git a/tests/Feature/Venues/VenueSubmissionApiTest.php b/tests/Feature/Venues/VenueSubmissionApiTest.php index 4932d39..9240660 100644 --- a/tests/Feature/Venues/VenueSubmissionApiTest.php +++ b/tests/Feature/Venues/VenueSubmissionApiTest.php @@ -103,7 +103,7 @@ function createAdmin(): User $flaggedSubmission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED]); $response = $this->actingAs($admin) - ->getJson('/api/v1/admin/venue-submissions?status=flagged'); + ->getJson('/api/v1/admin/venue-submissions/flagged'); $response->assertStatus(200); @@ -113,5 +113,5 @@ function createAdmin(): User test('unauthenticated user cannot access endpoints', function () { $this->postJson('/api/v1/organizer/venue-submissions', [])->assertStatus(401); - $this->getJson('/api/v1/admin/venue-submissions')->assertStatus(401); + $this->getJson('/api/v1/admin/venue-submissions/flagged')->assertStatus(401); }); From 9732dcb03bf9e75714dc9c5c46c706439d2baa5a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 13:49:13 +0100 Subject: [PATCH 34/81] test(admin): venue submission additional tests --- .../Venues/Enums/VenueSubmissionHttpEnum.php | 1 + .../Services/AdminVenueSubmissionService.php | 13 +++++ tests/Feature/Admin/VenueReviewTest.php | 54 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php index 9588fcf..7b7086d 100644 --- a/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php +++ b/app/Domains/Venues/Enums/VenueSubmissionHttpEnum.php @@ -15,4 +15,5 @@ enum VenueSubmissionHttpEnum: string case APPROVE_SUCCESS = 'venue_submissions.approve.success'; case REJECT_SUCCESS = 'venue_submissions.reject.success'; case MERGE_SUCCESS = 'venue_submissions.merge.success'; + case INVALID_TRANSITION = 'venue_submissions.invalid_transition'; } diff --git a/app/Domains/Venues/Services/AdminVenueSubmissionService.php b/app/Domains/Venues/Services/AdminVenueSubmissionService.php index e83aa4a..6204b4c 100644 --- a/app/Domains/Venues/Services/AdminVenueSubmissionService.php +++ b/app/Domains/Venues/Services/AdminVenueSubmissionService.php @@ -7,6 +7,7 @@ use App\Domains\Venues\Enums\VenueHttpEnum; use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; use App\Domains\Venues\Models\VenueSubmission; +use App\Domains\Venues\Enums\VenueSubmissionHttpEnum; use App\Domains\Venues\Notifications\VenueRejectedNotification; use App\Domains\Venues\Repositories\VenueRepositoryInterface; use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; @@ -22,6 +23,10 @@ public function __construct( public function approve(VenueSubmission $venueSubmission): void { + if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value); + } + $placeData = is_array($venueSubmission->places_result) ? $venueSubmission->places_result : []; $text = "{$venueSubmission->name}, {$venueSubmission->city}, {$venueSubmission->address_line_1}"; @@ -47,6 +52,10 @@ public function approve(VenueSubmission $venueSubmission): void public function reject(VenueSubmission $venueSubmission, string $reason): void { + if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value); + } + $this->venueSubmissionRepository->update($venueSubmission, [ 'status' => VenueSubmissionStatusEnum::REJECTED->value, 'admin_notes' => "Manually rejected: {$reason}", @@ -60,6 +69,10 @@ public function reject(VenueSubmission $venueSubmission, string $reason): void public function merge(VenueSubmission $venueSubmission, int $venueId): void { + if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value); + } + $venue = $this->venueRepository->findById($venueId); if (!$venue) { diff --git a/tests/Feature/Admin/VenueReviewTest.php b/tests/Feature/Admin/VenueReviewTest.php index 86fe660..fc469c5 100644 --- a/tests/Feature/Admin/VenueReviewTest.php +++ b/tests/Feature/Admin/VenueReviewTest.php @@ -169,3 +169,57 @@ function ($notification) { $this->postJson("/api/v1/admin/venue-submissions/{$submission->id}/reject", ['reason' => 'Testing']) ->assertStatus(403); }); + +test('admin cannot approve a submission that is not flagged', function () { + $submission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::APPROVED->value]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/approve"); + + $response->assertStatus(422) + ->assertJsonPath('success', false) + ->assertJsonPath('message', VenueSubmissionHttpEnum::INVALID_TRANSITION->value); +}); + +test('admin cannot reject a submission that is not flagged', function () { + $submission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::REJECTED->value]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/reject", [ + 'reason' => 'Duplicate rejection attempt.' + ]); + + $response->assertStatus(422) + ->assertJsonPath('success', false) + ->assertJsonPath('message', VenueSubmissionHttpEnum::INVALID_TRANSITION->value); +}); + +test('admin cannot merge a submission that is not flagged', function () { + $submission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::PENDING->value]); + $existingVenue = Venue::factory()->create(); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/merge", [ + 'venue_id' => $existingVenue->id + ]); + + $response->assertStatus(422) + ->assertJsonPath('success', false) + ->assertJsonPath('message', VenueSubmissionHttpEnum::INVALID_TRANSITION->value); +}); + +test('reject fails if reason is missing', function () { + $submission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED->value]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/reject", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['reason']); +}); + +test('merge fails if venue_id does not exist', function () { + $submission = VenueSubmission::factory()->create(['status' => VenueSubmissionStatusEnum::FLAGGED->value]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/venue-submissions/{$submission->id}/merge", [ + 'venue_id' => 99999 + ]); + + $response->assertStatus(422); +}); From fa434672e78cd93dcfe70bfa9d2f6616d21d5fa0 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 13:57:41 +0100 Subject: [PATCH 35/81] test(venues): additional error paths testS --- .../Venues/Jobs/ProcessVenueSubmissionJob.php | 9 +++++ .../Venues/ProcessVenueSubmissionJobTest.php | 16 ++++++++ tests/Feature/Venues/VenueApiTest.php | 29 ++++++++++++++ .../Feature/Venues/VenueSubmissionApiTest.php | 39 +++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php index 3f4b76a..394bd56 100644 --- a/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php +++ b/app/Domains/Venues/Jobs/ProcessVenueSubmissionJob.php @@ -5,6 +5,7 @@ use App\Domains\Venues\Exceptions\VenueProcessingException; use App\Domains\Venues\Models\VenueSubmission; use App\Domains\Venues\Services\ProcessVenueSubmissionService; +use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -26,6 +27,14 @@ public function __construct( */ public function handle(ProcessVenueSubmissionService $processService): void { + if ($this->venueSubmission->status !== VenueSubmissionStatusEnum::PENDING) { + Log::info('[ProcessVenueSubmissionJob] - handle - Submission already processed, skipping', [ + 'submission_id' => $this->venueSubmission->id, + 'status' => $this->venueSubmission->status, + ]); + return; + } + try { $processService->process($this->venueSubmission); } catch (Exception $e) { diff --git a/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php index 2cf8a82..748117c 100644 --- a/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php +++ b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php @@ -128,4 +128,20 @@ public function test_it_auto_approves_if_low_similarity_or_no_match(): void 'city' => $submission->city, ]); } + + public function test_it_exits_early_if_already_processed(): void + { + $submission = VenueSubmission::factory()->create([ + 'status' => VenueSubmissionStatusEnum::APPROVED, + ]); + + $this->googlePlacesService->shouldNotReceive('findPlace'); + $this->embeddingService->shouldNotReceive('generateEmbedding'); + + $job = new ProcessVenueSubmissionJob($submission); + app()->call([$job, 'handle']); + + $submission->refresh(); + $this->assertEquals(VenueSubmissionStatusEnum::APPROVED, $submission->status); + } } diff --git a/tests/Feature/Venues/VenueApiTest.php b/tests/Feature/Venues/VenueApiTest.php index bf1e2ac..75bce43 100644 --- a/tests/Feature/Venues/VenueApiTest.php +++ b/tests/Feature/Venues/VenueApiTest.php @@ -77,3 +77,32 @@ ->assertJsonFragment(['name' => 'Stary Klasztor']) ->assertJsonMissing(['name' => 'Hala Stulecia']); }); + +test('list venues returns empty data when no venues exist', function () { + Venue::query()->delete(); + + $response = $this->getJson('/api/v1/venues'); + + $response->assertOk() + ->assertJsonPath('data.venues.data', []); +}); + +test('search venues returns empty when no match found', function () { + $response = $this->getJson('/api/v1/venues/search?q=nonexistentvenue'); + + $response->assertOk() + ->assertJsonPath('data.venues', []); +}); + +test('search venues handles special characters', function () { + Venue::forceCreate([ + 'name' => 'Test % Venue', + 'city' => 'Wroclaw', + 'street_address' => 'Street 1', + ]); + + $response = $this->getJson('/api/v1/venues/search?q=%25%25'); // URL encoded %% + + $response->assertOk() + ->assertJsonFragment(['name' => 'Test % Venue']); +}); diff --git a/tests/Feature/Venues/VenueSubmissionApiTest.php b/tests/Feature/Venues/VenueSubmissionApiTest.php index 9240660..39cf8e8 100644 --- a/tests/Feature/Venues/VenueSubmissionApiTest.php +++ b/tests/Feature/Venues/VenueSubmissionApiTest.php @@ -1,6 +1,7 @@ postJson('/api/v1/organizer/venue-submissions', [])->assertStatus(401); $this->getJson('/api/v1/admin/venue-submissions/flagged')->assertStatus(401); }); + +test('organizer submission fails with validation errors', function () { + $organizer = createOrganizer(); + + $response = $this->actingAs($organizer) + ->postJson('/api/v1/organizer/venue-submissions', [ + 'name' => '', + 'city' => '', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'city', 'address_line_1']); +}); + +test('user without organizer profile cannot submit venue', function () { + $user = User::factory()->create(); + Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + $user->assignRole('organizer'); + + $response = $this->actingAs($user) + ->postJson('/api/v1/organizer/venue-submissions', [ + 'name' => 'Test', + 'city' => 'Test', + 'address_line_1' => 'Test', + ]); + + $response->assertStatus(403) + ->assertJsonPath('message', VenueSubmissionHttpEnum::NOT_FOUND_ERROR->value); +}); + +test('organizer cannot view non-existent submission', function () { + $organizer = createOrganizer(); + + $response = $this->actingAs($organizer) + ->getJson("/api/v1/organizer/venue-submissions/99999"); + + $response->assertStatus(404); +}); From 3b2a544edbe0ebf0293b9641e814d701a2c1e2bd Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 14:06:11 +0100 Subject: [PATCH 36/81] test(organizer): organizer and events feature tests enhancements --- app/Domains/Events/Enums/EventHttpEnum.php | 4 + app/Domains/Events/Services/EventService.php | 13 +++- .../Feature/Events/OrganizerEventCrudTest.php | 73 +++++++++++++++++++ tests/Feature/Events/PublicEventApiTest.php | 19 +++++ .../Organizer/OrganizerOnboardingTest.php | 62 ++++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) diff --git a/app/Domains/Events/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php index 93dcc25..9b39ea9 100644 --- a/app/Domains/Events/Enums/EventHttpEnum.php +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -14,6 +14,10 @@ enum EventHttpEnum: string case PUBLISH_ERROR = 'events.publish.error'; case CANCEL_SUCCESS = 'events.cancel.success'; case CANCEL_ERROR = 'events.cancel.error'; + case CANCEL_ALREADY_CANCELLED = 'events.cancel.already_cancelled'; case SHOW_SUCCESS = 'events.show.success'; case INDEX_SUCCESS = 'events.index.success'; + case DESTROY_INVALID_STATUS = 'events.destroy.invalid_status'; + case PUBLISH_ALREADY_PUBLISHED = 'events.publish.already_published'; + case PUBLISH_CANNOT_PUBLISH_CANCELLED = 'events.publish.cannot_publish_cancelled'; } diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index a345c4f..4bd9234 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -2,7 +2,9 @@ namespace App\Domains\Events\Services; +use App\Domains\Events\Enums\EventHttpEnum; use App\Domains\Events\Enums\EventStatusEnum; + use App\Domains\Events\Models\Event; use App\Domains\Events\Repositories\EventRepositoryInterface; use App\Domains\Shared\Exceptions\DomainException; @@ -46,7 +48,7 @@ public function updateEvent(Event $event, array $data): Event public function deleteEvent(Event $event): void { if ($event->status !== EventStatusEnum::DRAFT && $event->status !== EventStatusEnum::CANCELLED) { - throw new DomainException('events.destroy.invalid_status'); + throw new DomainException(EventHttpEnum::DESTROY_INVALID_STATUS->value); } $this->eventRepository->delete($event); @@ -55,17 +57,22 @@ public function deleteEvent(Event $event): void public function publishEvent(Event $event): Event { if ($event->status === EventStatusEnum::PUBLISHED) { - throw new DomainException('events.publish.already_published'); + throw new DomainException(EventHttpEnum::PUBLISH_ALREADY_PUBLISHED->value); + } + + if ($event->status === EventStatusEnum::CANCELLED) { + throw new DomainException(EventHttpEnum::PUBLISH_CANNOT_PUBLISH_CANCELLED->value); } $this->eventRepository->update($event, ['status' => EventStatusEnum::PUBLISHED->value]); + return $event->refresh(); } public function cancelEvent(Event $event): Event { if ($event->status === EventStatusEnum::CANCELLED) { - throw new DomainException('events.cancel.already_cancelled'); + throw new DomainException(EventHttpEnum::CANCEL_ALREADY_CANCELLED->value); } $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); diff --git a/tests/Feature/Events/OrganizerEventCrudTest.php b/tests/Feature/Events/OrganizerEventCrudTest.php index bcdb575..29ce845 100644 --- a/tests/Feature/Events/OrganizerEventCrudTest.php +++ b/tests/Feature/Events/OrganizerEventCrudTest.php @@ -192,4 +192,77 @@ public function test_organizer_cannot_delete_published_event(): void $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); $this->assertDatabaseHas('events', ['id' => $event->id]); } + + public function test_create_event_validation_fails(): void + { + $user = $this->createApprovedOrganizer(); + + $response = $this->actingAs($user)->postJson('/api/v1/organizer/events', [ + 'title' => '', + 'starts_at' => now()->subDay()->toIso8601String(), + 'ends_at' => now()->subDays(2)->toIso8601String(), + 'capacity' => 0, + ]); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['title', 'starts_at', 'ends_at', 'capacity']); + } + + public function test_organizer_cannot_publish_cancelled_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => EventStatusEnum::CANCELLED, + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/publish"); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function test_organizer_cannot_cancel_already_cancelled_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => EventStatusEnum::CANCELLED, + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/cancel"); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function test_organizer_cannot_publish_already_published_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => EventStatusEnum::PUBLISHED, + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/publish"); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + + public function test_unauthenticated_user_cannot_create_event(): void + { + $response = $this->postJson('/api/v1/organizer/events', []); + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + public function test_attendee_cannot_create_event(): void + { + $user = User::factory()->create(); + $user->assignRole('attendee'); + + $response = $this->actingAs($user)->postJson('/api/v1/organizer/events', [ + 'title' => 'Attendee Event', + ]); + + $response->assertStatus(Response::HTTP_FORBIDDEN); + } } diff --git a/tests/Feature/Events/PublicEventApiTest.php b/tests/Feature/Events/PublicEventApiTest.php index 59c5d1b..9d7ec1b 100644 --- a/tests/Feature/Events/PublicEventApiTest.php +++ b/tests/Feature/Events/PublicEventApiTest.php @@ -56,4 +56,23 @@ public function test_cannot_show_draft_event() $response->assertStatus(Response::HTTP_NOT_FOUND); } + + public function test_cannot_show_cancelled_event() + { + $event = Event::factory()->create(['status' => EventStatusEnum::CANCELLED]); + + $response = $this->getJson("/api/v1/events/{$event->id}"); + + $response->assertStatus(Response::HTTP_NOT_FOUND); + } + + public function test_index_returns_empty_data_when_no_events_exist() + { + Event::query()->delete(); + + $response = $this->getJson('/api/v1/events'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.events.data', []); + } } diff --git a/tests/Feature/Organizer/OrganizerOnboardingTest.php b/tests/Feature/Organizer/OrganizerOnboardingTest.php index 3edc687..dcb65f0 100644 --- a/tests/Feature/Organizer/OrganizerOnboardingTest.php +++ b/tests/Feature/Organizer/OrganizerOnboardingTest.php @@ -126,3 +126,65 @@ OrganizerApprovedNotification::class ); }); + +test('onboarding validation fails', function () { + $response = $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => '', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['organization_name']); +}); + +test('updating profile fails if not exists', function () { + $response = $this->actingAs($this->user)->putJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Should fail', + ]); + + $response->assertStatus(404) + ->assertJsonPath('success', false) + ->assertJsonPath('message', OrganizerHttpEnum::PROFILE_NOT_FOUND->value); +}); + +test('viewing profile fails if unauthenticated', function () { + $response = $this->getJson('/api/v1/organizer/profile'); + $response->assertStatus(401); +}); + +test('admin approval fails if no application exists', function () { + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $admin->assignRole($role); + + $response = $this->actingAs($admin)->postJson("/api/v1/admin/organizers/{$this->user->id}/approve"); + + $response->assertStatus(400) + ->assertJsonPath('message', OrganizerHttpEnum::APPROVAL_NO_APPLICATION->value); +}); + +test('admin approval fails if already approved', function () { + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $admin->assignRole($role); + + // Initial onboarding + $this->actingAs($this->user)->postJson('/api/v1/organizer/profile', [ + 'organization_name' => 'Wroclaw Jazz Club', + ]); + + // First approval + $this->actingAs($admin)->postJson("/api/v1/admin/organizers/{$this->user->id}/approve"); + + // Second approval attempt + $response = $this->actingAs($admin)->postJson("/api/v1/admin/organizers/{$this->user->id}/approve"); + + $response->assertStatus(400) + ->assertJsonPath('message', OrganizerHttpEnum::APPROVAL_ALREADY_APPROVED->value); +}); + +test('user without profile cannot see profile show', function () { + $response = $this->actingAs($this->user)->getJson('/api/v1/organizer/profile'); + + $response->assertStatus(404) + ->assertJsonPath('message', OrganizerHttpEnum::PROFILE_NOT_FOUND->value); +}); From e60577a7e3a38ced8208961673ac93567bb7d856 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 14:19:46 +0100 Subject: [PATCH 37/81] test(auth): add negative tests and password reset tests, refactor base response to support validation errors --- app/Domains/Shared/Http/BaseController.php | 13 ++-- .../Transformers/ApiResponseResource.php | 11 ++- tests/Feature/Auth/AuthTest.php | 62 +++++++++++++++++ tests/Feature/Auth/EmailVerificationTest.php | 10 +++ tests/Feature/Auth/PasswordResetTest.php | 67 +++++++++++++++++++ 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/Auth/PasswordResetTest.php diff --git a/app/Domains/Shared/Http/BaseController.php b/app/Domains/Shared/Http/BaseController.php index dbb6cbb..1abcb0c 100644 --- a/app/Domains/Shared/Http/BaseController.php +++ b/app/Domains/Shared/Http/BaseController.php @@ -2,6 +2,7 @@ namespace App\Domains\Shared\Http; +use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Transformers\ApiResponseResource; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -28,11 +29,15 @@ protected function apiResponseError(Throwable $error, ?string $message = null, ? // report($error); Log::error($error); - $message = $message ?? 'error.server'; + $message = $message ?? AppHttpEnum::SERVER_ERROR->value; $statusCode = $statusCode ?? ($error->getCode() ?: 500); - return ApiResponseResource::error($message) - ->response() - ->setStatusCode($statusCode); + $response = ApiResponseResource::error($message); + + if ($error instanceof \Illuminate\Validation\ValidationException) { + $response->setErrors($error->errors()); + } + + return $response->response()->setStatusCode($statusCode); } } diff --git a/app/Domains/Shared/Transformers/ApiResponseResource.php b/app/Domains/Shared/Transformers/ApiResponseResource.php index 1ee5198..6e7c416 100644 --- a/app/Domains/Shared/Transformers/ApiResponseResource.php +++ b/app/Domains/Shared/Transformers/ApiResponseResource.php @@ -13,9 +13,9 @@ class ApiResponseResource extends JsonResource protected ?string $message = null; /** - * Map of additional metadata. + * Map of validation errors. */ - protected array $meta = []; + protected array $errors = []; /** * Transform the resource into an array. @@ -26,6 +26,7 @@ public function toArray(Request $request): array 'success' => (bool) ($this->resource['success'] ?? true), 'message' => $this->message ?? ($this->resource['message'] ?? null), 'data' => $this->resource['data'] ?? null, + 'errors' => !empty($this->errors) ? $this->errors : null, 'meta' => !empty($this->meta) ? $this->meta : null, ]; } @@ -58,6 +59,12 @@ public function setMeta(array $meta): self return $this; } + public function setErrors(array $errors): self + { + $this->errors = $errors; + return $this; + } + public function setData($data): self { $this->resource['data'] = $data; diff --git a/tests/Feature/Auth/AuthTest.php b/tests/Feature/Auth/AuthTest.php index ca5bb07..906dea6 100644 --- a/tests/Feature/Auth/AuthTest.php +++ b/tests/Feature/Auth/AuthTest.php @@ -69,3 +69,65 @@ $response->assertStatus(200) ->assertJsonPath('data.name', 'Test User'); }); + +test('registration validation fails', function () { + $response = $this->postJson('/api/v1/auth/register', [ + 'name' => '', + 'email' => 'invalid-email', + 'password' => 'short', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'email', 'password']); +}); + +test('registration fails with duplicate email', function () { + User::factory()->create(['email' => 'duplicate@example.com']); + + $response = $this->postJson('/api/v1/auth/register', [ + 'name' => 'John Doe', + 'email' => 'duplicate@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); +}); + +test('login fails with invalid credentials', function () { + $user = User::factory()->create([ + 'email' => 'user@example.com', + 'password' => bcrypt('correct-password'), + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); +}); + +test('login fails with non-existent user', function () { + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'notfound@example.com', + 'password' => 'password', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); +}); + +test('fetching profile fails if unauthenticated', function () { + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertStatus(401); +}); + +test('logout fails if unauthenticated', function () { + $response = $this->postJson('/api/v1/auth/logout'); + + $response->assertStatus(401); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index f9ef37c..c9f1cee 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -40,3 +40,13 @@ $response->dump(); $response->assertStatus(400); }); + +test('email verification fails with invalid signature', function () { + $user = User::factory()->unverified()->create(); + + $response = $this->actingAs($user)->getJson( + route('verification.verify', ['id' => $user->id, 'hash' => 'invalid-hash']) + ); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..a72b666 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,67 @@ +create(); + + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => $user->email, + ]); + + $response->assertStatus(Response::HTTP_OK); + } + + public function test_non_existent_user_cannot_request_reset_link() + { + $response = $this->postJson('/api/v1/auth/forgot-password', [ + 'email' => 'nonexistent@example.com', + ]); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['email']); + } + + public function test_user_can_reset_password_with_valid_token() + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response->assertStatus(Response::HTTP_OK); + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_password_reset_fails_with_invalid_token() + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/reset-password', [ + 'token' => 'invalid-token', + 'email' => $user->email, + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['email']); + } +} From 5cb080213df606a867ac32f0ae3f29705c28fe95 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 14:49:50 +0100 Subject: [PATCH 38/81] test(categories): add CategoryApiTest to complete domain coverage --- tests/Feature/Categories/CategoryApiTest.php | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/Feature/Categories/CategoryApiTest.php diff --git a/tests/Feature/Categories/CategoryApiTest.php b/tests/Feature/Categories/CategoryApiTest.php new file mode 100644 index 0000000..68e5ff8 --- /dev/null +++ b/tests/Feature/Categories/CategoryApiTest.php @@ -0,0 +1,35 @@ +count(3)->create(); + + $response = $this->getJson('/api/v1/categories'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('success', true) + ->assertJsonPath('message', 'categories.index.success') + ->assertJsonCount(3, 'data.categories'); + } + + public function test_categories_list_includes_events_count() + { + $category = Category::factory()->create(); + + $response = $this->getJson('/api/v1/categories'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.categories.0.events_count', 0); + } +} From 352bc722731c289befa40716e15e5329a7dfd76f Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 16:45:21 +0100 Subject: [PATCH 39/81] feat(tickets): add registerAttendeeAction with atomic DB transaction capacity check --- .../Actions/RegisterAttendeeAction.php | 47 +++++++++++++++++++ ...02_21_202154_create_ticket_types_table.php | 6 ++- .../2026_02_21_202155_create_orders_table.php | 2 + ...2026_02_21_202156_create_tickets_table.php | 4 +- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Ticketing/Actions/RegisterAttendeeAction.php diff --git a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php new file mode 100644 index 0000000..9e4fded --- /dev/null +++ b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php @@ -0,0 +1,47 @@ +lockForUpdate(); + } + $ticketType = $query->findOrFail($ticketTypeId); + + $ticketType->checkCapacity(); + + $order = Order::create([ + 'user_id' => $user?->id, + 'event_id' => $ticketType->event_id, + 'email' => $user?->email ?? data_get($attendeeData, 'email'), + 'name' => $user?->name ?? data_get($attendeeData, 'name'), + 'status' => OrderStatusEnum::COMPLETED, + ]); + + // 4. Create Ticket + return Ticket::create([ + 'ticket_type_id' => $ticketTypeId, + 'user_id' => $user?->id, + 'order_id' => $order->id, + 'uuid' => (string) Str::uuid(), + 'name' => data_get($attendeeData, 'name') ?? $user?->name, + 'email' => data_get($attendeeData, 'email') ?? $user?->email, + 'status' => TicketStatusEnum::VALID, + ]); + }); + } +} diff --git a/database/migrations/2026_02_21_202154_create_ticket_types_table.php b/database/migrations/2026_02_21_202154_create_ticket_types_table.php index 7bfe383..f181af6 100644 --- a/database/migrations/2026_02_21_202154_create_ticket_types_table.php +++ b/database/migrations/2026_02_21_202154_create_ticket_types_table.php @@ -15,7 +15,11 @@ public function up(): void $table->id(); $table->foreignId('event_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->integer('quantity'); + $table->text('description')->nullable(); + $table->integer('price')->default(0); + $table->integer('quantity')->nullable(); + $table->dateTime('sale_starts_at')->nullable(); + $table->dateTime('sale_ends_at')->nullable(); $table->boolean('is_visible')->default(true); $table->timestamps(); }); diff --git a/database/migrations/2026_02_21_202155_create_orders_table.php b/database/migrations/2026_02_21_202155_create_orders_table.php index 28399a2..2dafaf3 100644 --- a/database/migrations/2026_02_21_202155_create_orders_table.php +++ b/database/migrations/2026_02_21_202155_create_orders_table.php @@ -14,6 +14,8 @@ public function up(): void Schema::create('orders', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); $table->foreignId('event_id')->constrained()->cascadeOnDelete(); $table->enum('status', ['pending', 'completed', 'cancelled', 'refunded'])->default('completed'); $table->timestamps(); diff --git a/database/migrations/2026_02_21_202156_create_tickets_table.php b/database/migrations/2026_02_21_202156_create_tickets_table.php index 3221a27..76e83c8 100644 --- a/database/migrations/2026_02_21_202156_create_tickets_table.php +++ b/database/migrations/2026_02_21_202156_create_tickets_table.php @@ -14,9 +14,11 @@ public function up(): void Schema::create('tickets', function (Blueprint $table) { $table->id(); $table->foreignId('ticket_type_id')->constrained()->cascadeOnDelete(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); $table->foreignId('order_id')->constrained()->cascadeOnDelete(); $table->uuid('uuid')->unique(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); $table->enum('status', ['valid', 'cancelled', 'checked_in'])->default('valid'); $table->timestamp('checked_in_at')->nullable(); $table->timestamps(); From 1b3b10167a322fdd9bee942ff98b172613b9a682 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 16:46:20 +0100 Subject: [PATCH 40/81] feat(tickets): add ticketPolicy and model relations --- app/Domains/Ticketing/Models/Order.php | 30 +++++++++- app/Domains/Ticketing/Models/Ticket.php | 33 ++++++++++- app/Domains/Ticketing/Models/TicketType.php | 40 ++++++++++++- app/Policies/TicketPolicy.php | 65 +++++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 app/Policies/TicketPolicy.php diff --git a/app/Domains/Ticketing/Models/Order.php b/app/Domains/Ticketing/Models/Order.php index c9985bd..837816e 100644 --- a/app/Domains/Ticketing/Models/Order.php +++ b/app/Domains/Ticketing/Models/Order.php @@ -2,9 +2,37 @@ namespace App\Domains\Ticketing\Models; +use App\Domains\Auth\Models\User; +use App\Domains\Events\Models\Event; +use App\Domains\Ticketing\Enums\OrderStatusEnum; use Illuminate\Database\Eloquent\Model; class Order extends Model { - // + protected $fillable = [ + 'user_id', + 'event_id', + 'email', + 'name', + 'status', + ]; + + protected $casts = [ + 'status' => OrderStatusEnum::class, + ]; + + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function event() + { + return $this->belongsTo(Event::class); + } } diff --git a/app/Domains/Ticketing/Models/Ticket.php b/app/Domains/Ticketing/Models/Ticket.php index 5a3e560..7f723e8 100644 --- a/app/Domains/Ticketing/Models/Ticket.php +++ b/app/Domains/Ticketing/Models/Ticket.php @@ -2,9 +2,40 @@ namespace App\Domains\Ticketing\Models; +use App\Domains\Auth\Models\User; +use App\Domains\Ticketing\Enums\TicketStatusEnum; use Illuminate\Database\Eloquent\Model; class Ticket extends Model { - // + protected $fillable = [ + 'ticket_type_id', + 'user_id', + 'order_id', + 'uuid', + 'name', + 'email', + 'status', + 'checked_in_at', + ]; + + protected $casts = [ + 'status' => TicketStatusEnum::class, + 'checked_in_at' => 'datetime', + ]; + + public function ticketType() + { + return $this->belongsTo(TicketType::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } } diff --git a/app/Domains/Ticketing/Models/TicketType.php b/app/Domains/Ticketing/Models/TicketType.php index 1bfb0e5..61f37d3 100644 --- a/app/Domains/Ticketing/Models/TicketType.php +++ b/app/Domains/Ticketing/Models/TicketType.php @@ -2,9 +2,47 @@ namespace App\Domains\Ticketing\Models; +use App\Domains\Events\Models\Event; +use App\Domains\Ticketing\Enums\TicketStatusEnum; +use App\Domains\Ticketing\Exceptions\CapacityExceededException; use Illuminate\Database\Eloquent\Model; class TicketType extends Model { - // + protected $fillable = [ + 'event_id', + 'name', + 'description', + 'price', + 'quantity', + 'sale_starts_at', + 'sale_ends_at', + 'is_visible', + ]; + + public function event() + { + return $this->belongsTo(Event::class); + } + + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + public function checkCapacity(): void + { + if ($this->quantity !== null) { + $confirmedTicketsCount = Ticket::where('ticket_type_id', $this->id) + ->whereIn('status', [ + TicketStatusEnum::VALID, + TicketStatusEnum::CHECKED_IN + ]) + ->count(); + + if ($confirmedTicketsCount >= $this->quantity) { + throw new CapacityExceededException(); + } + } + } } diff --git a/app/Policies/TicketPolicy.php b/app/Policies/TicketPolicy.php new file mode 100644 index 0000000..8ee7b56 --- /dev/null +++ b/app/Policies/TicketPolicy.php @@ -0,0 +1,65 @@ +id === $ticket->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Ticket $ticket): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Ticket $ticket): bool + { + return $user->id === $ticket->user_id && $ticket->status !== 'cancelled'; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Ticket $ticket): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Ticket $ticket): bool + { + return false; + } +} From 893591d32a3cc65c22ea645a8028d1172cf37443 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 16:48:14 +0100 Subject: [PATCH 41/81] feat(tickets): add ticket retrieval and registration logic --- app/Domains/Events/Models/Event.php | 6 ++ app/Domains/Shared/Http/BaseController.php | 7 +- .../Http/Controllers/TicketController.php | 76 +++++++++++++++++++ .../Http/Requests/RegisterTicketRequest.php | 22 ++++++ database/seeders/CategorySeeder.php | 1 - routes/api.php | 8 ++ 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Ticketing/Http/Controllers/TicketController.php create mode 100644 app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php diff --git a/app/Domains/Events/Models/Event.php b/app/Domains/Events/Models/Event.php index 2b40a0c..2c2de26 100644 --- a/app/Domains/Events/Models/Event.php +++ b/app/Domains/Events/Models/Event.php @@ -4,6 +4,7 @@ use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Organizer\Models\OrganizerProfile; +use App\Domains\Ticketing\Models\TicketType; use App\Domains\Venues\Models\Venue; use Database\Factories\EventFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -68,4 +69,9 @@ public function organizerProfile() { return $this->belongsTo(OrganizerProfile::class); } + + public function ticketTypes() + { + return $this->hasMany(TicketType::class); + } } diff --git a/app/Domains/Shared/Http/BaseController.php b/app/Domains/Shared/Http/BaseController.php index 1abcb0c..7cab131 100644 --- a/app/Domains/Shared/Http/BaseController.php +++ b/app/Domains/Shared/Http/BaseController.php @@ -4,13 +4,16 @@ use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Transformers\ApiResponseResource; -use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Illuminate\Support\Facades\Log; use Throwable; class BaseController extends Controller { + use AuthorizesRequests; protected function apiResponse(string $message, int $statusCode, $data = []): JsonResponse { return ApiResponseResource::success($data, $message) @@ -34,7 +37,7 @@ protected function apiResponseError(Throwable $error, ?string $message = null, ? $response = ApiResponseResource::error($message); - if ($error instanceof \Illuminate\Validation\ValidationException) { + if ($error instanceof ValidationException) { $response->setErrors($error->errors()); } diff --git a/app/Domains/Ticketing/Http/Controllers/TicketController.php b/app/Domains/Ticketing/Http/Controllers/TicketController.php new file mode 100644 index 0000000..f2cc2b0 --- /dev/null +++ b/app/Domains/Ticketing/Http/Controllers/TicketController.php @@ -0,0 +1,76 @@ +user(); + $ticket = $action->execute( + $request->integer('ticket_type_id'), + $request->only(['name', 'email']), + $user + ); + } catch (Exception $e) { + return $this->apiResponseError($e, TicketingHttpEnum::REGISTRATION_FAILED->value); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::REGISTRATION_SUCCESS->value, + Response::HTTP_CREATED, + new TicketResource($ticket) + ); + } + + public function index(Request $request): JsonResponse + { + try { + $tickets = Ticket::where('user_id', $request->user()?->id) + ->with(['ticketType.event.venue']) + ->latest() + ->get(); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::TICKETS_LISTED->value, + Response::HTTP_OK, + TicketResource::collection($tickets) + ); + } + + public function show(Request $request, string $uuid): JsonResponse + { + try { + $ticket = Ticket::where('uuid', $uuid) + ->with(['ticketType.event.venue']) + ->firstOrFail(); + + if ($ticket->user_id && $request->user()) { + $this->authorize('view', $ticket); + } + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::TICKET_SHOWN->value, + Response::HTTP_OK, + new TicketResource($ticket) + ); + } +} diff --git a/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php new file mode 100644 index 0000000..6289e98 --- /dev/null +++ b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php @@ -0,0 +1,22 @@ + ['required', 'exists:ticket_types,id'], + 'name' => ['required_if:user_id,null', 'string', 'max:255'], + 'email' => ['required_if:user_id,null', 'email', 'max:255'], + ]; + } +} diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php index 0f3293a..280ff66 100644 --- a/database/seeders/CategorySeeder.php +++ b/database/seeders/CategorySeeder.php @@ -35,4 +35,3 @@ public function run(): void } } } -feat(db): add core MVP database migrations (users, profiles, venues, events, ticketing, analytics) \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 29ac295..2b804c0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,8 +22,16 @@ use App\Domains\Events\Controllers\Organizer\CancelEventController; use App\Domains\Categories\Controllers\CategoryController; use App\Domains\Events\Controllers\PublicEventController; +use App\Domains\Ticketing\Http\Controllers\TicketController; use Illuminate\Support\Facades\Route; +Route::post('/tickets/register', [TicketController::class, 'store']); +Route::get('/tickets/{uuid}', [TicketController::class, 'show']); + +Route::middleware('auth:sanctum')->group(function () { + Route::get('/tickets', [TicketController::class, 'index']); +}); + Route::get('/venues', ListVenuesController::class); Route::get('/venues/search', SearchVenuesController::class); From 28ba385c7509de629d147cec69f4e418f943144a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 16:48:54 +0100 Subject: [PATCH 42/81] feat(tickets): add enums, exceptions and resources for Ticketing domain --- .../Ticketing/Enums/OrderStatusEnum.php | 11 ++++++++ .../Ticketing/Enums/TicketStatusEnum.php | 10 +++++++ .../Ticketing/Enums/TicketingHttpEnum.php | 14 ++++++++++ .../Exceptions/CapacityExceededException.php | 13 +++++++++ .../Ticketing/Transformers/TicketResource.php | 28 +++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 app/Domains/Ticketing/Enums/OrderStatusEnum.php create mode 100644 app/Domains/Ticketing/Enums/TicketStatusEnum.php create mode 100644 app/Domains/Ticketing/Enums/TicketingHttpEnum.php create mode 100644 app/Domains/Ticketing/Exceptions/CapacityExceededException.php create mode 100644 app/Domains/Ticketing/Transformers/TicketResource.php diff --git a/app/Domains/Ticketing/Enums/OrderStatusEnum.php b/app/Domains/Ticketing/Enums/OrderStatusEnum.php new file mode 100644 index 0000000..3967e40 --- /dev/null +++ b/app/Domains/Ticketing/Enums/OrderStatusEnum.php @@ -0,0 +1,11 @@ + $this->id, + 'ticket_type_id' => $this->ticket_type_id, + 'ticket_type_name' => $this->ticketType->name, + 'event_title' => $this->ticketType->event->title, + 'attendee_name' => $this->name, + 'attendee_email' => $this->email, + 'uuid' => $this->uuid, + 'status' => $this->status, + 'starts_at' => $this->ticketType->event->starts_at?->format('Y-m-d H:i'), + 'venue_name' => $this->ticketType->event->venue?->name, + 'venue_address' => $this->ticketType->event->venue?->address, + 'qr_code_url' => null, + 'created_at' => $this->created_at->toDateTimeString(), + ]; + } +} From aa2931cf442df634f072e01dba6fb13854dbee16 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Mon, 23 Feb 2026 16:49:19 +0100 Subject: [PATCH 43/81] test(tickets): ticket registration and cancellation tests --- tests/Feature/Ticketing/RegistrationTest.php | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/Feature/Ticketing/RegistrationTest.php diff --git a/tests/Feature/Ticketing/RegistrationTest.php b/tests/Feature/Ticketing/RegistrationTest.php new file mode 100644 index 0000000..f22a3c4 --- /dev/null +++ b/tests/Feature/Ticketing/RegistrationTest.php @@ -0,0 +1,101 @@ +create(['status' => EventStatusEnum::PUBLISHED->value]); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Free Entry', + 'price' => 0, + 'quantity' => 10, + ]); + + $response = $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'name' => 'John Guest', + 'email' => 'john@guest.com', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('tickets', [ + 'email' => 'john@guest.com', + 'name' => 'John Guest', + 'ticket_type_id' => $ticketType->id, + ]); + $this->assertDatabaseHas('orders', [ + 'email' => 'john@guest.com', + 'event_id' => $event->id, + ]); + } + + public function test_authenticated_user_can_register_for_free_ticket(): void + { + $user = User::factory()->create(); + $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Free Entry', + 'price' => 0, + 'quantity' => 10, + ]); + + $response = $this->actingAs($user)->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('tickets', [ + 'user_id' => $user->id, + 'ticket_type_id' => $ticketType->id, + ]); + } + + public function test_registration_fails_when_capacity_is_exceeded(): void + { + $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Limited Entry', + 'price' => 0, + 'quantity' => 1, + ]); + + $order = Order::create([ + 'event_id' => $event->id, + 'status' => OrderStatusEnum::COMPLETED, + ]); + + Ticket::create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'uuid' => 'test-uuid', + 'status' => TicketStatusEnum::VALID, + ]); + + $response = $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'name' => 'Late Guest', + 'email' => 'late@guest.com', + ]); + + $response->assertStatus(400); + $response->assertJsonPath('message', 'ticketing.registration_failed'); + } +} From 711a732622c84fe8f9c0bd2d87f55c5a818e4b11 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 00:13:38 +0100 Subject: [PATCH 44/81] feat(tickets): add Ticket mailing job and qr code generation using BaconQrCode --- .../Actions/RegisterAttendeeAction.php | 8 +++- .../Ticketing/Jobs/SendTicketEmailJob.php | 39 +++++++++++++++++++ .../Ticketing/Services/TicketMailService.php | 28 +++++++++++++ ..._02_23_213716_fix_ticket_types_columns.php | 39 +++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Ticketing/Jobs/SendTicketEmailJob.php create mode 100644 app/Domains/Ticketing/Services/TicketMailService.php create mode 100644 database/migrations/2026_02_23_213716_fix_ticket_types_columns.php diff --git a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php index 9e4fded..3470972 100644 --- a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php +++ b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php @@ -8,6 +8,7 @@ use App\Domains\Ticketing\Models\TicketType; use App\Domains\Ticketing\Enums\OrderStatusEnum; use App\Domains\Ticketing\Enums\TicketStatusEnum; +use App\Domains\Ticketing\Jobs\SendTicketEmailJob; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -32,8 +33,7 @@ public function execute(int $ticketTypeId, array $attendeeData, ?User $user = nu 'status' => OrderStatusEnum::COMPLETED, ]); - // 4. Create Ticket - return Ticket::create([ + $ticket = Ticket::create([ 'ticket_type_id' => $ticketTypeId, 'user_id' => $user?->id, 'order_id' => $order->id, @@ -42,6 +42,10 @@ public function execute(int $ticketTypeId, array $attendeeData, ?User $user = nu 'email' => data_get($attendeeData, 'email') ?? $user?->email, 'status' => TicketStatusEnum::VALID, ]); + + SendTicketEmailJob::dispatch($ticket); + + return $ticket; }); } } diff --git a/app/Domains/Ticketing/Jobs/SendTicketEmailJob.php b/app/Domains/Ticketing/Jobs/SendTicketEmailJob.php new file mode 100644 index 0000000..486c937 --- /dev/null +++ b/app/Domains/Ticketing/Jobs/SendTicketEmailJob.php @@ -0,0 +1,39 @@ +sendTicketEmail($this->ticket); + } + + public function failed(Exception $exception): void + { + Log::error("Failed to send ticket email for ticket ID: {$this->ticket->id}", [ + 'ticket_uuid' => $this->ticket->uuid, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString() + ]); + } +} diff --git a/app/Domains/Ticketing/Services/TicketMailService.php b/app/Domains/Ticketing/Services/TicketMailService.php new file mode 100644 index 0000000..bb2074d --- /dev/null +++ b/app/Domains/Ticketing/Services/TicketMailService.php @@ -0,0 +1,28 @@ +loadMissing(['ticketType.event.venue']); + + $qrData = config('app.frontend_url') . '/tickets/' . $ticket->uuid; + + $renderer = new GDLibRenderer(200); + $writer = new Writer($renderer); + + $qrCodeData = $writer->writeString($qrData); + + Mail::to($ticket->email)->send( + new TicketConfirmationMail($ticket, $qrCodeData) + ); + } +} diff --git a/database/migrations/2026_02_23_213716_fix_ticket_types_columns.php b/database/migrations/2026_02_23_213716_fix_ticket_types_columns.php new file mode 100644 index 0000000..75db6af --- /dev/null +++ b/database/migrations/2026_02_23_213716_fix_ticket_types_columns.php @@ -0,0 +1,39 @@ +text('description')->nullable()->after('name'); + } + if (!Schema::hasColumn('ticket_types', 'price')) { + $table->integer('price')->default(0)->after('description'); + } + if (!Schema::hasColumn('ticket_types', 'sale_starts_at')) { + $table->dateTime('sale_starts_at')->nullable()->after('quantity'); + } + if (!Schema::hasColumn('ticket_types', 'sale_ends_at')) { + $table->dateTime('sale_ends_at')->nullable()->after('sale_starts_at'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ticket_types', function (Blueprint $table) { + $table->dropColumn(['description', 'price', 'sale_starts_at', 'sale_ends_at']); + }); + } +}; From 47bd9d1dea2f6e44f1be74a135b731204ea80cae Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 00:14:52 +0100 Subject: [PATCH 45/81] feat(tickets): add ticket mails --- .../Mails/TicketConfirmationMail.php | 45 +++ app/Domains/Ticketing/Models/Order.php | 9 + app/Domains/Ticketing/Models/Ticket.php | 9 + app/Domains/Ticketing/Models/TicketType.php | 9 + database/factories/OrderFactory.php | 25 ++ database/factories/TicketFactory.php | 29 ++ database/factories/TicketTypeFactory.php | 22 ++ lang/en/ticketing.php | 21 ++ lang/pl/ticketing.php | 21 ++ .../views/emails/mjml/components/footer.mjml | 28 ++ .../views/emails/mjml/components/head.mjml | 39 +++ .../views/emails/mjml/components/header.mjml | 20 ++ .../emails/mjml/ticket-confirmation.mjml | 97 ++++++ .../emails/ticket-confirmation.blade.php | 306 ++++++++++++++++++ 14 files changed, 680 insertions(+) create mode 100644 app/Domains/Ticketing/Mails/TicketConfirmationMail.php create mode 100644 database/factories/OrderFactory.php create mode 100644 database/factories/TicketFactory.php create mode 100644 database/factories/TicketTypeFactory.php create mode 100644 lang/en/ticketing.php create mode 100644 lang/pl/ticketing.php create mode 100644 resources/views/emails/mjml/components/footer.mjml create mode 100644 resources/views/emails/mjml/components/head.mjml create mode 100644 resources/views/emails/mjml/components/header.mjml create mode 100644 resources/views/emails/mjml/ticket-confirmation.mjml create mode 100644 resources/views/emails/ticket-confirmation.blade.php diff --git a/app/Domains/Ticketing/Mails/TicketConfirmationMail.php b/app/Domains/Ticketing/Mails/TicketConfirmationMail.php new file mode 100644 index 0000000..2170782 --- /dev/null +++ b/app/Domains/Ticketing/Mails/TicketConfirmationMail.php @@ -0,0 +1,45 @@ + $this->ticket->ticketType->event->title]), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.ticket-confirmation', + ); + } + + public function attachments(): array + { + return [ + Attachment::fromData( + fn() => $this->qrCodeData, + 'ticket-qr.png' + )->withMime('image/png'), + ]; + } +} diff --git a/app/Domains/Ticketing/Models/Order.php b/app/Domains/Ticketing/Models/Order.php index 837816e..af36dab 100644 --- a/app/Domains/Ticketing/Models/Order.php +++ b/app/Domains/Ticketing/Models/Order.php @@ -5,10 +5,19 @@ use App\Domains\Auth\Models\User; use App\Domains\Events\Models\Event; use App\Domains\Ticketing\Enums\OrderStatusEnum; +use Database\Factories\OrderFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Order extends Model { + use HasFactory; + + protected static function newFactory() + { + return OrderFactory::new(); + } + protected $fillable = [ 'user_id', 'event_id', diff --git a/app/Domains/Ticketing/Models/Ticket.php b/app/Domains/Ticketing/Models/Ticket.php index 7f723e8..831bd03 100644 --- a/app/Domains/Ticketing/Models/Ticket.php +++ b/app/Domains/Ticketing/Models/Ticket.php @@ -4,10 +4,19 @@ use App\Domains\Auth\Models\User; use App\Domains\Ticketing\Enums\TicketStatusEnum; +use Database\Factories\TicketFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Ticket extends Model { + use HasFactory; + + protected static function newFactory() + { + return TicketFactory::new(); + } + protected $fillable = [ 'ticket_type_id', 'user_id', diff --git a/app/Domains/Ticketing/Models/TicketType.php b/app/Domains/Ticketing/Models/TicketType.php index 61f37d3..05c99bb 100644 --- a/app/Domains/Ticketing/Models/TicketType.php +++ b/app/Domains/Ticketing/Models/TicketType.php @@ -5,10 +5,19 @@ use App\Domains\Events\Models\Event; use App\Domains\Ticketing\Enums\TicketStatusEnum; use App\Domains\Ticketing\Exceptions\CapacityExceededException; +use Database\Factories\TicketTypeFactory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class TicketType extends Model { + use HasFactory; + + protected static function newFactory() + { + return TicketTypeFactory::new(); + } + protected $fillable = [ 'event_id', 'name', diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..3b9fb55 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,25 @@ + Event::factory(), + 'user_id' => User::factory(), + 'email' => $this->faker->safeEmail, + 'name' => $this->faker->name, + 'status' => OrderStatusEnum::COMPLETED, + ]; + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php new file mode 100644 index 0000000..df1fd59 --- /dev/null +++ b/database/factories/TicketFactory.php @@ -0,0 +1,29 @@ + TicketType::factory(), + 'order_id' => Order::factory(), + 'user_id' => User::factory(), + 'uuid' => (string) Str::uuid(), + 'name' => $this->faker->name, + 'email' => $this->faker->safeEmail, + 'status' => TicketStatusEnum::VALID, + ]; + } +} diff --git a/database/factories/TicketTypeFactory.php b/database/factories/TicketTypeFactory.php new file mode 100644 index 0000000..67b2e70 --- /dev/null +++ b/database/factories/TicketTypeFactory.php @@ -0,0 +1,22 @@ + Event::factory(), + 'name' => 'General Admission', + 'price' => 5000, + 'quantity' => 100, + ]; + } +} diff --git a/lang/en/ticketing.php b/lang/en/ticketing.php new file mode 100644 index 0000000..99f194a --- /dev/null +++ b/lang/en/ticketing.php @@ -0,0 +1,21 @@ + 'Ticket registered successfully.', + 'registration_failed' => 'Registration failed.', + 'tickets_listed' => 'Tickets listed successfully.', + 'ticket_shown' => 'Ticket data retrieved.', + 'confirmation_email_subject' => 'Your ticket for :event', + 'your_ticket_is_ready' => 'Your Ticket is Ready!', + 'hi' => 'Hi', + 'see_you_at' => 'we look forward to seeing you at', + 'ticket_type' => 'Ticket Type', + 'attendee' => 'Attendee', + 'scan_to_check_in' => 'Scan this QR code at the entrance to check in.', + 'ticket_resent' => 'Ticket confirmation email has been resent.', + 'view_ticket_online' => 'View Ticket Online', + 'online_event' => 'Online Event', + 'email_footer_text' => 'You received this email because you registered for an event on uCiebiewmiescie.pl.', + 'all_rights_reserved' => 'All rights reserved.', + 'privacy_policy' => 'Privacy Policy', +]; diff --git a/lang/pl/ticketing.php b/lang/pl/ticketing.php new file mode 100644 index 0000000..25eaa55 --- /dev/null +++ b/lang/pl/ticketing.php @@ -0,0 +1,21 @@ + 'Bilet został pomyślnie zarejestrowany.', + 'registration_failed' => 'Rejestracja nie powiodła się.', + 'tickets_listed' => 'Lista biletów została pobrana.', + 'ticket_shown' => 'Dane biletu zostały pobrane.', + 'confirmation_email_subject' => 'Twój bilet na :event', + 'your_ticket_is_ready' => 'Twój bilet jest gotowy!', + 'hi' => 'Cześć', + 'see_you_at' => 'czekamy na Ciebie na', + 'ticket_type' => 'Rodzaj biletu', + 'attendee' => 'Uczestnik', + 'scan_to_check_in' => 'Zeskanuj ten kod QR przy wejściu, aby się zameldować.', + 'ticket_resent' => 'Wiadomość z potwierdzeniem biletu została wysłana ponownie.', + 'view_ticket_online' => 'Zobacz bilet online', + 'online_event' => 'Wydarzenie online', + 'email_footer_text' => 'Otrzymałeś tę wiadomość, ponieważ zarejestrowałeś się na wydarzenie w serwisie uCiebiewmiescie.pl.', + 'all_rights_reserved' => 'Wszelkie prawa zastrzeżone.', + 'privacy_policy' => 'Polityka prywatności', +]; diff --git a/resources/views/emails/mjml/components/footer.mjml b/resources/views/emails/mjml/components/footer.mjml new file mode 100644 index 0000000..57f210a --- /dev/null +++ b/resources/views/emails/mjml/components/footer.mjml @@ -0,0 +1,28 @@ + + + + {{ __("ticketing.email_footer_text") }}
+ © {{ date("Y") }} uCiebiewmiescie.pl. + {{ __("ticketing.all_rights_reserved") }} +
+ + {{ __("ticketing.privacy_policy") }} + +
+
diff --git a/resources/views/emails/mjml/components/head.mjml b/resources/views/emails/mjml/components/head.mjml new file mode 100644 index 0000000..2997774 --- /dev/null +++ b/resources/views/emails/mjml/components/head.mjml @@ -0,0 +1,39 @@ +{{ $title ?? config("app.name") }} + + + + + + + + + + .ticket-box { + border-radius: 12px; + border: 2px solid #e5e7eb; + padding: 24px; + } + .qr-container { + text-align: center; + padding: 20px; + background-color: #f8fafc; + border-radius: 8px; + margin-top: 20px; + } + .brand-text { + letter-spacing: -1px; + } + diff --git a/resources/views/emails/mjml/components/header.mjml b/resources/views/emails/mjml/components/header.mjml new file mode 100644 index 0000000..505e6f8 --- /dev/null +++ b/resources/views/emails/mjml/components/header.mjml @@ -0,0 +1,20 @@ + + + + uCiebiewmiescie.pl + + + + diff --git a/resources/views/emails/mjml/ticket-confirmation.mjml b/resources/views/emails/mjml/ticket-confirmation.mjml new file mode 100644 index 0000000..ad7daba --- /dev/null +++ b/resources/views/emails/mjml/ticket-confirmation.mjml @@ -0,0 +1,97 @@ + + + + + + + + + + + {{ __("ticketing.your_ticket_is_ready") }} + + + {{ __("ticketing.hi") }} {{ $ticket->name }}, + {{ __("ticketing.see_you_at") }} + {{ $ticket->ticketType->event->title }}! + + + + + + + + + {{ $ticket->ticketType->event->title }} + + + {{ $ticket->ticketType->event->starts_at->format('d M Y, H:i') }} + + + {{ $ticket->ticketType->event->venue->name ?? __('ticketing.online_event') }} + + + + + + {{ __("ticketing.ticket_type") }}: + {{ $ticket->ticketType->name }} + + + {{ __("ticketing.attendee") }}: {{ $ticket->name }} + + + + + {{ __("ticketing.scan_to_check_in") }} + + + + + + + + {{ __("ticketing.view_ticket_online") }} + + + + + + + diff --git a/resources/views/emails/ticket-confirmation.blade.php b/resources/views/emails/ticket-confirmation.blade.php new file mode 100644 index 0000000..1a67766 --- /dev/null +++ b/resources/views/emails/ticket-confirmation.blade.php @@ -0,0 +1,306 @@ + + + + + {{ $title ?? config("app.name") }} + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
uCiebiewmiescie.pl
+
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
{{ __("ticketing.your_ticket_is_ready") }}
+
+
{{ __("ticketing.hi") }} {{ $ticket->name }}, {{ __("ticketing.see_you_at") }} + {{ $ticket->ticketType->event->title }}! +
+
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
{{ $ticket->ticketType->event->title }}
+
+
{{ $ticket->ticketType->event->starts_at->format('d M Y, H:i') }}
+
+
{{ $ticket->ticketType->event->venue->name ?? __('ticketing.online_event') }}
+
+

+

+ +
+
{{ __("ticketing.ticket_type") }}: {{ $ticket->ticketType->name }}
+
+
{{ __("ticketing.attendee") }}: {{ $ticket->name }}
+
+ + + + + + +
+ QR Code +
+
+
{{ __("ticketing.scan_to_check_in") }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + {{ __("ticketing.view_ticket_online") }} + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
{{ __("ticketing.email_footer_text") }}
© {{ date("Y") }} uCiebiewmiescie.pl. {{ __("ticketing.all_rights_reserved") }}
+
+ +
+
+ +
+
+ +
+ + + From d908406834856bdad2641d08c777971512501e11 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 00:20:19 +0100 Subject: [PATCH 46/81] add email resending and test command --- .env.example | 18 ++++- README.md | 81 ++++++++++--------- .../Commands/TestTicketEmailCommand.php | 34 ++++++++ .../Ticketing/Enums/TicketingHttpEnum.php | 1 + .../Http/Controllers/TicketController.php | 21 +++++ routes/api.php | 1 + 6 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 app/Console/Commands/TestTicketEmailCommand.php diff --git a/.env.example b/.env.example index 165489e..ccb8824 100644 --- a/.env.example +++ b/.env.example @@ -47,15 +47,25 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 + +# Use [::1] on Windows/WSL if 127.0.0.1 fails for Mailpit +MAIL_HOST=[::1] +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="hello@example.pl" MAIL_FROM_NAME="${APP_NAME}" +MEILISEARCH_HOST=http://127.0.0.1:7700 +MEILISEARCH_KEY=masterKey + +FRONTEND_URL=http://localhost:3000 + +# Ticketing & QR Codes +BACON_QR_RENDERER=gd + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/README.md b/README.md index 0165a77..de6515d 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,62 @@ -

Laravel Logo

-

-Build Status -Total Downloads -Latest Stable Version -License -

+## Requirements + +- **PHP 8.2+** +- **PostgreSQL 15+** +- **Meilisearch** (for event search) +- **PHP GD Extension** (Required for QR code generation in ticketing) +- **Node.js & NPM** (for MJML compilation) + +## Setup & Deployment -## About Laravel +### Mail Configuration (Local Development) -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +For local testing, we use **Mailpit**. -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +- **SMTP Port**: `1025` +- **Web UI**: `http://localhost:8025` -Laravel is accessible, powerful, and provides tools required for large, robust applications. +> [!IMPORTANT] +> **Windows/WSL Loopback Issue**: If you encounter a "Connection closed unexpectedly" error when sending mail to Mailpit on Windows, ensure your `MAIL_HOST` is set to `[::1]` (IPv6 loopback) instead of `127.0.0.1`. -## Learning Laravel +### Ticketing System -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. +The ticketing system generates QR codes and sends MJML-based emails. -If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +#### Mail Testing -## Laravel Sponsors +To verify the email layout and delivery, run: -We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). +```bash +php artisan mail:test-ticket +``` -### Premium Partners +#### MJML Compilation -- **[Vehikl](https://vehikl.com)** -- **[Tighten Co.](https://tighten.co)** -- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** -- **[64 Robots](https://64robots.com)** -- **[Curotec](https://www.curotec.com/services/technologies/laravel)** -- **[DevSquad](https://devsquad.com/hire-laravel-developers)** -- **[Redberry](https://redberry.international/laravel-development)** -- **[Active Logic](https://activelogic.com)** +If you modify MJML templates in `resources/views/emails/mjml`, recompile them using: -## Contributing +```bash +npx mjml resources/views/emails/mjml/ticket-confirmation.mjml -o resources/views/emails/ticket-confirmation.blade.php +``` -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +#### Queue Worker -## Code of Conduct +Production emails should be sent via the queue. -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +1. Set `QUEUE_CONNECTION=database` in `.env`. +2. Run the worker: -## Security Vulnerabilities +```bash +php artisan queue:work +``` -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. -## License +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

-The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/TestTicketEmailCommand.php b/app/Console/Commands/TestTicketEmailCommand.php new file mode 100644 index 0000000..0129299 --- /dev/null +++ b/app/Console/Commands/TestTicketEmailCommand.php @@ -0,0 +1,34 @@ +argument('ticket_id'); + + $ticket = $ticketId + ? Ticket::findOrFail($ticketId) + : Ticket::latest()->first(); + + if (!$ticket) { + $this->error('No tickets found in database. Please register a ticket first.'); + return 1; + } + + $this->info("Dispatching email for Ticket ID: {$ticket->id} (UUID: {$ticket->uuid}) to {$ticket->email}..."); + + SendTicketEmailJob::dispatch($ticket); + + $this->info('Job dispatched. Make sure queue worker is running'); + return 0; + } +} diff --git a/app/Domains/Ticketing/Enums/TicketingHttpEnum.php b/app/Domains/Ticketing/Enums/TicketingHttpEnum.php index 3a018f9..6137226 100644 --- a/app/Domains/Ticketing/Enums/TicketingHttpEnum.php +++ b/app/Domains/Ticketing/Enums/TicketingHttpEnum.php @@ -11,4 +11,5 @@ enum TicketingHttpEnum: string case TICKET_NOT_FOUND = 'ticketing.ticket_not_found'; case TICKETS_LISTED = 'ticketing.tickets_listed'; case TICKET_SHOWN = 'ticketing.ticket_shown'; + case TICKET_RESENT = 'ticketing.ticket_resent'; } diff --git a/app/Domains/Ticketing/Http/Controllers/TicketController.php b/app/Domains/Ticketing/Http/Controllers/TicketController.php index f2cc2b0..ffaf6a5 100644 --- a/app/Domains/Ticketing/Http/Controllers/TicketController.php +++ b/app/Domains/Ticketing/Http/Controllers/TicketController.php @@ -6,6 +6,7 @@ use App\Domains\Ticketing\Actions\RegisterAttendeeAction; use App\Domains\Ticketing\Enums\TicketingHttpEnum; use App\Domains\Ticketing\Http\Requests\RegisterTicketRequest; +use App\Domains\Ticketing\Jobs\SendTicketEmailJob; use App\Domains\Ticketing\Models\Ticket; use App\Domains\Ticketing\Transformers\TicketResource; use Exception; @@ -73,4 +74,24 @@ public function show(Request $request, string $uuid): JsonResponse new TicketResource($ticket) ); } + + public function resend(Request $request, string $uuid): JsonResponse + { + try { + $ticket = Ticket::where('uuid', $uuid)->firstOrFail(); + + if ($ticket->user_id) { + $this->authorize('view', $ticket); + } + + SendTicketEmailJob::dispatch($ticket); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::TICKET_RESENT->value, + Response::HTTP_OK + ); + } } diff --git a/routes/api.php b/routes/api.php index 2b804c0..b3b5e64 100644 --- a/routes/api.php +++ b/routes/api.php @@ -27,6 +27,7 @@ Route::post('/tickets/register', [TicketController::class, 'store']); Route::get('/tickets/{uuid}', [TicketController::class, 'show']); +Route::post('/tickets/{uuid}/resend', [TicketController::class, 'resend']); Route::middleware('auth:sanctum')->group(function () { Route::get('/tickets', [TicketController::class, 'index']); From c9d073b0b3bf90d8979dc4db194e6a833a47d3e2 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 00:21:28 +0100 Subject: [PATCH 47/81] test(tickets): add ticket mailing tests --- tests/Feature/Ticketing/RegistrationTest.php | 20 +++++--- tests/Feature/Ticketing/TicketEmailTest.php | 50 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Ticketing/TicketEmailTest.php diff --git a/tests/Feature/Ticketing/RegistrationTest.php b/tests/Feature/Ticketing/RegistrationTest.php index f22a3c4..01672cc 100644 --- a/tests/Feature/Ticketing/RegistrationTest.php +++ b/tests/Feature/Ticketing/RegistrationTest.php @@ -7,10 +7,12 @@ use App\Domains\Events\Models\Event; use App\Domains\Ticketing\Enums\OrderStatusEnum; use App\Domains\Ticketing\Enums\TicketStatusEnum; +use App\Domains\Ticketing\Jobs\SendTicketEmailJob; use App\Domains\Ticketing\Models\Ticket; use App\Domains\Ticketing\Models\TicketType; use App\Domains\Ticketing\Models\Order; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Bus; use Tests\TestCase; class RegistrationTest extends TestCase @@ -19,6 +21,7 @@ class RegistrationTest extends TestCase public function test_guest_can_register_for_free_ticket(): void { + Bus::fake(); $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); $ticketType = TicketType::create([ 'event_id' => $event->id, @@ -36,17 +39,15 @@ public function test_guest_can_register_for_free_ticket(): void $response->assertStatus(201); $this->assertDatabaseHas('tickets', [ 'email' => 'john@guest.com', - 'name' => 'John Guest', 'ticket_type_id' => $ticketType->id, ]); - $this->assertDatabaseHas('orders', [ - 'email' => 'john@guest.com', - 'event_id' => $event->id, - ]); + + Bus::assertDispatched(SendTicketEmailJob::class); } public function test_authenticated_user_can_register_for_free_ticket(): void { + Bus::fake(); $user = User::factory()->create(); $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); $ticketType = TicketType::create([ @@ -65,10 +66,13 @@ public function test_authenticated_user_can_register_for_free_ticket(): void 'user_id' => $user->id, 'ticket_type_id' => $ticketType->id, ]); + + Bus::assertDispatched(SendTicketEmailJob::class); } public function test_registration_fails_when_capacity_is_exceeded(): void { + Bus::fake(); $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); $ticketType = TicketType::create([ 'event_id' => $event->id, @@ -91,11 +95,13 @@ public function test_registration_fails_when_capacity_is_exceeded(): void $response = $this->postJson('/api/v1/tickets/register', [ 'ticket_type_id' => $ticketType->id, - 'name' => 'Late Guest', - 'email' => 'late@guest.com', + 'name' => 'John TooLate', + 'email' => 'john@late.com', ]); $response->assertStatus(400); $response->assertJsonPath('message', 'ticketing.registration_failed'); + + Bus::assertNotDispatched(SendTicketEmailJob::class); } } diff --git a/tests/Feature/Ticketing/TicketEmailTest.php b/tests/Feature/Ticketing/TicketEmailTest.php new file mode 100644 index 0000000..fbe7b89 --- /dev/null +++ b/tests/Feature/Ticketing/TicketEmailTest.php @@ -0,0 +1,50 @@ +create(['status' => EventStatusEnum::PUBLISHED->value]); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Free Entry', + 'price' => 0, + 'quantity' => 10, + ]); + + $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'name' => 'John Guest', + 'email' => 'john@guest.com', + ]); + + Bus::assertDispatched(SendTicketEmailJob::class); + } + + public function test_resend_endpoint_queues_email_job(): void + { + Bus::fake(); + + $ticket = Ticket::factory()->create(['user_id' => null]); + + $this->postJson("/api/v1/tickets/{$ticket->uuid}/resend") + ->assertStatus(200); + + Bus::assertDispatched(SendTicketEmailJob::class); + } +} From 67e73b893852c983e24a76c1c1908aa149ed924f Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:22:34 +0100 Subject: [PATCH 48/81] feat(organizer): attendee list endpoint with pagination and service layer --- .../Ticketing/Enums/TicketingHttpEnum.php | 4 ++ .../Organizer/AttendeeController.php | 59 +++++++++++++++++++ .../Eloquent/TicketRepository.php | 45 ++++++++++++++ .../TicketRepositoryInterface.php | 18 ++++++ .../Ticketing/Services/AttendeeService.php | 42 +++++++++++++ app/Providers/AppServiceProvider.php | 3 + lang/en/ticketing.php | 3 + lang/pl/ticketing.php | 3 + routes/api.php | 6 ++ 9 files changed, 183 insertions(+) create mode 100644 app/Domains/Ticketing/Http/Controllers/Organizer/AttendeeController.php create mode 100644 app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php create mode 100644 app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php create mode 100644 app/Domains/Ticketing/Services/AttendeeService.php diff --git a/app/Domains/Ticketing/Enums/TicketingHttpEnum.php b/app/Domains/Ticketing/Enums/TicketingHttpEnum.php index 6137226..250963f 100644 --- a/app/Domains/Ticketing/Enums/TicketingHttpEnum.php +++ b/app/Domains/Ticketing/Enums/TicketingHttpEnum.php @@ -12,4 +12,8 @@ enum TicketingHttpEnum: string case TICKETS_LISTED = 'ticketing.tickets_listed'; case TICKET_SHOWN = 'ticketing.ticket_shown'; case TICKET_RESENT = 'ticketing.ticket_resent'; + case CHECK_IN_SUCCESS = 'ticketing.check_in_success'; + case ALREADY_CHECKED_IN = 'ticketing.already_checked_in'; + case TICKETS_EXPORT_STARTED = 'ticketing.tickets_export_started'; + case UNAUTHORIZED = 'ticketing.unauthorized'; } diff --git a/app/Domains/Ticketing/Http/Controllers/Organizer/AttendeeController.php b/app/Domains/Ticketing/Http/Controllers/Organizer/AttendeeController.php new file mode 100644 index 0000000..fa93f39 --- /dev/null +++ b/app/Domains/Ticketing/Http/Controllers/Organizer/AttendeeController.php @@ -0,0 +1,59 @@ +organizer_profile_id !== $request->user()->organizerProfile->id) { + throw new DomainException(TicketingHttpEnum::UNAUTHORIZED->value, Response::HTTP_FORBIDDEN); + } + + $tickets = $this->attendeeService->getPaginatedAttendees($event); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::TICKETS_LISTED->value, + Response::HTTP_OK, + TicketResource::collection($tickets) + ); + } + + public function export(Request $request, Event $event): JsonResponse + { + try { + if ($event->organizer_profile_id !== $request->user()->organizerProfile->id) { + throw new DomainException(TicketingHttpEnum::UNAUTHORIZED->value, Response::HTTP_FORBIDDEN); + } + + ExportAttendeesJob::dispatch($event, $request->user()); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::TICKETS_EXPORT_STARTED->value, + Response::HTTP_ACCEPTED + ); + } +} diff --git a/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php new file mode 100644 index 0000000..7478c7e --- /dev/null +++ b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php @@ -0,0 +1,45 @@ +select('id') + ->from('ticket_types') + ->where('event_id', $eventId); + }) + ->with(['ticketType', 'user']) + ->paginate($perPage); + } + + public function findByUuid(string $uuid): ?Ticket + { + return Ticket::where('uuid', $uuid) + ->with(['ticketType.event']) + ->first(); + } + + public function update(Ticket $ticket, array $data): bool + { + return $ticket->update($data); + } + + public function getAllForEvent(int $eventId): Collection + { + return Ticket::whereIn('ticket_type_id', function ($query) use ($eventId) { + $query->select('id') + ->from('ticket_types') + ->where('event_id', $eventId); + }) + ->with(['ticketType']) + ->get(); + } +} diff --git a/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php new file mode 100644 index 0000000..8e6c172 --- /dev/null +++ b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php @@ -0,0 +1,18 @@ +ticketRepository->getPaginatedForEvent($event->id, $perPage); + } + + public function checkInAttendee(string $uuid, int $organizerProfileId): void + { + $ticket = $this->ticketRepository->findByUuid($uuid); + + if (!$ticket) { + throw new DomainException(TicketingHttpEnum::TICKET_NOT_FOUND->value); + } + + if ($ticket->ticketType->event->organizer_profile_id !== $organizerProfileId) { + throw new DomainException(TicketingHttpEnum::UNAUTHORIZED->value, 403); + } + + if ($ticket->checked_in_at) { + throw new DomainException(TicketingHttpEnum::ALREADY_CHECKED_IN->value); + } + + $this->ticketRepository->update($ticket, [ + 'checked_in_at' => now(), + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 30ce17f..992d024 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use App\Domains\Events\Repositories\EventRepository; use App\Domains\Events\Repositories\EventRepositoryInterface; +use App\Domains\Ticketing\Repositories\Eloquent\TicketRepository; +use App\Domains\Ticketing\Repositories\TicketRepositoryInterface; use App\Domains\Venues\Repositories\VenueSubmissionRepository; use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; use App\Domains\Venues\Repositories\VenueRepository; @@ -20,6 +22,7 @@ public function register(): void $this->app->bind(VenueRepositoryInterface::class, VenueRepository::class); $this->app->bind(EventRepositoryInterface::class, EventRepository::class); $this->app->bind(VenueSubmissionRepositoryInterface::class, VenueSubmissionRepository::class); + $this->app->bind(TicketRepositoryInterface::class, TicketRepository::class); } /** diff --git a/lang/en/ticketing.php b/lang/en/ticketing.php index 99f194a..968f5c0 100644 --- a/lang/en/ticketing.php +++ b/lang/en/ticketing.php @@ -18,4 +18,7 @@ 'email_footer_text' => 'You received this email because you registered for an event on uCiebiewmiescie.pl.', 'all_rights_reserved' => 'All rights reserved.', 'privacy_policy' => 'Privacy Policy', + 'export_started' => 'Attendee export has started and will be available shortly.', + 'check_in_success' => 'Attendee checked in successfully.', + 'already_checked_in' => 'This ticket has already been checked in.', ]; diff --git a/lang/pl/ticketing.php b/lang/pl/ticketing.php index 25eaa55..fdeeb94 100644 --- a/lang/pl/ticketing.php +++ b/lang/pl/ticketing.php @@ -18,4 +18,7 @@ 'email_footer_text' => 'Otrzymałeś tę wiadomość, ponieważ zarejestrowałeś się na wydarzenie w serwisie uCiebiewmiescie.pl.', 'all_rights_reserved' => 'Wszelkie prawa zastrzeżone.', 'privacy_policy' => 'Polityka prywatności', + 'export_started' => 'Eksport uczestników został rozpoczęty i będzie wkrótce dostępny.', + 'check_in_success' => 'Uczestnik został pomyślnie zameldowany.', + 'already_checked_in' => 'Ten bilet został już wcześniej zameldowany.', ]; diff --git a/routes/api.php b/routes/api.php index b3b5e64..9fcd109 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,8 @@ use App\Domains\Events\Controllers\Organizer\CancelEventController; use App\Domains\Categories\Controllers\CategoryController; use App\Domains\Events\Controllers\PublicEventController; +use App\Domains\Ticketing\Http\Controllers\Organizer\AttendeeController; +use App\Domains\Ticketing\Http\Controllers\Organizer\CheckInController; use App\Domains\Ticketing\Http\Controllers\TicketController; use Illuminate\Support\Facades\Route; @@ -76,6 +78,10 @@ Route::post('venue-submissions', [VenueSubmissionController::class, 'store']); Route::get('venue-submissions/{venueSubmission}', [VenueSubmissionController::class, 'show']); + + Route::get('events/{event}/attendees', [AttendeeController::class, 'index']); + Route::post('events/{event}/attendees/export', [AttendeeController::class, 'export']); + Route::post('checkin/{uuid}', CheckInController::class); }); }); From c6baa6b1548b6fbb472a8332b97923a96dd39a35 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:23:03 +0100 Subject: [PATCH 49/81] feat(organizer): CSV export queued job with signed S3 download URL --- .../Ticketing/Jobs/ExportAttendeesJob.php | 44 +++++++++++++++ .../Services/AttendeeExportService.php | 54 +++++++++++++++++++ config/filesystems.php | 11 +++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Ticketing/Jobs/ExportAttendeesJob.php create mode 100644 app/Domains/Ticketing/Services/AttendeeExportService.php diff --git a/app/Domains/Ticketing/Jobs/ExportAttendeesJob.php b/app/Domains/Ticketing/Jobs/ExportAttendeesJob.php new file mode 100644 index 0000000..d67c072 --- /dev/null +++ b/app/Domains/Ticketing/Jobs/ExportAttendeesJob.php @@ -0,0 +1,44 @@ +exportToCsv($this->event); + + // TODO: Send notification to the user with the download link + } + + public function failed(Exception $exception): void + { + Log::error('ExportAttendeesJob failed', [ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + } +} diff --git a/app/Domains/Ticketing/Services/AttendeeExportService.php b/app/Domains/Ticketing/Services/AttendeeExportService.php new file mode 100644 index 0000000..2732859 --- /dev/null +++ b/app/Domains/Ticketing/Services/AttendeeExportService.php @@ -0,0 +1,54 @@ +tickets->getAllForEvent($event->id); + + $filename = 'exports/attendees_' . $event->id . '_' . Str::random(8) . '.csv'; + + $handle = fopen('php://temp', 'r+'); + + fputcsv($handle, [ + 'Ticket UUID', + 'Attendee Name', + 'Attendee Email', + 'Ticket Type', + 'Status', + 'Checked In At', + 'Registered At' + ]); + + foreach ($tickets as $ticket) { + fputcsv($handle, [ + $ticket->uuid, + $ticket->name, + $ticket->email, + $ticket->ticketType->name, + $ticket->status->value, + $ticket->checked_in_at?->toDateTimeString(), + $ticket->created_at->toDateTimeString(), + ]); + } + + rewind($handle); + $csvContent = stream_get_contents($handle); + fclose($handle); + + Storage::disk('exports')->put($filename, $csvContent); + + return $filename; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..e3a33b6 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -41,7 +41,7 @@ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/') . '/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, @@ -60,6 +60,15 @@ 'report' => false, ], + 'exports' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/exports'), + 'url' => env('APP_URL') . '/storage/exports', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + ], /* From e95c614d8339c948e1652d959a18995bdc031bd8 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:23:29 +0100 Subject: [PATCH 50/81] feat(organizer): QR check-in endpoint with event ownership validation --- .../Organizer/CheckInController.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 app/Domains/Ticketing/Http/Controllers/Organizer/CheckInController.php diff --git a/app/Domains/Ticketing/Http/Controllers/Organizer/CheckInController.php b/app/Domains/Ticketing/Http/Controllers/Organizer/CheckInController.php new file mode 100644 index 0000000..effa810 --- /dev/null +++ b/app/Domains/Ticketing/Http/Controllers/Organizer/CheckInController.php @@ -0,0 +1,35 @@ +attendeeService->checkInAttendee( + $uuid, + $request->user()->organizerProfile->id + ); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + TicketingHttpEnum::CHECK_IN_SUCCESS->value, + Response::HTTP_OK + ); + } +} From ffa13257ac5b4972c84d2ad7c5424aed3442b18c Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:23:51 +0100 Subject: [PATCH 51/81] test(organizer): attendee management feature tests (list, export, check-in) --- .../Ticketing/AttendeeManagementTest.php | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/Feature/Ticketing/AttendeeManagementTest.php diff --git a/tests/Feature/Ticketing/AttendeeManagementTest.php b/tests/Feature/Ticketing/AttendeeManagementTest.php new file mode 100644 index 0000000..d40968a --- /dev/null +++ b/tests/Feature/Ticketing/AttendeeManagementTest.php @@ -0,0 +1,139 @@ +forgetCachedPermissions(); + + if (!Role::where('name', 'organizer')->where('guard_name', 'api')->exists()) { + Role::create(['name' => 'organizer', 'guard_name' => 'api']); + } + + $this->organizer = User::factory()->create(); + $this->organizer->assignRole('organizer'); + OrganizerProfile::factory()->create([ + 'user_id' => $this->organizer->id, + 'organization_name' => 'Test Org' + ]); + + $this->event = Event::factory()->create([ + 'organizer_profile_id' => $this->organizer->organizerProfile->id, + ]); + } + + public function test_organizer_can_list_attendees_for_their_event() + { + $this->setupOrganizer(); + $ticketType = TicketType::factory()->create(['event_id' => $this->event->id]); + Ticket::factory()->count(3)->create(['ticket_type_id' => $ticketType->id]); + + $response = $this->actingAs($this->organizer) + ->getJson("/api/v1/organizer/events/{$this->event->id}/attendees"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + public function test_organizer_cannot_list_attendees_for_other_events() + { + $this->setupOrganizer(); + $otherOrganizer = User::factory()->create(); + OrganizerProfile::factory()->create([ + 'user_id' => $otherOrganizer->id, + 'organization_name' => 'Other Org' + ]); + $otherEvent = Event::factory()->create([ + 'organizer_profile_id' => $otherOrganizer->organizerProfile->id, + ]); + + $response = $this->actingAs($this->organizer) + ->getJson("/api/v1/organizer/events/{$otherEvent->id}/attendees"); + + $response->assertStatus(403); + } + + public function test_organizer_can_check_in_attendee() + { + $this->setupOrganizer(); + $ticketType = TicketType::factory()->create(['event_id' => $this->event->id]); + $ticket = Ticket::factory()->create(['ticket_type_id' => $ticketType->id]); + + $response = $this->actingAs($this->organizer) + ->postJson("/api/v1/organizer/checkin/{$ticket->uuid}"); + + $response->assertStatus(200); + $this->assertNotNull($ticket->fresh()->checked_in_at); + } + + public function test_organizer_cannot_check_in_attendee_for_other_event() + { + $this->setupOrganizer(); + $otherOrganizer = User::factory()->create(); + OrganizerProfile::factory()->create([ + 'user_id' => $otherOrganizer->id, + 'organization_name' => 'Other Org' + ]); + $otherEvent = Event::factory()->create([ + 'organizer_profile_id' => $otherOrganizer->organizerProfile->id, + ]); + $ticketType = TicketType::factory()->create(['event_id' => $otherEvent->id]); + $ticket = Ticket::factory()->create(['ticket_type_id' => $ticketType->id]); + + $response = $this->actingAs($this->organizer) + ->postJson("/api/v1/organizer/checkin/{$ticket->uuid}"); + + $response->assertStatus(403); + $this->assertNull($ticket->fresh()->checked_in_at); + } + + public function test_organizer_can_trigger_attendee_export() + { + $this->setupOrganizer(); + Queue::fake(); + + $response = $this->actingAs($this->organizer) + ->postJson("/api/v1/organizer/events/{$this->event->id}/attendees/export"); + + $response->assertStatus(202); + Queue::assertPushed(ExportAttendeesJob::class); + } + + public function test_export_job_generates_csv_file() + { + $this->setupOrganizer(); + Storage::fake('exports'); + $ticketType = TicketType::factory()->create(['event_id' => $this->event->id]); + Ticket::factory()->create(['ticket_type_id' => $ticketType->id, 'name' => 'John Doe']); + + $service = app(AttendeeExportService::class); + (new ExportAttendeesJob($this->event, $this->organizer))->handle($service); + + $files = Storage::disk('exports')->files('exports'); + $this->assertNotEmpty($files); + + $content = Storage::disk('exports')->get($files[0]); + $this->assertStringContainsString('John Doe', $content); + } +} From 7958b693f47d74adbc29034807764d21ede96b7c Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:25:41 +0100 Subject: [PATCH 52/81] refactor(errors): standardize domain error handling across controllers and services --- .../Auth/Controllers/LoginController.php | 6 +----- .../Auth/Controllers/RegisterController.php | 6 +----- .../Controllers/CategoryController.php | 6 +++--- .../Categories/Enums/CategoryHttpEnum.php | 9 +++++++++ .../Organizer/OrganizerEventController.php | 19 ++++-------------- .../Controllers/PublicEventController.php | 13 ++++++------ app/Domains/Events/Enums/EventHttpEnum.php | 1 + app/Domains/Events/Services/EventService.php | 9 +++++---- app/Domains/Shared/Enums/AppHttpEnum.php | 1 + app/Domains/Shared/Http/BaseController.php | 5 +++-- .../Controllers/VenueSubmissionController.php | 20 +++++++------------ .../Services/AdminVenueSubmissionService.php | 5 +++-- bootstrap/app.php | 10 +++++++++- 13 files changed, 54 insertions(+), 56 deletions(-) create mode 100644 app/Domains/Categories/Enums/CategoryHttpEnum.php diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php index 7aab942..eb2433d 100644 --- a/app/Domains/Auth/Controllers/LoginController.php +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -23,11 +23,7 @@ public function __invoke(LoginRequest $request): JsonResponse try { $result = $this->authService->loginUser($request->validated()); } catch (Throwable $e) { - return $this->apiResponseError( - $e, - UserRegisterHttpEnum::LOGIN_ERROR->value, - $e instanceof ValidationException ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_INTERNAL_SERVER_ERROR - ); + return $this->apiResponseError($e, UserRegisterHttpEnum::LOGIN_ERROR->value); } return $this->apiResponseSuccess( diff --git a/app/Domains/Auth/Controllers/RegisterController.php b/app/Domains/Auth/Controllers/RegisterController.php index 300bd7f..9ede9db 100644 --- a/app/Domains/Auth/Controllers/RegisterController.php +++ b/app/Domains/Auth/Controllers/RegisterController.php @@ -22,11 +22,7 @@ public function __invoke(RegisterRequest $request): JsonResponse try { $result = $this->authService->registerUser($request->validated()); } catch (Throwable $e) { - return $this->apiResponseError( - $e, - UserRegisterHttpEnum::REGISTER_ERROR->value, - Response::HTTP_INTERNAL_SERVER_ERROR - ); + return $this->apiResponseError($e, UserRegisterHttpEnum::REGISTER_ERROR->value); } return $this->apiResponseSuccess( diff --git a/app/Domains/Categories/Controllers/CategoryController.php b/app/Domains/Categories/Controllers/CategoryController.php index 15f9cdb..903984c 100644 --- a/app/Domains/Categories/Controllers/CategoryController.php +++ b/app/Domains/Categories/Controllers/CategoryController.php @@ -2,8 +2,8 @@ namespace App\Domains\Categories\Controllers; +use App\Domains\Categories\Enums\CategoryHttpEnum; use App\Domains\Events\Models\Category; -use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Http\BaseController; use Exception; use Illuminate\Http\JsonResponse; @@ -16,11 +16,11 @@ public function index(): JsonResponse try { $categories = Category::withCount('events')->get(); } catch (Exception $e) { - return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e, CategoryHttpEnum::INDEX_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); } return $this->apiResponseSuccess( - 'categories.index.success', + CategoryHttpEnum::INDEX_SUCCESS->value, Response::HTTP_OK, ['categories' => $categories] ); diff --git a/app/Domains/Categories/Enums/CategoryHttpEnum.php b/app/Domains/Categories/Enums/CategoryHttpEnum.php new file mode 100644 index 0000000..ce095eb --- /dev/null +++ b/app/Domains/Categories/Enums/CategoryHttpEnum.php @@ -0,0 +1,9 @@ +input('per_page', 15) ); } catch (Exception $e) { - return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -56,7 +56,7 @@ public function store(StoreEventRequest $request): JsonResponse $organizerProfileId = $request->user()->organizerProfile->id; $event = $this->eventService->createEventForOrganizer($organizerProfileId, $request->validated()); } catch (Exception $e) { - return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -70,11 +70,6 @@ public function show(Event $event): JsonResponse { $this->authorize('view', $event); - try { - } catch (Exception $e) { - return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); - } - return $this->apiResponseSuccess( EventHttpEnum::SHOW_SUCCESS->value, Response::HTTP_OK, @@ -89,7 +84,7 @@ public function update(UpdateEventRequest $request, Event $event): JsonResponse try { $updatedEvent = $this->eventService->updateEvent($event, $request->validated()); } catch (Exception $e) { - return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -105,14 +100,8 @@ public function destroy(Event $event): JsonResponse try { $this->eventService->deleteEvent($event); - } catch (DomainException $e) { - return $this->apiResponseError( - $e, - $e->getMessage(), - Response::HTTP_UNPROCESSABLE_ENTITY - ); } catch (Exception $e) { - return $this->apiResponseError($e, 'error.server', Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( diff --git a/app/Domains/Events/Controllers/PublicEventController.php b/app/Domains/Events/Controllers/PublicEventController.php index 75f2fc0..6e84a02 100644 --- a/app/Domains/Events/Controllers/PublicEventController.php +++ b/app/Domains/Events/Controllers/PublicEventController.php @@ -8,6 +8,7 @@ use App\Domains\Events\Models\Event; use App\Domains\Events\Requests\IndexPublicEventsRequest; use App\Domains\Events\Transformers\EventResource; +use App\Domains\Shared\Exceptions\DomainException; use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Http\BaseController; use Exception; @@ -44,7 +45,7 @@ public function index(IndexPublicEventsRequest $request): JsonResponse $perPage = (int) $request->input('per_page', 15); $events = $query->paginate($perPage); } catch (Exception $e) { - return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -58,14 +59,14 @@ public function index(IndexPublicEventsRequest $request): JsonResponse public function show(Event $event): JsonResponse { - if ($event->status !== EventStatusEnum::PUBLISHED) { - return response()->json(['message' => 'Event not found.'], Response::HTTP_NOT_FOUND); - } - try { + if ($event->status !== EventStatusEnum::PUBLISHED) { + throw new DomainException(EventHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); + } + IncrementEventViewCountJob::dispatch($event->id); } catch (Exception $e) { - return $this->apiResponseError($e, AppHttpEnum::SERVER_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( diff --git a/app/Domains/Events/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php index 9b39ea9..e109fe2 100644 --- a/app/Domains/Events/Enums/EventHttpEnum.php +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -20,4 +20,5 @@ enum EventHttpEnum: string case DESTROY_INVALID_STATUS = 'events.destroy.invalid_status'; case PUBLISH_ALREADY_PUBLISHED = 'events.publish.already_published'; case PUBLISH_CANNOT_PUBLISH_CANCELLED = 'events.publish.cannot_publish_cancelled'; + case NOT_FOUND = 'events.not_found'; } diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index 4bd9234..9beae92 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -4,6 +4,7 @@ use App\Domains\Events\Enums\EventHttpEnum; use App\Domains\Events\Enums\EventStatusEnum; +use Symfony\Component\HttpFoundation\Response; use App\Domains\Events\Models\Event; use App\Domains\Events\Repositories\EventRepositoryInterface; @@ -48,7 +49,7 @@ public function updateEvent(Event $event, array $data): Event public function deleteEvent(Event $event): void { if ($event->status !== EventStatusEnum::DRAFT && $event->status !== EventStatusEnum::CANCELLED) { - throw new DomainException(EventHttpEnum::DESTROY_INVALID_STATUS->value); + throw new DomainException(EventHttpEnum::DESTROY_INVALID_STATUS->value, Response::HTTP_UNPROCESSABLE_ENTITY); } $this->eventRepository->delete($event); @@ -57,11 +58,11 @@ public function deleteEvent(Event $event): void public function publishEvent(Event $event): Event { if ($event->status === EventStatusEnum::PUBLISHED) { - throw new DomainException(EventHttpEnum::PUBLISH_ALREADY_PUBLISHED->value); + throw new DomainException(EventHttpEnum::PUBLISH_ALREADY_PUBLISHED->value, Response::HTTP_UNPROCESSABLE_ENTITY); } if ($event->status === EventStatusEnum::CANCELLED) { - throw new DomainException(EventHttpEnum::PUBLISH_CANNOT_PUBLISH_CANCELLED->value); + throw new DomainException(EventHttpEnum::PUBLISH_CANNOT_PUBLISH_CANCELLED->value, Response::HTTP_UNPROCESSABLE_ENTITY); } $this->eventRepository->update($event, ['status' => EventStatusEnum::PUBLISHED->value]); @@ -72,7 +73,7 @@ public function publishEvent(Event $event): Event public function cancelEvent(Event $event): Event { if ($event->status === EventStatusEnum::CANCELLED) { - throw new DomainException(EventHttpEnum::CANCEL_ALREADY_CANCELLED->value); + throw new DomainException(EventHttpEnum::CANCEL_ALREADY_CANCELLED->value, Response::HTTP_UNPROCESSABLE_ENTITY); } $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); diff --git a/app/Domains/Shared/Enums/AppHttpEnum.php b/app/Domains/Shared/Enums/AppHttpEnum.php index 631fb16..3a46e67 100644 --- a/app/Domains/Shared/Enums/AppHttpEnum.php +++ b/app/Domains/Shared/Enums/AppHttpEnum.php @@ -6,4 +6,5 @@ enum AppHttpEnum: string { case SERVER_ERROR = 'error.server'; case UNKNOWN_ERROR = 'error.unknown'; + case UNAUTHORIZED = 'error.unauthorized'; } diff --git a/app/Domains/Shared/Http/BaseController.php b/app/Domains/Shared/Http/BaseController.php index 7cab131..e37dcb5 100644 --- a/app/Domains/Shared/Http/BaseController.php +++ b/app/Domains/Shared/Http/BaseController.php @@ -5,6 +5,7 @@ use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Transformers\ApiResponseResource; use App\Http\Controllers\Controller; +use App\Domains\Shared\Exceptions\DomainException; use Illuminate\Http\JsonResponse; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Validation\ValidationException; @@ -32,8 +33,8 @@ protected function apiResponseError(Throwable $error, ?string $message = null, ? // report($error); Log::error($error); - $message = $message ?? AppHttpEnum::SERVER_ERROR->value; - $statusCode = $statusCode ?? ($error->getCode() ?: 500); + $message = $message ?? ($error instanceof DomainException ? $error->getMessage() : AppHttpEnum::SERVER_ERROR->value); + $statusCode = $statusCode ?? ($error instanceof ValidationException ? 422 : ($error->getCode() ?: 500)); $response = ApiResponseResource::error($message); diff --git a/app/Domains/Venues/Controllers/VenueSubmissionController.php b/app/Domains/Venues/Controllers/VenueSubmissionController.php index 6440d8c..d586da9 100644 --- a/app/Domains/Venues/Controllers/VenueSubmissionController.php +++ b/app/Domains/Venues/Controllers/VenueSubmissionController.php @@ -2,6 +2,8 @@ namespace App\Domains\Venues\Controllers; +use App\Domains\Shared\Enums\AppHttpEnum; +use App\Domains\Shared\Exceptions\DomainException; use App\Domains\Shared\Http\BaseController; use App\Domains\Venues\Enums\VenueSubmissionHttpEnum; use App\Domains\Venues\Enums\VenueSubmissionStatusEnum; @@ -29,7 +31,7 @@ public function index(Request $request): JsonResponse $submissions = $this->venueSubmissionService->getPaginatedSubmissions($filters, 15); } catch (Exception $e) { - return $this->apiResponseError($e, VenueSubmissionHttpEnum::INDEX_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -47,11 +49,7 @@ public function store(StoreVenueSubmissionRequest $request): JsonResponse $organizerProfile = $request->user()->organizerProfile; if (!$organizerProfile) { - return $this->apiResponseError( - new Exception('Organizer profile not found'), - VenueSubmissionHttpEnum::NOT_FOUND_ERROR->value, - Response::HTTP_FORBIDDEN - ); + throw new DomainException(VenueSubmissionHttpEnum::NOT_FOUND_ERROR->value, Response::HTTP_FORBIDDEN); } $submission = $this->venueSubmissionService->submitVenue( @@ -59,7 +57,7 @@ public function store(StoreVenueSubmissionRequest $request): JsonResponse $request->validated() ); } catch (Exception $e) { - return $this->apiResponseError($e, VenueSubmissionHttpEnum::STORE_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( @@ -75,14 +73,10 @@ public function show(Request $request, VenueSubmission $venueSubmission): JsonRe { try { if ($venueSubmission->organizer_profile_id !== $request->user()->organizerProfile?->id) { - return $this->apiResponseError( - new Exception('Forbidden'), - VenueSubmissionHttpEnum::FORBIDDEN_ERROR->value, - Response::HTTP_FORBIDDEN - ); + throw new DomainException(VenueSubmissionHttpEnum::FORBIDDEN_ERROR->value, Response::HTTP_FORBIDDEN); } } catch (Exception $e) { - return $this->apiResponseError($e, VenueSubmissionHttpEnum::SHOW_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( diff --git a/app/Domains/Venues/Services/AdminVenueSubmissionService.php b/app/Domains/Venues/Services/AdminVenueSubmissionService.php index 6204b4c..dc23f2a 100644 --- a/app/Domains/Venues/Services/AdminVenueSubmissionService.php +++ b/app/Domains/Venues/Services/AdminVenueSubmissionService.php @@ -11,6 +11,7 @@ use App\Domains\Venues\Notifications\VenueRejectedNotification; use App\Domains\Venues\Repositories\VenueRepositoryInterface; use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; +use Symfony\Component\HttpFoundation\Response; class AdminVenueSubmissionService { @@ -53,7 +54,7 @@ public function approve(VenueSubmission $venueSubmission): void public function reject(VenueSubmission $venueSubmission, string $reason): void { if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { - throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value); + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value, Response::HTTP_UNPROCESSABLE_ENTITY); } $this->venueSubmissionRepository->update($venueSubmission, [ @@ -76,7 +77,7 @@ public function merge(VenueSubmission $venueSubmission, int $venueId): void $venue = $this->venueRepository->findById($venueId); if (!$venue) { - throw new DomainException(VenueHttpEnum::NOT_FOUND->value); + throw new DomainException(VenueHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); } $this->venueSubmissionRepository->update($venueSubmission, [ diff --git a/bootstrap/app.php b/bootstrap/app.php index 2b740c1..794c801 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->render(function (DomainException $e, $request) { + if ($request->is('api/*')) { + return ApiResponseResource::error($e->getMessage()) + ->response() + ->setStatusCode($e->getCode() ?: 400); + } + }); })->create(); From 73be8a751570e991aeb5d8bf3b8224e8a9463b6c Mon Sep 17 00:00:00 2001 From: monte-dev Date: Tue, 24 Feb 2026 01:28:16 +0100 Subject: [PATCH 53/81] chore(api): add missing bruno ednpoints --- bruno/Organizer/CheckIn/CheckInController.bru | 15 ++++++++++++++ .../Events/AttendeeController@export.bru | 15 ++++++++++++++ .../Events/AttendeeController@index.bru | 15 ++++++++++++++ .../Public/Tickets/TicketController@index.bru | 11 ++++++++++ .../Tickets/TicketController@resend.bru | 15 ++++++++++++++ .../Public/Tickets/TicketController@show.bru | 15 ++++++++++++++ .../Public/Tickets/TicketController@store.bru | 20 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 bruno/Organizer/CheckIn/CheckInController.bru create mode 100644 bruno/Organizer/Events/AttendeeController@export.bru create mode 100644 bruno/Organizer/Events/AttendeeController@index.bru create mode 100644 bruno/Public/Tickets/TicketController@index.bru create mode 100644 bruno/Public/Tickets/TicketController@resend.bru create mode 100644 bruno/Public/Tickets/TicketController@show.bru create mode 100644 bruno/Public/Tickets/TicketController@store.bru diff --git a/bruno/Organizer/CheckIn/CheckInController.bru b/bruno/Organizer/CheckIn/CheckInController.bru new file mode 100644 index 0000000..c5cec41 --- /dev/null +++ b/bruno/Organizer/CheckIn/CheckInController.bru @@ -0,0 +1,15 @@ +meta { + name: CheckInController + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/checkin/{{ticketUuid}} + body: none + auth: inherit +} + +vars:pre-request { + ticketUuid: some-uuid-here +} diff --git a/bruno/Organizer/Events/AttendeeController@export.bru b/bruno/Organizer/Events/AttendeeController@export.bru new file mode 100644 index 0000000..7f82a0a --- /dev/null +++ b/bruno/Organizer/Events/AttendeeController@export.bru @@ -0,0 +1,15 @@ +meta { + name: AttendeeController@export + type: http + seq: 9 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/{{eventId}}/attendees/export + body: none + auth: inherit +} + +vars:pre-request { + eventId: 1 +} diff --git a/bruno/Organizer/Events/AttendeeController@index.bru b/bruno/Organizer/Events/AttendeeController@index.bru new file mode 100644 index 0000000..5666e31 --- /dev/null +++ b/bruno/Organizer/Events/AttendeeController@index.bru @@ -0,0 +1,15 @@ +meta { + name: AttendeeController@index + type: http + seq: 8 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/{{eventId}}/attendees + body: none + auth: inherit +} + +vars:pre-request { + eventId: 1 +} diff --git a/bruno/Public/Tickets/TicketController@index.bru b/bruno/Public/Tickets/TicketController@index.bru new file mode 100644 index 0000000..298ba32 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@index.bru @@ -0,0 +1,11 @@ +meta { + name: TicketController@index + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/tickets + body: none + auth: inherit +} diff --git a/bruno/Public/Tickets/TicketController@resend.bru b/bruno/Public/Tickets/TicketController@resend.bru new file mode 100644 index 0000000..ff406e9 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@resend.bru @@ -0,0 +1,15 @@ +meta { + name: TicketController@resend + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/tickets/{{ticketUuid}}/resend + body: none + auth: none +} + +vars:pre-request { + ticketUuid: some-uuid-here +} diff --git a/bruno/Public/Tickets/TicketController@show.bru b/bruno/Public/Tickets/TicketController@show.bru new file mode 100644 index 0000000..ee6f5cc --- /dev/null +++ b/bruno/Public/Tickets/TicketController@show.bru @@ -0,0 +1,15 @@ +meta { + name: TicketController@show + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/tickets/{{ticketUuid}} + body: none + auth: none +} + +vars:pre-request { + ticketUuid: some-uuid-here +} diff --git a/bruno/Public/Tickets/TicketController@store.bru b/bruno/Public/Tickets/TicketController@store.bru new file mode 100644 index 0000000..f8e7884 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@store.bru @@ -0,0 +1,20 @@ +meta { + name: TicketController@store + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/tickets/register + body: json + auth: none +} + +body:json { + { + "event_id": 1, + "email": "test@example.com", + "first_name": "Test", + "last_name": "User" + } +} From 7cf2b9dace0f7c2e2fed73c86a0c5c27366dc00d Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 01:30:49 +0100 Subject: [PATCH 54/81] feat(events): update events schema and create event_instances table, relations factories --- app/Domains/Events/Models/Event.php | 8 +++ app/Domains/Events/Models/EventInstance.php | 38 +++++++++++++ app/Domains/Ticketing/Models/Order.php | 8 +-- app/Domains/Ticketing/Models/Ticket.php | 7 +++ app/Domains/Ticketing/Models/TicketType.php | 3 +- database/factories/EventFactory.php | 14 +++++ database/factories/EventInstanceFactory.php | 25 ++++++++ database/factories/OrderFactory.php | 4 +- database/factories/TicketFactory.php | 2 + .../2026_02_21_202153_create_events_table.php | 3 +- .../2026_02_21_202155_create_orders_table.php | 3 +- ...2026_02_21_202156_create_tickets_table.php | 3 +- ..._232002_add_recurrence_to_events_table.php | 30 ++++++++++ ...24_232022_create_event_instances_table.php | 34 +++++++++++ ...pdate_orders_and_tickets_for_instances.php | 57 +++++++++++++++++++ 15 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 app/Domains/Events/Models/EventInstance.php create mode 100644 database/factories/EventInstanceFactory.php create mode 100644 database/migrations/2026_02_24_232002_add_recurrence_to_events_table.php create mode 100644 database/migrations/2026_02_24_232022_create_event_instances_table.php create mode 100644 database/migrations/2026_02_24_232223_update_orders_and_tickets_for_instances.php diff --git a/app/Domains/Events/Models/Event.php b/app/Domains/Events/Models/Event.php index 2c2de26..5106fcd 100644 --- a/app/Domains/Events/Models/Event.php +++ b/app/Domains/Events/Models/Event.php @@ -30,6 +30,9 @@ protected static function newFactory() 'description_en', 'starts_at', 'ends_at', + 'is_recurring', + 'recurrence_rule', + 'recurrence_end_at', 'capacity', 'status', 'cover_image_path', @@ -74,4 +77,9 @@ public function ticketTypes() { return $this->hasMany(TicketType::class); } + + public function instances() + { + return $this->hasMany(EventInstance::class); + } } diff --git a/app/Domains/Events/Models/EventInstance.php b/app/Domains/Events/Models/EventInstance.php new file mode 100644 index 0000000..31f7b24 --- /dev/null +++ b/app/Domains/Events/Models/EventInstance.php @@ -0,0 +1,38 @@ + 'datetime', + 'ends_at' => 'datetime', + 'status' => EventStatusEnum::class, + ]; + + public function event() + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Domains/Ticketing/Models/Order.php b/app/Domains/Ticketing/Models/Order.php index af36dab..ad17ddb 100644 --- a/app/Domains/Ticketing/Models/Order.php +++ b/app/Domains/Ticketing/Models/Order.php @@ -3,7 +3,7 @@ namespace App\Domains\Ticketing\Models; use App\Domains\Auth\Models\User; -use App\Domains\Events\Models\Event; +use App\Domains\Events\Models\EventInstance; use App\Domains\Ticketing\Enums\OrderStatusEnum; use Database\Factories\OrderFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,7 +20,7 @@ protected static function newFactory() protected $fillable = [ 'user_id', - 'event_id', + 'event_instance_id', 'email', 'name', 'status', @@ -40,8 +40,8 @@ public function user() return $this->belongsTo(User::class); } - public function event() + public function eventInstance() { - return $this->belongsTo(Event::class); + return $this->belongsTo(EventInstance::class); } } diff --git a/app/Domains/Ticketing/Models/Ticket.php b/app/Domains/Ticketing/Models/Ticket.php index 831bd03..43e75e5 100644 --- a/app/Domains/Ticketing/Models/Ticket.php +++ b/app/Domains/Ticketing/Models/Ticket.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Models; use App\Domains\Auth\Models\User; +use App\Domains\Events\Models\EventInstance; use App\Domains\Ticketing\Enums\TicketStatusEnum; use Database\Factories\TicketFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,6 +20,7 @@ protected static function newFactory() protected $fillable = [ 'ticket_type_id', + 'event_instance_id', 'user_id', 'order_id', 'uuid', @@ -47,4 +49,9 @@ public function order() { return $this->belongsTo(Order::class); } + + public function eventInstance() + { + return $this->belongsTo(EventInstance::class); + } } diff --git a/app/Domains/Ticketing/Models/TicketType.php b/app/Domains/Ticketing/Models/TicketType.php index 05c99bb..fb99571 100644 --- a/app/Domains/Ticketing/Models/TicketType.php +++ b/app/Domains/Ticketing/Models/TicketType.php @@ -39,10 +39,11 @@ public function tickets() return $this->hasMany(Ticket::class); } - public function checkCapacity(): void + public function checkCapacity(int $eventInstanceId): void { if ($this->quantity !== null) { $confirmedTicketsCount = Ticket::where('ticket_type_id', $this->id) + ->where('event_instance_id', $eventInstanceId) ->whereIn('status', [ TicketStatusEnum::VALID, TicketStatusEnum::CHECKED_IN diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php index 5915a55..e8387c8 100644 --- a/database/factories/EventFactory.php +++ b/database/factories/EventFactory.php @@ -34,4 +34,18 @@ public function definition(): array 'view_count' => 0, ]; } + + public function configure() + { + return $this->afterCreating(function (Event $event) { + if ($event->instances()->count() === 0) { + $event->instances()->create([ + 'starts_at' => $event->starts_at, + 'ends_at' => $event->ends_at, + 'capacity' => $event->capacity, + 'status' => $event->status, + ]); + } + }); + } } diff --git a/database/factories/EventInstanceFactory.php b/database/factories/EventInstanceFactory.php new file mode 100644 index 0000000..213a86d --- /dev/null +++ b/database/factories/EventInstanceFactory.php @@ -0,0 +1,25 @@ + Event::factory(), + 'starts_at' => now()->addDay(), + 'ends_at' => now()->addDay()->addHours(2), + 'capacity' => 100, + 'status' => EventStatusEnum::PUBLISHED, + 'view_count' => 0, + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index 3b9fb55..83ceb9f 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -4,8 +4,8 @@ use App\Domains\Ticketing\Models\Order; use App\Domains\Ticketing\Enums\OrderStatusEnum; -use App\Domains\Events\Models\Event; use App\Domains\Auth\Models\User; +use App\Domains\Events\Models\EventInstance; use Illuminate\Database\Eloquent\Factories\Factory; class OrderFactory extends Factory @@ -15,7 +15,7 @@ class OrderFactory extends Factory public function definition(): array { return [ - 'event_id' => Event::factory(), + 'event_instance_id' => EventInstance::factory(), 'user_id' => User::factory(), 'email' => $this->faker->safeEmail, 'name' => $this->faker->name, diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index df1fd59..cc30efb 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -6,6 +6,7 @@ use App\Domains\Ticketing\Models\TicketType; use App\Domains\Ticketing\Models\Order; use App\Domains\Auth\Models\User; +use App\Domains\Events\Models\EventInstance; use App\Domains\Ticketing\Enums\TicketStatusEnum; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -18,6 +19,7 @@ public function definition(): array { return [ 'ticket_type_id' => TicketType::factory(), + 'event_instance_id' => EventInstance::factory(), 'order_id' => Order::factory(), 'user_id' => User::factory(), 'uuid' => (string) Str::uuid(), diff --git a/database/migrations/2026_02_21_202153_create_events_table.php b/database/migrations/2026_02_21_202153_create_events_table.php index 732e81d..12bf9a5 100644 --- a/database/migrations/2026_02_21_202153_create_events_table.php +++ b/database/migrations/2026_02_21_202153_create_events_table.php @@ -1,5 +1,6 @@ dateTime('starts_at'); $table->dateTime('ends_at'); $table->integer('capacity'); - $table->enum('status', ['draft', 'published', 'cancelled'])->default('draft'); + $table->enum('status', array_column(EventStatusEnum::cases(), 'value'))->default(EventStatusEnum::DRAFT->value); $table->string('cover_image_path')->nullable(); $table->unsignedInteger('view_count')->default(0); $table->timestamps(); diff --git a/database/migrations/2026_02_21_202155_create_orders_table.php b/database/migrations/2026_02_21_202155_create_orders_table.php index 2dafaf3..105a9b0 100644 --- a/database/migrations/2026_02_21_202155_create_orders_table.php +++ b/database/migrations/2026_02_21_202155_create_orders_table.php @@ -1,5 +1,6 @@ string('email')->nullable(); $table->string('name')->nullable(); $table->foreignId('event_id')->constrained()->cascadeOnDelete(); - $table->enum('status', ['pending', 'completed', 'cancelled', 'refunded'])->default('completed'); + $table->enum('status', array_column(OrderStatusEnum::cases(), 'value'))->default(OrderStatusEnum::COMPLETED->value); $table->timestamps(); }); } diff --git a/database/migrations/2026_02_21_202156_create_tickets_table.php b/database/migrations/2026_02_21_202156_create_tickets_table.php index 76e83c8..f128a30 100644 --- a/database/migrations/2026_02_21_202156_create_tickets_table.php +++ b/database/migrations/2026_02_21_202156_create_tickets_table.php @@ -1,5 +1,6 @@ uuid('uuid')->unique(); $table->string('name')->nullable(); $table->string('email')->nullable(); - $table->enum('status', ['valid', 'cancelled', 'checked_in'])->default('valid'); + $table->enum('status', array_column(TicketStatusEnum::cases(), 'value'))->default(TicketStatusEnum::VALID->value); $table->timestamp('checked_in_at')->nullable(); $table->timestamps(); }); diff --git a/database/migrations/2026_02_24_232002_add_recurrence_to_events_table.php b/database/migrations/2026_02_24_232002_add_recurrence_to_events_table.php new file mode 100644 index 0000000..5f354dd --- /dev/null +++ b/database/migrations/2026_02_24_232002_add_recurrence_to_events_table.php @@ -0,0 +1,30 @@ +boolean('is_recurring')->default(false)->after('ends_at'); + $table->text('recurrence_rule')->nullable()->after('is_recurring'); + $table->dateTime('recurrence_end_at')->nullable()->after('recurrence_rule'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn(['is_recurring', 'recurrence_rule', 'recurrence_end_at']); + }); + } +}; diff --git a/database/migrations/2026_02_24_232022_create_event_instances_table.php b/database/migrations/2026_02_24_232022_create_event_instances_table.php new file mode 100644 index 0000000..1e182d0 --- /dev/null +++ b/database/migrations/2026_02_24_232022_create_event_instances_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->dateTime('starts_at'); + $table->dateTime('ends_at'); + $table->integer('capacity'); + $table->enum('status', array_column(EventStatusEnum::cases(), 'value'))->default(EventStatusEnum::DRAFT->value); + $table->unsignedInteger('view_count')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_instances'); + } +}; diff --git a/database/migrations/2026_02_24_232223_update_orders_and_tickets_for_instances.php b/database/migrations/2026_02_24_232223_update_orders_and_tickets_for_instances.php new file mode 100644 index 0000000..afef6f0 --- /dev/null +++ b/database/migrations/2026_02_24_232223_update_orders_and_tickets_for_instances.php @@ -0,0 +1,57 @@ +dropConstrainedForeignId('event_id'); + } + if (!Schema::hasColumn('orders', 'event_instance_id')) { + $table->foreignId('event_instance_id')->after('name')->nullable()->constrained()->cascadeOnDelete(); + } + }); + + Schema::table('tickets', function (Blueprint $table) { + if (Schema::hasColumn('tickets', 'event_id')) { + $table->dropConstrainedForeignId('event_id'); + } + if (!Schema::hasColumn('tickets', 'event_instance_id')) { + $table->foreignId('event_instance_id')->after('order_id')->nullable()->constrained()->cascadeOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + if (Schema::hasColumn('tickets', 'event_instance_id')) { + $table->dropConstrainedForeignId('event_instance_id'); + } + if (!Schema::hasColumn('tickets', 'event_id')) { + $table->foreignId('event_id')->after('order_id')->nullable()->constrained()->cascadeOnDelete(); + } + }); + + Schema::table('orders', function (Blueprint $table) { + if (Schema::hasColumn('orders', 'event_instance_id')) { + $table->dropConstrainedForeignId('event_instance_id'); + } + if (!Schema::hasColumn('orders', 'event_id')) { + $table->foreignId('event_id')->after('name')->nullable()->constrained()->cascadeOnDelete(); + } + }); + } +}; From 179e3aebeb4d8f0bd10dd6431fe9811d6aa20916 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 01:32:58 +0100 Subject: [PATCH 55/81] feat(events): implement SyncEventInstancesAction and RecurrenceFrequencyEnum --- .../Actions/SyncEventInstancesAction.php | 81 +++++++++++++++++++ .../Events/Enums/RecurrenceFrequencyEnum.php | 12 +++ 2 files changed, 93 insertions(+) create mode 100644 app/Domains/Events/Actions/SyncEventInstancesAction.php create mode 100644 app/Domains/Events/Enums/RecurrenceFrequencyEnum.php diff --git a/app/Domains/Events/Actions/SyncEventInstancesAction.php b/app/Domains/Events/Actions/SyncEventInstancesAction.php new file mode 100644 index 0000000..7496653 --- /dev/null +++ b/app/Domains/Events/Actions/SyncEventInstancesAction.php @@ -0,0 +1,81 @@ +is_recurring) { + $this->syncSingleInstance($event); + return; + } + + $this->syncRecurringInstances($event); + }); + } + + protected function syncSingleInstance(Event $event): void + { + EventInstance::updateOrCreate( + ['event_id' => $event->id], + [ + 'starts_at' => $event->starts_at, + 'ends_at' => $event->ends_at, + 'capacity' => $event->capacity, + 'status' => $event->status, + ] + ); + } + + protected function syncRecurringInstances(Event $event): void + { + if (!$event->recurrence_rule || !$event->recurrence_end_at) { + return; + } + + $frequency = $event->recurrence_rule; + $startDate = $event->starts_at->copy(); + $endDate = $event->recurrence_end_at; + $eventDurationMinutes = $event->starts_at->diffInMinutes($event->ends_at); + + $currentDate = $startDate->copy(); + + while ($currentDate->lte($endDate)) { + EventInstance::updateOrCreate( + [ + 'event_id' => $event->id, + 'starts_at' => $currentDate->copy(), + ], + [ + 'ends_at' => $currentDate->copy()->addMinutes($eventDurationMinutes), + 'capacity' => $event->capacity, + 'status' => $event->status, + ] + ); + + switch ($frequency) { + case RecurrenceFrequencyEnum::DAILY->value: + $currentDate->addDay(); + break; + case RecurrenceFrequencyEnum::WEEKLY->value: + $currentDate->addWeek(); + break; + case RecurrenceFrequencyEnum::BIWEEKLY->value: + $currentDate->addWeeks(2); + break; + case RecurrenceFrequencyEnum::MONTHLY->value: + $currentDate->addMonth(); + break; + default: + break 2; + } + } + } +} diff --git a/app/Domains/Events/Enums/RecurrenceFrequencyEnum.php b/app/Domains/Events/Enums/RecurrenceFrequencyEnum.php new file mode 100644 index 0000000..285e231 --- /dev/null +++ b/app/Domains/Events/Enums/RecurrenceFrequencyEnum.php @@ -0,0 +1,12 @@ + Date: Wed, 25 Feb 2026 01:38:04 +0100 Subject: [PATCH 56/81] feat(update RegisterAttendeeAction and resources for new events instance logic --- app/Domains/Events/Services/EventService.php | 11 +++++++-- .../Transformers/EventInstanceResource.php | 23 +++++++++++++++++++ .../Events/Transformers/EventResource.php | 4 ++++ .../Actions/RegisterAttendeeAction.php | 9 ++++---- 4 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 app/Domains/Events/Transformers/EventInstanceResource.php diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index 9beae92..451c43e 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -2,6 +2,7 @@ namespace App\Domains\Events\Services; +use App\Domains\Events\Actions\SyncEventInstancesAction; use App\Domains\Events\Enums\EventHttpEnum; use App\Domains\Events\Enums\EventStatusEnum; use Symfony\Component\HttpFoundation\Response; @@ -15,7 +16,8 @@ class EventService { public function __construct( - protected EventRepositoryInterface $eventRepository + protected EventRepositoryInterface $eventRepository, + protected SyncEventInstancesAction $syncEventInstancesAction ) {} public function getPaginatedEventsForOrganizer(int $organizerProfileId, int $perPage = 15): LengthAwarePaginator @@ -36,13 +38,18 @@ public function createEventForOrganizer(int $organizerProfileId, array $data): E $data['slug'] = Str::slug($data['title']) . '-' . uniqid(); } - return $this->eventRepository->create($data); + $event = $this->eventRepository->create($data); + $this->syncEventInstancesAction->execute($event); + + return $event; } public function updateEvent(Event $event, array $data): Event { // todo: post MVP: If title changes,update slug $this->eventRepository->update($event, $data); + $this->syncEventInstancesAction->execute($event->refresh()); + return $event->refresh(); } diff --git a/app/Domains/Events/Transformers/EventInstanceResource.php b/app/Domains/Events/Transformers/EventInstanceResource.php new file mode 100644 index 0000000..9cc2539 --- /dev/null +++ b/app/Domains/Events/Transformers/EventInstanceResource.php @@ -0,0 +1,23 @@ + $this->id, + 'event_id' => $this->event_id, + 'starts_at' => $this->starts_at, + 'ends_at' => $this->ends_at, + 'capacity' => $this->capacity, + 'status' => $this->status, + 'view_count' => $this->view_count, + 'event' => new EventResource($this->whenLoaded('event')), + ]; + } +} diff --git a/app/Domains/Events/Transformers/EventResource.php b/app/Domains/Events/Transformers/EventResource.php index fd95c27..2a14c41 100644 --- a/app/Domains/Events/Transformers/EventResource.php +++ b/app/Domains/Events/Transformers/EventResource.php @@ -24,8 +24,12 @@ public function toArray(Request $request): array 'ends_at' => $this->ends_at?->toIso8601String(), 'capacity' => $this->capacity, 'status' => $this->status, + 'is_recurring' => $this->is_recurring, + 'recurrence_rule' => $this->recurrence_rule, + 'recurrence_end_at' => $this->recurrence_end_at?->toIso8601String(), 'cover_image_path' => $this->cover_image_path, 'view_count' => $this->view_count, + 'instances' => EventResource::collection($this->whenLoaded('instances')), 'created_at' => $this->created_at?->toIso8601String(), 'updated_at' => $this->updated_at?->toIso8601String(), ]; diff --git a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php index 3470972..e7641b7 100644 --- a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php +++ b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php @@ -14,20 +14,20 @@ class RegisterAttendeeAction { - public function execute(int $ticketTypeId, array $attendeeData, ?User $user = null): Ticket + public function execute(int $ticketTypeId, int $eventInstanceId, array $attendeeData, ?User $user = null): Ticket { - return DB::transaction(function () use ($ticketTypeId, $attendeeData, $user) { + return DB::transaction(function () use ($ticketTypeId, $eventInstanceId, $attendeeData, $user) { $query = TicketType::query(); if (DB::getDriverName() !== 'sqlite') { $query->lockForUpdate(); } $ticketType = $query->findOrFail($ticketTypeId); - $ticketType->checkCapacity(); + $ticketType->checkCapacity($eventInstanceId); $order = Order::create([ 'user_id' => $user?->id, - 'event_id' => $ticketType->event_id, + 'event_instance_id' => $eventInstanceId, 'email' => $user?->email ?? data_get($attendeeData, 'email'), 'name' => $user?->name ?? data_get($attendeeData, 'name'), 'status' => OrderStatusEnum::COMPLETED, @@ -35,6 +35,7 @@ public function execute(int $ticketTypeId, array $attendeeData, ?User $user = nu $ticket = Ticket::create([ 'ticket_type_id' => $ticketTypeId, + 'event_instance_id' => $eventInstanceId, 'user_id' => $user?->id, 'order_id' => $order->id, 'uuid' => (string) Str::uuid(), From b6942bb615d3357931e4d59b7c51f7d4ff9ae363 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 01:42:43 +0100 Subject: [PATCH 57/81] feat(events): refactor PublicEventController to support instance-based discovery --- .../Organizer/OrganizerEventController.php | 3 +- .../Controllers/PublicEventController.php | 32 +++++++++++-------- app/Domains/Events/Enums/EventHttpEnum.php | 2 ++ .../Events/Requests/StoreEventRequest.php | 3 ++ .../Events/Requests/UpdateEventRequest.php | 3 ++ .../Http/Controllers/TicketController.php | 1 + .../Http/Requests/RegisterTicketRequest.php | 1 + 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php index 7a6f0b8..43b1fa3 100644 --- a/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php +++ b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php @@ -8,7 +8,6 @@ use App\Domains\Events\Requests\UpdateEventRequest; use App\Domains\Events\Services\EventService; use App\Domains\Events\Transformers\EventResource; -use App\Domains\Shared\Exceptions\DomainException; use App\Domains\Shared\Http\BaseController; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -70,6 +69,8 @@ public function show(Event $event): JsonResponse { $this->authorize('view', $event); + $event->load(['instances', 'ticketTypes', 'category', 'venue']); + return $this->apiResponseSuccess( EventHttpEnum::SHOW_SUCCESS->value, Response::HTTP_OK, diff --git a/app/Domains/Events/Controllers/PublicEventController.php b/app/Domains/Events/Controllers/PublicEventController.php index 6e84a02..db0fa6f 100644 --- a/app/Domains/Events/Controllers/PublicEventController.php +++ b/app/Domains/Events/Controllers/PublicEventController.php @@ -5,11 +5,10 @@ use App\Domains\Events\Enums\EventHttpEnum; use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Jobs\IncrementEventViewCountJob; -use App\Domains\Events\Models\Event; +use App\Domains\Events\Models\EventInstance; use App\Domains\Events\Requests\IndexPublicEventsRequest; -use App\Domains\Events\Transformers\EventResource; +use App\Domains\Events\Transformers\EventInstanceResource; use App\Domains\Shared\Exceptions\DomainException; -use App\Domains\Shared\Enums\AppHttpEnum; use App\Domains\Shared\Http\BaseController; use Exception; use Illuminate\Http\JsonResponse; @@ -20,16 +19,19 @@ class PublicEventController extends BaseController public function index(IndexPublicEventsRequest $request): JsonResponse { try { - $query = Event::query()->where('status', EventStatusEnum::PUBLISHED->value); + $query = EventInstance::query() + ->where('status', EventStatusEnum::PUBLISHED->value) + ->with(['event.category', 'event.venue', 'event.organizerProfile']); if ($request->filled('search')) { - $query = Event::search($request->input('search'))->query(function ($builder) { - $builder->where('status', EventStatusEnum::PUBLISHED->value); + // todo: Search might still be easiest on the Master Event - brainstorm later + $query->whereHas('event', function ($q) use ($request) { + $q->where('title', 'like', '%' . $request->input('search') . '%'); }); } if ($request->filled('category')) { - $query->whereHas('category', function ($q) use ($request) { + $query->whereHas('event.category', function ($q) use ($request) { $q->where('slug', $request->input('category')); }); } @@ -43,7 +45,7 @@ public function index(IndexPublicEventsRequest $request): JsonResponse } $perPage = (int) $request->input('per_page', 15); - $events = $query->paginate($perPage); + $instances = $query->orderBy('starts_at')->paginate($perPage); } catch (Exception $e) { return $this->apiResponseError($e); } @@ -52,19 +54,23 @@ public function index(IndexPublicEventsRequest $request): JsonResponse EventHttpEnum::INDEX_SUCCESS->value, Response::HTTP_OK, [ - 'events' => EventResource::collection($events)->response()->getData(true) + 'events' => EventInstanceResource::collection($instances)->response()->getData(true) ] ); } - public function show(Event $event): JsonResponse + public function show(EventInstance $instance): JsonResponse { try { - if ($event->status !== EventStatusEnum::PUBLISHED) { + if ($instance->status->value !== EventStatusEnum::PUBLISHED->value) { throw new DomainException(EventHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); } - IncrementEventViewCountJob::dispatch($event->id); + IncrementEventViewCountJob::dispatch($instance->event_id); + + $instance->increment('view_count'); + + $instance->load(['event.category', 'event.venue', 'event.organizerProfile']); } catch (Exception $e) { return $this->apiResponseError($e); } @@ -72,7 +78,7 @@ public function show(Event $event): JsonResponse return $this->apiResponseSuccess( EventHttpEnum::SHOW_SUCCESS->value, Response::HTTP_OK, - ['event' => new EventResource($event)] + ['event' => new EventInstanceResource($instance)] ); } } diff --git a/app/Domains/Events/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php index e109fe2..35c3f8b 100644 --- a/app/Domains/Events/Enums/EventHttpEnum.php +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -21,4 +21,6 @@ enum EventHttpEnum: string case PUBLISH_ALREADY_PUBLISHED = 'events.publish.already_published'; case PUBLISH_CANNOT_PUBLISH_CANCELLED = 'events.publish.cannot_publish_cancelled'; case NOT_FOUND = 'events.not_found'; + case DASHBOARD_STATS_SUCCESS = 'events.dashboard_stats.success'; + case ANALYTICS_STATS_SUCCESS = 'events.analytics_stats.success'; } diff --git a/app/Domains/Events/Requests/StoreEventRequest.php b/app/Domains/Events/Requests/StoreEventRequest.php index 15e03ba..7968e3b 100644 --- a/app/Domains/Events/Requests/StoreEventRequest.php +++ b/app/Domains/Events/Requests/StoreEventRequest.php @@ -25,6 +25,9 @@ public function rules(): array 'capacity' => ['required', 'integer', 'min:1'], 'status' => ['nullable', 'in:' . implode(',', array_column(EventStatusEnum::cases(), 'value'))], 'cover_image_path' => ['nullable', 'string', 'max:255'], + 'is_recurring' => ['nullable', 'boolean'], + 'recurrence_rule' => ['required_if:is_recurring,true', 'string', 'in:daily,weekly,biweekly,monthly'], + 'recurrence_end_at' => ['required_if:is_recurring,true', 'date', 'after:starts_at'], ]; } } diff --git a/app/Domains/Events/Requests/UpdateEventRequest.php b/app/Domains/Events/Requests/UpdateEventRequest.php index 6d76534..bcbfb5c 100644 --- a/app/Domains/Events/Requests/UpdateEventRequest.php +++ b/app/Domains/Events/Requests/UpdateEventRequest.php @@ -25,6 +25,9 @@ public function rules(): array 'capacity' => ['sometimes', 'required', 'integer', 'min:1'], 'status' => ['nullable', 'in:' . implode(',', array_column(EventStatusEnum::cases(), 'value'))], 'cover_image_path' => ['nullable', 'string', 'max:255'], + 'is_recurring' => ['nullable', 'boolean'], + 'recurrence_rule' => ['nullable', 'string', 'in:daily,weekly,biweekly,monthly'], + 'recurrence_end_at' => ['nullable', 'date', 'after:starts_at'], ]; } } diff --git a/app/Domains/Ticketing/Http/Controllers/TicketController.php b/app/Domains/Ticketing/Http/Controllers/TicketController.php index ffaf6a5..2c07ad7 100644 --- a/app/Domains/Ticketing/Http/Controllers/TicketController.php +++ b/app/Domains/Ticketing/Http/Controllers/TicketController.php @@ -22,6 +22,7 @@ public function store(RegisterTicketRequest $request, RegisterAttendeeAction $ac $user = $request->user(); $ticket = $action->execute( $request->integer('ticket_type_id'), + $request->integer('event_instance_id'), $request->only(['name', 'email']), $user ); diff --git a/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php index 6289e98..3f1b392 100644 --- a/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php +++ b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php @@ -15,6 +15,7 @@ public function rules(): array { return [ 'ticket_type_id' => ['required', 'exists:ticket_types,id'], + 'event_instance_id' => ['required', 'exists:event_instances,id'], 'name' => ['required_if:user_id,null', 'string', 'max:255'], 'email' => ['required_if:user_id,null', 'email', 'max:255'], ]; From 604efa7fca8e22f8d88d9105b642f427a7158a29 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 01:44:22 +0100 Subject: [PATCH 58/81] test(events): add tests for recurrence logic and instances --- .../TicketRegistrationInstanceTest.php | 91 +++++++++++++++++++ tests/Unit/SyncEventInstancesActionTest.php | 82 +++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/Feature/Ticketing/TicketRegistrationInstanceTest.php create mode 100644 tests/Unit/SyncEventInstancesActionTest.php diff --git a/tests/Feature/Ticketing/TicketRegistrationInstanceTest.php b/tests/Feature/Ticketing/TicketRegistrationInstanceTest.php new file mode 100644 index 0000000..f521f55 --- /dev/null +++ b/tests/Feature/Ticketing/TicketRegistrationInstanceTest.php @@ -0,0 +1,91 @@ +create([ + 'is_recurring' => true, + 'starts_at' => Carbon::now()->addDay(), + 'ends_at' => Carbon::now()->addDay()->addHours(2), + ]); + + $instance = EventInstance::create([ + 'event_id' => $event->id, + 'starts_at' => $event->starts_at, + 'ends_at' => $event->ends_at, + 'capacity' => 10, + 'status' => $event->status, + ]); + + $ticketType = TicketType::factory()->create([ + 'event_id' => $event->id, + 'quantity' => 10, + ]); + + $response = $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $instance->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('tickets', [ + 'event_instance_id' => $instance->id, + 'ticket_type_id' => $ticketType->id, + 'email' => 'john@example.com', + ]); + + $this->assertDatabaseHas('orders', [ + 'event_instance_id' => $instance->id, + 'email' => 'john@example.com', + ]); + } + + public function test_cannot_register_if_instance_capacity_reached() + { + $event = Event::factory()->create(); + $instance = EventInstance::create([ + 'event_id' => $event->id, + 'starts_at' => Carbon::now(), + 'ends_at' => Carbon::now()->addHour(), + 'capacity' => 1, + 'status' => 'published', + ]); + $ticketType = TicketType::factory()->create(['event_id' => $event->id, 'quantity' => 1]); + + Ticket::create([ + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $instance->id, + 'order_id' => Order::factory()->create(['event_instance_id' => $instance->id])->id, + 'uuid' => 'test-uuid', + 'name' => 'Existing', + 'email' => 'existing@example.com', + 'status' => 'valid', + ]); + + $response = $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $instance->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $response->assertStatus(422); + } +} diff --git a/tests/Unit/SyncEventInstancesActionTest.php b/tests/Unit/SyncEventInstancesActionTest.php new file mode 100644 index 0000000..c5f7773 --- /dev/null +++ b/tests/Unit/SyncEventInstancesActionTest.php @@ -0,0 +1,82 @@ +create([ + 'is_recurring' => false, + 'starts_at' => Carbon::parse('2026-03-01 10:00:00'), + 'ends_at' => Carbon::parse('2026-03-01 12:00:00'), + ]); + + $action = new SyncEventInstancesAction(); + $action->execute($event); + + $this->assertDatabaseCount('event_instances', 1); + $instance = EventInstance::first(); + $this->assertEquals($event->starts_at, $instance->starts_at); + $this->assertEquals($event->ends_at, $instance->ends_at); + $this->assertEquals($event->id, $instance->event_id); + } + + public function test_it_creates_weekly_instances() + { + // 4 weeks of events including start and end + $event = Event::factory()->create([ + 'is_recurring' => true, + 'recurrence_rule' => RecurrenceFrequencyEnum::WEEKLY->value, + 'starts_at' => Carbon::parse('2026-03-01 10:00:00'), // Sunday + 'ends_at' => Carbon::parse('2026-03-01 12:00:00'), + 'recurrence_end_at' => Carbon::parse('2026-03-22 23:59:59'), // 4th Sunday + ]); + + $action = new SyncEventInstancesAction(); + $action->execute($event); + + // March 1, 8, 15, 22 + $this->assertDatabaseCount('event_instances', 4); + + $instances = EventInstance::orderBy('starts_at')->get(); + $this->assertEquals('2026-03-01 10:00:00', $instances[0]->starts_at->toDateTimeString()); + $this->assertEquals('2026-03-08 10:00:00', $instances[1]->starts_at->toDateTimeString()); + $this->assertEquals('2026-03-15 10:00:00', $instances[2]->starts_at->toDateTimeString()); + $this->assertEquals('2026-03-22 10:00:00', $instances[3]->starts_at->toDateTimeString()); + } + + public function test_it_updates_existing_instances_but_does_not_duplicate() + { + $event = Event::factory()->create([ + 'is_recurring' => true, + 'recurrence_rule' => RecurrenceFrequencyEnum::WEEKLY->value, + 'starts_at' => Carbon::parse('2026-03-01 10:00:00'), + 'ends_at' => Carbon::parse('2026-03-01 12:00:00'), + 'recurrence_end_at' => Carbon::parse('2026-03-08 23:59:59'), + ]); + + $action = new SyncEventInstancesAction(); + $action->execute($event); + $this->assertDatabaseCount('event_instances', 2); + + // Run again + $action->execute($event); + $this->assertDatabaseCount('event_instances', 2); + + // Change capacity + $event->update(['capacity' => 100]); + $action->execute($event); + $this->assertEquals(100, EventInstance::first()->capacity); + } +} From 272a57ea8387df264e1b252bdd097f96e97bbb67 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 01:48:20 +0100 Subject: [PATCH 59/81] test(events): update existing tests to include events_instance logic --- tests/Feature/Events/AnalyticsApiTest.php | 113 ++++++++++++++++++ tests/Feature/Events/DashboardApiTest.php | 70 +++++++++++ tests/Feature/Events/PublicEventApiTest.php | 43 +++++-- tests/Feature/Ticketing/RegistrationTest.php | 8 +- tests/Feature/Ticketing/TicketEmailTest.php | 1 + .../Feature/Venues/VenueSubmissionApiTest.php | 16 --- tests/Pest.php | 22 +++- 7 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Events/AnalyticsApiTest.php create mode 100644 tests/Feature/Events/DashboardApiTest.php diff --git a/tests/Feature/Events/AnalyticsApiTest.php b/tests/Feature/Events/AnalyticsApiTest.php new file mode 100644 index 0000000..7161b0f --- /dev/null +++ b/tests/Feature/Events/AnalyticsApiTest.php @@ -0,0 +1,113 @@ +organizerProfile->id; + + $event = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + ]); + $ticketType = TicketType::factory()->create(['event_id' => $event->id]); + $order = Order::factory()->create(['event_instance_id' => $event->instances->first()->id]); + + // Create tickets on different days + Ticket::factory()->create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'created_at' => now()->subDays(2), + ]); + Ticket::factory()->create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'created_at' => now()->subDays(2), + ]); + Ticket::factory()->create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'created_at' => now()->subDays(1), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/organizer/analytics?days=7'); + + $response->assertStatus(Response::HTTP_OK); + + $registrations = collect($response->json('data.analytics.registrations')); + + $twoDaysAgo = now()->subDays(2)->format('Y-m-d'); + $oneDayAgo = now()->subDays(1)->format('Y-m-d'); + + expect($registrations->firstWhere('date', $twoDaysAgo)['count'])->toBe(2); + expect($registrations->firstWhere('date', $oneDayAgo)['count'])->toBe(1); +}); + +test('organizer can view view analytics time-series', function () { + $user = createOrganizer(); + $profileId = $user->organizerProfile->id; + + $event = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + ]); + + // Create view events + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $event->id], + 'created_at' => now()->subDays(1), + ]); + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $event->id], + 'created_at' => now()->subDays(1), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/organizer/analytics?days=7'); + + $response->assertStatus(Response::HTTP_OK); + + $views = collect($response->json('data.analytics.views')); + $oneDayAgo = now()->subDays(1)->format('Y-m-d'); + + expect($views->firstWhere('date', $oneDayAgo)['count'])->toBe(2); +}); + +test('organizer can filter analytics by event_id', function () { + $user = createOrganizer(); + $profileId = $user->organizerProfile->id; + + $event1 = Event::factory()->create(['organizer_profile_id' => $profileId]); + $event2 = Event::factory()->create(['organizer_profile_id' => $profileId]); + + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $event1->id], + 'created_at' => now(), + ]); + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $event2->id], + 'created_at' => now(), + ]); + + $response = $this->actingAs($user)->getJson("/api/v1/organizer/analytics?event_id={$event1->id}"); + + $response->assertStatus(Response::HTTP_OK); + + $views = collect($response->json('data.analytics.views')); + $today = now()->format('Y-m-d'); + + expect($views->firstWhere('date', $today)['count'])->toBe(1); +}); diff --git a/tests/Feature/Events/DashboardApiTest.php b/tests/Feature/Events/DashboardApiTest.php new file mode 100644 index 0000000..24221e6 --- /dev/null +++ b/tests/Feature/Events/DashboardApiTest.php @@ -0,0 +1,70 @@ +organizerProfile->id; + + // Create some events + $event1 = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + 'starts_at' => now()->addDays(5), + ]); + $event2 = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + 'starts_at' => now()->addDays(10), + ]); + + // Create a ticket type for event 1 + $ticketType = TicketType::factory()->create(['event_id' => $event1->id]); + + // Create an order and tickets + $order = Order::factory()->create(['event_instance_id' => $event1->instances->first()->id]); + Ticket::factory()->count(3)->create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'checked_in_at' => null, + ]); + Ticket::factory()->create([ + 'ticket_type_id' => $ticketType->id, + 'order_id' => $order->id, + 'checked_in_at' => now(), + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/organizer/dashboard'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.stats.total_events', 2) + ->assertJsonPath('data.stats.total_tickets', 4) + ->assertJsonPath('data.stats.check_in_rate', 25) + ->assertJsonCount(2, 'data.upcoming_events') + ->assertJsonCount(4, 'data.recent_registrations'); +}); + +test('organizer cannot see other organizer data on dashboard', function () { + $user1 = createOrganizer(); + $user2 = createOrganizer(); + + Event::factory()->create([ + 'organizer_profile_id' => $user1->organizerProfile->id, + 'status' => EventStatusEnum::PUBLISHED, + ]); + + $response = $this->actingAs($user2)->getJson('/api/v1/organizer/dashboard'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.stats.total_events', 0) + ->assertJsonPath('data.stats.total_tickets', 0); +}); diff --git a/tests/Feature/Events/PublicEventApiTest.php b/tests/Feature/Events/PublicEventApiTest.php index 9d7ec1b..96bd481 100644 --- a/tests/Feature/Events/PublicEventApiTest.php +++ b/tests/Feature/Events/PublicEventApiTest.php @@ -5,6 +5,7 @@ use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Models\Category; use App\Domains\Events\Models\Event; +use App\Domains\Events\Models\EventInstance; use Illuminate\Foundation\Testing\RefreshDatabase; use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; @@ -15,11 +16,15 @@ class PublicEventApiTest extends TestCase public function test_can_list_published_events() { - Event::factory()->count(3)->create(['status' => EventStatusEnum::PUBLISHED]); - Event::factory()->count(2)->create(['status' => EventStatusEnum::DRAFT]); + EventInstance::factory()->count(3)->create(['status' => EventStatusEnum::PUBLISHED]); + EventInstance::factory()->count(2)->create(['status' => EventStatusEnum::DRAFT]); $response = $this->getJson('/api/v1/events'); + if ($response->status() !== 200) { + dump($response->json()); + } + $response->assertStatus(Response::HTTP_OK) ->assertJsonCount(3, 'data.events.data'); } @@ -29,8 +34,17 @@ public function test_can_filter_events_by_category() $category1 = Category::factory()->create(['slug' => 'music']); $category2 = Category::factory()->create(['slug' => 'tech']); - Event::factory()->count(2)->create(['status' => EventStatusEnum::PUBLISHED, 'category_id' => $category1->id]); - Event::factory()->count(1)->create(['status' => EventStatusEnum::PUBLISHED, 'category_id' => $category2->id]); + $event1 = Event::factory()->create(['category_id' => $category1->id]); + $event2 = Event::factory()->create(['category_id' => $category2->id]); + + EventInstance::factory()->count(2)->create([ + 'event_id' => $event1->id, + 'status' => EventStatusEnum::PUBLISHED + ]); + EventInstance::factory()->count(1)->create([ + 'event_id' => $event2->id, + 'status' => EventStatusEnum::PUBLISHED + ]); $response = $this->getJson('/api/v1/events?category=music'); @@ -40,34 +54,39 @@ public function test_can_filter_events_by_category() public function test_can_show_published_event() { - $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED]); + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::PUBLISHED]); - $response = $this->getJson("/api/v1/events/{$event->id}"); + $response = $this->getJson("/api/v1/events/{$instance->id}"); - $response->assertStatus(Response::HTTP_OK) - ->assertJsonPath('data.event.id', $event->id); + if ($response->status() !== 200) { + dump("Response from /api/v1/events/{$instance->id}: ", $response->json()); + } + + $response->assertStatus(Response::HTTP_OK); + $this->assertEquals($instance->id, $response->json('data.event.id')); } public function test_cannot_show_draft_event() { - $event = Event::factory()->create(['status' => EventStatusEnum::DRAFT]); + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::DRAFT]); - $response = $this->getJson("/api/v1/events/{$event->id}"); + $response = $this->getJson("/api/v1/events/{$instance->id}"); $response->assertStatus(Response::HTTP_NOT_FOUND); } public function test_cannot_show_cancelled_event() { - $event = Event::factory()->create(['status' => EventStatusEnum::CANCELLED]); + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::CANCELLED]); - $response = $this->getJson("/api/v1/events/{$event->id}"); + $response = $this->getJson("/api/v1/events/{$instance->id}"); $response->assertStatus(Response::HTTP_NOT_FOUND); } public function test_index_returns_empty_data_when_no_events_exist() { + EventInstance::query()->delete(); Event::query()->delete(); $response = $this->getJson('/api/v1/events'); diff --git a/tests/Feature/Ticketing/RegistrationTest.php b/tests/Feature/Ticketing/RegistrationTest.php index 01672cc..a46dcc7 100644 --- a/tests/Feature/Ticketing/RegistrationTest.php +++ b/tests/Feature/Ticketing/RegistrationTest.php @@ -32,6 +32,7 @@ public function test_guest_can_register_for_free_ticket(): void $response = $this->postJson('/api/v1/tickets/register', [ 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, 'name' => 'John Guest', 'email' => 'john@guest.com', ]); @@ -59,6 +60,7 @@ public function test_authenticated_user_can_register_for_free_ticket(): void $response = $this->actingAs($user)->postJson('/api/v1/tickets/register', [ 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, ]); $response->assertStatus(201); @@ -82,12 +84,13 @@ public function test_registration_fails_when_capacity_is_exceeded(): void ]); $order = Order::create([ - 'event_id' => $event->id, + 'event_instance_id' => $event->instances->first()->id, 'status' => OrderStatusEnum::COMPLETED, ]); Ticket::create([ 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, 'order_id' => $order->id, 'uuid' => 'test-uuid', 'status' => TicketStatusEnum::VALID, @@ -95,11 +98,12 @@ public function test_registration_fails_when_capacity_is_exceeded(): void $response = $this->postJson('/api/v1/tickets/register', [ 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, 'name' => 'John TooLate', 'email' => 'john@late.com', ]); - $response->assertStatus(400); + $response->assertStatus(422); $response->assertJsonPath('message', 'ticketing.registration_failed'); Bus::assertNotDispatched(SendTicketEmailJob::class); diff --git a/tests/Feature/Ticketing/TicketEmailTest.php b/tests/Feature/Ticketing/TicketEmailTest.php index fbe7b89..0d42b03 100644 --- a/tests/Feature/Ticketing/TicketEmailTest.php +++ b/tests/Feature/Ticketing/TicketEmailTest.php @@ -29,6 +29,7 @@ public function test_registration_queues_email_job(): void $this->postJson('/api/v1/tickets/register', [ 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, 'name' => 'John Guest', 'email' => 'john@guest.com', ]); diff --git a/tests/Feature/Venues/VenueSubmissionApiTest.php b/tests/Feature/Venues/VenueSubmissionApiTest.php index 39cf8e8..bf2bd92 100644 --- a/tests/Feature/Venues/VenueSubmissionApiTest.php +++ b/tests/Feature/Venues/VenueSubmissionApiTest.php @@ -8,22 +8,6 @@ use Illuminate\Support\Facades\Queue; use Spatie\Permission\Models\Role; -function createOrganizer(): User -{ - static $counter = 0; - $counter++; - $user = User::factory()->create(); - $user->organizerProfile()->create([ - 'organization_name' => "Test Organizer {$counter}", - 'slug' => "test-organizer-{$counter}", - 'contact_email' => "test{$counter}@test.com", - 'phone' => '123456789' - ]); - Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); - $user->assignRole('organizer'); - return $user; -} - function createAdmin(): User { $user = User::factory()->create(); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..e164a0b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,7 +12,7 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); /* @@ -41,7 +41,23 @@ | */ -function something() +function createOrganizer(): \App\Domains\Auth\Models\User { - // .. + \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + + $user = \App\Domains\Auth\Models\User::factory()->create(); + $user->assignRole('organizer'); + + static $counter = 0; + $counter++; + + $user->organizerProfile()->create([ + 'organization_name' => "Test Organizer {$counter}", + 'slug' => "test-organizer-{$counter}-" . bin2hex(random_bytes(2)), + 'description' => 'Test Description', + 'phone' => '123456789', + 'verified_at' => now(), + ]); + + return $user; } From 80a53b8ecad4a6e79de24f4fe37cd9c11a899d8b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 02:03:07 +0100 Subject: [PATCH 60/81] feat(dashboard): add summary statistics for organizer dashboard --- .../Organizer/DashboardController.php | 36 +++++++ .../Events/Services/DashboardService.php | 51 ++++++++++ .../Eloquent/TicketRepository.php | 98 +++++++++++++++++++ .../TicketRepositoryInterface.php | 15 +++ app/Providers/AppServiceProvider.php | 3 + 5 files changed, 203 insertions(+) create mode 100644 app/Domains/Events/Controllers/Organizer/DashboardController.php create mode 100644 app/Domains/Events/Services/DashboardService.php diff --git a/app/Domains/Events/Controllers/Organizer/DashboardController.php b/app/Domains/Events/Controllers/Organizer/DashboardController.php new file mode 100644 index 0000000..7646e18 --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/DashboardController.php @@ -0,0 +1,36 @@ +user()->organizerProfile->id; + + $stats = $this->dashboardService->getStats($organizerProfileId); + $upcomingEvents = $this->dashboardService->getUpcomingEvents($organizerProfileId); + $recentRegistrations = $this->dashboardService->getRecentRegistrations($organizerProfileId); + + return $this->apiResponseSuccess( + EventHttpEnum::DASHBOARD_STATS_SUCCESS->value, + Response::HTTP_OK, + [ + 'stats' => $stats, + 'upcoming_events' => $upcomingEvents, + 'recent_registrations' => $recentRegistrations, + ] + ); + } +} diff --git a/app/Domains/Events/Services/DashboardService.php b/app/Domains/Events/Services/DashboardService.php new file mode 100644 index 0000000..064a24d --- /dev/null +++ b/app/Domains/Events/Services/DashboardService.php @@ -0,0 +1,51 @@ +eventRepository->getTotalCountForOrganizer($organizerProfileId); + $totalTickets = $this->ticketRepository->getTotalCountForOrganizer($organizerProfileId); + $checkedInTickets = $this->ticketRepository->getCheckedInCountForOrganizer($organizerProfileId); + + $checkInRate = $totalTickets > 0 ? round(($checkedInTickets / $totalTickets) * 100, 2) : 0; + $totalRevenue = $this->ticketRepository->getTotalRevenueForOrganizer($organizerProfileId); + + // todo: Story 6.3 - Implement actual net revenue after Stripe integration + // For now: Placeholder Net revenue (Gross - estimated 5% fee) + $netRevenue = round($totalRevenue * 0.95, 2); + + return [ + 'total_events' => $totalEvents, + 'total_tickets' => $totalTickets, + 'check_in_rate' => $checkInRate, + 'gross_revenue' => $totalRevenue, + 'net_revenue_estimate' => $netRevenue, + ]; + } + + public function getUpcomingEvents(int $organizerProfileId, int $limit = 5): array + { + $events = $this->eventRepository->getUpcomingForOrganizer($organizerProfileId, $limit); + return EventResource::collection($events)->resolve(); + } + + public function getRecentRegistrations(int $organizerProfileId, int $limit = 5): array + { + $tickets = $this->ticketRepository->getRecentForOrganizer($organizerProfileId, $limit); + + return RecentRegistrationResource::collection($tickets)->resolve(); + } +} diff --git a/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php index 7478c7e..62854d9 100644 --- a/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php +++ b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php @@ -6,6 +6,7 @@ use App\Domains\Ticketing\Repositories\TicketRepositoryInterface; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; class TicketRepository implements TicketRepositoryInterface { @@ -42,4 +43,101 @@ public function getAllForEvent(int $eventId): Collection ->with(['ticketType']) ->get(); } + + public function getTotalCountForOrganizer(int $organizerProfileId): int + { + return Ticket::whereIn('ticket_type_id', function ($query) use ($organizerProfileId) { + $query->select('ticket_types.id') + ->from('ticket_types') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId); + })->count(); + } + + public function getCheckedInCountForOrganizer(int $organizerProfileId): int + { + return Ticket::whereIn('ticket_type_id', function ($query) use ($organizerProfileId) { + $query->select('ticket_types.id') + ->from('ticket_types') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId); + }) + ->whereNotNull('checked_in_at') + ->count(); + } + + public function getRecentForOrganizer(int $organizerProfileId, int $limit = 5): Collection + { + return Ticket::whereIn('ticket_type_id', function ($query) use ($organizerProfileId) { + $query->select('ticket_types.id') + ->from('ticket_types') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId); + }) + ->with(['ticketType.event', 'user', 'order']) + ->latest() + ->limit($limit) + ->get(); + } + + public function getTotalRevenueForOrganizer(int $organizerProfileId): float + { + return (float) Ticket::join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId) + ->sum('ticket_types.price'); + } + + public function getRevenueForEvent(int $eventId): float + { + return (float) Ticket::join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->where('ticket_types.event_id', $eventId) + ->sum('ticket_types.price'); + } + + public function getRegistrationTimeSeries(int $organizerProfileId, ?int $eventId, \Carbon\Carbon $startDate): array + { + $query = Ticket::query() + ->join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId) + ->where('tickets.created_at', '>=', $startDate); + + if ($eventId) { + $query->where('events.id', $eventId); + } + + return $query->select([ + DB::raw('DATE(tickets.created_at) as date'), + DB::raw('COUNT(*) as count') + ]) + ->groupBy('date') + ->orderBy('date') + ->get() + ->pluck('count', 'date') + ->toArray(); + } + + public function getRevenueTimeSeries(int $organizerProfileId, ?int $eventId, \Carbon\Carbon $startDate): array + { + $query = Ticket::query() + ->join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->join('events', 'ticket_types.event_id', '=', 'events.id') + ->where('events.organizer_profile_id', $organizerProfileId) + ->where('tickets.created_at', '>=', $startDate); + + if ($eventId) { + $query->where('events.id', $eventId); + } + + return $query->select([ + DB::raw('DATE(tickets.created_at) as date'), + DB::raw('SUM(ticket_types.price) as sum') + ]) + ->groupBy('date') + ->orderBy('date') + ->get() + ->pluck('sum', 'date') + ->toArray(); + } } diff --git a/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php index 8e6c172..5d8a089 100644 --- a/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php +++ b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Repositories; use App\Domains\Ticketing\Models\Ticket; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; @@ -15,4 +16,18 @@ public function findByUuid(string $uuid): ?Ticket; public function update(Ticket $ticket, array $data): bool; public function getAllForEvent(int $eventId): Collection; + + public function getTotalCountForOrganizer(int $organizerProfileId): int; + + public function getCheckedInCountForOrganizer(int $organizerProfileId): int; + + public function getRecentForOrganizer(int $organizerProfileId, int $limit = 5): Collection; + + public function getTotalRevenueForOrganizer(int $organizerProfileId): float; + + public function getRevenueForEvent(int $eventId): float; + + public function getRegistrationTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array; + + public function getRevenueTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 992d024..8069744 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,8 @@ use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; use App\Domains\Venues\Repositories\VenueRepository; use App\Domains\Venues\Repositories\VenueRepositoryInterface; +use App\Domains\Analytics\Repositories\AnalyticsRepositoryInterface; +use App\Domains\Analytics\Repositories\Eloquent\AnalyticsRepository; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -23,6 +25,7 @@ public function register(): void $this->app->bind(EventRepositoryInterface::class, EventRepository::class); $this->app->bind(VenueSubmissionRepositoryInterface::class, VenueSubmissionRepository::class); $this->app->bind(TicketRepositoryInterface::class, TicketRepository::class); + $this->app->bind(AnalyticsRepositoryInterface::class, AnalyticsRepository::class); } /** From 2267623a0484a02dfc72a7fcedd1d8e9c92945ed Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 02:03:37 +0100 Subject: [PATCH 61/81] feat(dashboard): implement recent registration activity feed --- .../RecentRegistrationResource.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/Domains/Ticketing/Transformers/RecentRegistrationResource.php diff --git a/app/Domains/Ticketing/Transformers/RecentRegistrationResource.php b/app/Domains/Ticketing/Transformers/RecentRegistrationResource.php new file mode 100644 index 0000000..5105a32 --- /dev/null +++ b/app/Domains/Ticketing/Transformers/RecentRegistrationResource.php @@ -0,0 +1,20 @@ + $this->id, + 'event_title' => $this->ticketType->event->title, + 'attendee_name' => $this->name ?: $this->user?->name, + 'status' => $this->status->value, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} From 0d44a56c2b6bfa9b59d266639d7a64036ec6da2d Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 02:03:45 +0100 Subject: [PATCH 62/81] feat(analytics): add time-series data for registration trends --- .../AnalyticsRepositoryInterface.php | 10 +++ .../Eloquent/AnalyticsRepository.php | 43 +++++++++++++ .../Organizer/AnalyticsController.php | 34 ++++++++++ .../Events/Services/AnalyticsService.php | 63 +++++++++++++++++++ routes/api.php | 7 ++- 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Analytics/Repositories/AnalyticsRepositoryInterface.php create mode 100644 app/Domains/Analytics/Repositories/Eloquent/AnalyticsRepository.php create mode 100644 app/Domains/Events/Controllers/Organizer/AnalyticsController.php create mode 100644 app/Domains/Events/Services/AnalyticsService.php diff --git a/app/Domains/Analytics/Repositories/AnalyticsRepositoryInterface.php b/app/Domains/Analytics/Repositories/AnalyticsRepositoryInterface.php new file mode 100644 index 0000000..fa7b2fc --- /dev/null +++ b/app/Domains/Analytics/Repositories/AnalyticsRepositoryInterface.php @@ -0,0 +1,10 @@ +where('event_type', 'event.view') + ->where('created_at', '>=', $startDate); + + if ($eventId) { + $query->whereJsonContains('properties->event_id', (int) $eventId); + } else { + $eventIds = DB::table('events') + ->where('organizer_profile_id', $organizerProfileId) + ->pluck('id') + ->toArray(); + + $query->where(function ($q) use ($eventIds) { + foreach ($eventIds as $id) { + $q->orWhereJsonContains('properties->event_id', (int) $id); + } + }); + } + + return $query->select([ + DB::raw('DATE(created_at) as date'), + DB::raw('COUNT(*) as count') + ]) + ->groupBy('date') + ->orderBy('date') + ->get() + ->pluck('count', 'date') + ->toArray(); + } +} diff --git a/app/Domains/Events/Controllers/Organizer/AnalyticsController.php b/app/Domains/Events/Controllers/Organizer/AnalyticsController.php new file mode 100644 index 0000000..4e791c9 --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/AnalyticsController.php @@ -0,0 +1,34 @@ +user()->organizerProfile->id; + $eventId = $request->query('event_id') ? (int) $request->query('event_id') : null; + $days = $request->query('days') ? (int) $request->query('days') : 30; + + $stats = $this->analyticsService->getStats($organizerProfileId, $eventId, $days); + + return $this->apiResponseSuccess( + EventHttpEnum::ANALYTICS_STATS_SUCCESS->value, + Response::HTTP_OK, + [ + 'analytics' => $stats + ] + ); + } +} diff --git a/app/Domains/Events/Services/AnalyticsService.php b/app/Domains/Events/Services/AnalyticsService.php new file mode 100644 index 0000000..6d1b89b --- /dev/null +++ b/app/Domains/Events/Services/AnalyticsService.php @@ -0,0 +1,63 @@ +subDays($days)->startOfDay(); + + return [ + 'registrations' => $this->getRegistrationTimeSeries($organizerProfileId, $eventId, $startDate), + 'views' => $this->getViewTimeSeries($organizerProfileId, $eventId, $startDate), + 'revenue' => $this->getRevenueTimeSeries($organizerProfileId, $eventId, $startDate), + ]; + } + + protected function getRegistrationTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array + { + $data = $this->ticketRepository->getRegistrationTimeSeries($organizerProfileId, $eventId, $startDate); + + return $this->fillMissingDays($data, $startDate); + } + + protected function getViewTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array + { + $data = $this->analyticsRepository->getViewTimeSeries($organizerProfileId, $eventId, $startDate); + + return $this->fillMissingDays($data, $startDate); + } + + protected function getRevenueTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array + { + $data = $this->ticketRepository->getRevenueTimeSeries($organizerProfileId, $eventId, $startDate); + + return $this->fillMissingDays($data, $startDate); + } + + protected function fillMissingDays(array $data, Carbon $startDate): array + { + $result = []; + $endDate = now()->startOfDay(); + + for ($date = $startDate->copy(); $date <= $endDate; $date->addDay()) { + $dateString = $date->format('Y-m-d'); + $result[] = [ + 'date' => $dateString, + 'count' => (float) ($data[$dateString] ?? 0), + ]; + } + + return $result; + } +} diff --git a/routes/api.php b/routes/api.php index 9fcd109..25be7c1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,8 @@ use App\Domains\Events\Controllers\Organizer\CancelEventController; use App\Domains\Categories\Controllers\CategoryController; use App\Domains\Events\Controllers\PublicEventController; +use App\Domains\Events\Controllers\Organizer\DashboardController; +use App\Domains\Events\Controllers\Organizer\AnalyticsController; use App\Domains\Ticketing\Http\Controllers\Organizer\AttendeeController; use App\Domains\Ticketing\Http\Controllers\Organizer\CheckInController; use App\Domains\Ticketing\Http\Controllers\TicketController; @@ -40,7 +42,7 @@ Route::get('/categories', [CategoryController::class, 'index']); Route::get('/events', [PublicEventController::class, 'index']); -Route::get('/events/{event}', [PublicEventController::class, 'show']); +Route::get('/events/{instance}', [PublicEventController::class, 'show']); Route::prefix('auth')->group(function () { Route::post('/register', RegisterController::class); @@ -82,6 +84,9 @@ Route::get('events/{event}/attendees', [AttendeeController::class, 'index']); Route::post('events/{event}/attendees/export', [AttendeeController::class, 'export']); Route::post('checkin/{uuid}', CheckInController::class); + + Route::get('dashboard', DashboardController::class); + Route::get('analytics', AnalyticsController::class); }); }); From 79bf7be966773fdc56acf80994a4aab18d2acb98 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 02:03:54 +0100 Subject: [PATCH 63/81] test(analytics): verify dashboard and analytics data integrity --- tests/Feature/Events/AnalyticsApiTest.php | 13 +++++++++---- tests/Feature/Events/DashboardApiTest.php | 11 ++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/Feature/Events/AnalyticsApiTest.php b/tests/Feature/Events/AnalyticsApiTest.php index 7161b0f..6b08ab8 100644 --- a/tests/Feature/Events/AnalyticsApiTest.php +++ b/tests/Feature/Events/AnalyticsApiTest.php @@ -1,6 +1,5 @@ $profileId, 'status' => EventStatusEnum::PUBLISHED, ]); - $ticketType = TicketType::factory()->create(['event_id' => $event->id]); + $ticketType = TicketType::factory()->create([ + 'event_id' => $event->id, + 'price' => 100.00 + ]); $order = Order::factory()->create(['event_instance_id' => $event->instances->first()->id]); - // Create tickets on different days Ticket::factory()->create([ 'ticket_type_id' => $ticketType->id, 'order_id' => $order->id, @@ -51,6 +52,11 @@ expect($registrations->firstWhere('date', $twoDaysAgo)['count'])->toBe(2); expect($registrations->firstWhere('date', $oneDayAgo)['count'])->toBe(1); + + $revenue = collect($response->json('data.analytics.revenue')); + + expect((float) $revenue->firstWhere('date', $twoDaysAgo)['count'])->toBe(200.00); + expect((float) $revenue->firstWhere('date', $oneDayAgo)['count'])->toBe(100.00); }); test('organizer can view view analytics time-series', function () { @@ -62,7 +68,6 @@ 'status' => EventStatusEnum::PUBLISHED, ]); - // Create view events AnalyticsEvent::create([ 'event_type' => 'event.view', 'properties' => ['event_id' => $event->id], diff --git a/tests/Feature/Events/DashboardApiTest.php b/tests/Feature/Events/DashboardApiTest.php index 24221e6..5490226 100644 --- a/tests/Feature/Events/DashboardApiTest.php +++ b/tests/Feature/Events/DashboardApiTest.php @@ -1,6 +1,5 @@ organizerProfile->id; - // Create some events $event1 = Event::factory()->create([ 'organizer_profile_id' => $profileId, 'status' => EventStatusEnum::PUBLISHED, @@ -27,10 +25,11 @@ 'starts_at' => now()->addDays(10), ]); - // Create a ticket type for event 1 - $ticketType = TicketType::factory()->create(['event_id' => $event1->id]); + $ticketType = TicketType::factory()->create([ + 'event_id' => $event1->id, + 'price' => 100.00 + ]); - // Create an order and tickets $order = Order::factory()->create(['event_instance_id' => $event1->instances->first()->id]); Ticket::factory()->count(3)->create([ 'ticket_type_id' => $ticketType->id, @@ -49,6 +48,8 @@ ->assertJsonPath('data.stats.total_events', 2) ->assertJsonPath('data.stats.total_tickets', 4) ->assertJsonPath('data.stats.check_in_rate', 25) + ->assertJsonPath('data.stats.gross_revenue', 400) + ->assertJsonPath('data.stats.net_revenue_estimate', 380) ->assertJsonCount(2, 'data.upcoming_events') ->assertJsonCount(4, 'data.recent_registrations'); }); From a02daed78d26c2ed9d2a35986d833b37c6017d3b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 02:07:07 +0100 Subject: [PATCH 64/81] feat(analytics): add bruno endpoints , refactor events code --- app/Domains/Analytics/Models/AnalyticsEvent.php | 12 +++++++++++- .../Events/Jobs/IncrementEventViewCountJob.php | 6 ++++++ .../Events/Repositories/EventRepository.php | 16 ++++++++++++++++ .../Repositories/EventRepositoryInterface.php | 4 ++++ .../Exceptions/CapacityExceededException.php | 8 ++++---- .../Organizer/Analytics/AnalyticsController.bru | 16 ++++++++++++++++ .../Organizer/Dashboard/DashboardController.bru | 11 +++++++++++ 7 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 bruno/Organizer/Analytics/AnalyticsController.bru create mode 100644 bruno/Organizer/Dashboard/DashboardController.bru diff --git a/app/Domains/Analytics/Models/AnalyticsEvent.php b/app/Domains/Analytics/Models/AnalyticsEvent.php index 55409fa..93d2702 100644 --- a/app/Domains/Analytics/Models/AnalyticsEvent.php +++ b/app/Domains/Analytics/Models/AnalyticsEvent.php @@ -6,5 +6,15 @@ class AnalyticsEvent extends Model { - // + protected $fillable = [ + 'event_type', + 'properties', + 'user_id', + 'session_id', + 'created_at', + ]; + + protected $casts = [ + 'properties' => 'array', + ]; } diff --git a/app/Domains/Events/Jobs/IncrementEventViewCountJob.php b/app/Domains/Events/Jobs/IncrementEventViewCountJob.php index d7c51d3..7d7d479 100644 --- a/app/Domains/Events/Jobs/IncrementEventViewCountJob.php +++ b/app/Domains/Events/Jobs/IncrementEventViewCountJob.php @@ -2,6 +2,7 @@ namespace App\Domains\Events\Jobs; +use App\Domains\Analytics\Models\AnalyticsEvent; use App\Domains\Events\Models\Event; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -20,5 +21,10 @@ public function __construct( public function handle(): void { Event::where('id', $this->eventId)->increment('view_count'); + + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $this->eventId], + ]); } } diff --git a/app/Domains/Events/Repositories/EventRepository.php b/app/Domains/Events/Repositories/EventRepository.php index 1770bee..0edd549 100644 --- a/app/Domains/Events/Repositories/EventRepository.php +++ b/app/Domains/Events/Repositories/EventRepository.php @@ -2,6 +2,7 @@ namespace App\Domains\Events\Repositories; +use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Models\Event; use Illuminate\Pagination\LengthAwarePaginator; @@ -33,4 +34,19 @@ public function delete(Event $event): bool { return $event->delete(); } + + public function getTotalCountForOrganizer(int $organizerProfileId): int + { + return Event::where('organizer_profile_id', $organizerProfileId)->count(); + } + + public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): \Illuminate\Database\Eloquent\Collection + { + return Event::where('organizer_profile_id', $organizerProfileId) + ->where('starts_at', '>', now()) + ->where('status', EventStatusEnum::PUBLISHED) + ->orderBy('starts_at', 'asc') + ->limit($limit) + ->get(); + } } diff --git a/app/Domains/Events/Repositories/EventRepositoryInterface.php b/app/Domains/Events/Repositories/EventRepositoryInterface.php index 8003141..4fc57f8 100644 --- a/app/Domains/Events/Repositories/EventRepositoryInterface.php +++ b/app/Domains/Events/Repositories/EventRepositoryInterface.php @@ -12,4 +12,8 @@ public function findById(int $id): ?Event; public function create(array $data): Event; public function update(Event $event, array $data): bool; public function delete(Event $event): bool; + + public function getTotalCountForOrganizer(int $organizerProfileId): int; + + public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): \Illuminate\Database\Eloquent\Collection; } diff --git a/app/Domains/Ticketing/Exceptions/CapacityExceededException.php b/app/Domains/Ticketing/Exceptions/CapacityExceededException.php index a1d7df1..c96c364 100644 --- a/app/Domains/Ticketing/Exceptions/CapacityExceededException.php +++ b/app/Domains/Ticketing/Exceptions/CapacityExceededException.php @@ -2,12 +2,12 @@ namespace App\Domains\Ticketing\Exceptions; -use Exception; +use App\Domains\Shared\Exceptions\DomainException; -class CapacityExceededException extends Exception +class CapacityExceededException extends DomainException { - public function __construct(string $message = 'Capacity exceeded for this ticket type.', int $code = 400, ?Exception $previous = null) + public function __construct(string $message = 'Capacity exceeded for this ticket type.', int $code = 422) { - parent::__construct($message, $code, $previous); + parent::__construct($message, $code); } } diff --git a/bruno/Organizer/Analytics/AnalyticsController.bru b/bruno/Organizer/Analytics/AnalyticsController.bru new file mode 100644 index 0000000..be438b7 --- /dev/null +++ b/bruno/Organizer/Analytics/AnalyticsController.bru @@ -0,0 +1,16 @@ +meta { + name: AnalyticsController + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/analytics + body: none + auth: inherit +} + +params:query { + ~event_id: 1 + ~days: 30 +} diff --git a/bruno/Organizer/Dashboard/DashboardController.bru b/bruno/Organizer/Dashboard/DashboardController.bru new file mode 100644 index 0000000..984e121 --- /dev/null +++ b/bruno/Organizer/Dashboard/DashboardController.bru @@ -0,0 +1,11 @@ +meta { + name: DashboardController + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/dashboard + body: none + auth: inherit +} From b28f97efafdc6466ddb0cdc12e6c428ed4f0c0ac Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 22:48:58 +0100 Subject: [PATCH 65/81] feat(admin): admin moderation logic --- .../Controllers/AdminStatsController.php | 25 +++++ .../ApproveOrganizerController.php | 16 +--- .../Controllers/RejectOrganizerController.php | 35 +++++++ .../Admin/Enums/Http/AdminHttpEnum.php | 10 ++ .../Admin/Services/AdminStatsService.php | 26 ++++++ .../Admin/AdminEventController.php | 93 +++++++++++++++++++ app/Domains/Events/Enums/EventHttpEnum.php | 4 + app/Domains/Events/Services/EventService.php | 27 ++++++ .../Enums/Http/OrganizerHttpEnum.php | 2 + .../OrganizerRejectedNotification.php | 30 ++++++ .../Organizer/Services/OrganizerService.php | 18 ++++ routes/api.php | 21 ++++- 12 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 app/Domains/Admin/Controllers/AdminStatsController.php create mode 100644 app/Domains/Admin/Controllers/RejectOrganizerController.php create mode 100644 app/Domains/Admin/Enums/Http/AdminHttpEnum.php create mode 100644 app/Domains/Admin/Services/AdminStatsService.php create mode 100644 app/Domains/Events/Controllers/Admin/AdminEventController.php create mode 100644 app/Domains/Organizer/Notifications/OrganizerRejectedNotification.php diff --git a/app/Domains/Admin/Controllers/AdminStatsController.php b/app/Domains/Admin/Controllers/AdminStatsController.php new file mode 100644 index 0000000..8355a35 --- /dev/null +++ b/app/Domains/Admin/Controllers/AdminStatsController.php @@ -0,0 +1,25 @@ +apiResponseSuccess( + AdminHttpEnum::STATS_RETRIEVED->value, + Response::HTTP_OK, + ['stats' => $this->adminStatsService->getGlobalStats()] + ); + } +} diff --git a/app/Domains/Admin/Controllers/ApproveOrganizerController.php b/app/Domains/Admin/Controllers/ApproveOrganizerController.php index e211c1f..1f60812 100644 --- a/app/Domains/Admin/Controllers/ApproveOrganizerController.php +++ b/app/Domains/Admin/Controllers/ApproveOrganizerController.php @@ -3,7 +3,7 @@ namespace App\Domains\Admin\Controllers; use App\Domains\Auth\Models\User; -use App\Domains\Organizer\Enums\Http\OrganizerHttpEnum; +use App\Domains\Admin\Enums\Http\AdminHttpEnum; use App\Domains\Organizer\Services\OrganizerService; use App\Domains\Shared\Exceptions\DomainException; use App\Domains\Shared\Http\BaseController; @@ -22,21 +22,13 @@ public function __invoke(User $user): JsonResponse try { $this->organizerService->approveOrganizer($user); } catch (DomainException $e) { - return $this->apiResponseError( - $e, - $e->getMessage(), - Response::HTTP_BAD_REQUEST - ); + return $this->apiResponseError($e, null, Response::HTTP_BAD_REQUEST); } catch (Exception $e) { - return $this->apiResponseError( - $e, - null, - Response::HTTP_INTERNAL_SERVER_ERROR - ); + return $this->apiResponseError($e); } return $this->apiResponseSuccess( - OrganizerHttpEnum::APPROVAL_SUCCESS->value, + AdminHttpEnum::ORGANIZER_APPROVED->value, Response::HTTP_OK ); } diff --git a/app/Domains/Admin/Controllers/RejectOrganizerController.php b/app/Domains/Admin/Controllers/RejectOrganizerController.php new file mode 100644 index 0000000..0414322 --- /dev/null +++ b/app/Domains/Admin/Controllers/RejectOrganizerController.php @@ -0,0 +1,35 @@ +organizerService->rejectOrganizer($user); + } catch (DomainException $e) { + return $this->apiResponseError($e, null, Response::HTTP_BAD_REQUEST); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + AdminHttpEnum::ORGANIZER_REJECTED->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Admin/Enums/Http/AdminHttpEnum.php b/app/Domains/Admin/Enums/Http/AdminHttpEnum.php new file mode 100644 index 0000000..be291dd --- /dev/null +++ b/app/Domains/Admin/Enums/Http/AdminHttpEnum.php @@ -0,0 +1,10 @@ + $this->eventRepository->getTotalCount(), + 'total_organizers' => $this->organizerRepository->getTotalCount(), + 'total_tickets_sold' => $this->ticketRepository->getTotalCount(), + 'total_revenue' => $this->ticketRepository->getTotalRevenue(), + ]; + } +} diff --git a/app/Domains/Events/Controllers/Admin/AdminEventController.php b/app/Domains/Events/Controllers/Admin/AdminEventController.php new file mode 100644 index 0000000..7baabf4 --- /dev/null +++ b/app/Domains/Events/Controllers/Admin/AdminEventController.php @@ -0,0 +1,93 @@ +eventService->getPaginatedEventsForAdmin(); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'events' => EventResource::collection($events)->response()->getData(true) + ] + ); + } + + public function show(Event $event): JsonResponse + { + return $this->apiResponseSuccess( + EventHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($event)] + ); + } + + public function unpublish(Event $event): JsonResponse + { + try { + $unpublishedEvent = $this->eventService->unpublishByAdmin($event); + } catch (DomainException $e) { + return $this->apiResponseError($e, null, Response::HTTP_BAD_REQUEST); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::UNPUBLISH_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($unpublishedEvent)] + ); + } + + public function feature(Event $event): JsonResponse + { + try { + $featuredEvent = $this->eventService->featureEvent($event); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::FEATURE_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($featuredEvent)] + ); + } + + public function unfeature(Event $event): JsonResponse + { + try { + $unfeaturedEvent = $this->eventService->unfeatureEvent($event); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::UNFEATURE_SUCCESS->value, + Response::HTTP_OK, + ['event' => new EventResource($unfeaturedEvent)] + ); + } +} diff --git a/app/Domains/Events/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php index 35c3f8b..c1386d8 100644 --- a/app/Domains/Events/Enums/EventHttpEnum.php +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -23,4 +23,8 @@ enum EventHttpEnum: string case NOT_FOUND = 'events.not_found'; case DASHBOARD_STATS_SUCCESS = 'events.dashboard_stats.success'; case ANALYTICS_STATS_SUCCESS = 'events.analytics_stats.success'; + + case UNPUBLISH_SUCCESS = 'events.unpublish.success'; + case FEATURE_SUCCESS = 'events.feature.success'; + case UNFEATURE_SUCCESS = 'events.unfeature.success'; } diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index 451c43e..b5351b1 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -25,6 +25,11 @@ public function getPaginatedEventsForOrganizer(int $organizerProfileId, int $per return $this->eventRepository->getPaginatedForOrganizer($organizerProfileId, $perPage); } + public function getPaginatedEventsForAdmin(int $perPage = 15): LengthAwarePaginator + { + return $this->eventRepository->getPaginatedForAdmin($perPage); + } + public function findEventById(int $id): ?Event { return $this->eventRepository->findById($id); @@ -88,4 +93,26 @@ public function cancelEvent(Event $event): Event return $event->refresh(); } + + public function unpublishByAdmin(Event $event): Event + { + $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); + + // TODO: dispatch ModeratorActionNotification to organizer + return $event->refresh(); + } + + public function featureEvent(Event $event): Event + { + $this->eventRepository->update($event, ['is_featured' => true]); + + return $event->refresh(); + } + + public function unfeatureEvent(Event $event): Event + { + $this->eventRepository->update($event, ['is_featured' => false]); + + return $event->refresh(); + } } diff --git a/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php index e6ed8a5..766e736 100644 --- a/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php +++ b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php @@ -14,4 +14,6 @@ enum OrganizerHttpEnum: string case APPROVAL_SUCCESS = 'organizer.approval.success'; case APPROVAL_NO_APPLICATION = 'organizer.approval.no_application'; case APPROVAL_ALREADY_APPROVED = 'organizer.approval.already_approved'; + + case REJECTION_SUCCESS = 'organizer.rejection.success'; } diff --git a/app/Domains/Organizer/Notifications/OrganizerRejectedNotification.php b/app/Domains/Organizer/Notifications/OrganizerRejectedNotification.php new file mode 100644 index 0000000..d7966d6 --- /dev/null +++ b/app/Domains/Organizer/Notifications/OrganizerRejectedNotification.php @@ -0,0 +1,30 @@ +subject('Organizer Application Status') + ->line('Thank you for your interest in becoming an organizer.') + ->line('After reviewing your application, we are unable to approve it at this time.') + ->line('You can update your profile and try applying again in the future.') + ->line('Thank you for understanding!'); + } +} diff --git a/app/Domains/Organizer/Services/OrganizerService.php b/app/Domains/Organizer/Services/OrganizerService.php index 5354653..94f1bbc 100644 --- a/app/Domains/Organizer/Services/OrganizerService.php +++ b/app/Domains/Organizer/Services/OrganizerService.php @@ -7,6 +7,7 @@ use App\Domains\Organizer\Enums\Http\OrganizerHttpEnum; use App\Domains\Organizer\Models\OrganizerProfile; use App\Domains\Organizer\Notifications\OrganizerApprovedNotification; +use App\Domains\Organizer\Notifications\OrganizerRejectedNotification; use App\Domains\Organizer\Repositories\OrganizerRepository; use App\Domains\Shared\Exceptions\DomainException; use Spatie\Permission\Models\Role; @@ -68,4 +69,21 @@ public function approveOrganizer(User $user): void $user->notify(new OrganizerApprovedNotification()); } + + public function rejectOrganizer(User $user): void + { + $profile = $user->organizerProfile; + + if (!$profile) { + throw new DomainException(OrganizerHttpEnum::APPROVAL_NO_APPLICATION->value); + } + + if ($profile->verified_at !== null) { + throw new DomainException(OrganizerHttpEnum::APPROVAL_ALREADY_APPROVED->value); + } + + $profile->delete(); + + $user->notify(new OrganizerRejectedNotification()); + } } diff --git a/routes/api.php b/routes/api.php index 25be7c1..ddf98f5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,9 @@ use App\Domains\Organizer\Controllers\ShowOrganizerProfileController; use App\Domains\Organizer\Controllers\UpdateOrganizerProfileController; use App\Domains\Admin\Controllers\ApproveOrganizerController; +use App\Domains\Admin\Controllers\RejectOrganizerController; +use App\Domains\Admin\Controllers\AdminStatsController; +use App\Domains\Events\Controllers\Admin\AdminEventController; use App\Domains\Venues\Controllers\ListVenuesController; use App\Domains\Venues\Controllers\SearchVenuesController; use App\Domains\Venues\Controllers\Admin\AdminVenueSubmissionController; @@ -29,7 +32,7 @@ use App\Domains\Ticketing\Http\Controllers\TicketController; use Illuminate\Support\Facades\Route; -Route::post('/tickets/register', [TicketController::class, 'store']); +Route::post('/tickets/register', [TicketController::class, 'store'])->middleware('throttle:public-registration'); Route::get('/tickets/{uuid}', [TicketController::class, 'show']); Route::post('/tickets/{uuid}/resend', [TicketController::class, 'resend']); @@ -43,12 +46,13 @@ Route::get('/categories', [CategoryController::class, 'index']); Route::get('/events', [PublicEventController::class, 'index']); Route::get('/events/{instance}', [PublicEventController::class, 'show']); +Route::get('/events/{instance}/similar', [PublicEventController::class, 'similar']); Route::prefix('auth')->group(function () { - Route::post('/register', RegisterController::class); - Route::post('/login', LoginController::class)->name('login'); + Route::post('/register', RegisterController::class)->middleware('throttle:auth'); + Route::post('/login', LoginController::class)->name('login')->middleware('throttle:auth'); - Route::post('/forgot-password', SendPasswordResetLinkController::class)->name('password.email'); + Route::post('/forgot-password', SendPasswordResetLinkController::class)->name('password.email')->middleware('throttle:auth'); Route::get('/reset-password/{token}', PasswordResetRedirectController::class)->name('password.reset'); Route::post('/reset-password', ResetPasswordController::class)->name('password.store'); @@ -93,6 +97,15 @@ // Admin Routes Route::prefix('admin')->middleware(['role:admin'])->group(function () { Route::post('/organizers/{user}/approve', ApproveOrganizerController::class); + Route::post('/organizers/{user}/reject', RejectOrganizerController::class); + + Route::get('/events', [AdminEventController::class, 'index']); + Route::get('/events/{event}', [AdminEventController::class, 'show']); + Route::post('/events/{event}/unpublish', [AdminEventController::class, 'unpublish']); + Route::post('/events/{event}/feature', [AdminEventController::class, 'feature']); + Route::post('/events/{event}/unfeature', [AdminEventController::class, 'unfeature']); + + Route::get('/stats', AdminStatsController::class); Route::get('/venue-submissions/flagged', [AdminVenueSubmissionController::class, 'index']); Route::post('/venue-submissions/{venueSubmission}/approve', [AdminVenueSubmissionController::class, 'approve']); From b2e073c67c7d45ee861f27c6153f8a37f7fdb933 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 22:54:52 +0100 Subject: [PATCH 66/81] feat: update repositories for admin and organizer stats methods --- app/Domains/Events/Models/Event.php | 1 + .../Events/Repositories/EventRepository.php | 13 ++++++++- .../Repositories/EventRepositoryInterface.php | 7 +++-- .../Repositories/OrganizerRepository.php | 7 ++++- .../OrganizerRepositoryInterface.php | 15 ++++++++++ .../Eloquent/TicketRepository.php | 11 ++++++++ .../TicketRepositoryInterface.php | 11 ++------ ...151733_add_is_featured_to_events_table.php | 28 +++++++++++++++++++ 8 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 app/Domains/Organizer/Repositories/OrganizerRepositoryInterface.php create mode 100644 database/migrations/2026_02_25_151733_add_is_featured_to_events_table.php diff --git a/app/Domains/Events/Models/Event.php b/app/Domains/Events/Models/Event.php index 5106fcd..d1f8519 100644 --- a/app/Domains/Events/Models/Event.php +++ b/app/Domains/Events/Models/Event.php @@ -35,6 +35,7 @@ protected static function newFactory() 'recurrence_end_at', 'capacity', 'status', + 'is_featured', 'cover_image_path', 'view_count', ]; diff --git a/app/Domains/Events/Repositories/EventRepository.php b/app/Domains/Events/Repositories/EventRepository.php index 0edd549..46f6417 100644 --- a/app/Domains/Events/Repositories/EventRepository.php +++ b/app/Domains/Events/Repositories/EventRepository.php @@ -4,6 +4,7 @@ use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Models\Event; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class EventRepository implements EventRepositoryInterface @@ -35,12 +36,22 @@ public function delete(Event $event): bool return $event->delete(); } + public function getPaginatedForAdmin(int $perPage = 15): LengthAwarePaginator + { + return Event::latest()->paginate($perPage); + } + + public function getTotalCount(): int + { + return Event::count(); + } + public function getTotalCountForOrganizer(int $organizerProfileId): int { return Event::where('organizer_profile_id', $organizerProfileId)->count(); } - public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): \Illuminate\Database\Eloquent\Collection + public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): Collection { return Event::where('organizer_profile_id', $organizerProfileId) ->where('starts_at', '>', now()) diff --git a/app/Domains/Events/Repositories/EventRepositoryInterface.php b/app/Domains/Events/Repositories/EventRepositoryInterface.php index 4fc57f8..7119e30 100644 --- a/app/Domains/Events/Repositories/EventRepositoryInterface.php +++ b/app/Domains/Events/Repositories/EventRepositoryInterface.php @@ -3,6 +3,7 @@ namespace App\Domains\Events\Repositories; use App\Domains\Events\Models\Event; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; interface EventRepositoryInterface @@ -13,7 +14,9 @@ public function create(array $data): Event; public function update(Event $event, array $data): bool; public function delete(Event $event): bool; - public function getTotalCountForOrganizer(int $organizerProfileId): int; + public function getPaginatedForAdmin(int $perPage = 15): LengthAwarePaginator; + public function getTotalCount(): int; - public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): \Illuminate\Database\Eloquent\Collection; + public function getTotalCountForOrganizer(int $organizerProfileId): int; + public function getUpcomingForOrganizer(int $organizerProfileId, int $limit = 5): Collection; } diff --git a/app/Domains/Organizer/Repositories/OrganizerRepository.php b/app/Domains/Organizer/Repositories/OrganizerRepository.php index 400ce66..3cc3a3a 100644 --- a/app/Domains/Organizer/Repositories/OrganizerRepository.php +++ b/app/Domains/Organizer/Repositories/OrganizerRepository.php @@ -6,7 +6,7 @@ use App\Domains\Organizer\Models\OrganizerProfile; use App\Domains\Organizer\Data\OrganizerProfileData; -class OrganizerRepository +class OrganizerRepository implements OrganizerRepositoryInterface { public function createForUser(User $user, OrganizerProfileData $data): OrganizerProfile { @@ -24,4 +24,9 @@ public function verify(OrganizerProfile $profile): bool 'verified_at' => now(), ]); } + + public function getTotalCount(): int + { + return OrganizerProfile::count(); + } } diff --git a/app/Domains/Organizer/Repositories/OrganizerRepositoryInterface.php b/app/Domains/Organizer/Repositories/OrganizerRepositoryInterface.php new file mode 100644 index 0000000..bd5d60c --- /dev/null +++ b/app/Domains/Organizer/Repositories/OrganizerRepositoryInterface.php @@ -0,0 +1,15 @@ +get(); } + public function getTotalCount(): int + { + return Ticket::count(); + } + + public function getTotalRevenue(): float + { + return (float) Ticket::join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->sum('ticket_types.price'); + } + public function getTotalCountForOrganizer(int $organizerProfileId): int { return Ticket::whereIn('ticket_type_id', function ($query) use ($organizerProfileId) { diff --git a/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php index 5d8a089..31ed91a 100644 --- a/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php +++ b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php @@ -10,24 +10,19 @@ interface TicketRepositoryInterface { public function getPaginatedForEvent(int $eventId, int $perPage = 15): LengthAwarePaginator; - public function findByUuid(string $uuid): ?Ticket; - public function update(Ticket $ticket, array $data): bool; - public function getAllForEvent(int $eventId): Collection; - public function getTotalCountForOrganizer(int $organizerProfileId): int; + public function getTotalCount(): int; + public function getTotalRevenue(): float; + public function getTotalCountForOrganizer(int $organizerProfileId): int; public function getCheckedInCountForOrganizer(int $organizerProfileId): int; - public function getRecentForOrganizer(int $organizerProfileId, int $limit = 5): Collection; - public function getTotalRevenueForOrganizer(int $organizerProfileId): float; - public function getRevenueForEvent(int $eventId): float; public function getRegistrationTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array; - public function getRevenueTimeSeries(int $organizerProfileId, ?int $eventId, Carbon $startDate): array; } diff --git a/database/migrations/2026_02_25_151733_add_is_featured_to_events_table.php b/database/migrations/2026_02_25_151733_add_is_featured_to_events_table.php new file mode 100644 index 0000000..366874c --- /dev/null +++ b/database/migrations/2026_02_25_151733_add_is_featured_to_events_table.php @@ -0,0 +1,28 @@ +boolean('is_featured')->default(false)->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn('is_featured'); + }); + } +}; From 2e77b414d9b5bcac1d3341b451d1783bc6202394 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 22:56:14 +0100 Subject: [PATCH 67/81] add rate limiting --- app/Providers/AppServiceProvider.php | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8069744..680b585 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,8 +10,13 @@ use App\Domains\Venues\Repositories\VenueSubmissionRepositoryInterface; use App\Domains\Venues\Repositories\VenueRepository; use App\Domains\Venues\Repositories\VenueRepositoryInterface; +use App\Domains\Organizer\Repositories\OrganizerRepository; +use App\Domains\Organizer\Repositories\OrganizerRepositoryInterface; use App\Domains\Analytics\Repositories\AnalyticsRepositoryInterface; use App\Domains\Analytics\Repositories\Eloquent\AnalyticsRepository; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -25,6 +30,7 @@ public function register(): void $this->app->bind(EventRepositoryInterface::class, EventRepository::class); $this->app->bind(VenueSubmissionRepositoryInterface::class, VenueSubmissionRepository::class); $this->app->bind(TicketRepositoryInterface::class, TicketRepository::class); + $this->app->bind(OrganizerRepositoryInterface::class, OrganizerRepository::class); $this->app->bind(AnalyticsRepositoryInterface::class, AnalyticsRepository::class); } @@ -33,6 +39,21 @@ public function register(): void */ public function boot(): void { - // + $this->configureRateLimiting(); + } + + protected function configureRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('public-registration', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); + }); + + RateLimiter::for('auth', function (Request $request) { + return Limit::perMinute(5)->by($request->ip()); + }); } } From 6aa136b9deeb85d91cb57f6d591aaf9c2f58fba0 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 23:00:43 +0100 Subject: [PATCH 68/81] refactor: decouple public event logic into EventInstance service and repository --- app/Domains/Auth/Services/AuthService.php | 8 ++- .../Controllers/PublicEventController.php | 57 +++++++++-------- .../Repositories/EventInstanceRepository.php | 63 +++++++++++++++++++ .../EventInstanceRepositoryInterface.php | 18 ++++++ .../Events/Services/EventInstanceService.php | 35 +++++++++++ .../Actions/LinkGuestTicketsAction.php | 21 +++++++ app/Providers/AppServiceProvider.php | 3 + 7 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 app/Domains/Events/Repositories/EventInstanceRepository.php create mode 100644 app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php create mode 100644 app/Domains/Events/Services/EventInstanceService.php create mode 100644 app/Domains/Ticketing/Actions/LinkGuestTicketsAction.php diff --git a/app/Domains/Auth/Services/AuthService.php b/app/Domains/Auth/Services/AuthService.php index 5d300de..c77f10f 100644 --- a/app/Domains/Auth/Services/AuthService.php +++ b/app/Domains/Auth/Services/AuthService.php @@ -4,6 +4,7 @@ use App\Domains\Auth\Models\User; use App\Domains\Auth\Repositories\UserRepository; +use App\Domains\Ticketing\Actions\LinkGuestTicketsAction; use Illuminate\Auth\Events\Registered; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -12,7 +13,8 @@ class AuthService { public function __construct( - protected UserRepository $userRepository + protected UserRepository $userRepository, + protected LinkGuestTicketsAction $linkGuestTicketsAction ) {} public function registerUser(array $data): array @@ -26,6 +28,8 @@ public function registerUser(array $data): array event(new Registered($user)); + $this->linkGuestTicketsAction->execute($user); + $token = $user->createToken('auth_token')->plainTextToken; return [ @@ -44,6 +48,8 @@ public function loginUser(array $credentials): array ]); } + $this->linkGuestTicketsAction->execute($user); + $token = $user->createToken('auth_token')->plainTextToken; return [ diff --git a/app/Domains/Events/Controllers/PublicEventController.php b/app/Domains/Events/Controllers/PublicEventController.php index db0fa6f..a5cebe2 100644 --- a/app/Domains/Events/Controllers/PublicEventController.php +++ b/app/Domains/Events/Controllers/PublicEventController.php @@ -7,6 +7,7 @@ use App\Domains\Events\Jobs\IncrementEventViewCountJob; use App\Domains\Events\Models\EventInstance; use App\Domains\Events\Requests\IndexPublicEventsRequest; +use App\Domains\Events\Services\EventInstanceService; use App\Domains\Events\Transformers\EventInstanceResource; use App\Domains\Shared\Exceptions\DomainException; use App\Domains\Shared\Http\BaseController; @@ -16,36 +17,17 @@ class PublicEventController extends BaseController { + public function __construct( + protected EventInstanceService $eventInstanceService + ) {} + public function index(IndexPublicEventsRequest $request): JsonResponse { try { - $query = EventInstance::query() - ->where('status', EventStatusEnum::PUBLISHED->value) - ->with(['event.category', 'event.venue', 'event.organizerProfile']); - - if ($request->filled('search')) { - // todo: Search might still be easiest on the Master Event - brainstorm later - $query->whereHas('event', function ($q) use ($request) { - $q->where('title', 'like', '%' . $request->input('search') . '%'); - }); - } - - if ($request->filled('category')) { - $query->whereHas('event.category', function ($q) use ($request) { - $q->where('slug', $request->input('category')); - }); - } - - if ($request->filled('date_from')) { - $query->where('starts_at', '>=', $request->input('date_from')); - } - - if ($request->filled('date_to')) { - $query->where('starts_at', '<=', $request->input('date_to')); - } - - $perPage = (int) $request->input('per_page', 15); - $instances = $query->orderBy('starts_at')->paginate($perPage); + $instances = $this->eventInstanceService->getPaginatedEvents( + $request->only(['search', 'category', 'date_from', 'date_to']), + (int) $request->input('per_page', 15) + ); } catch (Exception $e) { return $this->apiResponseError($e); } @@ -62,13 +44,13 @@ public function index(IndexPublicEventsRequest $request): JsonResponse public function show(EventInstance $instance): JsonResponse { try { - if ($instance->status->value !== EventStatusEnum::PUBLISHED->value) { + if ($instance->status !== EventStatusEnum::PUBLISHED) { throw new DomainException(EventHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); } IncrementEventViewCountJob::dispatch($instance->event_id); - $instance->increment('view_count'); + $this->eventInstanceService->incrementViewCount($instance); $instance->load(['event.category', 'event.venue', 'event.organizerProfile']); } catch (Exception $e) { @@ -81,4 +63,21 @@ public function show(EventInstance $instance): JsonResponse ['event' => new EventInstanceResource($instance)] ); } + + public function similar(EventInstance $instance): JsonResponse + { + try { + $similar = $this->eventInstanceService->getSimilarEvents($instance); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'events' => EventInstanceResource::collection($similar) + ] + ); + } } diff --git a/app/Domains/Events/Repositories/EventInstanceRepository.php b/app/Domains/Events/Repositories/EventInstanceRepository.php new file mode 100644 index 0000000..f893707 --- /dev/null +++ b/app/Domains/Events/Repositories/EventInstanceRepository.php @@ -0,0 +1,63 @@ +where('status', EventStatusEnum::PUBLISHED->value) + ->with(['event.category', 'event.venue', 'event.organizerProfile']); + + if (!empty($filters['search'])) { + $query->whereHas('event', function ($q) use ($filters) { + $q->where('title', 'like', '%' . $filters['search'] . '%'); + }); + } + + if (!empty($filters['category'])) { + $query->whereHas('event.category', function ($q) use ($filters) { + $q->where('slug', $filters['category']); + }); + } + + if (!empty($filters['date_from'])) { + $query->where('starts_at', '>=', $filters['date_from']); + } + + if (!empty($filters['date_to'])) { + $query->where('starts_at', '<=', $filters['date_to']); + } + + return $query->orderBy('starts_at')->paginate($perPage); + } + + public function getSimilar(EventInstance $instance, int $limit = 4): Collection + { + return EventInstance::query() + ->where('status', EventStatusEnum::PUBLISHED->value) + ->where('id', '!=', $instance->id) + ->whereHas('event', function ($query) use ($instance) { + $query->where('category_id', $instance->event->category_id); + }) + ->with(['event.category', 'event.venue', 'event.organizerProfile']) + ->limit($limit) + ->get(); + } + + public function findById(int $id): ?EventInstance + { + return EventInstance::with(['event.category', 'event.venue', 'event.organizerProfile'])->find($id); + } + + public function incrementViewCount(EventInstance $instance): void + { + $instance->increment('view_count'); + } +} diff --git a/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php b/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php new file mode 100644 index 0000000..658e54d --- /dev/null +++ b/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php @@ -0,0 +1,18 @@ +eventInstanceRepository->getPaginatedPublished($filters, $perPage); + } + + public function getSimilarEvents(EventInstance $instance, int $limit = 4): Collection + { + return $this->eventInstanceRepository->getSimilar($instance, $limit); + } + + public function incrementViewCount(EventInstance $instance): void + { + $this->eventInstanceRepository->incrementViewCount($instance); + } + + public function findInstanceById(int $id): ?EventInstance + { + return $this->eventInstanceRepository->findById($id); + } +} diff --git a/app/Domains/Ticketing/Actions/LinkGuestTicketsAction.php b/app/Domains/Ticketing/Actions/LinkGuestTicketsAction.php new file mode 100644 index 0000000..ae463b0 --- /dev/null +++ b/app/Domains/Ticketing/Actions/LinkGuestTicketsAction.php @@ -0,0 +1,21 @@ +email) + ->whereNull('user_id') + ->update(['user_id' => $user->id]); + + Ticket::where('email', $user->email) + ->whereNull('user_id') + ->update(['user_id' => $user->id]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 680b585..5190f52 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Domains\Events\Repositories\EventInstanceRepository; +use App\Domains\Events\Repositories\EventInstanceRepositoryInterface; use App\Domains\Events\Repositories\EventRepository; use App\Domains\Events\Repositories\EventRepositoryInterface; use App\Domains\Ticketing\Repositories\Eloquent\TicketRepository; @@ -28,6 +30,7 @@ public function register(): void { $this->app->bind(VenueRepositoryInterface::class, VenueRepository::class); $this->app->bind(EventRepositoryInterface::class, EventRepository::class); + $this->app->bind(EventInstanceRepositoryInterface::class, EventInstanceRepository::class); $this->app->bind(VenueSubmissionRepositoryInterface::class, VenueSubmissionRepository::class); $this->app->bind(TicketRepositoryInterface::class, TicketRepository::class); $this->app->bind(OrganizerRepositoryInterface::class, OrganizerRepository::class); From 37a0ef1cb3fc219500744763535c9a41bfe88d0b Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 23:03:25 +0100 Subject: [PATCH 69/81] test(admin): add AdminGeneralTEst --- tests/Feature/Admin/AdminGeneralTest.php | 92 +++++++++++++++++++ .../Organizer/OrganizerOnboardingTest.php | 3 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Admin/AdminGeneralTest.php diff --git a/tests/Feature/Admin/AdminGeneralTest.php b/tests/Feature/Admin/AdminGeneralTest.php new file mode 100644 index 0000000..95c907b --- /dev/null +++ b/tests/Feature/Admin/AdminGeneralTest.php @@ -0,0 +1,92 @@ +forgetCachedPermissions(); + + $this->admin = User::factory()->create(); + $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $this->admin->assignRole($adminRole); + + $this->user = User::factory()->create(); +}); + +test('admin can retrieve global stats', function () { + OrganizerProfile::factory()->count(3)->create(); + Event::factory()->count(5)->create(); + + $event = Event::first(); + $ticketType = TicketType::factory()->create(['event_id' => $event->id, 'price' => 100]); + Ticket::factory()->count(2)->create(['ticket_type_id' => $ticketType->id]); + + $totalEvents = Event::count(); + $totalOrganizers = OrganizerProfile::count(); + $totalTickets = Ticket::count(); + + $response = $this->actingAs($this->admin)->getJson('/api/v1/admin/stats'); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', AdminHttpEnum::STATS_RETRIEVED->value) + ->assertJsonPath('data.stats.total_events', $totalEvents) + ->assertJsonPath('data.stats.total_organizers', $totalOrganizers) + ->assertJsonPath('data.stats.total_tickets_sold', $totalTickets); +}); + +test('admin can reject an organizer application', function () { + Notification::fake(); + + $organizer = User::factory()->create(); + OrganizerProfile::factory()->create(['user_id' => $organizer->id, 'verified_at' => null]); + + $response = $this->actingAs($this->admin)->postJson("/api/v1/admin/organizers/{$organizer->id}/reject"); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('message', AdminHttpEnum::ORGANIZER_REJECTED->value); + + $this->assertDatabaseMissing('organizer_profiles', ['user_id' => $organizer->id]); + + Notification::assertSentTo( + [$organizer], + OrganizerRejectedNotification::class + ); +}); + +test('a user can find similar events', function () { + $category1 = Category::factory()->create(); + $category2 = Category::factory()->create(); + + $event1 = Event::factory()->create(['category_id' => $category1->id, 'status' => EventStatusEnum::PUBLISHED]); + $instance1 = EventInstance::where('event_id', $event1->id)->first(); + + // This event should be returned as similar + $event2 = Event::factory()->create(['category_id' => $category1->id, 'status' => EventStatusEnum::PUBLISHED]); + + // This event should NOT be returned (different category) + $event3 = Event::factory()->create(['category_id' => $category2->id, 'status' => EventStatusEnum::PUBLISHED]); + + $response = $this->getJson("/api/v1/events/{$instance1->id}/similar"); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data.events') + ->assertJsonPath('data.events.0.id', $event2->instances->first()->id); +}); diff --git a/tests/Feature/Organizer/OrganizerOnboardingTest.php b/tests/Feature/Organizer/OrganizerOnboardingTest.php index dcb65f0..43c2019 100644 --- a/tests/Feature/Organizer/OrganizerOnboardingTest.php +++ b/tests/Feature/Organizer/OrganizerOnboardingTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Organizer; +use App\Domains\Admin\Enums\Http\AdminHttpEnum; use App\Domains\Auth\Models\User; use App\Domains\Organizer\Enums\Http\OrganizerHttpEnum; use App\Domains\Organizer\Notifications\OrganizerApprovedNotification; @@ -114,7 +115,7 @@ $response->assertStatus(200) ->assertJsonPath('success', true) - ->assertJsonPath('message', OrganizerHttpEnum::APPROVAL_SUCCESS->value); + ->assertJsonPath('message', AdminHttpEnum::ORGANIZER_APPROVED->value); $this->user->refresh(); From 7a06cc044cd341f6216990248314c95821049c09 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 23:28:44 +0100 Subject: [PATCH 70/81] feat(events): add specific instance cancellation and expose remaining capacity --- .../CancelEventInstanceController.php | 61 +++++++++++++++++++ app/Domains/Events/Models/EventInstance.php | 11 ++++ .../EventCancelledNotification.php | 2 +- .../EventInstanceCancelledNotification.php | 43 +++++++++++++ .../Repositories/EventInstanceRepository.php | 5 ++ .../EventInstanceRepositoryInterface.php | 2 + .../Events/Services/EventInstanceService.php | 26 ++++++++ app/Domains/Events/Services/EventService.php | 23 +++++-- .../Transformers/EventInstanceResource.php | 1 + 9 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 app/Domains/Events/Controllers/Organizer/CancelEventInstanceController.php create mode 100644 app/Domains/Events/Notifications/EventInstanceCancelledNotification.php diff --git a/app/Domains/Events/Controllers/Organizer/CancelEventInstanceController.php b/app/Domains/Events/Controllers/Organizer/CancelEventInstanceController.php new file mode 100644 index 0000000..f690f6b --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/CancelEventInstanceController.php @@ -0,0 +1,61 @@ +organizerProfile->user_id !== $request->user()->id) { + return $this->apiResponseError( + new Exception('Forbidden'), + null, + Response::HTTP_FORBIDDEN + ); + } + + if ($instance->event_id !== $event->id) { + return $this->apiResponseError( + new Exception('Instance does not belong to this event'), + null, + Response::HTTP_NOT_FOUND + ); + } + + try { + $this->eventInstanceService->cancelInstance($instance); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + $e->getCode() ?: Response::HTTP_UNPROCESSABLE_ENTITY + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + EventHttpEnum::CANCEL_SUCCESS->value, + Response::HTTP_OK + ); + } +} diff --git a/app/Domains/Events/Models/EventInstance.php b/app/Domains/Events/Models/EventInstance.php index 31f7b24..4195d81 100644 --- a/app/Domains/Events/Models/EventInstance.php +++ b/app/Domains/Events/Models/EventInstance.php @@ -3,6 +3,7 @@ namespace App\Domains\Events\Models; use App\Domains\Events\Enums\EventStatusEnum; +use App\Domains\Ticketing\Models\Ticket; use Database\Factories\EventInstanceFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,4 +36,14 @@ public function event() { return $this->belongsTo(Event::class); } + + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + public function getRemainingCapacityAttribute(): int + { + return max(0, $this->capacity - $this->tickets()->count()); + } } diff --git a/app/Domains/Events/Notifications/EventCancelledNotification.php b/app/Domains/Events/Notifications/EventCancelledNotification.php index bba1b93..3a1b858 100644 --- a/app/Domains/Events/Notifications/EventCancelledNotification.php +++ b/app/Domains/Events/Notifications/EventCancelledNotification.php @@ -28,7 +28,7 @@ public function toMail(object $notifiable): MailMessage ->subject("Event Cancelled: {$this->event->title}") ->line("We regret to inform you that the event '{$this->event->title}' has been cancelled by the organizer.") ->line('If you had purchased a ticket, you will be receiving a refund shortly.') - ->action('View Events', url('/events')) + ->action('View Events', config('app.frontend_url')) ->line('Thank you for using our application!'); } diff --git a/app/Domains/Events/Notifications/EventInstanceCancelledNotification.php b/app/Domains/Events/Notifications/EventInstanceCancelledNotification.php new file mode 100644 index 0000000..43d1772 --- /dev/null +++ b/app/Domains/Events/Notifications/EventInstanceCancelledNotification.php @@ -0,0 +1,43 @@ +instance->starts_at->format('Y-m-d H:i'); + return (new MailMessage) + ->subject("Event Date Cancelled: {$this->instance->event->title}") + ->line("We regret to inform you that the specific date ({$date}) for the event '{$this->instance->event->title}' has been cancelled by the organizer.") + ->line('If you had purchased a ticket for this specific date, you will be receiving a refund shortly.') + ->action('View Events', config('app.frontend_url')) + ->line('Thank you for using our application!'); + } + + public function toArray(object $notifiable): array + { + return [ + 'event_id' => $this->instance->event->id, + 'event_instance_id' => $this->instance->id, + 'title' => $this->instance->event->title, + ]; + } +} diff --git a/app/Domains/Events/Repositories/EventInstanceRepository.php b/app/Domains/Events/Repositories/EventInstanceRepository.php index f893707..1ea3b56 100644 --- a/app/Domains/Events/Repositories/EventInstanceRepository.php +++ b/app/Domains/Events/Repositories/EventInstanceRepository.php @@ -60,4 +60,9 @@ public function incrementViewCount(EventInstance $instance): void { $instance->increment('view_count'); } + + public function update(EventInstance $instance, array $data): bool + { + return $instance->update($data); + } } diff --git a/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php b/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php index 658e54d..1ea0d38 100644 --- a/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php +++ b/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php @@ -15,4 +15,6 @@ public function getSimilar(EventInstance $instance, int $limit = 4): Collection; public function findById(int $id): ?EventInstance; public function incrementViewCount(EventInstance $instance): void; + + public function update(EventInstance $instance, array $data): bool; } diff --git a/app/Domains/Events/Services/EventInstanceService.php b/app/Domains/Events/Services/EventInstanceService.php index 8c7421c..18b76c3 100644 --- a/app/Domains/Events/Services/EventInstanceService.php +++ b/app/Domains/Events/Services/EventInstanceService.php @@ -2,10 +2,16 @@ namespace App\Domains\Events\Services; +use App\Domains\Events\Enums\EventHttpEnum; +use App\Domains\Events\Enums\EventStatusEnum; use App\Domains\Events\Models\EventInstance; +use App\Domains\Events\Notifications\EventInstanceCancelledNotification; use App\Domains\Events\Repositories\EventInstanceRepositoryInterface; +use DomainException; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\Notification; +use Symfony\Component\HttpFoundation\Response; class EventInstanceService { @@ -32,4 +38,24 @@ public function findInstanceById(int $id): ?EventInstance { return $this->eventInstanceRepository->findById($id); } + + public function cancelInstance(EventInstance $instance): EventInstance + { + if ($instance->status === EventStatusEnum::CANCELLED) { + throw new DomainException( + EventHttpEnum::CANCEL_ALREADY_CANCELLED->value, + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } + + $this->eventInstanceRepository->update($instance, ['status' => EventStatusEnum::CANCELLED->value]); + + $attendees = $instance->tickets->pluck('user')->filter()->unique('id'); + + if ($attendees->isNotEmpty()) { + Notification::send($attendees, new EventInstanceCancelledNotification($instance)); + } + + return $instance->refresh(); + } } diff --git a/app/Domains/Events/Services/EventService.php b/app/Domains/Events/Services/EventService.php index b5351b1..371278a 100644 --- a/app/Domains/Events/Services/EventService.php +++ b/app/Domains/Events/Services/EventService.php @@ -5,13 +5,14 @@ use App\Domains\Events\Actions\SyncEventInstancesAction; use App\Domains\Events\Enums\EventHttpEnum; use App\Domains\Events\Enums\EventStatusEnum; -use Symfony\Component\HttpFoundation\Response; - use App\Domains\Events\Models\Event; +use App\Domains\Events\Notifications\EventCancelledNotification; use App\Domains\Events\Repositories\EventRepositoryInterface; use App\Domains\Shared\Exceptions\DomainException; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Str; +use Symfony\Component\HttpFoundation\Response; class EventService { @@ -89,7 +90,8 @@ public function cancelEvent(Event $event): Event } $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); - // TODO: in Phase 4 dispatch EventCancelledNotification to attendees here + + $this->notifyAttendees($event); return $event->refresh(); } @@ -98,10 +100,23 @@ public function unpublishByAdmin(Event $event): Event { $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); - // TODO: dispatch ModeratorActionNotification to organizer + $this->notifyAttendees($event); + return $event->refresh(); } + private function notifyAttendees(Event $event): void + { + $attendees = $event->instances + ->flatMap(fn($instance) => $instance->tickets->pluck('user')) + ->filter() + ->unique('id'); + + if ($attendees->isNotEmpty()) { + Notification::send($attendees, new EventCancelledNotification($event)); + } + } + public function featureEvent(Event $event): Event { $this->eventRepository->update($event, ['is_featured' => true]); diff --git a/app/Domains/Events/Transformers/EventInstanceResource.php b/app/Domains/Events/Transformers/EventInstanceResource.php index 9cc2539..9b469ae 100644 --- a/app/Domains/Events/Transformers/EventInstanceResource.php +++ b/app/Domains/Events/Transformers/EventInstanceResource.php @@ -15,6 +15,7 @@ public function toArray(Request $request): array 'starts_at' => $this->starts_at, 'ends_at' => $this->ends_at, 'capacity' => $this->capacity, + 'remaining_capacity' => $this->remaining_capacity, 'status' => $this->status, 'view_count' => $this->view_count, 'event' => new EventResource($this->whenLoaded('event')), From 25238588d3e5b0a5bb77e4d6335022678d7e814c Mon Sep 17 00:00:00 2001 From: monte-dev Date: Wed, 25 Feb 2026 23:29:58 +0100 Subject: [PATCH 71/81] feat: add organizer logo upload and update related resources --- .../UploadOrganizerLogoController.php | 48 +++++++++++++++++++ .../Enums/Http/OrganizerHttpEnum.php | 2 + .../Organizer/Models/OrganizerProfile.php | 1 + .../Requests/UploadOrganizerLogoRequest.php | 20 ++++++++ .../Organizer/Services/OrganizerService.php | 21 ++++++++ .../Transformers/OrganizerProfileResource.php | 1 + ...d_logo_url_to_organizer_profiles_table.php | 22 +++++++++ routes/api.php | 4 ++ 8 files changed, 119 insertions(+) create mode 100644 app/Domains/Organizer/Controllers/UploadOrganizerLogoController.php create mode 100644 app/Domains/Organizer/Requests/UploadOrganizerLogoRequest.php create mode 100644 database/migrations/2026_02_25_221238_add_logo_url_to_organizer_profiles_table.php diff --git a/app/Domains/Organizer/Controllers/UploadOrganizerLogoController.php b/app/Domains/Organizer/Controllers/UploadOrganizerLogoController.php new file mode 100644 index 0000000..f3f756a --- /dev/null +++ b/app/Domains/Organizer/Controllers/UploadOrganizerLogoController.php @@ -0,0 +1,48 @@ +organizerService->uploadLogo( + $request->user(), + $request->file('logo') + ); + } catch (DomainException $e) { + return $this->apiResponseError( + $e, + $e->getMessage(), + Response::HTTP_NOT_FOUND + ); + } catch (Exception $e) { + return $this->apiResponseError( + $e, + null, + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return $this->apiResponseSuccess( + OrganizerHttpEnum::LOGO_UPLOADED->value, + Response::HTTP_OK, + ['profile' => new OrganizerProfileResource($profile)] + ); + } +} diff --git a/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php index 766e736..2a9f648 100644 --- a/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php +++ b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php @@ -16,4 +16,6 @@ enum OrganizerHttpEnum: string case APPROVAL_ALREADY_APPROVED = 'organizer.approval.already_approved'; case REJECTION_SUCCESS = 'organizer.rejection.success'; + + case LOGO_UPLOADED = 'organizer.logo.uploaded'; } diff --git a/app/Domains/Organizer/Models/OrganizerProfile.php b/app/Domains/Organizer/Models/OrganizerProfile.php index e716ab5..f4763e9 100644 --- a/app/Domains/Organizer/Models/OrganizerProfile.php +++ b/app/Domains/Organizer/Models/OrganizerProfile.php @@ -23,6 +23,7 @@ protected static function newFactory() 'slug', 'description', 'phone', + 'logo_url', 'verified_at', ]; diff --git a/app/Domains/Organizer/Requests/UploadOrganizerLogoRequest.php b/app/Domains/Organizer/Requests/UploadOrganizerLogoRequest.php new file mode 100644 index 0000000..9774759 --- /dev/null +++ b/app/Domains/Organizer/Requests/UploadOrganizerLogoRequest.php @@ -0,0 +1,20 @@ + ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; + } +} diff --git a/app/Domains/Organizer/Services/OrganizerService.php b/app/Domains/Organizer/Services/OrganizerService.php index 94f1bbc..661b141 100644 --- a/app/Domains/Organizer/Services/OrganizerService.php +++ b/app/Domains/Organizer/Services/OrganizerService.php @@ -10,6 +10,8 @@ use App\Domains\Organizer\Notifications\OrganizerRejectedNotification; use App\Domains\Organizer\Repositories\OrganizerRepository; use App\Domains\Shared\Exceptions\DomainException; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Spatie\Permission\Models\Role; use Illuminate\Support\Str; @@ -86,4 +88,23 @@ public function rejectOrganizer(User $user): void $user->notify(new OrganizerRejectedNotification()); } + + public function uploadLogo(User $user, UploadedFile $file): OrganizerProfile + { + $profile = $user->organizerProfile; + + if (!$profile) { + throw new DomainException(OrganizerHttpEnum::PROFILE_NOT_FOUND->value); + } + + if ($profile->logo_url) { + Storage::disk('public')->delete($profile->logo_url); + } + + $path = $file->store('organizer-logos', 'public'); + + $profile->update(['logo_url' => $path]); + + return $profile->fresh(); + } } diff --git a/app/Domains/Organizer/Transformers/OrganizerProfileResource.php b/app/Domains/Organizer/Transformers/OrganizerProfileResource.php index 102856f..9756347 100644 --- a/app/Domains/Organizer/Transformers/OrganizerProfileResource.php +++ b/app/Domains/Organizer/Transformers/OrganizerProfileResource.php @@ -24,6 +24,7 @@ public function toArray(Request $request): array 'slug' => $this->slug, 'description' => $this->description, 'phone' => $this->phone, + 'logo_url' => $this->logo_url, 'verified_at' => $this->verified_at ? $this->verified_at->toIso8601String() : null, 'is_verified' => $this->verified_at !== null, ]; diff --git a/database/migrations/2026_02_25_221238_add_logo_url_to_organizer_profiles_table.php b/database/migrations/2026_02_25_221238_add_logo_url_to_organizer_profiles_table.php new file mode 100644 index 0000000..2a78535 --- /dev/null +++ b/database/migrations/2026_02_25_221238_add_logo_url_to_organizer_profiles_table.php @@ -0,0 +1,22 @@ +string('logo_url')->nullable()->after('phone'); + }); + } + + public function down(): void + { + Schema::table('organizer_profiles', function (Blueprint $table) { + $table->dropColumn('logo_url'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index ddf98f5..2589992 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Domains\Organizer\Controllers\StoreOrganizerProfileController; use App\Domains\Organizer\Controllers\ShowOrganizerProfileController; use App\Domains\Organizer\Controllers\UpdateOrganizerProfileController; +use App\Domains\Organizer\Controllers\UploadOrganizerLogoController; use App\Domains\Admin\Controllers\ApproveOrganizerController; use App\Domains\Admin\Controllers\RejectOrganizerController; use App\Domains\Admin\Controllers\AdminStatsController; @@ -23,6 +24,7 @@ use App\Domains\Events\Controllers\Organizer\OrganizerEventController; use App\Domains\Events\Controllers\Organizer\PublishEventController; use App\Domains\Events\Controllers\Organizer\CancelEventController; +use App\Domains\Events\Controllers\Organizer\CancelEventInstanceController; use App\Domains\Categories\Controllers\CategoryController; use App\Domains\Events\Controllers\PublicEventController; use App\Domains\Events\Controllers\Organizer\DashboardController; @@ -76,11 +78,13 @@ Route::post('/profile', StoreOrganizerProfileController::class); Route::get('/profile', ShowOrganizerProfileController::class); Route::put('/profile', UpdateOrganizerProfileController::class); + Route::post('/profile/logo', UploadOrganizerLogoController::class); Route::middleware(['role:organizer'])->group(function () { Route::apiResource('events', OrganizerEventController::class); Route::post('events/{event}/publish', PublishEventController::class); Route::post('events/{event}/cancel', CancelEventController::class); + Route::post('events/{event}/instances/{instance}/cancel', CancelEventInstanceController::class); Route::post('venue-submissions', [VenueSubmissionController::class, 'store']); Route::get('venue-submissions/{venueSubmission}', [VenueSubmissionController::class, 'show']); From b565edada426184796d3811d622d2873dc16b806 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Thu, 26 Feb 2026 22:43:17 +0100 Subject: [PATCH 72/81] feat(payments): add stripe sdk and base domain model updates --- .../Organizer/Models/OrganizerProfile.php | 3 + app/Domains/Shared/Http/BaseController.php | 13 +++- .../Ticketing/Enums/TicketStatusEnum.php | 1 + app/Domains/Ticketing/Models/Order.php | 27 ++++++++ composer.json | 3 +- composer.lock | 61 ++++++++++++++++++- config/payments.php | 31 ++++++++++ 7 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 config/payments.php diff --git a/app/Domains/Organizer/Models/OrganizerProfile.php b/app/Domains/Organizer/Models/OrganizerProfile.php index f4763e9..b54604d 100644 --- a/app/Domains/Organizer/Models/OrganizerProfile.php +++ b/app/Domains/Organizer/Models/OrganizerProfile.php @@ -25,10 +25,13 @@ protected static function newFactory() 'phone', 'logo_url', 'verified_at', + 'stripe_account_id', + 'stripe_onboarding_completed', ]; protected $casts = [ 'verified_at' => 'datetime', + 'stripe_onboarding_completed' => 'boolean', ]; public function user(): BelongsTo diff --git a/app/Domains/Shared/Http/BaseController.php b/app/Domains/Shared/Http/BaseController.php index e37dcb5..692a30b 100644 --- a/app/Domains/Shared/Http/BaseController.php +++ b/app/Domains/Shared/Http/BaseController.php @@ -34,7 +34,7 @@ protected function apiResponseError(Throwable $error, ?string $message = null, ? Log::error($error); $message = $message ?? ($error instanceof DomainException ? $error->getMessage() : AppHttpEnum::SERVER_ERROR->value); - $statusCode = $statusCode ?? ($error instanceof ValidationException ? 422 : ($error->getCode() ?: 500)); + $statusCode = $this->resolveStatusCode($error, $statusCode); $response = ApiResponseResource::error($message); @@ -44,4 +44,15 @@ protected function apiResponseError(Throwable $error, ?string $message = null, ? return $response->response()->setStatusCode($statusCode); } + + private function resolveStatusCode(Throwable $error, ?int $override): int + { + if ($override !== null) return $override; + + if ($error instanceof ValidationException) return 422; + + $code = $error->getCode(); + + return ($code >= 100 && $code < 600) ? $code : 500; + } } diff --git a/app/Domains/Ticketing/Enums/TicketStatusEnum.php b/app/Domains/Ticketing/Enums/TicketStatusEnum.php index d4701f1..a7b09d9 100644 --- a/app/Domains/Ticketing/Enums/TicketStatusEnum.php +++ b/app/Domains/Ticketing/Enums/TicketStatusEnum.php @@ -4,6 +4,7 @@ enum TicketStatusEnum: string { + case PENDING = 'pending'; case VALID = 'valid'; case CANCELLED = 'cancelled'; case CHECKED_IN = 'checked_in'; diff --git a/app/Domains/Ticketing/Models/Order.php b/app/Domains/Ticketing/Models/Order.php index ad17ddb..5b2c48d 100644 --- a/app/Domains/Ticketing/Models/Order.php +++ b/app/Domains/Ticketing/Models/Order.php @@ -5,6 +5,7 @@ use App\Domains\Auth\Models\User; use App\Domains\Events\Models\EventInstance; use App\Domains\Ticketing\Enums\OrderStatusEnum; +use App\Domains\Payments\Enums\PaymentStatusEnum; use Database\Factories\OrderFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -24,10 +25,16 @@ protected static function newFactory() 'email', 'name', 'status', + 'total_amount', + 'currency', + 'payment_provider', + 'payment_intent_id', + 'payment_status', ]; protected $casts = [ 'status' => OrderStatusEnum::class, + 'payment_status' => PaymentStatusEnum::class, ]; public function tickets() @@ -44,4 +51,24 @@ public function eventInstance() { return $this->belongsTo(EventInstance::class); } + + public function isFree(): bool + { + return $this->total_amount === null || $this->total_amount === 0; + } + + public function isPaid(): bool + { + return $this->payment_status === PaymentStatusEnum::PAID; + } + + public function markAsPaid(string $intentId, string $provider): void + { + $this->update([ + 'status' => OrderStatusEnum::COMPLETED, + 'payment_status' => PaymentStatusEnum::PAID, + 'payment_intent_id' => $intentId, + 'payment_provider' => $provider, + ]); + } } diff --git a/composer.json b/composer.json index 794e007..3af35f1 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "laravel/scout": "^10.24", "laravel/tinker": "^2.10.1", "meilisearch/meilisearch-php": "^1.16", - "spatie/laravel-permission": "^7.2" + "spatie/laravel-permission": "^7.2", + "stripe/stripe-php": "^19.4" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 37fafc4..1f47a44 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dd0b97d8940e071dce70cf7c5f96d735", + "content-hash": "6d1cfdb97ccc3afe505ca70b7ea27e66", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3847,6 +3847,65 @@ ], "time": "2026-02-16T22:23:26+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v19.4.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "a8c9db819f86ec47e603b92f9be99661e6a62cf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a8c9db819f86ec47e603b92f9be99661e6a62cf4", + "reference": "a8c9db819f86ec47e603b92f9be99661e6a62cf4", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v19.4.0" + }, + "time": "2026-02-25T17:46:20+00:00" + }, { "name": "symfony/clock", "version": "v7.4.0", diff --git a/config/payments.php b/config/payments.php new file mode 100644 index 0000000..fd5ddc1 --- /dev/null +++ b/config/payments.php @@ -0,0 +1,31 @@ + env('PAYMENT_PROVIDER', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Payment Architecture & Fees + |-------------------------------------------------------------------------- + */ + 'currency' => env('PAYMENT_CURRENCY', 'pln'), + + 'collect_platform_fee' => env('PAYMENTS_COLLECT_PLATFORM_FEE', false), + + 'platform_fee_percentage' => (int) env('PLATFORM_FEE_PERCENTAGE', 5), + + /* + |-------------------------------------------------------------------------- + | Provider Configurations + |-------------------------------------------------------------------------- + */ + 'stripe' => [ + 'secret_key' => env('STRIPE_SECRET_KEY') ?? env('STRIPE_TEST_SECRET_KEY'), + 'publishable_key' => env('STRIPE_PUBLISHABLE_KEY') ?? env('STRIPE_TEST_PUBLISHABLE_KEY'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + + 'test_secret_key' => env('STRIPE_TEST_SECRET_KEY'), + 'test_publishable_key' => env('STRIPE_TEST_PUBLISHABLE_KEY'), + ], +]; From 034f4c3328583fff471f43ddfc8a8ad49af0f7d4 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:09:15 +0100 Subject: [PATCH 73/81] feat(payments): implement database schema and api routing for payments --- app/Providers/AppServiceProvider.php | 11 +++++++ ...44_add_payment_columns_to_orders_table.php | 32 +++++++++++++++++++ ...pe_columns_to_organizer_profiles_table.php | 26 +++++++++++++++ ..._create_processed_webhook_events_table.php | 27 ++++++++++++++++ ...2026_02_26_193258_create_payouts_table.php | 29 +++++++++++++++++ routes/api.php | 10 ++++++ 6 files changed, 135 insertions(+) create mode 100644 database/migrations/2026_02_25_234044_add_payment_columns_to_orders_table.php create mode 100644 database/migrations/2026_02_25_234045_add_stripe_columns_to_organizer_profiles_table.php create mode 100644 database/migrations/2026_02_25_234045_create_processed_webhook_events_table.php create mode 100644 database/migrations/2026_02_26_193258_create_payouts_table.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5190f52..2175c56 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -16,6 +16,8 @@ use App\Domains\Organizer\Repositories\OrganizerRepositoryInterface; use App\Domains\Analytics\Repositories\AnalyticsRepositoryInterface; use App\Domains\Analytics\Repositories\Eloquent\AnalyticsRepository; +use App\Domains\Payments\Contracts\PaymentProviderInterface; +use App\Domains\Payments\Providers\StripePaymentProvider; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; @@ -35,6 +37,15 @@ public function register(): void $this->app->bind(TicketRepositoryInterface::class, TicketRepository::class); $this->app->bind(OrganizerRepositoryInterface::class, OrganizerRepository::class); $this->app->bind(AnalyticsRepositoryInterface::class, AnalyticsRepository::class); + + $this->app->bind(PaymentProviderInterface::class, function ($app) { + $provider = config('payments.default'); + + return match ($provider) { + 'stripe' => $app->make(StripePaymentProvider::class), + default => $app->make(StripePaymentProvider::class), + }; + }); } /** diff --git a/database/migrations/2026_02_25_234044_add_payment_columns_to_orders_table.php b/database/migrations/2026_02_25_234044_add_payment_columns_to_orders_table.php new file mode 100644 index 0000000..fb8a75c --- /dev/null +++ b/database/migrations/2026_02_25_234044_add_payment_columns_to_orders_table.php @@ -0,0 +1,32 @@ +integer('total_amount')->nullable(); + $table->string('currency', 3)->default('PLN'); + $table->string('payment_provider')->nullable(); + $table->string('payment_intent_id')->nullable()->index(); + $table->string('payment_status')->default('none'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn([ + 'total_amount', + 'currency', + 'payment_provider', + 'payment_intent_id', + 'payment_status', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_25_234045_add_stripe_columns_to_organizer_profiles_table.php b/database/migrations/2026_02_25_234045_add_stripe_columns_to_organizer_profiles_table.php new file mode 100644 index 0000000..2b6b908 --- /dev/null +++ b/database/migrations/2026_02_25_234045_add_stripe_columns_to_organizer_profiles_table.php @@ -0,0 +1,26 @@ +string('stripe_account_id')->nullable()->unique(); + $table->boolean('stripe_onboarding_completed')->default(false); + }); + } + + public function down(): void + { + Schema::table('organizer_profiles', function (Blueprint $table) { + $table->dropColumn([ + 'stripe_account_id', + 'stripe_onboarding_completed', + ]); + }); + } +}; diff --git a/database/migrations/2026_02_25_234045_create_processed_webhook_events_table.php b/database/migrations/2026_02_25_234045_create_processed_webhook_events_table.php new file mode 100644 index 0000000..d1132ac --- /dev/null +++ b/database/migrations/2026_02_25_234045_create_processed_webhook_events_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('payment_provider'); + $table->string('event_id')->unique(); + $table->string('event_type'); + $table->timestamp('processed_at')->useCurrent(); + $table->timestamps(); + + $table->index(['payment_provider', 'event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('processed_webhook_events'); + } +}; diff --git a/database/migrations/2026_02_26_193258_create_payouts_table.php b/database/migrations/2026_02_26_193258_create_payouts_table.php new file mode 100644 index 0000000..1c266ef --- /dev/null +++ b/database/migrations/2026_02_26_193258_create_payouts_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('organizer_profile_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('amount'); + $table->string('currency', 3)->default('pln'); + $table->string('status')->default('pending'); + $table->dateTime('scheduled_for'); + $table->dateTime('paid_at')->nullable(); + $table->string('stripe_transfer_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payouts'); + } +}; diff --git a/routes/api.php b/routes/api.php index 2589992..f314552 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,9 @@ use App\Domains\Organizer\Controllers\ShowOrganizerProfileController; use App\Domains\Organizer\Controllers\UpdateOrganizerProfileController; use App\Domains\Organizer\Controllers\UploadOrganizerLogoController; +use App\Domains\Payments\Controllers\CheckoutController; +use App\Domains\Payments\Controllers\OrganizerPayoutController; +use App\Domains\Payments\Controllers\WebhookController; use App\Domains\Admin\Controllers\ApproveOrganizerController; use App\Domains\Admin\Controllers\RejectOrganizerController; use App\Domains\Admin\Controllers\AdminStatsController; @@ -96,6 +99,9 @@ Route::get('dashboard', DashboardController::class); Route::get('analytics', AnalyticsController::class); }); + + Route::post('/payout/onboard', [OrganizerPayoutController::class, 'onboard']); + Route::get('/payout/status', [OrganizerPayoutController::class, 'status']); }); // Admin Routes @@ -117,3 +123,7 @@ Route::post('/venue-submissions/{venueSubmission}/merge', [AdminVenueSubmissionController::class, 'merge']); }); }); + +// Payment Routes +Route::post('/checkout', CheckoutController::class)->middleware('auth:sanctum'); +Route::post('/webhooks/stripe', WebhookController::class); From 9140a15fbe28f9864d3e70401f830d3410ba1f8e Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:09:29 +0100 Subject: [PATCH 74/81] feat(payments): implement stripe provider with escrow payout model --- .../Contracts/PaymentProviderInterface.php | 78 +++++ .../Controllers/CheckoutController.php | 43 +++ .../Controllers/OrganizerPayoutController.php | 55 ++++ .../Controllers/WebhookController.php | 47 +++ app/Domains/Payments/DTOs/CheckoutResult.php | 12 + app/Domains/Payments/DTOs/WebhookResult.php | 25 ++ .../Payments/Enums/PaymentHttpEnum.php | 14 + .../Payments/Enums/PaymentProviderEnum.php | 9 + .../Payments/Enums/PaymentStatusEnum.php | 12 + .../Payments/Enums/PayoutStatusEnum.php | 11 + app/Domains/Payments/Models/Payout.php | 41 +++ .../Providers/StripePaymentProvider.php | 239 ++++++++++++++++ .../Requests/InitiateCheckoutRequest.php | 20 ++ .../Payments/Services/PaymentService.php | 268 ++++++++++++++++++ .../Actions/RegisterAttendeeAction.php | 14 +- app/Jobs/Payments/ReleasePayoutsJob.php | 37 +++ 16 files changed, 922 insertions(+), 3 deletions(-) create mode 100644 app/Domains/Payments/Contracts/PaymentProviderInterface.php create mode 100644 app/Domains/Payments/Controllers/CheckoutController.php create mode 100644 app/Domains/Payments/Controllers/OrganizerPayoutController.php create mode 100644 app/Domains/Payments/Controllers/WebhookController.php create mode 100644 app/Domains/Payments/DTOs/CheckoutResult.php create mode 100644 app/Domains/Payments/DTOs/WebhookResult.php create mode 100644 app/Domains/Payments/Enums/PaymentHttpEnum.php create mode 100644 app/Domains/Payments/Enums/PaymentProviderEnum.php create mode 100644 app/Domains/Payments/Enums/PaymentStatusEnum.php create mode 100644 app/Domains/Payments/Enums/PayoutStatusEnum.php create mode 100644 app/Domains/Payments/Models/Payout.php create mode 100644 app/Domains/Payments/Providers/StripePaymentProvider.php create mode 100644 app/Domains/Payments/Requests/InitiateCheckoutRequest.php create mode 100644 app/Domains/Payments/Services/PaymentService.php create mode 100644 app/Jobs/Payments/ReleasePayoutsJob.php diff --git a/app/Domains/Payments/Contracts/PaymentProviderInterface.php b/app/Domains/Payments/Contracts/PaymentProviderInterface.php new file mode 100644 index 0000000..d9ede83 --- /dev/null +++ b/app/Domains/Payments/Contracts/PaymentProviderInterface.php @@ -0,0 +1,78 @@ +validated('order_id')); + + if ($order->user_id && (!$request->user() || $order->user_id !== $request->user()->id)) { + return $this->apiResponseError(new Exception('Unauthorized access to order'), 403); + } + + try { + $result = $this->paymentService->initiateCheckout($order); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + PaymentHttpEnum::CHECKOUT_INITIATED->value, + Response::HTTP_OK, + [ + 'session_id' => $result->sessionId, + 'checkout_url' => $result->checkoutUrl, + ] + ); + } +} diff --git a/app/Domains/Payments/Controllers/OrganizerPayoutController.php b/app/Domains/Payments/Controllers/OrganizerPayoutController.php new file mode 100644 index 0000000..fba128c --- /dev/null +++ b/app/Domains/Payments/Controllers/OrganizerPayoutController.php @@ -0,0 +1,55 @@ +paymentService->onboardOrganizer( + user: $request->user(), + ipAddress: $request->ip() + ); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + PaymentHttpEnum::ONBOARDING_STARTED->value, + Response::HTTP_OK, + ['onboarding_url' => $url] + ); + } + + public function status(Request $request): JsonResponse + { + try { + $user = $request->user(); + $profile = $user->organizerProfile; + + if (!$profile) { + throw new Exception("Organizer profile not found", Response::HTTP_NOT_FOUND); + } + + return $this->apiResponseSuccess('Success', Response::HTTP_OK, [ + 'stripe_account_id' => $profile->stripe_account_id, + 'stripe_onboarding_completed' => (bool) $profile->stripe_onboarding_completed, + ]); + } catch (Exception $e) { + return $this->apiResponseError($e, $e->getMessage(), (int) ($e->getCode() ?: 500)); + } + } +} diff --git a/app/Domains/Payments/Controllers/WebhookController.php b/app/Domains/Payments/Controllers/WebhookController.php new file mode 100644 index 0000000..bed8316 --- /dev/null +++ b/app/Domains/Payments/Controllers/WebhookController.php @@ -0,0 +1,47 @@ +getContent(); + $signature = $request->header('Stripe-Signature', ''); + + if (!$signature) { + Log::warning('Stripe webhook received without signature'); + + return response()->json(['error' => 'No signature'], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->paymentService->processWebhook($payload, $signature); + + if (!$result->success) { + return response()->json(['error' => 'Webhook processing failed: ' . $result->eventType], Response::HTTP_BAD_REQUEST); + } + } catch (Exception $e) { + Log::error('Webhook Controller Error', ['message' => $e->getMessage()]); + + return response()->json( + ['error' => 'Internal server error processing webhook'], + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return response()->json(['status' => 'success']); + } +} diff --git a/app/Domains/Payments/DTOs/CheckoutResult.php b/app/Domains/Payments/DTOs/CheckoutResult.php new file mode 100644 index 0000000..2c638ea --- /dev/null +++ b/app/Domains/Payments/DTOs/CheckoutResult.php @@ -0,0 +1,12 @@ + PayoutStatusEnum::class, + 'scheduled_for' => 'datetime', + 'paid_at' => 'datetime', + ]; + + public function organizerProfile() + { + return $this->belongsTo(OrganizerProfile::class); + } + + public function order() + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Domains/Payments/Providers/StripePaymentProvider.php b/app/Domains/Payments/Providers/StripePaymentProvider.php new file mode 100644 index 0000000..263bde5 --- /dev/null +++ b/app/Domains/Payments/Providers/StripePaymentProvider.php @@ -0,0 +1,239 @@ +value; + } + + /** + * Create a Stripe Checkout Session for an order. + */ + public function createCheckoutSession( + Order $order, + array $lineItems, + string $successUrl, + string $cancelUrl, + ?int $applicationFeeAmount = null + ): CheckoutResult { + $organizerProfile = $order->eventInstance->event->organizerProfile; + + $metadata = [ + 'order_id' => $order->id, + 'event_instance_id' => $order->event_instance_id, + 'user_id' => $order->user_id, + 'organizer_id' => $organizerProfile->id, + 'organizer_stripe_account' => $organizerProfile->stripe_account_id, + 'expected_fee_cents' => $applicationFeeAmount ?? 0, + ]; + + $sessionParams = [ + 'payment_method_types' => ['card', 'blik', 'p24'], + 'customer_email' => $order->email, + 'line_items' => $lineItems, + 'mode' => 'payment', + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, + 'client_reference_id' => (string) $order->id, + 'metadata' => $metadata, + 'payment_intent_data' => [ + 'transfer_group' => 'ORDER_' . $order->id, + ] + ]; + + try { + $session = Session::create($sessionParams); + + return new CheckoutResult( + sessionId: $session->id, + checkoutUrl: $session->url, + paymentIntentId: $session->payment_intent + ); + } catch (ApiErrorException $e) { + Log::error('Stripe Checkout Creation Failed', [ + 'error' => $e->getMessage(), + 'order_id' => $order->id + ]); + + throw new Exception("Payment provider error: " . $e->getMessage()); + } + } + + public function handleWebhook(string $payload, string $signature): WebhookResult + { + try { + $event = Webhook::constructEvent( + $payload, + $signature, + config('payments.stripe.webhook_secret') + ); + } catch (UnexpectedValueException $e) { + Log::error('Stripe Webhook Invalid Payload'); + + return new WebhookResult(success: false, eventType: 'invalid_payload', eventId: ''); + } catch (SignatureVerificationException $e) { + Log::error('Stripe Webhook Invalid Signature'); + + return new WebhookResult(success: false, eventType: 'invalid_signature', eventId: ''); + } + + $session = $event->data->object; + + $orderData = null; + + if ($event->type === 'checkout.session.completed') { + + $orderData = new OrderData( + orderId: (int) $session->client_reference_id, + paymentIntentId: $session->payment_intent, + paymentStatus: $session->payment_status, + amountPaid: $session->amount_total, + currency: $session->currency, + customerEmail: $session->customer_details->email ?? null + ); + } + + return new WebhookResult( + success: true, + eventType: $event->type, + eventId: $event->id, + orderData: $orderData + ); + } + + /** + * Create a Stripe Connect Standard account for an organizer. + */ + public function createConnectAccount(OrganizerProfile $profile, string $ipAddress): string + { + try { + $businessUrl = config('app.frontend_url') . '/organizer/' . $profile->slug; + + if (str_contains($businessUrl, 'localhost')) { + $businessUrl = config('app.frontend_url_local_stripe_dummy') . '/organizer/' . $profile->slug; + } + + Log::info('Creating Stripe Connect Account', [ + 'organizer_id' => $profile->id, + 'business_url' => $businessUrl, + 'ip_address' => $ipAddress + ]); + + $account = Account::create([ + 'type' => 'standard', + 'email' => $profile->user->email, + 'business_profile' => [ + 'url' => $businessUrl, + 'name' => $profile->name, + ], + 'tos_acceptance' => [ + 'ip' => $ipAddress, + 'date' => time() + ] + ]); + } catch (ApiErrorException $e) { + Log::error('Stripe Connect Account Creation Failed', [ + 'error' => $e->getMessage(), + 'organizer_id' => $profile->id + ]); + throw new Exception("Failed to create payment account: " . $e->getMessage()); + } + + return $account->id; + } + + public function createAccountLink(string $accountId, string $returnUrl, string $refreshUrl): string + { + try { + $accountLink = AccountLink::create([ + 'account' => $accountId, + 'refresh_url' => $refreshUrl, + 'return_url' => $returnUrl, + 'type' => 'account_onboarding', + ]); + } catch (ApiErrorException $e) { + Log::error('Stripe Account Link Creation Failed', [ + 'error' => $e->getMessage(), + 'account_id' => $accountId + ]); + throw new Exception("Failed to generate onboarding link"); + } + + return $accountLink->url; + } + + public function refundPayment(Order $order): bool + { + if (!$order->payment_intent_id) { + return false; + } + + try { + $refund = Refund::create([ + 'payment_intent' => $order->payment_intent_id, + 'reason' => 'requested_by_customer', + ]); + } catch (ApiErrorException $e) { + Log::error('Stripe Refund Failed', [ + 'error' => $e->getMessage(), + 'order_id' => $order->id + ]); + return false; + } + + return $refund->status === 'succeeded' || $refund->status === 'pending'; + } + + /** + * Transfer funds to an organizer. + */ + public function transferToOrganizer(string $destinationAccountId, int $amount, string $currency, string $transferGroup): bool + { + try { + Transfer::create([ + 'amount' => $amount, + 'currency' => $currency, + 'destination' => $destinationAccountId, + 'transfer_group' => $transferGroup, + ]); + } catch (ApiErrorException $e) { + Log::error('Stripe Transfer Failed', [ + 'error' => $e->getMessage(), + 'destination' => $destinationAccountId, + 'amount' => $amount, + 'transfer_group' => $transferGroup + ]); + return false; + } + + return true; + } +} diff --git a/app/Domains/Payments/Requests/InitiateCheckoutRequest.php b/app/Domains/Payments/Requests/InitiateCheckoutRequest.php new file mode 100644 index 0000000..dee108c --- /dev/null +++ b/app/Domains/Payments/Requests/InitiateCheckoutRequest.php @@ -0,0 +1,20 @@ + ['required', 'integer', 'exists:orders,id'], + ]; + } +} diff --git a/app/Domains/Payments/Services/PaymentService.php b/app/Domains/Payments/Services/PaymentService.php new file mode 100644 index 0000000..6e2c914 --- /dev/null +++ b/app/Domains/Payments/Services/PaymentService.php @@ -0,0 +1,268 @@ + $order->id]); + + $this->ensureOrderIsCheckoutReady($order); + + $ticketType = $order->tickets()->first()->ticketType; + $totalAmount = $ticketType->price * $order->tickets()->count(); + + $this->markOrderAsPending($order, $totalAmount); + + $result = $this->provider->createCheckoutSession( + order: $order, + lineItems: $this->buildStripeLineItems($order, $ticketType), + successUrl: rtrim(config('app.frontend_url'), '/') . "/checkout/success?session_id={CHECKOUT_SESSION_ID}&order_id={$order->id}", + cancelUrl: rtrim(config('app.frontend_url'), '/') . "/checkout/cancel?order_id={$order->id}", + applicationFeeAmount: $this->calculateApplicationFee($totalAmount) + ); + + if ($result->paymentIntentId) { + $order->update(['payment_intent_id' => $result->paymentIntentId]); + } + + return $result; + } + + public function processWebhook(string $payload, string $signature): WebhookResult + { + $result = $this->provider->handleWebhook($payload, $signature); + + if (!$result->success || !$result->orderData || $this->isWebhookAlreadyProcessed($result->eventId)) { + return $result; + } + + DB::transaction(function () use ($result) { + $orderData = $result->orderData; + + /** @var Order|null $order */ + $order = Order::with('tickets')->lockForUpdate()->find($orderData->orderId); + + if (!$order) { + Log::error("Webhook references unknown order_id: {$orderData->orderId}"); + return; + } + + if ($order->payment_status === PaymentStatusEnum::PENDING) { + if ($orderData->paymentStatus === 'paid') { + $this->handleSuccessfulPayment($order, $orderData); + } elseif ($orderData->paymentStatus === 'failed') { + $this->handleFailedPayment($order); + } + } + + $this->recordWebhookAsProcessed($result); + }); + + return $result; + } + + public function onboardOrganizer(User $user, string $ipAddress): string + { + $profile = $user->organizerProfile; + + if (!$profile) { + throw new Exception("User is not an organizer."); + } + + if (!$profile->stripe_account_id) { + $accountId = $this->provider->createConnectAccount($profile, $ipAddress); + $profile->update(['stripe_account_id' => $accountId]); + } + + $frontendUrl = rtrim(config('app.frontend_url'), '/'); + + return $this->provider->createAccountLink( + accountId: $profile->stripe_account_id, + returnUrl: "{$frontendUrl}/organizer/finances/onboarding-return", + refreshUrl: "{$frontendUrl}/organizer/finances/onboarding-refresh" + ); + } + + public function releasePayoutsForEvent($eventInstanceId): void + { + $payouts = Payout::with('organizerProfile', 'order') + ->where('status', PayoutStatusEnum::PENDING) + ->whereHas('order', function ($q) use ($eventInstanceId) { + $q->where('event_instance_id', $eventInstanceId); + }) + ->get(); + + foreach ($payouts as $payout) { + /** @var Payout $payout */ + $success = $this->provider->transferToOrganizer( + destinationAccountId: $payout->organizerProfile->stripe_account_id, + amount: $payout->amount, + currency: $payout->currency, + transferGroup: 'ORDER_' . $payout->order_id + ); + + if ($success) { + $payout->update([ + 'status' => PayoutStatusEnum::PAID, + 'paid_at' => now(), + ]); + Log::info("Payout released for Order {$payout->order_id}"); + } else { + Log::error("Failed to release payout for Order {$payout->order_id}"); + } + } + } + + /* -------------------------------------------------------------------------- + * Private Helper Methods + * -------------------------------------------------------------------------- */ + + private function ensureOrderIsCheckoutReady(Order $order): void + { + if ($order->isFree()) { + throw new Exception("Cannot initiate checkout for a free order."); + } + + if ($order->payment_status === PaymentStatusEnum::PAID) { + throw new Exception("Order is already paid."); + } + + $organizerProfile = $order->eventInstance->event->organizerProfile; + if (!$organizerProfile || !$organizerProfile->stripe_account_id) { + throw new Exception("Organizer has not completed payment onboarding. Payout destination missing."); + } + + $ticket = $order->tickets()->with('ticketType')->first(); + if (!$ticket || !$ticket->ticketType) { + throw new Exception("Order has no valid tickets attached."); + } + } + + private function markOrderAsPending(Order $order, int $totalAmount): void + { + try { + $order->update([ + 'total_amount' => $totalAmount, + 'currency' => config('payments.currency', 'pln'), + 'payment_provider' => $this->provider->getProviderName(), + 'payment_status' => PaymentStatusEnum::PENDING, + ]); + } catch (Exception $e) { + Log::error("Failed to update order in initiateCheckout", ['error' => $e->getMessage()]); + throw $e; + } + } + + private function calculateApplicationFee(int $totalAmount): ?int + { + if (!config('payments.collect_platform_fee', false)) { + return null; + } + + $feePercentage = config('payments.platform_fee_percentage', 5); + return (int) round($totalAmount * ($feePercentage / 100)); + } + + private function buildStripeLineItems(Order $order, $ticketType): array + { + return [ + [ + 'price_data' => [ + 'currency' => $order->currency, + 'product_data' => [ + 'name' => $order->eventInstance->event->title . ' - ' . $ticketType->name, + ], + 'unit_amount' => $ticketType->price, + ], + 'quantity' => $order->tickets()->count(), + ] + ]; + } + + private function isWebhookAlreadyProcessed(string $eventId): bool + { + $processed = DB::table('processed_webhook_events') + ->where('payment_provider', $this->provider->getProviderName()) + ->where('event_id', $eventId) + ->exists(); + + if ($processed) { + Log::info("Webhook event already processed: {$eventId}"); + } + + return $processed; + } + + private function recordWebhookAsProcessed(WebhookResult $result): void + { + DB::table('processed_webhook_events')->insert([ + 'payment_provider' => $this->provider->getProviderName(), + 'event_id' => $result->eventId, + 'event_type' => $result->eventType, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function handleSuccessfulPayment(Order $order, OrderData $orderData): void + { + $order->markAsPaid( + intentId: $orderData->paymentIntentId, + provider: $this->provider->getProviderName() + ); + + foreach ($order->tickets as $ticket) { + $ticket->update(['status' => TicketStatusEnum::VALID]); + SendTicketEmailJob::dispatch($ticket); + } + + $this->createEscrowPayout($order); + + Log::info("Order {$order->id} successfully paid via webhook. Payout scheduled."); + } + + private function handleFailedPayment(Order $order): void + { + $order->update([ + 'payment_status' => PaymentStatusEnum::FAILED, + 'status' => OrderStatusEnum::CANCELLED, + ]); + } + + private function createEscrowPayout(Order $order): void + { + $feeAmount = $this->calculateApplicationFee((int) $order->total_amount) ?? 0; + $netAmount = (int) $order->total_amount - $feeAmount; + + Payout::create([ + 'organizer_profile_id' => $order->eventInstance->event->organizer_profile_id, + 'order_id' => $order->id, + 'amount' => $netAmount, + 'currency' => $order->currency, + 'status' => PayoutStatusEnum::PENDING, + 'scheduled_for' => $order->eventInstance->ends_at->addHours(24), + ]); + } +} diff --git a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php index e7641b7..be5daf7 100644 --- a/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php +++ b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php @@ -8,6 +8,7 @@ use App\Domains\Ticketing\Models\TicketType; use App\Domains\Ticketing\Enums\OrderStatusEnum; use App\Domains\Ticketing\Enums\TicketStatusEnum; +use App\Domains\Payments\Enums\PaymentStatusEnum; use App\Domains\Ticketing\Jobs\SendTicketEmailJob; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -25,12 +26,17 @@ public function execute(int $ticketTypeId, int $eventInstanceId, array $attendee $ticketType->checkCapacity($eventInstanceId); + $isFree = $ticketType->price == 0; + $order = Order::create([ 'user_id' => $user?->id, 'event_instance_id' => $eventInstanceId, 'email' => $user?->email ?? data_get($attendeeData, 'email'), 'name' => $user?->name ?? data_get($attendeeData, 'name'), - 'status' => OrderStatusEnum::COMPLETED, + 'status' => $isFree ? OrderStatusEnum::COMPLETED : OrderStatusEnum::PENDING, + 'total_amount' => $ticketType->price, + 'currency' => config('payments.currency', 'pln'), + 'payment_status' => $isFree ? PaymentStatusEnum::NONE : PaymentStatusEnum::PENDING, ]); $ticket = Ticket::create([ @@ -41,10 +47,12 @@ public function execute(int $ticketTypeId, int $eventInstanceId, array $attendee 'uuid' => (string) Str::uuid(), 'name' => data_get($attendeeData, 'name') ?? $user?->name, 'email' => data_get($attendeeData, 'email') ?? $user?->email, - 'status' => TicketStatusEnum::VALID, + 'status' => $isFree ? TicketStatusEnum::VALID : TicketStatusEnum::PENDING, ]); - SendTicketEmailJob::dispatch($ticket); + if ($isFree) { + SendTicketEmailJob::dispatch($ticket); + } return $ticket; }); diff --git a/app/Jobs/Payments/ReleasePayoutsJob.php b/app/Jobs/Payments/ReleasePayoutsJob.php new file mode 100644 index 0000000..1efa139 --- /dev/null +++ b/app/Jobs/Payments/ReleasePayoutsJob.php @@ -0,0 +1,37 @@ +subHours(24)) + ->whereHas('orders.payouts', function ($q) { + $q->where('status', PayoutStatusEnum::PENDING); + }) + ->get(); + + foreach ($expiredInstances as $instance) { + Log::info("[ReleasePayoutsJob] - Releasing payouts for EventInstance #{$instance->id}"); + $paymentService->releasePayoutsForEvent($instance->id); + } + + Log::info("[ReleasePayoutsJob] - finished"); + } +} From 4eb37bd94e6f1dbc88b147ea7dda0d6de67e7453 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:10:02 +0100 Subject: [PATCH 75/81] chore(bruno): remove obsolete ticket store request --- .../Public/Tickets/TicketController@store.bru | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 bruno/Public/Tickets/TicketController@store.bru diff --git a/bruno/Public/Tickets/TicketController@store.bru b/bruno/Public/Tickets/TicketController@store.bru deleted file mode 100644 index f8e7884..0000000 --- a/bruno/Public/Tickets/TicketController@store.bru +++ /dev/null @@ -1,20 +0,0 @@ -meta { - name: TicketController@store - type: http - seq: 1 -} - -post { - url: {{baseUrl}}/api/{{apiVersion}}/tickets/register - body: json - auth: none -} - -body:json { - { - "event_id": 1, - "email": "test@example.com", - "first_name": "Test", - "last_name": "User" - } -} From cb88bffe5f37d89e57aa5a985f6700bb08e5dc23 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:15:41 +0100 Subject: [PATCH 76/81] chore(bruno): rename all bruno endpoints --- bruno/Admin/ApproveOrganizerController.bru | 2 +- ...AdminVenueSubmissionController@approve.bru | 2 +- .../AdminVenueSubmissionController@index.bru | 2 +- .../AdminVenueSubmissionController@merge.bru | 2 +- .../AdminVenueSubmissionController@reject.bru | 2 +- bruno/Auth/LoginController.bru | 2 +- bruno/Auth/LogoutController.bru | 2 +- bruno/Auth/MeController.bru | 2 +- bruno/Auth/RegisterController.bru | 2 +- .../ResendVerificationEmailController.bru | 2 +- bruno/Auth/ResetPasswordController.bru | 2 +- .../Auth/SendPasswordResetLinkController.bru | 2 +- .../Analytics/AnalyticsController.bru | 2 +- bruno/Organizer/CheckIn/CheckInController.bru | 2 +- .../Dashboard/DashboardController.bru | 2 +- .../Events/AttendeeController@export.bru | 2 +- .../Events/AttendeeController@index.bru | 2 +- .../Events/CancelEventController.bru | 2 +- .../OrganizerEventController@destroy.bru | 2 +- .../Events/OrganizerEventController@index.bru | 2 +- .../Events/OrganizerEventController@show.bru | 2 +- .../Events/OrganizerEventController@store.bru | 2 +- .../OrganizerEventController@update.bru | 2 +- .../Events/PublishEventController.bru | 2 +- .../OrganizerPayoutController@onboard.bru | 11 ++++++ .../OrganizerPayoutController@status.bru | 11 ++++++ .../ShowOrganizerProfileController.bru | 2 +- .../StoreOrganizerProfileController.bru | 2 +- .../UpdateOrganizerProfileController.bru | 2 +- bruno/Payments/CheckoutController.bru | 17 ++++++++++ bruno/Payments/WebhookController.bru | 34 +++++++++++++++++++ .../Categories/CategoryController@index.bru | 2 +- .../Events/PublicEventController@index.bru | 2 +- .../Events/PublicEventController@show.bru | 2 +- .../Public/Tickets/TicketController@index.bru | 2 +- .../Tickets/TicketController@resend.bru | 2 +- .../Public/Tickets/TicketController@show.bru | 2 +- .../Public/Tickets/TicketController@store.bru | 20 +++++++++++ bruno/Public/Venues/ListVenuesController.bru | 2 +- .../Public/Venues/SearchVenuesController.bru | 2 +- .../VenueSubmissionController@index.bru | 2 +- .../Venues/VenueSubmissionController@show.bru | 2 +- .../VenueSubmissionController@store.bru | 2 +- bruno/collection.bru | 6 +++- 44 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 bruno/Organizer/Payout/OrganizerPayoutController@onboard.bru create mode 100644 bruno/Organizer/Payout/OrganizerPayoutController@status.bru create mode 100644 bruno/Payments/CheckoutController.bru create mode 100644 bruno/Payments/WebhookController.bru create mode 100644 bruno/Public/Tickets/TicketController@store.bru diff --git a/bruno/Admin/ApproveOrganizerController.bru b/bruno/Admin/ApproveOrganizerController.bru index 832109e..cb4dd00 100644 --- a/bruno/Admin/ApproveOrganizerController.bru +++ b/bruno/Admin/ApproveOrganizerController.bru @@ -1,5 +1,5 @@ meta { - name: ApproveOrganizerController + name: Approve Organizer type: http seq: 1 } diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru index 8527ab7..cb7d93e 100644 --- a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru @@ -1,5 +1,5 @@ meta { - name: AdminVenueSubmissionController@approve + name: Admin Venue Submission - Approve type: http seq: 2 } diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru index e0fba4d..5f2ca79 100644 --- a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru @@ -1,5 +1,5 @@ meta { - name: AdminVenueSubmissionController@index + name: Admin Venue Submission - Index type: http seq: 1 } diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru index ebd3306..be06b28 100644 --- a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru @@ -1,5 +1,5 @@ meta { - name: AdminVenueSubmissionController@merge + name: Admin Venue Submission - Merge type: http seq: 4 } diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru index bda7906..a7a052b 100644 --- a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru @@ -1,5 +1,5 @@ meta { - name: AdminVenueSubmissionController@reject + name: Admin Venue Submission - Reject type: http seq: 3 } diff --git a/bruno/Auth/LoginController.bru b/bruno/Auth/LoginController.bru index eef9602..605d739 100644 --- a/bruno/Auth/LoginController.bru +++ b/bruno/Auth/LoginController.bru @@ -1,5 +1,5 @@ meta { - name: LoginController + name: Login type: http seq: 2 } diff --git a/bruno/Auth/LogoutController.bru b/bruno/Auth/LogoutController.bru index 4ab3c00..e7d4f24 100644 --- a/bruno/Auth/LogoutController.bru +++ b/bruno/Auth/LogoutController.bru @@ -1,5 +1,5 @@ meta { - name: LogoutController + name: Logout type: http seq: 4 } diff --git a/bruno/Auth/MeController.bru b/bruno/Auth/MeController.bru index 205b162..0554ffb 100644 --- a/bruno/Auth/MeController.bru +++ b/bruno/Auth/MeController.bru @@ -1,5 +1,5 @@ meta { - name: MeController + name: Me type: http seq: 3 } diff --git a/bruno/Auth/RegisterController.bru b/bruno/Auth/RegisterController.bru index 6d0a199..07ff820 100644 --- a/bruno/Auth/RegisterController.bru +++ b/bruno/Auth/RegisterController.bru @@ -1,5 +1,5 @@ meta { - name: RegisterController + name: Register type: http seq: 1 } diff --git a/bruno/Auth/ResendVerificationEmailController.bru b/bruno/Auth/ResendVerificationEmailController.bru index bb66707..32882f5 100644 --- a/bruno/Auth/ResendVerificationEmailController.bru +++ b/bruno/Auth/ResendVerificationEmailController.bru @@ -1,5 +1,5 @@ meta { - name: ResendVerificationEmailController + name: Resend Verification Email type: http seq: 5 } diff --git a/bruno/Auth/ResetPasswordController.bru b/bruno/Auth/ResetPasswordController.bru index 2488987..d1cd374 100644 --- a/bruno/Auth/ResetPasswordController.bru +++ b/bruno/Auth/ResetPasswordController.bru @@ -1,5 +1,5 @@ meta { - name: ResetPasswordController + name: Reset Password type: http seq: 7 } diff --git a/bruno/Auth/SendPasswordResetLinkController.bru b/bruno/Auth/SendPasswordResetLinkController.bru index 3095742..8d8596d 100644 --- a/bruno/Auth/SendPasswordResetLinkController.bru +++ b/bruno/Auth/SendPasswordResetLinkController.bru @@ -1,5 +1,5 @@ meta { - name: SendPasswordResetLinkController + name: Send Password Reset Link type: http seq: 6 } diff --git a/bruno/Organizer/Analytics/AnalyticsController.bru b/bruno/Organizer/Analytics/AnalyticsController.bru index be438b7..16a9828 100644 --- a/bruno/Organizer/Analytics/AnalyticsController.bru +++ b/bruno/Organizer/Analytics/AnalyticsController.bru @@ -1,5 +1,5 @@ meta { - name: AnalyticsController + name: Analytics type: http seq: 1 } diff --git a/bruno/Organizer/CheckIn/CheckInController.bru b/bruno/Organizer/CheckIn/CheckInController.bru index c5cec41..1351e6e 100644 --- a/bruno/Organizer/CheckIn/CheckInController.bru +++ b/bruno/Organizer/CheckIn/CheckInController.bru @@ -1,5 +1,5 @@ meta { - name: CheckInController + name: Check In type: http seq: 1 } diff --git a/bruno/Organizer/Dashboard/DashboardController.bru b/bruno/Organizer/Dashboard/DashboardController.bru index 984e121..7a48422 100644 --- a/bruno/Organizer/Dashboard/DashboardController.bru +++ b/bruno/Organizer/Dashboard/DashboardController.bru @@ -1,5 +1,5 @@ meta { - name: DashboardController + name: Dashboard type: http seq: 1 } diff --git a/bruno/Organizer/Events/AttendeeController@export.bru b/bruno/Organizer/Events/AttendeeController@export.bru index 7f82a0a..945e2c9 100644 --- a/bruno/Organizer/Events/AttendeeController@export.bru +++ b/bruno/Organizer/Events/AttendeeController@export.bru @@ -1,5 +1,5 @@ meta { - name: AttendeeController@export + name: Attendee - Export type: http seq: 9 } diff --git a/bruno/Organizer/Events/AttendeeController@index.bru b/bruno/Organizer/Events/AttendeeController@index.bru index 5666e31..6e8f0ab 100644 --- a/bruno/Organizer/Events/AttendeeController@index.bru +++ b/bruno/Organizer/Events/AttendeeController@index.bru @@ -1,5 +1,5 @@ meta { - name: AttendeeController@index + name: Attendee - Index type: http seq: 8 } diff --git a/bruno/Organizer/Events/CancelEventController.bru b/bruno/Organizer/Events/CancelEventController.bru index 289c678..70cbdd5 100644 --- a/bruno/Organizer/Events/CancelEventController.bru +++ b/bruno/Organizer/Events/CancelEventController.bru @@ -1,5 +1,5 @@ meta { - name: CancelEventController + name: Cancel Event type: http seq: 7 } diff --git a/bruno/Organizer/Events/OrganizerEventController@destroy.bru b/bruno/Organizer/Events/OrganizerEventController@destroy.bru index 87ba210..7bd5b68 100644 --- a/bruno/Organizer/Events/OrganizerEventController@destroy.bru +++ b/bruno/Organizer/Events/OrganizerEventController@destroy.bru @@ -1,5 +1,5 @@ meta { - name: OrganizerEventController@destroy + name: Organizer Event - Destroy type: http seq: 5 } diff --git a/bruno/Organizer/Events/OrganizerEventController@index.bru b/bruno/Organizer/Events/OrganizerEventController@index.bru index b055ae5..7c1d2cf 100644 --- a/bruno/Organizer/Events/OrganizerEventController@index.bru +++ b/bruno/Organizer/Events/OrganizerEventController@index.bru @@ -1,5 +1,5 @@ meta { - name: OrganizerEventController@index + name: Organizer Event - Index type: http seq: 1 } diff --git a/bruno/Organizer/Events/OrganizerEventController@show.bru b/bruno/Organizer/Events/OrganizerEventController@show.bru index 361b089..99bcbe1 100644 --- a/bruno/Organizer/Events/OrganizerEventController@show.bru +++ b/bruno/Organizer/Events/OrganizerEventController@show.bru @@ -1,5 +1,5 @@ meta { - name: OrganizerEventController@show + name: Organizer Event - Show type: http seq: 2 } diff --git a/bruno/Organizer/Events/OrganizerEventController@store.bru b/bruno/Organizer/Events/OrganizerEventController@store.bru index 3e8886e..9d2afef 100644 --- a/bruno/Organizer/Events/OrganizerEventController@store.bru +++ b/bruno/Organizer/Events/OrganizerEventController@store.bru @@ -1,5 +1,5 @@ meta { - name: OrganizerEventController@store + name: Organizer Event - Store type: http seq: 3 } diff --git a/bruno/Organizer/Events/OrganizerEventController@update.bru b/bruno/Organizer/Events/OrganizerEventController@update.bru index 2a89921..163eda9 100644 --- a/bruno/Organizer/Events/OrganizerEventController@update.bru +++ b/bruno/Organizer/Events/OrganizerEventController@update.bru @@ -1,5 +1,5 @@ meta { - name: OrganizerEventController@update + name: Organizer Event - Update type: http seq: 4 } diff --git a/bruno/Organizer/Events/PublishEventController.bru b/bruno/Organizer/Events/PublishEventController.bru index b78a6a7..2f62818 100644 --- a/bruno/Organizer/Events/PublishEventController.bru +++ b/bruno/Organizer/Events/PublishEventController.bru @@ -1,5 +1,5 @@ meta { - name: PublishEventController + name: Publish Event type: http seq: 6 } diff --git a/bruno/Organizer/Payout/OrganizerPayoutController@onboard.bru b/bruno/Organizer/Payout/OrganizerPayoutController@onboard.bru new file mode 100644 index 0000000..f438cf7 --- /dev/null +++ b/bruno/Organizer/Payout/OrganizerPayoutController@onboard.bru @@ -0,0 +1,11 @@ +meta { + name: Organizer Payout - Onboard + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/v1/organizer/payout/onboard + body: none + auth: inherit +} diff --git a/bruno/Organizer/Payout/OrganizerPayoutController@status.bru b/bruno/Organizer/Payout/OrganizerPayoutController@status.bru new file mode 100644 index 0000000..a72db14 --- /dev/null +++ b/bruno/Organizer/Payout/OrganizerPayoutController@status.bru @@ -0,0 +1,11 @@ +meta { + name: Organizer Payout - Status + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/organizer/payout/status + body: none + auth: inherit +} diff --git a/bruno/Organizer/ShowOrganizerProfileController.bru b/bruno/Organizer/ShowOrganizerProfileController.bru index d7215eb..f919df8 100644 --- a/bruno/Organizer/ShowOrganizerProfileController.bru +++ b/bruno/Organizer/ShowOrganizerProfileController.bru @@ -1,5 +1,5 @@ meta { - name: ShowOrganizerProfileController + name: Show Organizer Profile type: http seq: 2 } diff --git a/bruno/Organizer/StoreOrganizerProfileController.bru b/bruno/Organizer/StoreOrganizerProfileController.bru index f4a7961..8a0d0dc 100644 --- a/bruno/Organizer/StoreOrganizerProfileController.bru +++ b/bruno/Organizer/StoreOrganizerProfileController.bru @@ -1,5 +1,5 @@ meta { - name: StoreOrganizerProfileController + name: Store Organizer Profile type: http seq: 1 } diff --git a/bruno/Organizer/UpdateOrganizerProfileController.bru b/bruno/Organizer/UpdateOrganizerProfileController.bru index 4efd982..702f544 100644 --- a/bruno/Organizer/UpdateOrganizerProfileController.bru +++ b/bruno/Organizer/UpdateOrganizerProfileController.bru @@ -1,5 +1,5 @@ meta { - name: UpdateOrganizerProfileController + name: Update Organizer Profile type: http seq: 3 } diff --git a/bruno/Payments/CheckoutController.bru b/bruno/Payments/CheckoutController.bru new file mode 100644 index 0000000..75ec9c2 --- /dev/null +++ b/bruno/Payments/CheckoutController.bru @@ -0,0 +1,17 @@ +meta { + name: Checkout + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/v1/checkout + body: json + auth: inherit +} + +body:json { + { + "order_id": 1 + } +} diff --git a/bruno/Payments/WebhookController.bru b/bruno/Payments/WebhookController.bru new file mode 100644 index 0000000..9db704f --- /dev/null +++ b/bruno/Payments/WebhookController.bru @@ -0,0 +1,34 @@ +meta { + name: Webhook + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/v1/webhooks/stripe + body: json + auth: none +} + +headers { + Stripe-Signature: t=123,v1=abc +} + +body:json { + { + "id": "evt_test", + "type": "checkout.session.completed", + "data": { + "object": { + "client_reference_id": "1", + "payment_intent": "pi_test", + "payment_status": "paid", + "amount_total": 5000, + "currency": "pln", + "customer_details": { + "email": "test@example.com" + } + } + } + } +} diff --git a/bruno/Public/Categories/CategoryController@index.bru b/bruno/Public/Categories/CategoryController@index.bru index ba690b5..89004ff 100644 --- a/bruno/Public/Categories/CategoryController@index.bru +++ b/bruno/Public/Categories/CategoryController@index.bru @@ -1,5 +1,5 @@ meta { - name: CategoryController@index + name: Category - Index type: http seq: 1 } diff --git a/bruno/Public/Events/PublicEventController@index.bru b/bruno/Public/Events/PublicEventController@index.bru index 7f90b9a..a447df1 100644 --- a/bruno/Public/Events/PublicEventController@index.bru +++ b/bruno/Public/Events/PublicEventController@index.bru @@ -1,5 +1,5 @@ meta { - name: PublicEventController@index + name: Public Event - Index type: http seq: 1 } diff --git a/bruno/Public/Events/PublicEventController@show.bru b/bruno/Public/Events/PublicEventController@show.bru index 4180487..07e80aa 100644 --- a/bruno/Public/Events/PublicEventController@show.bru +++ b/bruno/Public/Events/PublicEventController@show.bru @@ -1,5 +1,5 @@ meta { - name: PublicEventController@show + name: Public Event - Show type: http seq: 2 } diff --git a/bruno/Public/Tickets/TicketController@index.bru b/bruno/Public/Tickets/TicketController@index.bru index 298ba32..f0cbad7 100644 --- a/bruno/Public/Tickets/TicketController@index.bru +++ b/bruno/Public/Tickets/TicketController@index.bru @@ -1,5 +1,5 @@ meta { - name: TicketController@index + name: Ticket - Index type: http seq: 4 } diff --git a/bruno/Public/Tickets/TicketController@resend.bru b/bruno/Public/Tickets/TicketController@resend.bru index ff406e9..e0c3670 100644 --- a/bruno/Public/Tickets/TicketController@resend.bru +++ b/bruno/Public/Tickets/TicketController@resend.bru @@ -1,5 +1,5 @@ meta { - name: TicketController@resend + name: Ticket - Resend type: http seq: 3 } diff --git a/bruno/Public/Tickets/TicketController@show.bru b/bruno/Public/Tickets/TicketController@show.bru index ee6f5cc..cc0175a 100644 --- a/bruno/Public/Tickets/TicketController@show.bru +++ b/bruno/Public/Tickets/TicketController@show.bru @@ -1,5 +1,5 @@ meta { - name: TicketController@show + name: Ticket - Show type: http seq: 2 } diff --git a/bruno/Public/Tickets/TicketController@store.bru b/bruno/Public/Tickets/TicketController@store.bru new file mode 100644 index 0000000..7aee587 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@store.bru @@ -0,0 +1,20 @@ +meta { + name: Ticket - Store + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/tickets/register + body: json + auth: none +} + +body:json { + { + "ticket_type_id": 1, + "event_instance_id": 1, + "email": "test@example.com", + "name": "Test User" + } +} diff --git a/bruno/Public/Venues/ListVenuesController.bru b/bruno/Public/Venues/ListVenuesController.bru index 8319e71..777bb71 100644 --- a/bruno/Public/Venues/ListVenuesController.bru +++ b/bruno/Public/Venues/ListVenuesController.bru @@ -1,5 +1,5 @@ meta { - name: ListVenuesController + name: List Venues type: http seq: 1 } diff --git a/bruno/Public/Venues/SearchVenuesController.bru b/bruno/Public/Venues/SearchVenuesController.bru index dc75216..24255d9 100644 --- a/bruno/Public/Venues/SearchVenuesController.bru +++ b/bruno/Public/Venues/SearchVenuesController.bru @@ -1,5 +1,5 @@ meta { - name: SearchVenuesController + name: Search Venues type: http seq: 2 } diff --git a/bruno/Venues/VenueSubmissionController@index.bru b/bruno/Venues/VenueSubmissionController@index.bru index e3da255..2979e03 100644 --- a/bruno/Venues/VenueSubmissionController@index.bru +++ b/bruno/Venues/VenueSubmissionController@index.bru @@ -1,5 +1,5 @@ meta { - name: VenueSubmissionController@index + name: Venue Submission - Index type: http seq: 3 } diff --git a/bruno/Venues/VenueSubmissionController@show.bru b/bruno/Venues/VenueSubmissionController@show.bru index a063284..cda61f6 100644 --- a/bruno/Venues/VenueSubmissionController@show.bru +++ b/bruno/Venues/VenueSubmissionController@show.bru @@ -1,5 +1,5 @@ meta { - name: VenueSubmissionController@show + name: Venue Submission - Show type: http seq: 2 } diff --git a/bruno/Venues/VenueSubmissionController@store.bru b/bruno/Venues/VenueSubmissionController@store.bru index 1db05bd..07fcb01 100644 --- a/bruno/Venues/VenueSubmissionController@store.bru +++ b/bruno/Venues/VenueSubmissionController@store.bru @@ -1,5 +1,5 @@ meta { - name: VenueSubmissionController@store + name: Venue Submission - Store type: http seq: 1 } diff --git a/bruno/collection.bru b/bruno/collection.bru index 75dde62..e91348f 100644 --- a/bruno/collection.bru +++ b/bruno/collection.bru @@ -1,7 +1,11 @@ +headers { + : +} + auth { mode: bearer } auth:bearer { - token: 2|G5cR9ChqgIbsZxANSCdhFtMDE9Y8Btx9qovIvrkv91f6025d + token: 1|9aT0vH7IFF45xLcwtOj7eX2UhU6Ml7spqpn20dzU6c721ad7 } From 172c9d31d49df093cec57b8240deb67d43b9265d Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:23:51 +0100 Subject: [PATCH 77/81] docs(payments): update readme and add testing tools for stripe flow --- README.md | 41 ++++- app/Console/Commands/ConfirmOrderCommand.php | 66 +++++++ database/seeders/DevSetupSeeder.php | 100 +++++++++++ .../Payments/OrganizerOnboardingTest.php | 85 +++++++++ tests/Feature/Payments/PaymentFlowTest.php | 143 ++++++++++++++++ tests/Feature/Payments/WebhookTest.php | 162 ++++++++++++++++++ 6 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/ConfirmOrderCommand.php create mode 100644 database/seeders/DevSetupSeeder.php create mode 100644 tests/Feature/Payments/OrganizerOnboardingTest.php create mode 100644 tests/Feature/Payments/PaymentFlowTest.php create mode 100644 tests/Feature/Payments/WebhookTest.php diff --git a/README.md b/README.md index de6515d..44f7891 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - ## Requirements - **PHP 8.2+** @@ -19,6 +18,44 @@ For local testing, we use **Mailpit**. > [!IMPORTANT] > **Windows/WSL Loopback Issue**: If you encounter a "Connection closed unexpectedly" error when sending mail to Mailpit on Windows, ensure your `MAIL_HOST` is set to `[::1]` (IPv6 loopback) instead of `127.0.0.1`. +### Payment System (Stripe) + +We use an **Escrow Model** (Separate Charges and Transfers) for organizer payouts. Funds are held until the event ends. + +#### Manual Testing (Local Development) + +Since webhooks cannot reach `localhost`, use the "Manual Bridge" command to simulate a successful payment notification: + +1. Initiate checkout via Bruno. +2. Complete the payment on the Stripe Checkout page. +3. Confirm the order manually on the backend: + +```bash +php artisan app:confirm-order {order_id} +``` + +This will mark the order as paid, validate tickets, and create a "Pending Payout" record. + +#### Payout Release + +To release funds to organizers for finished events: + +```bash +php artisan queue:work +# Or run the job manually via tinker: +# App\Jobs\Payments\ReleasePayoutsJob::dispatch(); +``` + +### Automation + +The `ReleasePayoutsJob` is scheduled to run **daily at 02:00 AM** (configured in `routes/console.php`). + +#### Failure Handling + +- **Idempotency**: The job only selects `pending` payouts. Once a transfer is successful, the payout is marked as `paid`. If the job fails midway, already-paid transfers will not be retried. +- **Fail-Safe**: If the Stripe API is down, the job will log an error and the payout remains `pending`. It will be picked up by the next run automatically. +- **Manual Retry**: If a specific payout needs immediate release after a failure, re-run the job via Tinker or use the manual bridge logic. + ### Ticketing System The ticketing system generates QR codes and sends MJML-based emails. @@ -50,7 +87,6 @@ Production emails should be sent via the queue. php artisan queue:work ``` -

Laravel Logo

@@ -59,4 +95,3 @@ php artisan queue:work Latest Stable Version License

- diff --git a/app/Console/Commands/ConfirmOrderCommand.php b/app/Console/Commands/ConfirmOrderCommand.php new file mode 100644 index 0000000..46e1f88 --- /dev/null +++ b/app/Console/Commands/ConfirmOrderCommand.php @@ -0,0 +1,66 @@ +isProduction()) { + $this->error('This command is for local development and testing only. Do not run in prod'); + return; + } + + $orderId = $this->argument('id'); + $order = Order::with('tickets', 'eventInstance.event')->find($orderId); + + if (!$order) { + $this->error("Order #{$orderId} not found."); + return; + } + + if ($order->payment_status === PaymentStatusEnum::PAID) { + $this->warn("Order #{$orderId} is already paid."); + return; + } + + $this->info("Confirming Order #{$orderId}..."); + + DB::transaction(function () use ($order) { + $order->markAsPaid( + intentId: 'manual_' . uniqid(), + provider: 'stripe' + ); + + foreach ($order->tickets as $ticket) { + $ticket->update(['status' => TicketStatusEnum::VALID]); + } + + $feePercentage = config('payments.platform_fee_percentage', 5); + $feeAmount = (int) round($order->total_amount * ($feePercentage / 100)); + $netAmount = $order->total_amount - $feeAmount; + + Payout::create([ + 'organizer_profile_id' => $order->eventInstance->event->organizer_profile_id, + 'order_id' => $order->id, + 'amount' => $netAmount, + 'currency' => $order->currency, + 'status' => PayoutStatusEnum::PENDING, + 'scheduled_for' => $order->eventInstance->ends_at->addHours(24), + ]); + }); + + $this->info("Success! Order confirmed and Payout scheduled for release."); + } +} diff --git a/database/seeders/DevSetupSeeder.php b/database/seeders/DevSetupSeeder.php new file mode 100644 index 0000000..07f44ca --- /dev/null +++ b/database/seeders/DevSetupSeeder.php @@ -0,0 +1,100 @@ +call(CategorySeeder::class); + + // 1. Create Roles (for 'api' guard) + $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $organizerRole = Role::firstOrCreate(['name' => 'organizer', 'guard_name' => 'api']); + $attendeeRole = Role::firstOrCreate(['name' => 'attendee', 'guard_name' => 'api']); + + // 2. Create Admin User + $admin = User::updateOrCreate( + ['email' => 'admin@localevents.pl'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('password'), + ] + ); + $admin->assignRole($adminRole); + + // 3. Create Organizer User & Profile + $organizerUser = User::updateOrCreate( + ['email' => 'organizer@localevents.pl'], + [ + 'name' => 'Main Organizer', + 'password' => Hash::make('password'), + ] + ); + $organizerUser->assignRole($organizerRole); + + $profile = OrganizerProfile::updateOrCreate( + ['user_id' => $organizerUser->id], + [ + 'organization_name' => 'Great Events Inc.', + 'slug' => 'great-events-inc', + 'description' => 'We organize the best local events.', + 'phone' => '+48123456789', + 'verified_at' => now(), + 'stripe_account_id' => null, + 'stripe_onboarding_completed' => false, + ] + ); + + // 4. Create Venue + $venue = Venue::factory()->create([ + 'name' => 'Development Hub', + 'city' => 'Warsaw', + ]); + + // 5. Create Event + $event = Event::factory()->create([ + 'organizer_profile_id' => $profile->id, + 'venue_id' => $venue->id, + 'category_id' => 1, + 'title' => 'Stripe Integration Test Party', + 'status' => EventStatusEnum::PUBLISHED, + ]); + + // 6. Create Ticket Types + TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Standard Ticket', + 'price' => 5000, + 'quantity' => 100, + ]); + + TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Free Pass', + 'price' => 0, + 'quantity' => 50, + ]); + }); + + $this->command->info('Development environment setup complete!'); + $this->command->info('Users:'); + $this->command->info('- admin@example.pl / password'); + $this->command->info('- organizer@example.pl / password'); + $this->command->warn('NOTE: You must now use the "Create Onboarding Link" Bruno request to link a real Stripe test account.'); + } +} diff --git a/tests/Feature/Payments/OrganizerOnboardingTest.php b/tests/Feature/Payments/OrganizerOnboardingTest.php new file mode 100644 index 0000000..fed2f7c --- /dev/null +++ b/tests/Feature/Payments/OrganizerOnboardingTest.php @@ -0,0 +1,85 @@ +app->make(PermissionRegistrar::class)->forgetCachedPermissions(); + + Role::create(['name' => 'organizer', 'guard_name' => 'api']); + Role::create(['name' => 'admin', 'guard_name' => 'api']); + + $this->mockProvider = Mockery::mock(PaymentProviderInterface::class); + $this->app->instance(PaymentProviderInterface::class, $this->mockProvider); + } + + public function test_organizer_can_initiate_stripe_onboarding(): void + { + $user = User::factory()->create(); + $user->assignRole('organizer'); + $profile = OrganizerProfile::factory()->create(['user_id' => $user->id]); + + $this->mockProvider->shouldReceive('getProviderName') + ->andReturn('stripe'); + + $this->mockProvider->shouldReceive('createConnectAccount') + ->once() + ->with(Mockery::on(function ($arg) use ($profile) { + return $arg->id === $profile->id; + }), '127.0.0.1') + ->andReturn('acct_test_123'); + + $this->mockProvider->shouldReceive('createAccountLink') + ->once() + ->with('acct_test_123', Mockery::any(), Mockery::any()) + ->andReturn('https://connect.stripe.com/setup/s/test_link'); + + $response = $this->actingAs($user)->postJson('/api/v1/organizer/payout/onboard'); + + $response->assertStatus(200); + $response->assertJsonPath('data.onboarding_url', 'https://connect.stripe.com/setup/s/test_link'); + + $this->assertDatabaseHas('organizer_profiles', [ + 'id' => $profile->id, + 'stripe_account_id' => 'acct_test_123', + ]); + } + + public function test_organizer_can_check_onboarding_status(): void + { + $user = User::factory()->create(); + $user->assignRole('organizer'); + $profile = OrganizerProfile::factory()->create([ + 'user_id' => $user->id, + 'stripe_account_id' => 'acct_done_123', + 'stripe_onboarding_completed' => true + ]); + + $response = $this->actingAs($user)->getJson('/api/v1/organizer/payout/status'); + + $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + 'stripe_account_id' => 'acct_done_123', + 'stripe_onboarding_completed' => true, + ] + ]); + } +} diff --git a/tests/Feature/Payments/PaymentFlowTest.php b/tests/Feature/Payments/PaymentFlowTest.php new file mode 100644 index 0000000..c089477 --- /dev/null +++ b/tests/Feature/Payments/PaymentFlowTest.php @@ -0,0 +1,143 @@ +app->make(PermissionRegistrar::class)->forgetCachedPermissions(); + + Role::create(['name' => 'organizer', 'guard_name' => 'api']); + Role::create(['name' => 'attendee', 'guard_name' => 'api']); + + $this->mockProvider = Mockery::mock(PaymentProviderInterface::class); + $this->app->instance(PaymentProviderInterface::class, $this->mockProvider); + } + + public function test_paid_ticket_registration_results_in_pending_order(): void + { + $event = Event::factory()->create(['status' => EventStatusEnum::PUBLISHED->value]); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Paid Entry', + 'price' => 5000, + 'quantity' => 10, + ]); + + $response = $this->postJson('/api/v1/tickets/register', [ + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, + 'name' => 'John Buyer', + 'email' => 'john@buyer.com', + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('orders', [ + 'status' => OrderStatusEnum::PENDING->value, + 'payment_status' => PaymentStatusEnum::PENDING->value, + 'total_amount' => 5000, + ]); + + $this->assertDatabaseHas('tickets', [ + 'email' => 'john@buyer.com', + 'status' => TicketStatusEnum::PENDING->value, + ]); + } + + public function test_can_initiate_checkout_for_pending_order(): void + { + $user = User::factory()->create(); + $organizerUser = User::factory()->create(); + $organizerUser->assignRole('organizer'); + + $profile = OrganizerProfile::factory()->create([ + 'user_id' => $organizerUser->id, + 'stripe_account_id' => 'acct_test_123', + 'stripe_onboarding_completed' => true, + ]); + + $event = Event::factory()->create([ + 'organizer_profile_id' => $profile->id, + 'status' => EventStatusEnum::PUBLISHED->value + ]); + + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Paid Entry', + 'price' => 5000, + 'quantity' => 10, + ]); + + $order = Order::create([ + 'user_id' => $user->id, + 'event_instance_id' => $event->instances->first()->id, + 'status' => OrderStatusEnum::PENDING, + 'payment_status' => PaymentStatusEnum::PENDING, + 'currency' => 'pln', + 'email' => 'customer@test.com', + 'name' => 'Test Customer', + 'total_amount' => 5000, + ]); + + Ticket::create([ + 'order_id' => $order->id, + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, + 'uuid' => 'test-uuid-checkout-final-3', + 'status' => TicketStatusEnum::PENDING, + 'name' => 'John', + 'email' => 'john@test.com' + ]); + + $this->mockProvider->shouldReceive('getProviderName') + ->andReturn('stripe'); + + $this->mockProvider->shouldReceive('createCheckoutSession') + ->once() + ->andReturn(new CheckoutResult( + sessionId: 'cs_test_123', + checkoutUrl: 'https://checkout.stripe.com/test_123', + paymentIntentId: 'pi_test_123' + )); + + $response = $this->actingAs($user)->postJson('/api/v1/checkout', [ + 'order_id' => $order->id, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.checkout_url', 'https://checkout.stripe.com/test_123'); + + $this->assertDatabaseHas('orders', [ + 'id' => $order->id, + 'total_amount' => 5000, + 'payment_provider' => 'stripe', + 'payment_intent_id' => 'pi_test_123' + ]); + } +} diff --git a/tests/Feature/Payments/WebhookTest.php b/tests/Feature/Payments/WebhookTest.php new file mode 100644 index 0000000..c673a15 --- /dev/null +++ b/tests/Feature/Payments/WebhookTest.php @@ -0,0 +1,162 @@ +withoutExceptionHandling(); + $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); + + Role::create(['name' => 'organizer', 'guard_name' => 'api']); + Role::create(['name' => 'attendee', 'guard_name' => 'api']); + + $this->mockProvider = Mockery::mock(PaymentProviderInterface::class); + $this->app->instance(PaymentProviderInterface::class, $this->mockProvider); + } + + public function test_successful_webhook_completes_order_and_dispatches_email(): void + { + Bus::fake(); + $event = Event::factory()->create(); + $ticketType = TicketType::create([ + 'event_id' => $event->id, + 'name' => 'Paid Entry', + 'price' => 5000, + 'quantity' => 10, + ]); + + $order = Order::create([ + 'event_instance_id' => $event->instances->first()->id, + 'status' => OrderStatusEnum::PENDING, + 'payment_status' => PaymentStatusEnum::PENDING, + ]); + + $ticket = Ticket::create([ + 'order_id' => $order->id, + 'ticket_type_id' => $ticketType->id, + 'event_instance_id' => $event->instances->first()->id, + 'uuid' => 'test-uuid-webhook', + 'status' => TicketStatusEnum::PENDING, + 'name' => 'John', + 'email' => 'john@webhook.com' + ]); + + $this->mockProvider->shouldReceive('handleWebhook') + ->once() + ->andReturn(new WebhookResult( + success: true, + eventType: 'checkout.session.completed', + eventId: 'evt_test_123', + orderData: new OrderData( + orderId: $order->id, + paymentIntentId: 'pi_test_123', + paymentStatus: 'paid', + amountPaid: 5000, + currency: 'pln' + ) + )); + + $this->mockProvider->shouldReceive('getProviderName') + ->andReturn('stripe'); + + $response = $this->withHeaders(['Stripe-Signature' => 'test-sig']) + ->postJson('/api/v1/webhooks/stripe', ['payload' => 'dummy']); + + $response->assertStatus(200); + + $this->assertDatabaseHas('orders', [ + 'id' => $order->id, + 'status' => OrderStatusEnum::COMPLETED->value, + 'payment_status' => PaymentStatusEnum::PAID->value, + 'payment_intent_id' => 'pi_test_123', + ]); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'status' => TicketStatusEnum::VALID->value, + ]); + + Bus::assertDispatched(SendTicketEmailJob::class); + + $this->assertDatabaseHas('processed_webhook_events', [ + 'event_id' => 'evt_test_123', + 'payment_provider' => 'stripe' + ]); + } + + public function test_webhook_idempotency_prevents_duplicate_processing(): void + { + Bus::fake(); + $event = Event::factory()->create(); + $order = Order::create([ + 'event_instance_id' => $event->instances->first()->id, + 'status' => OrderStatusEnum::PENDING, + 'payment_status' => PaymentStatusEnum::PENDING, + ]); + + DB::table('processed_webhook_events')->insert([ + 'payment_provider' => 'stripe', + 'event_id' => 'evt_idempotent_123', + 'event_type' => 'checkout.session.completed', + 'created_at' => now(), + ]); + + $this->mockProvider->shouldReceive('handleWebhook') + ->once() + ->andReturn(new WebhookResult( + success: true, + eventType: 'checkout.session.completed', + eventId: 'evt_idempotent_123', + orderData: new OrderData( + orderId: $order->id, + paymentIntentId: 'pi_idemp_123', + paymentStatus: 'paid', + amountPaid: 5000, + currency: 'pln' + ) + )); + + $this->mockProvider->shouldReceive('getProviderName') + ->andReturn('stripe'); + + $response = $this->withHeaders(['Stripe-Signature' => 'test-sig']) + ->postJson('/api/v1/webhooks/stripe', ['payload' => 'dummy']); + + $response->assertStatus(200); + + // Order should STILL be pending because it was already "processed" (skipped) + $this->assertDatabaseHas('orders', [ + 'id' => $order->id, + 'status' => OrderStatusEnum::PENDING->value, + ]); + + Bus::assertNotDispatched(SendTicketEmailJob::class); + } +} From 635a3c6d2155b0ded76bdb6852790235206ce38a Mon Sep 17 00:00:00 2001 From: monte-dev Date: Fri, 27 Feb 2026 00:24:23 +0100 Subject: [PATCH 78/81] docs(payments): update readme and add testing tools for stripe flow --- .env.example | 18 ++++++++++++++++-- app/Domains/Ticketing/Models/Order.php | 2 ++ config/app.php | 2 +- routes/console.php | 5 +++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index ccb8824..7b1982e 100644 --- a/.env.example +++ b/.env.example @@ -62,7 +62,7 @@ MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_KEY=masterKey FRONTEND_URL=http://localhost:3000 - +FRONTEND_URL_LOCAL_STRIPE_DUMMY=https://github.com # Ticketing & QR Codes BACON_QR_RENDERER=gd @@ -74,8 +74,22 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +API_VERSION=v1 + +SCOUT_DRIVER=meilisearch +MEILISEARCH_HOST=http://127.0.0.1:7700 +MEILISEARCH_KEY= + +STRIPE_TEST_PUBLISHABLE_KEY= +# STRIPE_PUBLISHABLE_KEY= +# STRIPE_SECRET_KEY= +STRIPE_TEST_SECRET_KEY= + +STRIPE_WEBHOOK_SECRET= + GOOGLE_PLACES_API_KEY= GOOGLE_PLACES_API_URL=https://places.googleapis.com/v1/places:searchText OPEN_AI_API_KEY= -OPENAI_EMBEDDINGS_URL=https://api.openai.com/v1/embeddings \ No newline at end of file +OPENAI_EMBEDDINGS_URL=https://api.openai.com/v1/embeddings diff --git a/app/Domains/Ticketing/Models/Order.php b/app/Domains/Ticketing/Models/Order.php index 5b2c48d..d3874f9 100644 --- a/app/Domains/Ticketing/Models/Order.php +++ b/app/Domains/Ticketing/Models/Order.php @@ -6,6 +6,7 @@ use App\Domains\Events\Models\EventInstance; use App\Domains\Ticketing\Enums\OrderStatusEnum; use App\Domains\Payments\Enums\PaymentStatusEnum; +use App\Domains\Payments\Enums\PaymentProviderEnum; use Database\Factories\OrderFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,6 +36,7 @@ protected static function newFactory() protected $casts = [ 'status' => OrderStatusEnum::class, 'payment_status' => PaymentStatusEnum::class, + 'payment_provider' => PaymentProviderEnum::class, ]; public function tickets() diff --git a/config/app.php b/config/app.php index f916da4..64796f3 100644 --- a/config/app.php +++ b/config/app.php @@ -55,7 +55,7 @@ 'url' => env('APP_URL', 'http://localhost'), 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'), - + 'frontend_url_local_stripe_dummy' => env('FRONTEND_URL_LOCAL_STRIPE_DUMMY', 'http://github.com'), /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/routes/console.php b/routes/console.php index 3c9adf1..2c6c175 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,3 +6,8 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +use App\Jobs\Payments\ReleasePayoutsJob; +use Illuminate\Support\Facades\Schedule; + +Schedule::job(new ReleasePayoutsJob)->dailyAt('02:00'); From ccbb7500bf81c70bdc3936f42981a9d812b81729 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 28 Feb 2026 20:24:27 +0100 Subject: [PATCH 79/81] docs: add README.md --- README.md | 388 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 329 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 44f7891..58ecb4b 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,367 @@ -## Requirements +
+

🎟️ Event Discovery & Ticketing API

+ +

+ A high-performance RESTful API for event discovery and ticketing, featuring Domain-Driven Design, Stripe Connect escrow payouts, and pessimistic locking for high-concurrency ticket drops. +

-- **PHP 8.2+** -- **PostgreSQL 15+** -- **Meilisearch** (for event search) -- **PHP GD Extension** (Required for QR code generation in ticketing) -- **Node.js & NPM** (for MJML compilation) +

+ Laravel + PHP + PostgreSQL + Redis + Stripe Connect + Tests +

+
-## Setup & Deployment +--- -### Mail Configuration (Local Development) +## About This Project -For local testing, we use **Mailpit**. +This is a **two-sided marketplace** connecting local event organizers with attendees. Unlike standard tutorial CRUD projects, this backend simulates a real-world startup handling: -- **SMTP Port**: `1025` -- **Web UI**: `http://localhost:8025` +> **Real money flows** — Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. +> +> **Concurrency under pressure** — Pessimistic database locking guarantees zero overselling when hundreds of users race to buy the last tickets simultaneously. +> +> **Scalable architecture** — Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. -> [!IMPORTANT] -> **Windows/WSL Loopback Issue**: If you encounter a "Connection closed unexpectedly" error when sending mail to Mailpit on Windows, ensure your `MAIL_HOST` is set to `[::1]` (IPv6 loopback) instead of `127.0.0.1`. +--- -### Payment System (Stripe) +## Tech Stack -We use an **Escrow Model** (Separate Charges and Transfers) for organizer payouts. Funds are held until the event ends. +| Layer | Technology | Why This Choice | +| :------------ | :------------------- | :--------------------------------------------------------------------------------------------- | +| **Framework** | Laravel 12 (PHP 8.3) | Elegant syntax, built-in queue management, and comprehensive testing utilities | +| **Database** | PostgreSQL 17 | ACID-compliant transactions ensure escrow payments are fully completed or fully rolled back | +| **Auth** | Sanctum / JWT | Stateless, token-based authentication optimized for mobile and SPA clients | +| **Queues** | Redis | Offloads blocking I/O (MJML email rendering, webhook processing) to background workers | +| **Payments** | Stripe Connect API | Two-sided marketplace with Destination Charges — platform takes a fee, organizer gets the rest | +| **Testing** | Pest / PHPUnit | **118 tests, 349 assertions** covering auth, checkout, webhooks, and payout flows | -#### Manual Testing (Local Development) +--- -Since webhooks cannot reach `localhost`, use the "Manual Bridge" command to simulate a successful payment notification: +## Project Structure (Domain-Driven Design) -1. Initiate checkout via Bruno. -2. Complete the payment on the Stripe Checkout page. -3. Confirm the order manually on the backend: +The codebase is organized by **business domain**, not by technical layer. Each domain owns its Models, Controllers, Actions, DTOs, and Enums. -```bash -php artisan app:confirm-order {order_id} +``` +app/Domains/ +├── Auth/ # Registration, login, Sanctum tokens +│ ├── Controllers/ # AuthController, ProfileController +│ ├── Models/ # User +│ └── Requests/ # LoginRequest, RegisterRequest +│ +├── Events/ # Event CRUD, scheduling, search +│ ├── Controllers/ # EventController, EventInstanceController +│ ├── Models/ # Event, EventInstance, Category +│ ├── Resources/ # EventResource (strict JSON contracts) +│ └── Requests/ # StoreEventRequest, UpdateEventRequest +│ +├── Ticketing/ # Orders, tickets, QR codes, check-in +│ ├── Actions/ # RegisterAttendeeAction, LinkGuestTicketsAction +│ ├── Models/ # Order, Ticket, TicketType +│ ├── Enums/ # OrderStatusEnum, TicketStatusEnum +│ └── Jobs/ # SendTicketEmailJob +│ +├── Payments/ # Stripe Connect, escrow, refunds +│ ├── Contracts/ # PaymentProviderInterface +│ ├── Providers/ # StripePaymentProvider +│ ├── DTOs/ # CheckoutResult, WebhookResult +│ ├── Enums/ # PaymentStatusEnum, PayoutStatusEnum +│ └── Controllers/ # CheckoutController, WebhookController +│ +├── Organizer/ # Profiles, onboarding, dashboard analytics +│ ├── Controllers/ # OrganizerProfileController, DashboardController +│ └── Models/ # OrganizerProfile +│ +└── Venues/ # Venue management, crowdsourced submissions + ├── Controllers/ # VenueController, VenueSubmissionController + └── Models/ # Venue, VenueSubmission ``` -This will mark the order as paid, validate tickets, and create a "Pending Payout" record. +--- + +## Interesting Architectural Decisions + +This is not a tutorial CRUD app. The architecture was specifically designed to handle the scale and edge cases of a high-traffic ticketing platform. + +### Design Patterns + + + + + + + + + + + + + + + + + + +
Domain-Driven DesignAbandoned the default app/Models + app/Http/Controllers structure. Each business domain (Ticketing, Payments, Organizer) is a self-contained module with its own Models, DTOs, Enums, and Actions.
Action ClassesBusiness logic lives in single-responsibility Action classes (e.g. RegisterAttendeeAction), not in fat Controllers. Actions can be reused across HTTP requests, CLI commands, and queued jobs.
DTOs & EnumsAll inter-layer communication uses strictly typed Data Transfer Objects and PHP 8.1 Backed Enums (OrderStatusEnum::COMPLETED), eliminating magic strings and ensuring compile-time safety.
Interface ContractsPaymentProviderInterface decouples the application from Stripe. The entire payment gateway can be swapped (e.g. to PayPal) without changing a single line of business logic.
+ +### The "Hard Problem": Concurrency & Race Conditions + +> **The Problem:** If 500 users attempt to buy the last 10 tickets to an event at the exact same millisecond, standard code will read the database, think tickets are available, and oversell the venue. +> +> **The Solution:** The checkout action implements **Pessimistic Locking** via `$query->lockForUpdate()` wrapped in a `DB::transaction()`. This forces concurrent queries to queue mathematically, guaranteeing zero oversold tickets and preventing orphaned database states if errors occur. + +### Security & Optimization + + + + + + + + + + + + + + +
Webhook VerificationThe /webhook/stripe endpoint cryptographically verifies the Stripe-Signature header before processing. Invalid or replayed payloads are rejected with 403 Forbidden.
N+1 PreventionAll API endpoints enforce Eager Loading (->with()) so complex relational queries (Event → Venue → Ticket Tiers) execute in constant O(1) database calls.
Async EmailTicket confirmation emails (with generated QR codes, rendered via MJML) are dispatched to Redis queues, keeping API response times under 100ms.
+ +--- + +## Design Decisions + +Every architectural choice was made deliberately. Here's a summary of _what_ was chosen and _why_: + +| Decision | Chosen Approach | Why Not the Alternative | +| :----------------------- | :----------------------------------- | :------------------------------------------------------------------------------------------ | +| **Code Organization** | Domain-Driven Design | Standard MVC leads to 50+ file `Controllers/` folders with no logical grouping | +| **Business Logic** | Action Classes | Fat Controllers can't be reused in CLI commands or queued jobs | +| **Payment Architecture** | Stripe Connect (Destination Charges) | Standard Checkout deposits to site's account — site would assume tax liability for every organizer | +| **Payout Timing** | 24h post-event escrow via cron | Instant payouts would allow organizers to take money and cancel events | +| **Ticket Inventory** | Pessimistic DB locking | Optimistic locking allows overselling under concurrent load | +| **Email Rendering** | MJML → Blade compilation | Raw HTML emails break on 60%+ of email clients | +| **API Responses** | Eloquent API Resources | Returning raw models leaks internal DB columns and relationships | +| **Status Management** | PHP 8.1 Backed Enums | String-based statuses like `"paid"` allow typos and invalid states | + +--- + +## Key Endpoint Flows + +### `POST /api/v1/checkout` — Ticket Purchase -#### Payout Release +``` +Request Response (201) +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ { │ │ { │ +│ "ticket_type_id": 5, │ ───> │ "checkout_url": "https:// │ +│ "event_instance_id": 2,│ │ checkout.stripe.com/...",│ +│ "name": "Test User", │ │ "order_id": 42, │ +│ "email": "test@ab.com" │ │ "status": "pending" │ +│ } │ │ } │ +└──────────────────────────┘ └──────────────────────────────┘ +``` -To release funds to organizers for finished events: +**What happens internally:** -```bash -php artisan queue:work -# Or run the job manually via tinker: -# App\Jobs\Payments\ReleasePayoutsJob::dispatch(); -``` +1. `CheckoutController` validates the request via `CheckoutRequest` (Form Request) +2. `RegisterAttendeeAction` locks the ticket row, checks capacity, creates Order + Ticket +3. `StripePaymentProvider::createCheckoutSession()` generates a hosted Stripe Checkout URL +4. User is redirected to Stripe — zero PCI compliance burden on our servers -### Automation +### `POST /api/v1/webhook/stripe` — Payment Confirmation -The `ReleasePayoutsJob` is scheduled to run **daily at 02:00 AM** (configured in `routes/console.php`). +``` +Stripe Server ──▶ POST /webhook/stripe + Headers: { Stripe-Signature: "t=1234,v1=abc..." } + Body: { type: "checkout.session.completed", ... } + +Server Flow: + 1. Verify signature ──▶ reject if invalid (403) + 2. Parse event type + 3. Dispatch ProcessPaymentWebhookJob to queue + 4. Mark Order as COMPLETED, Ticket as VALID + 5. Create Payout record (status: "pending", scheduled_at: event_end + 24h) + 6. Dispatch SendTicketEmailJob (QR code generation + MJML render) +``` -#### Failure Handling +### `GET /api/v1/organizer/payout/status` — Payout Dashboard -- **Idempotency**: The job only selects `pending` payouts. Once a transfer is successful, the payout is marked as `paid`. If the job fails midway, already-paid transfers will not be retried. -- **Fail-Safe**: If the Stripe API is down, the job will log an error and the payout remains `pending`. It will be picked up by the next run automatically. -- **Manual Retry**: If a specific payout needs immediate release after a failure, re-run the job via Tinker or use the manual bridge logic. +``` +Response (200) +{ + "data": [ + { + "event": "Event Test 123", + "amount": 15000, // 150.00 PLN (stored as integers) + "currency": "pln", + "status": "pending", // Backed Enum: pending | paid | failed + "scheduled_at": "2026-03-02T02:00:00Z" + } + ] +} +``` -### Ticketing System +--- + +## Performance & Safety + + + + + + + + + + + + + + + + + + + + + + + + + + +
Rate LimitingAll API routes are protected by Laravel's ThrottleRequests middleware. Auth endpoints (/login, /register) are aggressively throttled to prevent brute-force attacks. Checkout endpoints use per-user rate limits to block bot-driven ticket scalping.
Input ValidationEvery mutating endpoint uses dedicated Form Request classes (StoreEventRequest, CheckoutRequest). Validation rules enforce types, max lengths, enum membership, and relational existence checks (exists:ticket_types,id) before any business logic executes.
Mass Assignment ProtectionAll Eloquent models use strict $fillable whitelists. A malicious user cannot inject fields like role=admin or payment_status=paid into a POST body.
Server-Side Price AuthorityFrontend-submitted prices are always ignored. The checkout action looks up the canonical price from the database, preventing manipulation of ticket amounts via browser dev tools.
Idempotent PayoutsThe ReleasePayoutsJob only processes payouts with status pending. If the cron runs twice or retries after a failure, already-paid transfers are never duplicated.
Graceful Error HandlingDomain-specific exceptions (CapacityExceededException, InvalidWebhookSignatureException) are caught and returned as structured JSON error responses with proper HTTP status codes (422, 403) — never raw stack traces.
+ +--- + +## 🔀 Visualizing the Flow: The Escrow Payout Model + +Organizers don't get paid until _after_ the event finishes, preventing fraud. Here is the full lifecycle: + +```mermaid +sequenceDiagram + participant User + participant App as my API + participant Stripe + participant DB as PostgreSQL + participant Cron as Task Scheduler + + User->>App: Clicks "Buy Ticket" + App->>DB: Lock Ticket Row (Pessimistic) + App->>Stripe: Create Checkout Session + Stripe-->>User: Show Payment Modal + User->>Stripe: Enters Credit Card + Stripe->>App: POST /webhook/stripe (Async) + + note over App,Stripe: 🛡️ Security Check: Verify Signature + + App->>DB: 1. Create Order & Ticket + App->>DB: 2. Create "Pending" Escrow Payout + + note over Cron,DB: ⏳ Event Concludes (24 hours pass) + + Cron->>App: Trigger ReleasePayoutsJob + App->>DB: Query events ready for payout + App->>Stripe: Transfer funds to Organizer Account + Stripe-->>App: Success + App->>DB: Mark Payout as "Paid" +``` -The ticketing system generates QR codes and sends MJML-based emails. +--- + +## Database Schema (Key Entities) + +```mermaid +erDiagram + USERS ||--o{ ORDERS : places + USERS ||--o{ ORGANIZER_PROFILES : has + EVENTS ||--o{ EVENT_INSTANCES : schedules + EVENT_INSTANCES ||--o{ TICKET_TYPES : offers + EVENT_INSTANCES ||--o{ ORDERS : receives + ORDERS ||--o{ TICKETS : contains + ORDERS ||--o{ PAYOUTS : generates + VENUES ||--o{ EVENTS : hosts + + USERS { + bigint id PK + string email + string name + enum role + } + ORDERS { + bigint id PK + enum status + int total_amount + string currency + enum payment_status + string stripe_session_id + } + PAYOUTS { + bigint id PK + enum status + int amount + datetime scheduled_at + string stripe_transfer_id + } +``` -#### Mail Testing +--- -To verify the email layout and delivery, run: +## Interactive API Documentation -```bash -php artisan mail:test-ticket +The full API contract is documented via a [Bruno](https://www.usebruno.com/) collection, organized by domain: + +``` +bruno/ +├── Auth/ # Register, Login, Logout, Profile +├── Public/ # Browse Events, Event Details, Ticket Registration +├── Organizer/ # Create Events, Manage Ticket Types, Payout Status +│ └── Payout/ # Check Payout Status +├── Payments/ # Checkout Session, Webhook Simulation +└── Admin/ # Moderate Venues, Manage Categories ``` -#### MJML Compilation +> 💡 **To test locally:** Import the `/bruno` folder into Bruno, set `{{baseUrl}}` to `http://localhost:8000`, and explore all endpoints with pre-configured request bodies. + +--- -If you modify MJML templates in `resources/views/emails/mjml`, recompile them using: +## 🛠️ Getting Started + +Get the backend running locally in under 3 minutes. (assumed you have your Stripe Connect Keys ready to go) + +**Prerequisites:** PHP 8.2+, Composer, PostgreSQL, Redis ```bash -npx mjml resources/views/emails/mjml/ticket-confirmation.mjml -o resources/views/emails/ticket-confirmation.blade.php -``` +# 1. Clone & Enter +git clone https://github.com/monte-dev/lokalnie_server.git +cd lokalnie_server -#### Queue Worker +# 2. Install Dependencies +composer install -Production emails should be sent via the queue. +# 3. Setup Environment +cp .env.example .env +php artisan key:generate -1. Set `QUEUE_CONNECTION=database` in `.env`. -2. Run the worker: +# 4. Run Migrations & Seed Demo Data +php artisan migrate:fresh --seed --seeder=DevSetupSeeder -```bash -php artisan queue:work +# 5. Start Server + Queue Worker +php artisan serve & php artisan queue:work ``` -

Laravel Logo

+### ✅ Running the Test Suite + +```bash +php artisan test +``` -

-Build Status -Total Downloads -Latest Stable Version -License -

+``` +Tests: 118 passed (349 assertions) +Duration: 6.06s +``` From 14b907a8d674b0e8b9b74f7479eaac76be7ef546 Mon Sep 17 00:00:00 2001 From: monte-dev Date: Sat, 28 Feb 2026 20:39:29 +0100 Subject: [PATCH 80/81] docs: add openAI and google places integration details to README --- README.md | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 58ecb4b..1032b6a 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,19 @@ --- -## About This Project +## About This Project This is a **two-sided marketplace** connecting local event organizers with attendees. Unlike standard tutorial CRUD projects, this backend simulates a real-world startup handling: -> **Real money flows** — Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. +> **Real money flows** — Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. > > **Concurrency under pressure** — Pessimistic database locking guarantees zero overselling when hundreds of users race to buy the last tickets simultaneously. > -> **Scalable architecture** — Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. +> **Scalable architecture** — Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. --- -## Tech Stack +## Tech Stack | Layer | Technology | Why This Choice | | :------------ | :------------------- | :--------------------------------------------------------------------------------------------- | @@ -37,12 +37,14 @@ This is a **two-sided marketplace** connecting local event organizers with atten | **Database** | PostgreSQL 17 | ACID-compliant transactions ensure escrow payments are fully completed or fully rolled back | | **Auth** | Sanctum / JWT | Stateless, token-based authentication optimized for mobile and SPA clients | | **Queues** | Redis | Offloads blocking I/O (MJML email rendering, webhook processing) to background workers | -| **Payments** | Stripe Connect API | Two-sided marketplace with Destination Charges — platform takes a fee, organizer gets the rest | +| **Payments** | Stripe Connect API | Two-sided marketplace with Destination Charges — platform takes a fee, organizer gets the rest | +| **Location** | Google Places API | Standardizes venue addresses and provides precise geocoding for interactive map rendering | +| **Discovery** | OpenAI Embeddings | Vectorizes event descriptions for semantic search ("find rock event near me") | | **Testing** | Pest / PHPUnit | **118 tests, 349 assertions** covering auth, checkout, webhooks, and payout flows | --- -## Project Structure (Domain-Driven Design) +## Project Structure (Domain-Driven Design) The codebase is organized by **business domain**, not by technical layer. Each domain owns its Models, Controllers, Actions, DTOs, and Enums. @@ -83,7 +85,7 @@ app/Domains/ --- -## Interesting Architectural Decisions +## Interesting Architectural Decisions This is not a tutorial CRUD app. The architecture was specifically designed to handle the scale and edge cases of a high-traffic ticketing platform. @@ -133,20 +135,22 @@ This is not a tutorial CRUD app. The architecture was specifically designed to h --- -## Design Decisions +## Design Decisions Every architectural choice was made deliberately. Here's a summary of _what_ was chosen and _why_: -| Decision | Chosen Approach | Why Not the Alternative | -| :----------------------- | :----------------------------------- | :------------------------------------------------------------------------------------------ | -| **Code Organization** | Domain-Driven Design | Standard MVC leads to 50+ file `Controllers/` folders with no logical grouping | -| **Business Logic** | Action Classes | Fat Controllers can't be reused in CLI commands or queued jobs | +| Decision | Chosen Approach | Why Not the Alternative | +| :----------------------- | :----------------------------------- | :------------------------------------------------------------------------------------------------- | +| **Code Organization** | Domain-Driven Design | Standard MVC leads to 50+ file `Controllers/` folders with no logical grouping | +| **Business Logic** | Action Classes | Fat Controllers can't be reused in CLI commands or queued jobs | | **Payment Architecture** | Stripe Connect (Destination Charges) | Standard Checkout deposits to site's account — site would assume tax liability for every organizer | -| **Payout Timing** | 24h post-event escrow via cron | Instant payouts would allow organizers to take money and cancel events | -| **Ticket Inventory** | Pessimistic DB locking | Optimistic locking allows overselling under concurrent load | -| **Email Rendering** | MJML → Blade compilation | Raw HTML emails break on 60%+ of email clients | -| **API Responses** | Eloquent API Resources | Returning raw models leaks internal DB columns and relationships | -| **Status Management** | PHP 8.1 Backed Enums | String-based statuses like `"paid"` allow typos and invalid states | +| **Payout Timing** | 24h post-event escrow via cron | Instant payouts would allow organizers to take money and cancel events | +| **Ticket Inventory** | Pessimistic DB locking | Optimistic locking allows overselling under concurrent load | +| **Venue Data** | Google Places API Integration | User-typed addresses cause spelling errors and break mapping/routing geometry | +| **Search Architecture** | pgvector + OpenAI Embeddings | Standard SQL `LIKE '%rock%'` fails on semantic queries like "underground electronic" | +| **Email Rendering** | MJML → Blade compilation | Raw HTML emails break on 60%+ of email clients | +| **API Responses** | Eloquent API Resources | Returning raw models leaks internal DB columns and relationships | +| **Status Management** | PHP 8.1 Backed Enums | String-based statuses like `"paid"` allow typos and invalid states | --- @@ -208,7 +212,7 @@ Response (200) --- -## Performance & Safety +## Performance & Safety @@ -274,7 +278,7 @@ sequenceDiagram --- -## Database Schema (Key Entities) +## Database Schema (Key Entities) ```mermaid erDiagram @@ -312,7 +316,7 @@ erDiagram --- -## Interactive API Documentation +## Interactive API Documentation The full API contract is documented via a [Bruno](https://www.usebruno.com/) collection, organized by domain: From 0dad37d67b0489fb96b42723dba2cb67d88b0d08 Mon Sep 17 00:00:00 2001 From: monte-dev <119732972+monte-dev@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:42:30 +0200 Subject: [PATCH 81/81] Update README.md --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1032b6a..3f94523 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ This is a **two-sided marketplace** connecting local event organizers with attendees. Unlike standard tutorial CRUD projects, this backend simulates a real-world startup handling: -> **Real money flows** — Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. -> -> **Concurrency under pressure** — Pessimistic database locking guarantees zero overselling when hundreds of users race to buy the last tickets simultaneously. -> -> **Scalable architecture** — Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. +**Real money flows** — Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. + +**Concurrency under pressure** — Pessimistic database locking guarantees zero overselling when hundreds of users race to buy the last tickets simultaneously. + +**Scalable architecture** — Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. --- @@ -87,7 +87,7 @@ app/Domains/ ## Interesting Architectural Decisions -This is not a tutorial CRUD app. The architecture was specifically designed to handle the scale and edge cases of a high-traffic ticketing platform. +The architecture was specifically designed to handle the scale and edge cases of a high-traffic ticketing platform. ### Design Patterns @@ -175,7 +175,7 @@ Request Response (201) 1. `CheckoutController` validates the request via `CheckoutRequest` (Form Request) 2. `RegisterAttendeeAction` locks the ticket row, checks capacity, creates Order + Ticket 3. `StripePaymentProvider::createCheckoutSession()` generates a hosted Stripe Checkout URL -4. User is redirected to Stripe — zero PCI compliance burden on our servers +4. User is redirected to Stripe which handles compliance ### `POST /api/v1/webhook/stripe` — Payment Confirmation @@ -243,7 +243,7 @@ Response (200) --- -## 🔀 Visualizing the Flow: The Escrow Payout Model +## Visualizing the Flow: The Escrow Payout Model Organizers don't get paid until _after_ the event finishes, preventing fraud. Here is the full lifecycle: @@ -262,12 +262,12 @@ sequenceDiagram User->>Stripe: Enters Credit Card Stripe->>App: POST /webhook/stripe (Async) - note over App,Stripe: 🛡️ Security Check: Verify Signature + note over App,Stripe: Security Check: Verify Signature App->>DB: 1. Create Order & Ticket App->>DB: 2. Create "Pending" Escrow Payout - note over Cron,DB: ⏳ Event Concludes (24 hours pass) + note over Cron,DB: Event Concludes (24 hours pass) Cron->>App: Trigger ReleasePayoutsJob App->>DB: Query events ready for payout @@ -330,11 +330,11 @@ bruno/ └── Admin/ # Moderate Venues, Manage Categories ``` -> 💡 **To test locally:** Import the `/bruno` folder into Bruno, set `{{baseUrl}}` to `http://localhost:8000`, and explore all endpoints with pre-configured request bodies. +> **To test locally:** Import the `/bruno` folder into Bruno, set `{{baseUrl}}` to `http://localhost:8000`, and explore all endpoints with pre-configured request bodies. --- -## 🛠️ Getting Started +## Getting Started Get the backend running locally in under 3 minutes. (assumed you have your Stripe Connect Keys ready to go) @@ -359,7 +359,7 @@ php artisan migrate:fresh --seed --seeder=DevSetupSeeder php artisan serve & php artisan queue:work ``` -### ✅ Running the Test Suite +### Running the Test Suite ```bash php artisan test