diff --git a/.env.example b/.env.example index c0660ea..7b1982e 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 +FRONTEND_URL_LOCAL_STRIPE_DUMMY=https://github.com +# Ticketing & QR Codes +BACON_QR_RENDERER=gd + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 @@ -63,3 +73,23 @@ AWS_BUCKET= 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 diff --git a/README.md b/README.md index 0165a77..3f94523 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,371 @@ -

Laravel Logo

+
+

🎟️ 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. +

-

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

+

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

+
-## About Laravel +--- -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: +## About This Project -- [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). +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: -Laravel is accessible, powerful, and provides tools required for large, robust applications. +**Real money flows** β€” Stripe Connect escrow holds funds until an event concludes, then a scheduled cron job releases payouts to organizers automatically. -## Learning Laravel +**Concurrency under pressure** β€” Pessimistic database locking guarantees zero overselling when hundreds of users race to buy the last tickets simultaneously. -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. +**Scalable architecture** β€” Domain-Driven Design (DDD) isolates business logic into bounded contexts, ready for extraction into microservices. -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. +--- -## Laravel Sponsors +## Tech Stack -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). +| 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 | +| **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 | -### Premium Partners +--- -- **[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)** +## Project Structure (Domain-Driven Design) -## Contributing +The codebase is organized by **business domain**, not by technical layer. Each domain owns its Models, Controllers, Actions, DTOs, and Enums. -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +``` +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 +``` -## Code of Conduct +--- -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). +## Interesting Architectural Decisions -## Security Vulnerabilities +The architecture was specifically designed to handle the scale and edge cases of a high-traffic ticketing platform. -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. +### Design Patterns -## License + + + + + + + + + + + + + + + + + +
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 Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +### 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 | +| **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 | + +--- + +## Key Endpoint Flows + +### `POST /api/v1/checkout` β€” Ticket Purchase + +``` +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" β”‚ +β”‚ } β”‚ β”‚ } β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**What happens internally:** + +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 which handles compliance + +### `POST /api/v1/webhook/stripe` β€” Payment Confirmation + +``` +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) +``` + +### `GET /api/v1/organizer/payout/status` β€” Payout Dashboard + +``` +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" + } + ] +} +``` + +--- + +## 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" +``` + +--- + +## 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 + } +``` + +--- + +## Interactive API Documentation + +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 +``` + +> **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 + +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 +# 1. Clone & Enter +git clone https://github.com/monte-dev/lokalnie_server.git +cd lokalnie_server + +# 2. Install Dependencies +composer install + +# 3. Setup Environment +cp .env.example .env +php artisan key:generate + +# 4. Run Migrations & Seed Demo Data +php artisan migrate:fresh --seed --seeder=DevSetupSeeder + +# 5. Start Server + Queue Worker +php artisan serve & php artisan queue:work +``` + +### Running the Test Suite + +```bash +php artisan test +``` + +``` +Tests: 118 passed (349 assertions) +Duration: 6.06s +``` 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/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/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 new file mode 100644 index 0000000..1f60812 --- /dev/null +++ b/app/Domains/Admin/Controllers/ApproveOrganizerController.php @@ -0,0 +1,35 @@ +organizerService->approveOrganizer($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_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/Analytics/Models/AnalyticsEvent.php b/app/Domains/Analytics/Models/AnalyticsEvent.php new file mode 100644 index 0000000..93d2702 --- /dev/null +++ b/app/Domains/Analytics/Models/AnalyticsEvent.php @@ -0,0 +1,20 @@ + 'array', + ]; +} 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/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php new file mode 100644 index 0000000..eb2433d --- /dev/null +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -0,0 +1,38 @@ +authService->loginUser($request->validated()); + } catch (Throwable $e) { + return $this->apiResponseError($e, UserRegisterHttpEnum::LOGIN_ERROR->value); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::LOGIN_SUCCESS->value, + Response::HTTP_OK, + [ + 'user' => new UserResource($result['user']), + 'token' => $result['token'], + ] + ); + } +} diff --git a/app/Domains/Auth/Controllers/LogoutController.php b/app/Domains/Auth/Controllers/LogoutController.php new file mode 100644 index 0000000..a2c9f4b --- /dev/null +++ b/app/Domains/Auth/Controllers/LogoutController.php @@ -0,0 +1,36 @@ +authService->logoutUser($request->user()); + } 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/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 new file mode 100644 index 0000000..9ede9db --- /dev/null +++ b/app/Domains/Auth/Controllers/RegisterController.php @@ -0,0 +1,37 @@ +authService->registerUser($request->validated()); + } catch (Throwable $e) { + return $this->apiResponseError($e, UserRegisterHttpEnum::REGISTER_ERROR->value); + } + + return $this->apiResponseSuccess( + UserRegisterHttpEnum::REGISTER_SUCCESS->value, + Response::HTTP_CREATED, + [ + 'user' => new UserResource($result['user']), + 'token' => $result['token'], + ] + ); + } +} 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 string $guard_name = 'api'; + + protected static function newFactory() + { + return UserFactory::new(); + } /** * The attributes that are mass assignable. @@ -21,6 +33,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'locale', ]; /** @@ -45,4 +58,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function organizerProfile(): HasOne + { + return $this->hasOne(OrganizerProfile::class); + } } 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..c77f10f --- /dev/null +++ b/app/Domains/Auth/Services/AuthService.php @@ -0,0 +1,65 @@ +userRepository->create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => Hash::make($data['password']), + 'locale' => $data['locale'] ?? 'pl', + ]); + + event(new Registered($user)); + + $this->linkGuestTicketsAction->execute($user); + + $token = $user->createToken('auth_token')->plainTextToken; + + return [ + 'user' => $user, + 'token' => $token, + ]; + } + + public function loginUser(array $credentials): array + { + $user = $this->userRepository->findByEmail($credentials['email']); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $this->linkGuestTicketsAction->execute($user); + + $token = $user->createToken('auth_token')->plainTextToken; + + return [ + 'user' => $user, + 'token' => $token, + ]; + } + + public function logoutUser(User $user): void + { + $user->tokens()->delete(); + } +} 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/Categories/Controllers/CategoryController.php b/app/Domains/Categories/Controllers/CategoryController.php new file mode 100644 index 0000000..903984c --- /dev/null +++ b/app/Domains/Categories/Controllers/CategoryController.php @@ -0,0 +1,28 @@ +get(); + } catch (Exception $e) { + return $this->apiResponseError($e, CategoryHttpEnum::INDEX_ERROR->value, Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->apiResponseSuccess( + 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 @@ +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/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/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/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/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/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/Controllers/Organizer/OrganizerEventController.php b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php new file mode 100644 index 0000000..43b1fa3 --- /dev/null +++ b/app/Domains/Events/Controllers/Organizer/OrganizerEventController.php @@ -0,0 +1,114 @@ +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); + } + + 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); + } + + return $this->apiResponseSuccess( + EventHttpEnum::STORE_SUCCESS->value, + Response::HTTP_CREATED, + ['event' => new EventResource($event)] + ); + } + + 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, + ['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); + } + + 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 (Exception $e) { + return $this->apiResponseError($e); + } + + 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/Controllers/PublicEventController.php b/app/Domains/Events/Controllers/PublicEventController.php new file mode 100644 index 0000000..a5cebe2 --- /dev/null +++ b/app/Domains/Events/Controllers/PublicEventController.php @@ -0,0 +1,83 @@ +eventInstanceService->getPaginatedEvents( + $request->only(['search', 'category', 'date_from', 'date_to']), + (int) $request->input('per_page', 15) + ); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::INDEX_SUCCESS->value, + Response::HTTP_OK, + [ + 'events' => EventInstanceResource::collection($instances)->response()->getData(true) + ] + ); + } + + public function show(EventInstance $instance): JsonResponse + { + try { + if ($instance->status !== EventStatusEnum::PUBLISHED) { + throw new DomainException(EventHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); + } + + IncrementEventViewCountJob::dispatch($instance->event_id); + + $this->eventInstanceService->incrementViewCount($instance); + + $instance->load(['event.category', 'event.venue', 'event.organizerProfile']); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + EventHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + ['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/Enums/EventHttpEnum.php b/app/Domains/Events/Enums/EventHttpEnum.php new file mode 100644 index 0000000..c1386d8 --- /dev/null +++ b/app/Domains/Events/Enums/EventHttpEnum.php @@ -0,0 +1,30 @@ +eventId)->increment('view_count'); + + AnalyticsEvent::create([ + 'event_type' => 'event.view', + 'properties' => ['event_id' => $this->eventId], + ]); + } +} diff --git a/app/Domains/Events/Models/Category.php b/app/Domains/Events/Models/Category.php new file mode 100644 index 0000000..a4887dd --- /dev/null +++ b/app/Domains/Events/Models/Category.php @@ -0,0 +1,24 @@ +hasMany(Event::class); + } +} diff --git a/app/Domains/Events/Models/Event.php b/app/Domains/Events/Models/Event.php new file mode 100644 index 0000000..d1f8519 --- /dev/null +++ b/app/Domains/Events/Models/Event.php @@ -0,0 +1,86 @@ + '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); + } + + 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..4195d81 --- /dev/null +++ b/app/Domains/Events/Models/EventInstance.php @@ -0,0 +1,49 @@ + 'datetime', + 'ends_at' => 'datetime', + 'status' => EventStatusEnum::class, + ]; + + 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 new file mode 100644 index 0000000..3a1b858 --- /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', config('app.frontend_url')) + ->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/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 new file mode 100644 index 0000000..1ea3b56 --- /dev/null +++ b/app/Domains/Events/Repositories/EventInstanceRepository.php @@ -0,0 +1,68 @@ +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'); + } + + 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 new file mode 100644 index 0000000..1ea0d38 --- /dev/null +++ b/app/Domains/Events/Repositories/EventInstanceRepositoryInterface.php @@ -0,0 +1,20 @@ +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(); + } + + 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): 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 new file mode 100644 index 0000000..7119e30 --- /dev/null +++ b/app/Domains/Events/Repositories/EventRepositoryInterface.php @@ -0,0 +1,22 @@ + ['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/Requests/StoreEventRequest.php b/app/Domains/Events/Requests/StoreEventRequest.php new file mode 100644 index 0000000..7968e3b --- /dev/null +++ b/app/Domains/Events/Requests/StoreEventRequest.php @@ -0,0 +1,33 @@ + ['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'], + '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 new file mode 100644 index 0000000..bcbfb5c --- /dev/null +++ b/app/Domains/Events/Requests/UpdateEventRequest.php @@ -0,0 +1,33 @@ + ['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'], + '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/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/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/Events/Services/EventInstanceService.php b/app/Domains/Events/Services/EventInstanceService.php new file mode 100644 index 0000000..18b76c3 --- /dev/null +++ b/app/Domains/Events/Services/EventInstanceService.php @@ -0,0 +1,61 @@ +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); + } + + 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 new file mode 100644 index 0000000..371278a --- /dev/null +++ b/app/Domains/Events/Services/EventService.php @@ -0,0 +1,133 @@ +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); + } + + public function createEventForOrganizer(int $organizerProfileId, array $data): Event + { + $data['organizer_profile_id'] = $organizerProfileId; + + if (empty($data['slug'])) { + $data['slug'] = Str::slug($data['title']) . '-' . uniqid(); + } + + $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(); + } + + public function deleteEvent(Event $event): void + { + if ($event->status !== EventStatusEnum::DRAFT && $event->status !== EventStatusEnum::CANCELLED) { + throw new DomainException(EventHttpEnum::DESTROY_INVALID_STATUS->value, Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->eventRepository->delete($event); + } + + public function publishEvent(Event $event): Event + { + if ($event->status === EventStatusEnum::PUBLISHED) { + 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, Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $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(EventHttpEnum::CANCEL_ALREADY_CANCELLED->value, Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); + + $this->notifyAttendees($event); + + return $event->refresh(); + } + + public function unpublishByAdmin(Event $event): Event + { + $this->eventRepository->update($event, ['status' => EventStatusEnum::CANCELLED->value]); + + $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]); + + return $event->refresh(); + } + + public function unfeatureEvent(Event $event): Event + { + $this->eventRepository->update($event, ['is_featured' => false]); + + 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..9b469ae --- /dev/null +++ b/app/Domains/Events/Transformers/EventInstanceResource.php @@ -0,0 +1,24 @@ + $this->id, + 'event_id' => $this->event_id, + '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')), + ]; + } +} diff --git a/app/Domains/Events/Transformers/EventResource.php b/app/Domains/Events/Transformers/EventResource.php new file mode 100644 index 0000000..2a14c41 --- /dev/null +++ b/app/Domains/Events/Transformers/EventResource.php @@ -0,0 +1,37 @@ + + */ + 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, + '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/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/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/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..2a9f648 --- /dev/null +++ b/app/Domains/Organizer/Enums/Http/OrganizerHttpEnum.php @@ -0,0 +1,21 @@ + 'datetime', + 'stripe_onboarding_completed' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isApproved(): bool + { + return $this->verified_at !== null; + } +} 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/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/Repositories/OrganizerRepository.php b/app/Domains/Organizer/Repositories/OrganizerRepository.php new file mode 100644 index 0000000..3cc3a3a --- /dev/null +++ b/app/Domains/Organizer/Repositories/OrganizerRepository.php @@ -0,0 +1,32 @@ +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(), + ]); + } + + 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 @@ +|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/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 new file mode 100644 index 0000000..661b141 --- /dev/null +++ b/app/Domains/Organizer/Services/OrganizerService.php @@ -0,0 +1,110 @@ +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()); + } + + 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()); + } + + 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 new file mode 100644 index 0000000..9756347 --- /dev/null +++ b/app/Domains/Organizer/Transformers/OrganizerProfileResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organization_name' => $this->organization_name, + '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/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/Shared/Enums/AppHttpEnum.php b/app/Domains/Shared/Enums/AppHttpEnum.php new file mode 100644 index 0000000..3a46e67 --- /dev/null +++ b/app/Domains/Shared/Enums/AppHttpEnum.php @@ -0,0 +1,10 @@ +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 instanceof DomainException ? $error->getMessage() : AppHttpEnum::SERVER_ERROR->value); + $statusCode = $this->resolveStatusCode($error, $statusCode); + + $response = ApiResponseResource::error($message); + + if ($error instanceof ValidationException) { + $response->setErrors($error->errors()); + } + + 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/Shared/Transformers/ApiResponseResource.php b/app/Domains/Shared/Transformers/ApiResponseResource.php new file mode 100644 index 0000000..6e7c416 --- /dev/null +++ b/app/Domains/Shared/Transformers/ApiResponseResource.php @@ -0,0 +1,73 @@ + (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, + ]; + } + + /** + * 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 setErrors(array $errors): self + { + $this->errors = $errors; + return $this; + } + + public function setData($data): self + { + $this->resource['data'] = $data; + return $this; + } +} 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/Domains/Ticketing/Actions/RegisterAttendeeAction.php b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php new file mode 100644 index 0000000..be5daf7 --- /dev/null +++ b/app/Domains/Ticketing/Actions/RegisterAttendeeAction.php @@ -0,0 +1,60 @@ +lockForUpdate(); + } + $ticketType = $query->findOrFail($ticketTypeId); + + $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' => $isFree ? OrderStatusEnum::COMPLETED : OrderStatusEnum::PENDING, + 'total_amount' => $ticketType->price, + 'currency' => config('payments.currency', 'pln'), + 'payment_status' => $isFree ? PaymentStatusEnum::NONE : PaymentStatusEnum::PENDING, + ]); + + $ticket = Ticket::create([ + 'ticket_type_id' => $ticketTypeId, + 'event_instance_id' => $eventInstanceId, + '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' => $isFree ? TicketStatusEnum::VALID : TicketStatusEnum::PENDING, + ]); + + if ($isFree) { + SendTicketEmailJob::dispatch($ticket); + } + + return $ticket; + }); + } +} 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 @@ +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/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 + ); + } +} diff --git a/app/Domains/Ticketing/Http/Controllers/TicketController.php b/app/Domains/Ticketing/Http/Controllers/TicketController.php new file mode 100644 index 0000000..2c07ad7 --- /dev/null +++ b/app/Domains/Ticketing/Http/Controllers/TicketController.php @@ -0,0 +1,98 @@ +user(); + $ticket = $action->execute( + $request->integer('ticket_type_id'), + $request->integer('event_instance_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) + ); + } + + 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/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php new file mode 100644 index 0000000..3f1b392 --- /dev/null +++ b/app/Domains/Ticketing/Http/Requests/RegisterTicketRequest.php @@ -0,0 +1,23 @@ + ['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'], + ]; + } +} 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/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/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/GuestRegistration.php b/app/Domains/Ticketing/Models/GuestRegistration.php new file mode 100644 index 0000000..4b2a828 --- /dev/null +++ b/app/Domains/Ticketing/Models/GuestRegistration.php @@ -0,0 +1,10 @@ + OrderStatusEnum::class, + 'payment_status' => PaymentStatusEnum::class, + 'payment_provider' => PaymentProviderEnum::class, + ]; + + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + 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/app/Domains/Ticketing/Models/Ticket.php b/app/Domains/Ticketing/Models/Ticket.php new file mode 100644 index 0000000..43e75e5 --- /dev/null +++ b/app/Domains/Ticketing/Models/Ticket.php @@ -0,0 +1,57 @@ + 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); + } + + public function eventInstance() + { + return $this->belongsTo(EventInstance::class); + } +} diff --git a/app/Domains/Ticketing/Models/TicketType.php b/app/Domains/Ticketing/Models/TicketType.php new file mode 100644 index 0000000..fb99571 --- /dev/null +++ b/app/Domains/Ticketing/Models/TicketType.php @@ -0,0 +1,58 @@ +belongsTo(Event::class); + } + + public function tickets() + { + return $this->hasMany(Ticket::class); + } + + 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 + ]) + ->count(); + + if ($confirmedTicketsCount >= $this->quantity) { + throw new CapacityExceededException(); + } + } + } +} diff --git a/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php new file mode 100644 index 0000000..83e1f48 --- /dev/null +++ b/app/Domains/Ticketing/Repositories/Eloquent/TicketRepository.php @@ -0,0 +1,154 @@ +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(); + } + + 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) { + $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 new file mode 100644 index 0000000..31ed91a --- /dev/null +++ b/app/Domains/Ticketing/Repositories/TicketRepositoryInterface.php @@ -0,0 +1,28 @@ +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/app/Domains/Ticketing/Services/AttendeeService.php b/app/Domains/Ticketing/Services/AttendeeService.php new file mode 100644 index 0000000..7de5710 --- /dev/null +++ b/app/Domains/Ticketing/Services/AttendeeService.php @@ -0,0 +1,42 @@ +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/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/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(), + ]; + } +} diff --git a/app/Domains/Ticketing/Transformers/TicketResource.php b/app/Domains/Ticketing/Transformers/TicketResource.php new file mode 100644 index 0000000..7a238de --- /dev/null +++ b/app/Domains/Ticketing/Transformers/TicketResource.php @@ -0,0 +1,28 @@ + $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(), + ]; + } +} 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/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/Controllers/ListVenuesController.php b/app/Domains/Venues/Controllers/ListVenuesController.php new file mode 100644 index 0000000..b37494a --- /dev/null +++ b/app/Domains/Venues/Controllers/ListVenuesController.php @@ -0,0 +1,38 @@ +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/Controllers/VenueSubmissionController.php b/app/Domains/Venues/Controllers/VenueSubmissionController.php new file mode 100644 index 0000000..d586da9 --- /dev/null +++ b/app/Domains/Venues/Controllers/VenueSubmissionController.php @@ -0,0 +1,90 @@ +input('status') === VenueSubmissionStatusEnum::FLAGGED->value) { + $filters['status'] = VenueSubmissionStatusEnum::FLAGGED->value; + } + + $submissions = $this->venueSubmissionService->getPaginatedSubmissions($filters, 15); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + 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) { + throw new DomainException(VenueSubmissionHttpEnum::NOT_FOUND_ERROR->value, Response::HTTP_FORBIDDEN); + } + + $submission = $this->venueSubmissionService->submitVenue( + $organizerProfile->id, + $request->validated() + ); + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + 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) { + throw new DomainException(VenueSubmissionHttpEnum::FORBIDDEN_ERROR->value, Response::HTTP_FORBIDDEN); + } + } catch (Exception $e) { + return $this->apiResponseError($e); + } + + return $this->apiResponseSuccess( + VenueSubmissionHttpEnum::SHOW_SUCCESS->value, + Response::HTTP_OK, + [ + 'venue_submission' => new VenueSubmissionResource($venueSubmission) + ] + ); + } +} diff --git a/app/Domains/Venues/Enums/VenueHttpEnum.php b/app/Domains/Venues/Enums/VenueHttpEnum.php new file mode 100644 index 0000000..2f4ceed --- /dev/null +++ b/app/Domains/Venues/Enums/VenueHttpEnum.php @@ -0,0 +1,12 @@ +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) { + 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 new file mode 100644 index 0000000..2c24ee1 --- /dev/null +++ b/app/Domains/Venues/Models/Venue.php @@ -0,0 +1,34 @@ + 'decimal:7', + 'lng' => 'decimal:7', + 'embedding' => 'array', + ]; +} diff --git a/app/Domains/Venues/Models/VenueSubmission.php b/app/Domains/Venues/Models/VenueSubmission.php new file mode 100644 index 0000000..5cc5180 --- /dev/null +++ b/app/Domains/Venues/Models/VenueSubmission.php @@ -0,0 +1,41 @@ + VenueSubmissionStatusEnum::class, + 'places_result' => 'array', + ]; + + public function organizerProfile() + { + return $this->belongsTo(OrganizerProfile::class); + } +} 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 new file mode 100644 index 0000000..9577385 --- /dev/null +++ b/app/Domains/Venues/Repositories/VenueRepository.php @@ -0,0 +1,38 @@ +limit($limit) + ->get(); + } + + public function create(array $data): Venue + { + return Venue::create($data); + } + + 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 new file mode 100644 index 0000000..1fe2da2 --- /dev/null +++ b/app/Domains/Venues/Repositories/VenueRepositoryInterface.php @@ -0,0 +1,20 @@ +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', '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/app/Domains/Venues/Requests/SearchVenuesRequest.php b/app/Domains/Venues/Requests/SearchVenuesRequest.php new file mode 100644 index 0000000..ceba09f --- /dev/null +++ b/app/Domains/Venues/Requests/SearchVenuesRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'q' => ['required', 'string', 'min:2'], + ]; + } +} diff --git a/app/Domains/Venues/Requests/StoreVenueSubmissionRequest.php b/app/Domains/Venues/Requests/StoreVenueSubmissionRequest.php new file mode 100644 index 0000000..ddd3eef --- /dev/null +++ b/app/Domains/Venues/Requests/StoreVenueSubmissionRequest.php @@ -0,0 +1,24 @@ + ['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/Resources/VenueSubmissionResource.php b/app/Domains/Venues/Resources/VenueSubmissionResource.php new file mode 100644 index 0000000..ea941ee --- /dev/null +++ b/app/Domains/Venues/Resources/VenueSubmissionResource.php @@ -0,0 +1,26 @@ + $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, + ]; + } +} diff --git a/app/Domains/Venues/Services/AdminVenueSubmissionService.php b/app/Domains/Venues/Services/AdminVenueSubmissionService.php new file mode 100644 index 0000000..dc23f2a --- /dev/null +++ b/app/Domains/Venues/Services/AdminVenueSubmissionService.php @@ -0,0 +1,90 @@ +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}"; + $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 + { + if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value, Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $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 + { + if ($venueSubmission->status !== VenueSubmissionStatusEnum::FLAGGED) { + throw new DomainException(VenueSubmissionHttpEnum::INVALID_TRANSITION->value); + } + + $venue = $this->venueRepository->findById($venueId); + + if (!$venue) { + throw new DomainException(VenueHttpEnum::NOT_FOUND->value, Response::HTTP_NOT_FOUND); + } + + $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); + } +} 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/app/Domains/Venues/Services/GooglePlacesService.php b/app/Domains/Venues/Services/GooglePlacesService.php new file mode 100644 index 0000000..8d152cf --- /dev/null +++ b/app/Domains/Venues/Services/GooglePlacesService.php @@ -0,0 +1,65 @@ +apiKey = config('services.google_places.key'); + $this->apiUrl = config('services.google_places.url'); + } + + /** + * 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('[GooglePlacesService] - findPlace - 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($this->apiUrl, [ + 'textQuery' => $query, + 'maxResultCount' => 1, + ]); + + if ($response->failed()) { + Log::error('[GooglePlacesService] - findPlace - api call error', [ + '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('[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/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/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)); + } +} 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/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/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"); + } +} 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/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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..2175c56 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,25 @@ 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; +use App\Domains\Ticketing\Repositories\TicketRepositoryInterface; +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 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 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; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +30,22 @@ class AppServiceProvider extends ServiceProvider */ 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); + $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), + }; + }); } /** @@ -19,6 +53,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()); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..794c801 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,18 +1,41 @@ 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(); + $middleware->redirectTo(function ($request) { + if ($request->is('api/*')) { + return null; + } + return route('login'); + }); + $middleware->alias([ + 'role' => RoleMiddleware::class, + 'permission' => RoleOrPermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + ]); }) ->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(); diff --git a/bruno/Admin/ApproveOrganizerController.bru b/bruno/Admin/ApproveOrganizerController.bru new file mode 100644 index 0000000..cb4dd00 --- /dev/null +++ b/bruno/Admin/ApproveOrganizerController.bru @@ -0,0 +1,11 @@ +meta { + name: Approve Organizer + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/admin/organizers/2/approve + body: none + auth: inherit +} diff --git a/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru new file mode 100644 index 0000000..cb7d93e --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@approve.bru @@ -0,0 +1,15 @@ +meta { + name: Admin Venue Submission - 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..5f2ca79 --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Admin Venue Submission - 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..be06b28 --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@merge.bru @@ -0,0 +1,21 @@ +meta { + name: Admin Venue Submission - 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..a7a052b --- /dev/null +++ b/bruno/Admin/VenueSubmissions/AdminVenueSubmissionController@reject.bru @@ -0,0 +1,21 @@ +meta { + name: Admin Venue Submission - 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/bruno/Auth/LoginController.bru b/bruno/Auth/LoginController.bru new file mode 100644 index 0000000..605d739 --- /dev/null +++ b/bruno/Auth/LoginController.bru @@ -0,0 +1,18 @@ +meta { + name: Login + 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..e7d4f24 --- /dev/null +++ b/bruno/Auth/LogoutController.bru @@ -0,0 +1,11 @@ +meta { + name: Logout + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/auth/logout + body: none + auth: inherit +} diff --git a/bruno/Auth/MeController.bru b/bruno/Auth/MeController.bru new file mode 100644 index 0000000..0554ffb --- /dev/null +++ b/bruno/Auth/MeController.bru @@ -0,0 +1,11 @@ +meta { + name: Me + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/auth/me + body: none + auth: inherit +} diff --git a/bruno/Auth/RegisterController.bru b/bruno/Auth/RegisterController.bru new file mode 100644 index 0000000..07ff820 --- /dev/null +++ b/bruno/Auth/RegisterController.bru @@ -0,0 +1,20 @@ +meta { + name: Register + 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..32882f5 --- /dev/null +++ b/bruno/Auth/ResendVerificationEmailController.bru @@ -0,0 +1,11 @@ +meta { + name: Resend Verification Email + 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..d1cd374 --- /dev/null +++ b/bruno/Auth/ResetPasswordController.bru @@ -0,0 +1,20 @@ +meta { + name: Reset Password + 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..8d8596d --- /dev/null +++ b/bruno/Auth/SendPasswordResetLinkController.bru @@ -0,0 +1,17 @@ +meta { + name: Send Password Reset Link + 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/Organizer/Analytics/AnalyticsController.bru b/bruno/Organizer/Analytics/AnalyticsController.bru new file mode 100644 index 0000000..16a9828 --- /dev/null +++ b/bruno/Organizer/Analytics/AnalyticsController.bru @@ -0,0 +1,16 @@ +meta { + name: Analytics + 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/CheckIn/CheckInController.bru b/bruno/Organizer/CheckIn/CheckInController.bru new file mode 100644 index 0000000..1351e6e --- /dev/null +++ b/bruno/Organizer/CheckIn/CheckInController.bru @@ -0,0 +1,15 @@ +meta { + name: Check In + 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/Dashboard/DashboardController.bru b/bruno/Organizer/Dashboard/DashboardController.bru new file mode 100644 index 0000000..7a48422 --- /dev/null +++ b/bruno/Organizer/Dashboard/DashboardController.bru @@ -0,0 +1,11 @@ +meta { + name: Dashboard + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/dashboard + body: none + auth: inherit +} diff --git a/bruno/Organizer/Events/AttendeeController@export.bru b/bruno/Organizer/Events/AttendeeController@export.bru new file mode 100644 index 0000000..945e2c9 --- /dev/null +++ b/bruno/Organizer/Events/AttendeeController@export.bru @@ -0,0 +1,15 @@ +meta { + name: Attendee - 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..6e8f0ab --- /dev/null +++ b/bruno/Organizer/Events/AttendeeController@index.bru @@ -0,0 +1,15 @@ +meta { + name: Attendee - 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/Organizer/Events/CancelEventController.bru b/bruno/Organizer/Events/CancelEventController.bru new file mode 100644 index 0000000..70cbdd5 --- /dev/null +++ b/bruno/Organizer/Events/CancelEventController.bru @@ -0,0 +1,11 @@ +meta { + name: Cancel Event + 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..7bd5b68 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@destroy.bru @@ -0,0 +1,11 @@ +meta { + name: Organizer Event - 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..7c1d2cf --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Organizer Event - 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..99bcbe1 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@show.bru @@ -0,0 +1,11 @@ +meta { + name: Organizer Event - 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..9d2afef --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@store.bru @@ -0,0 +1,23 @@ +meta { + name: Organizer Event - 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..163eda9 --- /dev/null +++ b/bruno/Organizer/Events/OrganizerEventController@update.bru @@ -0,0 +1,23 @@ +meta { + name: Organizer Event - 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..2f62818 --- /dev/null +++ b/bruno/Organizer/Events/PublishEventController.bru @@ -0,0 +1,11 @@ +meta { + name: Publish Event + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/{{apiVersion}}/organizer/events/1/publish + body: none + auth: inherit +} 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 new file mode 100644 index 0000000..f919df8 --- /dev/null +++ b/bruno/Organizer/ShowOrganizerProfileController.bru @@ -0,0 +1,11 @@ +meta { + name: Show Organizer Profile + 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..8a0d0dc --- /dev/null +++ b/bruno/Organizer/StoreOrganizerProfileController.bru @@ -0,0 +1,19 @@ +meta { + name: Store Organizer Profile + 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..702f544 --- /dev/null +++ b/bruno/Organizer/UpdateOrganizerProfileController.bru @@ -0,0 +1,19 @@ +meta { + name: Update Organizer Profile + 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/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 new file mode 100644 index 0000000..89004ff --- /dev/null +++ b/bruno/Public/Categories/CategoryController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Category - 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..a447df1 --- /dev/null +++ b/bruno/Public/Events/PublicEventController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Public Event - 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..07e80aa --- /dev/null +++ b/bruno/Public/Events/PublicEventController@show.bru @@ -0,0 +1,11 @@ +meta { + name: Public Event - Show + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/{{apiVersion}}/events/1 + body: none + auth: none +} diff --git a/bruno/Public/Tickets/TicketController@index.bru b/bruno/Public/Tickets/TicketController@index.bru new file mode 100644 index 0000000..f0cbad7 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Ticket - 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..e0c3670 --- /dev/null +++ b/bruno/Public/Tickets/TicketController@resend.bru @@ -0,0 +1,15 @@ +meta { + name: Ticket - 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..cc0175a --- /dev/null +++ b/bruno/Public/Tickets/TicketController@show.bru @@ -0,0 +1,15 @@ +meta { + name: Ticket - 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..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 new file mode 100644 index 0000000..777bb71 --- /dev/null +++ b/bruno/Public/Venues/ListVenuesController.bru @@ -0,0 +1,11 @@ +meta { + name: List Venues + 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..24255d9 --- /dev/null +++ b/bruno/Public/Venues/SearchVenuesController.bru @@ -0,0 +1,11 @@ +meta { + name: Search Venues + 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..2979e03 --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@index.bru @@ -0,0 +1,11 @@ +meta { + name: Venue Submission - 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..cda61f6 --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@show.bru @@ -0,0 +1,11 @@ +meta { + name: Venue Submission - 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..07fcb01 --- /dev/null +++ b/bruno/Venues/VenueSubmissionController@store.bru @@ -0,0 +1,20 @@ +meta { + name: Venue Submission - 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/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/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..e91348f --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,11 @@ +headers { + : +} + +auth { + mode: bearer +} + +auth:bearer { + token: 1|9aT0vH7IFF45xLcwtOj7eX2UhU6Ml7spqpn20dzU6c721ad7 +} diff --git a/bruno/environments/Local.bru b/bruno/environments/Local.bru new file mode 100644 index 0000000..bfcc500 --- /dev/null +++ b/bruno/environments/Local.bru @@ -0,0 +1,4 @@ +vars { + baseUrl: http://127.0.0.1:8002 + apiVersion: v1 +} diff --git a/composer.json b/composer.json index 5a0f88d..3af35f1 100644 --- a/composer.json +++ b/composer.json @@ -9,9 +9,12 @@ "php": "^8.2", "bacon/bacon-qr-code": "^3.0", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.3", + "laravel/sanctum": "^4.0", + "laravel/scout": "^10.24", "laravel/tinker": "^2.10.1", - "spatie/laravel-permission": "^7.2" + "meilisearch/meilisearch-php": "^1.16", + "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 aae6a22..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": "2db66c16d883db2f4a44d522a7f278fa", + "content-hash": "6d1cfdb97ccc3afe505ca70b7ea27e66", "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", @@ -3608,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", @@ -5111,6 +5409,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/app.php b/config/app.php index 423eed5..64796f3 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'), + 'frontend_url_local_stripe_dummy' => env('FRONTEND_URL_LOCAL_STRIPE_DUMMY', 'http://github.com'), /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..2d5103f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -62,7 +62,7 @@ 'providers' => [ '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/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, + ], + ], /* 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'), + ], +]; 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/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/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/config/services.php b/config/services.php index 6a90eb8..c6d6cf2 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,14 @@ ], ], + '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'), + ], + ]; 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), + ]; + } +} diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000..e8387c8 --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,51 @@ +faker->words(3, true); + $startsAt = $this->faker->dateTimeBetween('+1 week', '+2 weeks'); + $endsAt = (clone $startsAt)->modify('+4 hours'); + + return [ + 'organizer_profile_id' => OrganizerProfile::factory(), + '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' => EventStatusEnum::DRAFT, + 'cover_image_path' => null, + '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 new file mode 100644 index 0000000..83ceb9f --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,25 @@ + EventInstance::factory(), + 'user_id' => User::factory(), + 'email' => $this->faker->safeEmail, + 'name' => $this->faker->name, + 'status' => OrderStatusEnum::COMPLETED, + ]; + } +} diff --git a/database/factories/OrganizerProfileFactory.php b/database/factories/OrganizerProfileFactory.php new file mode 100644 index 0000000..ec256a7 --- /dev/null +++ b/database/factories/OrganizerProfileFactory.php @@ -0,0 +1,27 @@ +faker->company(); + + return [ + 'user_id' => User::factory(), + 'organization_name' => $name, + 'slug' => Str::slug($name), + 'description' => $this->faker->paragraph(), + 'phone' => $this->faker->phoneNumber(), + 'verified_at' => null, + ]; + } +} diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php new file mode 100644 index 0000000..cc30efb --- /dev/null +++ b/database/factories/TicketFactory.php @@ -0,0 +1,31 @@ + TicketType::factory(), + 'event_instance_id' => EventInstance::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/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..db41745 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,15 +2,23 @@ namespace Database\Factories; +use App\Domains\Auth\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; 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 = User::class; + /** * The current password being used by the factory. */ @@ -37,7 +45,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/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/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/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..3b8eed6 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -17,6 +17,7 @@ public function up(): void $table->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_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/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..12bf9a5 --- /dev/null +++ b/database/migrations/2026_02_21_202153_create_events_table.php @@ -0,0 +1,41 @@ +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', array_column(EventStatusEnum::cases(), 'value'))->default(EventStatusEnum::DRAFT->value); + $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..f181af6 --- /dev/null +++ b/database/migrations/2026_02_21_202154_create_ticket_types_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $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(); + }); + } + + /** + * 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..105a9b0 --- /dev/null +++ b/database/migrations/2026_02_21_202155_create_orders_table.php @@ -0,0 +1,33 @@ +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', array_column(OrderStatusEnum::cases(), 'value'))->default(OrderStatusEnum::COMPLETED->value); + $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..f128a30 --- /dev/null +++ b/database/migrations/2026_02_21_202156_create_tickets_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('ticket_type_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', array_column(TicketStatusEnum::cases(), 'value'))->default(TicketStatusEnum::VALID->value); + $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/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/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/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'); + }); + } +}; 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']); + }); + } +}; 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(); + } + }); + } +}; 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'); + }); + } +}; 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/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/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..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; @@ -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, ]); } } 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/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/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 0000000..6598e2c --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + '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/ticketing.php b/lang/en/ticketing.php new file mode 100644 index 0000000..968f5c0 --- /dev/null +++ b/lang/en/ticketing.php @@ -0,0 +1,24 @@ + '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', + '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/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.', + ], +]; diff --git a/lang/pl/ticketing.php b/lang/pl/ticketing.php new file mode 100644 index 0000000..fdeeb94 --- /dev/null +++ b/lang/pl/ticketing.php @@ -0,0 +1,24 @@ + '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', + '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/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/phpunit.xml b/phpunit.xml index d703241..e28d57e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,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/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") }}
+
+ +
+
+ +
+
+ +
+ + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..f314552 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,129 @@ +middleware('throttle:public-registration'); +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']); +}); + +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/{instance}', [PublicEventController::class, 'show']); +Route::get('/events/{instance}/similar', [PublicEventController::class, 'similar']); + +Route::prefix('auth')->group(function () { + 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')->middleware('throttle:auth'); + 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 () { + 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'); + }); +}); + +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); + 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']); + + 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); + }); + + Route::post('/payout/onboard', [OrganizerPayoutController::class, 'onboard']); + Route::get('/payout/status', [OrganizerPayoutController::class, 'status']); + }); + + // 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']); + Route::post('/venue-submissions/{venueSubmission}/reject', [AdminVenueSubmissionController::class, 'reject']); + 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); 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'); 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/Admin/VenueReviewTest.php b/tests/Feature/Admin/VenueReviewTest.php new file mode 100644 index 0000000..fc469c5 --- /dev/null +++ b/tests/Feature/Admin/VenueReviewTest.php @@ -0,0 +1,225 @@ + '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); +}); + +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); +}); diff --git a/tests/Feature/Auth/AuthTest.php b/tests/Feature/Auth/AuthTest.php new file mode 100644 index 0000000..906dea6 --- /dev/null +++ b/tests/Feature/Auth/AuthTest.php @@ -0,0 +1,133 @@ +postJson('/api/v1/auth/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'message', + 'data' => [ + 'user' => ['id', 'name', 'email'], + 'token' + ] + ]); + + $this->assertDatabaseHas('users', ['email' => 'john@example.com']); +}); + +test('a user can login', function () { + $user = User::factory()->create([ + 'email' => 'jane@example.com', + 'password' => bcrypt('password123'), + 'email_verified_at' => now(), + ]); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => 'jane@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'message', + 'data' => [ + 'user' => ['id', 'name', 'email'], + 'token' + ] + ]); +}); + +test('a user can logout', function () { + $user = User::factory()->create(); + $token = $user->createToken('test_token')->plainTextToken; + + $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']); + + $response = $this->actingAs($user, 'sanctum') + ->getJson('/api/v1/auth/me'); + + $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 new file mode 100644 index 0000000..c9f1cee --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,52 @@ +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); +}); + +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']); + } +} 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); + } +} diff --git a/tests/Feature/Events/AnalyticsApiTest.php b/tests/Feature/Events/AnalyticsApiTest.php new file mode 100644 index 0000000..6b08ab8 --- /dev/null +++ b/tests/Feature/Events/AnalyticsApiTest.php @@ -0,0 +1,118 @@ +organizerProfile->id; + + $event = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + ]); + $ticketType = TicketType::factory()->create([ + 'event_id' => $event->id, + 'price' => 100.00 + ]); + $order = Order::factory()->create(['event_instance_id' => $event->instances->first()->id]); + + 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); + + $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 () { + $user = createOrganizer(); + $profileId = $user->organizerProfile->id; + + $event = Event::factory()->create([ + 'organizer_profile_id' => $profileId, + 'status' => EventStatusEnum::PUBLISHED, + ]); + + 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..5490226 --- /dev/null +++ b/tests/Feature/Events/DashboardApiTest.php @@ -0,0 +1,71 @@ +organizerProfile->id; + + $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), + ]); + + $ticketType = TicketType::factory()->create([ + 'event_id' => $event1->id, + 'price' => 100.00 + ]); + + $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) + ->assertJsonPath('data.stats.gross_revenue', 400) + ->assertJsonPath('data.stats.net_revenue_estimate', 380) + ->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/OrganizerEventCrudTest.php b/tests/Feature/Events/OrganizerEventCrudTest.php new file mode 100644 index 0000000..29ce845 --- /dev/null +++ b/tests/Feature/Events/OrganizerEventCrudTest.php @@ -0,0 +1,268 @@ +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' => 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', EventStatusEnum::DRAFT->value) + ->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' => EventStatusEnum::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' => EventStatusEnum::DRAFT, + ]); + + $response = $this->actingAs($user)->postJson("/api/v1/organizer/events/{$event->id}/publish"); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.status', EventStatusEnum::PUBLISHED->value); + + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => EventStatusEnum::PUBLISHED]); + } + + public function test_organizer_can_cancel_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}/cancel"); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.event.status', EventStatusEnum::CANCELLED->value); + } + + public function test_organizer_can_delete_draft_event(): void + { + $user = $this->createApprovedOrganizer(); + $event = Event::factory()->create([ + 'organizer_profile_id' => $user->organizerProfile->id, + 'status' => EventStatusEnum::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' => EventStatusEnum::PUBLISHED, + ]); + + $response = $this->actingAs($user)->deleteJson("/api/v1/organizer/events/{$event->id}"); + + $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 new file mode 100644 index 0000000..96bd481 --- /dev/null +++ b/tests/Feature/Events/PublicEventApiTest.php @@ -0,0 +1,97 @@ +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'); + } + + public function test_can_filter_events_by_category() + { + $category1 = Category::factory()->create(['slug' => 'music']); + $category2 = Category::factory()->create(['slug' => 'tech']); + + $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'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonCount(2, 'data.events.data'); + } + + public function test_can_show_published_event() + { + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::PUBLISHED]); + + $response = $this->getJson("/api/v1/events/{$instance->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() + { + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::DRAFT]); + + $response = $this->getJson("/api/v1/events/{$instance->id}"); + + $response->assertStatus(Response::HTTP_NOT_FOUND); + } + + public function test_cannot_show_cancelled_event() + { + $instance = EventInstance::factory()->create(['status' => EventStatusEnum::CANCELLED]); + + $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'); + + $response->assertStatus(Response::HTTP_OK) + ->assertJsonPath('data.events.data', []); + } +} diff --git a/tests/Feature/Organizer/OrganizerOnboardingTest.php b/tests/Feature/Organizer/OrganizerOnboardingTest.php new file mode 100644 index 0000000..43c2019 --- /dev/null +++ b/tests/Feature/Organizer/OrganizerOnboardingTest.php @@ -0,0 +1,191 @@ +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', AdminHttpEnum::ORGANIZER_APPROVED->value); + + $this->user->refresh(); + + expect($this->user->organizerProfile->verified_at)->not->toBeNull() + ->and($this->user->hasRole('organizer'))->toBeTrue(); + + Notification::assertSentTo( + [$this->user], + 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); +}); 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); + } +} 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); + } +} diff --git a/tests/Feature/Ticketing/RegistrationTest.php b/tests/Feature/Ticketing/RegistrationTest.php new file mode 100644 index 0000000..a46dcc7 --- /dev/null +++ b/tests/Feature/Ticketing/RegistrationTest.php @@ -0,0 +1,111 @@ +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, + 'event_instance_id' => $event->instances->first()->id, + 'name' => 'John Guest', + 'email' => 'john@guest.com', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('tickets', [ + 'email' => 'john@guest.com', + 'ticket_type_id' => $ticketType->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([ + '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, + 'event_instance_id' => $event->instances->first()->id, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('tickets', [ + '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, + 'name' => 'Limited Entry', + 'price' => 0, + 'quantity' => 1, + ]); + + $order = Order::create([ + '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, + ]); + + $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(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 new file mode 100644 index 0000000..0d42b03 --- /dev/null +++ b/tests/Feature/Ticketing/TicketEmailTest.php @@ -0,0 +1,51 @@ +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, + 'event_instance_id' => $event->instances->first()->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); + } +} 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/Feature/Venues/ProcessVenueSubmissionJobTest.php b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php new file mode 100644 index 0000000..748117c --- /dev/null +++ b/tests/Feature/Venues/ProcessVenueSubmissionJobTest.php @@ -0,0 +1,147 @@ +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_flags_submission_if_embedding_fails(): void + { + $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([ + '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); + app()->call([$job, 'handle']); + + $submission->refresh(); + $this->assertEquals(VenueSubmissionStatusEnum::REJECTED, $submission->status); + $this->assertStringContainsString('Auto-rejected due to high similarity', $submission->admin_notes); + } + + public function test_it_flags_submission_if_moderate_similarity(): void + { + $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([ + '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); + app()->call([$job, 'handle']); + + $submission->refresh(); + + $this->assertEquals(VenueSubmissionStatusEnum::APPROVED, $submission->status); + $this->assertEquals('google_test_id', $submission->places_result['id']); + + $this->assertDatabaseHas('venues', [ + 'name' => $submission->name, + '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 new file mode 100644 index 0000000..75bce43 --- /dev/null +++ b/tests/Feature/Venues/VenueApiTest.php @@ -0,0 +1,108 @@ + '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']); +}); + +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 new file mode 100644 index 0000000..bf2bd92 --- /dev/null +++ b/tests/Feature/Venues/VenueSubmissionApiTest.php @@ -0,0 +1,140 @@ +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/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/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); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..e164a0b --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,63 @@ +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 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; +} 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); + } +} 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); + } +} 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/**'], - }, - }, -});