diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 000000000..2b98dfddb --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,245 @@ +# Hi.Events Backend Documentation + +Welcome to the Hi.Events backend documentation. This directory contains comprehensive guides on the architecture, patterns, and best practices used throughout the backend codebase. + +**🚨 These docs are AI generated and are not 100% accurate. Always verify anything important by looking at the actual code.** + +## Documentation Index + +### [Architecture Overview](architecture-overview.md) +**Start here** - High-level overview of the entire backend architecture. + +**Contents**: +- Architectural layers (HTTP, Application, Domain, Infrastructure) +- Core components (Domain Objects, DTOs, Repositories, Events) +- Request flow and data flow +- Key design patterns +- Multi-tenancy architecture +- Best practices summary + +**Who should read**: Everyone working on the backend + +--- + +### [Domain-Driven Design](domain-driven-design.md) +Deep dive into the DDD patterns used in Hi.Events. + +**Contents**: +- Application Layer (Handlers) +- Domain Services Layer +- Data Transfer Objects (DTOs) +- Domain Objects (auto-generated) +- Enums and constants +- DTO flow patterns +- Transaction management +- Service composition +- Validation patterns + +**Who should read**: Backend developers implementing new features + +--- + +### [Database Schema](database-schema.md) +Complete database schema architecture and entity relationships. + +**Contents**: +- Core entity hierarchy +- Multi-tenancy architecture +- All database entities (Account, Event, Order, Attendee, etc.) +- Entity relationships and diagrams +- Architectural patterns (soft deletes, JSONB, indexes) +- PostgreSQL-specific features + +**Who should read**: Backend developers, database administrators + +--- + +### [Repository Pattern](repository-pattern.md) +Guide to the repository pattern implementation. + +**Contents**: +- Base repository interface (40+ methods) +- Creating new repositories +- Usage in handlers +- Best practices (favor base methods, eager loading) +- Common patterns (pagination, filters, bulk operations) +- Testing with mocks + +**Who should read**: Backend developers working with data access + +--- + +### [Events and Background Jobs](events-and-jobs.md) +Event-driven architecture and asynchronous processing. + +**Contents**: +- Application Events vs Infrastructure Events +- Event listeners +- Background jobs +- Event flow examples +- Retry strategies +- Transaction boundaries +- Queue separation + +**Who should read**: Backend developers implementing workflows and integrations + +--- + +### [API Patterns and HTTP Layer](api-patterns.md) +HTTP layer patterns and API design. + +**Contents**: +- BaseAction pattern +- Response methods +- Authorization patterns +- JSON API resources +- Routing patterns +- Request validation +- Exception handling + +**Who should read**: Backend developers building API endpoints + +--- + +## Quick Reference + +### Common Tasks + +#### Creating a New Feature + +1. **Read**: [Architecture Overview](architecture-overview.md) - Understand the layers +2. **Read**: [Domain-Driven Design](domain-driven-design.md) - Understand DTOs, Handlers, Services +3. **Reference**: Existing feature in `/prompts` directory +4. **Implement**: Following the established patterns + +#### Adding a New Entity + +1. **Create migration**: `php artisan make:migration create_xxx_table` +2. **Run migration**: `php artisan migrate` +3. **Generate domain objects**: `php artisan generate-domain-objects` +4. **Create repository interface and implementation**: See [Repository Pattern](repository-pattern.md) +5. **Register repository**: Add to `RepositoryServiceProvider` +6. **Update database docs**: Reference [Database Schema](database-schema.md) + +#### Adding a New API Endpoint + +1. **Create FormRequest**: See [API Patterns](api-patterns.md#request-validation) +2. **Create Action**: Extend `BaseAction`, see [API Patterns](api-patterns.md#baseaction-pattern) +3. **Create DTO**: Extend `BaseDataObject`, see [DDD](domain-driven-design.md#dtos) +4. **Create Handler**: See [DDD](domain-driven-design.md#application-layer) +5. **Create Domain Service** (if needed): See [DDD](domain-driven-design.md#domain-services-layer) +6. **Create JSON Resource**: See [API Patterns](api-patterns.md#json-api-resources) +7. **Add route**: `routes/api.php` + +#### Adding Background Processing + +1. **Create Event**: See [Events and Jobs](events-and-jobs.md#application-events) +2. **Create Job**: See [Events and Jobs](events-and-jobs.md#background-jobs) +3. **Create Listener**: See [Events and Jobs](events-and-jobs.md#event-listeners) +4. **Register** (if needed): See [Events and Jobs](events-and-jobs.md#event-registration) + +### Key Commands + +```bash +# Backend (run in Docker container) +cd docker/development +docker compose -f docker-compose.dev.yml exec backend bash + +# Generate domain objects +php artisan generate-domain-objects + +# Run migrations +php artisan migrate + +# Run unit tests +php artisan test --testsuite=Unit + +# Run specific test +php artisan test --filter=TestName +``` + +### File Locations + +``` +backend/ +├── app/ +│ ├── DomainObjects/ # Auto-generated domain objects +│ │ ├── Generated/ # Don't edit these +│ │ ├── Enums/ # General enums +│ │ └── Status/ # Status enums +│ ├── Events/ # Application events +│ ├── Http/ +│ │ ├── Actions/ # HTTP actions (controllers) +│ │ ├── Request/ # Form requests +│ │ └── Resources/ # JSON API resources +│ ├── Jobs/ # Background jobs +│ ├── Listeners/ # Event listeners +│ ├── Models/ # Eloquent models +│ ├── Repository/ +│ │ ├── Interfaces/ # Repository contracts +│ │ └── Eloquent/ # Implementations +│ └── Services/ +│ ├── Application/ # Application handlers +│ │ └── Handlers/ # Use case handlers +│ ├── Domain/ # Domain services +│ └── Infrastructure/ # External services +├── database/ +│ └── migrations/ # Database migrations +└── routes/ + └── api.php # API routes +``` + +## Architecture Principles + +### Core Principles + +1. **Domain-Driven Design**: Clear separation between domain, application, and infrastructure +2. **Repository Pattern**: All data access through interfaces +3. **DTO Pattern**: Immutable data transfer between layers +4. **Event-Driven**: Decoupled communication via events +5. **Type Safety**: Strong typing with domain objects and DTOs + +### Best Practices + +1. **Always extend `BaseDataObject`** for new DTOs (not `BaseDTO`) +2. **Use domain object constants** for field names +3. **Favor base repository methods** over custom methods +4. **Extend `BaseAction`** for all HTTP actions +5. **Use enums** for domain constants + +### Code Quality + +- Follow PSR-12 coding standards +- Wrap all translatable strings in `__()` helper +- Create unit tests for new features +- Don't add comments unless absolutely necessary +- Refactor complex code instead of documenting it + +## External Resources + +- [CLAUDE.md](../../CLAUDE.md) - Project guidelines for AI assistants +- [Laravel Documentation](https://laravel.com/docs) +- [Spatie Laravel Data](https://spatie.be/docs/laravel-data) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) + +## Contributing + +When adding new features or making significant changes: + +1. Follow the patterns documented here +2. Update relevant documentation if patterns change +3. Add examples to `/prompts` for reference +4. Ensure tests pass: `php artisan test --testsuite=Unit` + +## Getting Help + +1. **Start with**: [Architecture Overview](architecture-overview.md) +2. **Look at examples**: `/prompts` directory contains feature documentation +3. **Check CLAUDE.md**: Project-specific guidelines and patterns +4. **Reference specific guides**: Use the documentation index above + +--- + +**Last Updated**: 2025-10-29 + +**Documentation Version**: 1.0 diff --git a/backend/docs/api-patterns.md b/backend/docs/api-patterns.md new file mode 100644 index 000000000..af4664889 --- /dev/null +++ b/backend/docs/api-patterns.md @@ -0,0 +1,853 @@ +# API Patterns and HTTP Layer + +## Overview + +The Hi.Events backend HTTP layer uses a standardized pattern built around invokable action classes that extend `BaseAction`. This provides consistent response methods, authorization patterns, and request handling across the entire API. + +```mermaid +graph TB + A[HTTP Request] --> B[Middleware] + B --> C[Request Validation] + C --> D[Action] + D --> E[Authorization Check] + E --> F[Handler] + F --> G[Domain Service] + G --> H[Repository] + H --> F + F --> D + D --> I[JSON Resource] + I --> J[HTTP Response] +``` + +## BaseAction Pattern + +### Core Concept + +All HTTP actions extend `BaseAction` which provides standardized methods for: +- Handling responses (JSON, resources, errors) +- Authorization checks +- Authentication +- Request parameter parsing + +**File**: `backend/app/Http/Actions/BaseAction.php` + +### Action Structure + +```php +namespace HiEvents\Http\Actions\PromoCodes; + +use HiEvents\Http\Actions\BaseAction; + +class CreatePromoCodeAction extends BaseAction +{ + private CreatePromoCodeHandler $handler; + + public function __construct(CreatePromoCodeHandler $handler) + { + $this->handler = $handler; + } + + public function __invoke(CreatePromoCodeRequest $request, int $eventId): JsonResponse + { + // 1. Authorization check + $this->isActionAuthorized($eventId, EventDomainObject::class); + + // 2. Build DTO from validated request + try { + $promoCode = $this->handler->handle($eventId, new UpsertPromoCodeDTO( + code: strtolower($request->input('code')), + event_id: $eventId, + discount_type: PromoCodeDiscountTypeEnum::fromName($request->input('discount_type')), + discount: $request->float('discount'), + // ... + )); + } catch (ResourceConflictException $e) { + throw ValidationException::withMessages([ + 'code' => $e->getMessage(), + ]); + } + + // 3. Return resource response + return $this->resourceResponse( + PromoCodeResource::class, + $promoCode, + ResponseCodes::HTTP_CREATED + ); + } +} +``` + +**File**: `backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php` + +### Key Principles + +1. **Single Invokable Method**: Use `__invoke()` magic method +2. **Dependency Injection**: Handler services injected via constructor +3. **Thin Controllers**: Minimal logic - delegate to handlers +4. **Exception Handling**: Convert domain exceptions to ValidationException +5. **Type Safety**: Use DTOs for passing data to handlers + +## Response Methods + +### resourceResponse() + +Transform domain objects/collections into JSON API resources. + +```php +protected function resourceResponse( + string $resource, + Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO $data, + int $statusCode = ResponseCodes::HTTP_OK, + array $meta = [], + array $headers = [], + array $errors = [], +): JsonResponse +``` + +**Usage**: +```php +return $this->resourceResponse( + PromoCodeResource::class, + $promoCode, + ResponseCodes::HTTP_CREATED +); +``` + +**Response Format**: +```json +{ + "data": { + "id": 123, + "code": "SUMMER2025", + "discount": 20.00, + "discount_type": "PERCENTAGE" + } +} +``` + +### filterableResourceResponse() + +Special response for filterable/sortable resources with metadata. + +```php +protected function filterableResourceResponse( + string $resource, + Collection|DomainObjectInterface|LengthAwarePaginator $data, + string $domainObject, + int $statusCode = ResponseCodes::HTTP_OK, +): JsonResponse +``` + +**Usage**: +```php +return $this->filterableResourceResponse( + resource: OrderResource::class, + data: $orders, + domainObject: OrderDomainObject::class +); +``` + +**Response Format**: +```json +{ + "data": [...], + "meta": { + "allowed_sorts": ["created_at", "total_gross", "status"], + "allowed_filters": ["status", "payment_status", "event_id"], + "pagination": { + "total": 150, + "per_page": 20, + "current_page": 1 + } + } +} +``` + +### jsonResponse() + +Return raw JSON data (not wrapped in a resource). + +```php +protected function jsonResponse( + mixed $data, + int $statusCode = ResponseCodes::HTTP_OK, + bool $wrapInData = false, +): JsonResponse +``` + +### errorResponse() + +Return error response with message. + +```php +protected function errorResponse( + string $message, + int $statusCode = ResponseCodes::HTTP_BAD_REQUEST, + array $errors = [], +): JsonResponse +``` + +**Usage**: +```php +catch (UnauthorizedException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_UNAUTHORIZED, + ); +} +``` + +**Response Format**: +```json +{ + "message": "Unauthorized access", + "errors": {} +} +``` + +### deletedResponse() + +Return 204 No Content for successful deletions. + +```php +protected function deletedResponse(): LaravelResponse +``` + +**Usage**: +```php +$this->handler->handle($productId, $eventId); +return $this->deletedResponse(); +``` + +### Other Response Methods + +```php +protected function notFoundResponse(): LaravelResponse; // 404 +protected function noContentResponse(int $status = 204): LaravelResponse; // 204 +``` + +## Authorization Patterns + +### isActionAuthorized() + +Primary authorization method used throughout the codebase. + +```php +protected function isActionAuthorized( + int $entityId, + string $entityType, + Role $minimumRole = Role::ORGANIZER +): void +``` + +**Usage**: +```php +public function __invoke(int $eventId, Request $request): JsonResponse +{ + // Check if user has access to this event + $this->isActionAuthorized($eventId, EventDomainObject::class); + + // Continue with action logic... +} +``` + +### How Authorization Works + +The `IsAuthorizedService` performs these checks: + +1. **User Status Validation**: Ensures user is ACTIVE +2. **Role Validation**: Checks minimum role requirement +3. **Entity Ownership Validation**: Verifies entity belongs to authenticated account + +**Implementation**: +```php +public function isActionAuthorized( + int $entityId, + string $entityType, + UserDomainObject $authUser, + int $authAccountId, + Role $minimumRole +): void { + $this->validateUserStatus($authUser); + $this->validateUserRole($minimumRole, $authUser); + + // Fetch entity and verify ownership + $repository = $this->getRepositoryForEntity($entityType); + $entity = $repository->findById($entityId); + + if ($entity->getAccountId() !== $authAccountId) { + throw new UnauthorizedException(); + } +} +``` + +### Alternative Authorization Methods + +```php +// For actions that don't need entity-level authorization +protected function minimumAllowedRole(Role $minimumRole): void; + +// Get current authenticated user +protected function getAuthenticatedUser(): UserDomainObject; + +// Get current authenticated account ID +protected function getAuthenticatedAccountId(): int; + +// Check if user is authenticated (boolean) +protected function isUserAuthenticated(): bool; +``` + +### Public Actions + +Public actions don't call `isActionAuthorized()` but may have custom logic: + +```php +public function __invoke(int $eventId, Request $request): JsonResponse +{ + $event = $this->handler->handle(GetPublicEventDTO::fromArray([ + 'eventId' => $eventId, + 'isAuthenticated' => $this->isUserAuthenticated(), + ])); + + // Custom authorization + if (!$this->canUserViewEvent($event)) { + return $this->notFoundResponse(); + } + + return $this->resourceResponse(EventResourcePublic::class, $event); +} + +private function canUserViewEvent(EventDomainObject $event): bool +{ + // Public events are viewable by anyone + if ($event->getStatus() === EventStatus::LIVE->name) { + return true; + } + + // Draft events only viewable by account members + if ($this->isUserAuthenticated() + && $event->getAccountId() === $this->getAuthenticatedAccountId()) { + return true; + } + + return false; +} +``` + +## JSON API Resources + +### BaseResource + +All resources extend `BaseResource`: + +**File**: `backend/app/Resources/BaseResource.php` + +```php +abstract class BaseResource extends JsonResource +{ + protected static array $additionalData = []; + + public static function collectionWithAdditionalData($resource, $data): JsonResource + { + static::$additionalData = $data; + return static::collection($resource); + } + + public function getAdditionalDataByKey(string $key) + { + return static::$additionalData[$key] ?? null; + } +} +``` + +### Simple Resource + +**File**: `backend/app/Resources/PromoCode/PromoCodeResource.php` + +```php +/** + * @mixin PromoCodeDomainObject + */ +class PromoCodeResource extends JsonResource +{ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getId(), + 'code' => $this->getCode(), + 'applicable_product_ids' => $this->getApplicableProductIds(), + 'discount' => $this->getDiscount(), + 'discount_type' => $this->getDiscountType(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + 'expiry_date' => $this->getExpiryDate(), + 'max_allowed_usages' => $this->getMaxAllowedUsages(), + ]; + } +} +``` + +### Resource with Nested Resources + +**File**: `backend/app/Resources/Order/OrderResource.php` + +```php +/** + * @mixin OrderDomainObject + */ +class OrderResource extends BaseResource +{ + public function toArray(Request $request): array + { + return [ + 'id' => $this->getId(), + 'short_id' => $this->getShortId(), + 'total_gross' => $this->getTotalGross(), + 'status' => $this->getStatus(), + + // Conditional nested resources + 'order_items' => $this->when( + !is_null($this->getOrderItems()), + fn() => OrderItemResource::collection($this->getOrderItems()) + ), + 'attendees' => $this->when( + !is_null($this->getAttendees()), + fn() => AttendeeResource::collection($this->getAttendees()) + ), + 'latest_invoice' => $this->when( + !is_null($this->getLatestInvoice()), + fn() => (new InvoiceResource($this->getLatestInvoice()))->toArray($request), + ), + ]; + } +} +``` + +### Resource Patterns + +1. **@mixin Annotation**: Provides IDE autocomplete +```php +/** + * @mixin EventDomainObject + */ +``` + +2. **Use Getter Methods**: Always use domain object methods +```php +'id' => $this->getId(), +'title' => $this->getTitle(), +``` + +3. **Conditional Fields**: Use `$this->when()` for optional fields +```php +'settings' => $this->when( + !is_null($this->getSettings()), + fn() => new SettingsResource($this->getSettings()) +), +``` + +4. **Nested Resources**: Transform related objects +```php +'order_items' => OrderItemResource::collection($this->getOrderItems()), +``` + +5. **Collections**: Use `Resource::collection()` for arrays +```php +'products' => ProductResource::collection($this->getProducts()), +``` + +## Routing Patterns + +**File**: `backend/routes/api.php` + +### Route Organization + +Routes are organized into three groups: + +#### 1. Public Routes (No Authentication) + +```php +$router->prefix('/auth')->group(function (Router $router): void { + $router->post('/login', LoginAction::class)->name('auth.login'); + $router->post('/register', CreateAccountAction::class); + $router->post('/forgot-password', ForgotPasswordAction::class); +}); + +$router->prefix('/public')->group(function (Router $router): void { + $router->get('/events/{event_id}', GetEventPublicAction::class); + $router->post('/events/{event_id}/order', CreateOrderActionPublic::class); + $router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class); +}); +``` + +#### 2. Authenticated Routes (Require JWT) + +```php +$router->middleware(['auth:api'])->group(function (Router $router): void { + // User routes + $router->get('/users/me', GetMeAction::class); + $router->post('/users', CreateUserAction::class); + + // Event routes + $router->post('/events', CreateEventAction::class); + $router->get('/events/{event_id}', GetEventAction::class); + $router->put('/events/{event_id}', UpdateEventAction::class); + + // Nested resource routes + $router->post('/events/{event_id}/products', CreateProductAction::class); + $router->get('/events/{event_id}/attendees', GetAttendeesAction::class); +}); +``` + +### Common Route Patterns + +```php +// CRUD operations +POST /events CreateEventAction +GET /events GetEventsAction +GET /events/{event_id} GetEventAction +PUT /events/{event_id} UpdateEventAction + +// Status updates (separate from full updates) +PUT /events/{event_id}/status UpdateEventStatusAction + +// Nested resources +POST /events/{event_id}/products CreateProductAction +GET /events/{event_id}/products GetProductsAction +GET /events/{event_id}/products/{id} GetProductAction + +// Special actions +POST /events/{event_id}/duplicate DuplicateEventAction +POST /events/{event_id}/orders/export ExportOrdersAction +``` + +### Naming Conventions + +- **Controller-less**: Routes point directly to Action classes +- **REST-like**: Follow RESTful patterns with HTTP verbs +- **Nested Resources**: Parent resource ID in path +- **Named Routes**: Optional for reverse routing + +## Request Validation + +### BaseRequest + +All request classes extend `BaseRequest`: + +**File**: `backend/app/Http/Request/BaseRequest.php` + +```php +abstract class BaseRequest extends FormRequest +{ +} +``` + +### Simple Validation + +**File**: `backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php` + +```php +class CreateUpdatePromoCodeRequest extends BaseRequest +{ + public function rules(): array + { + return [ + 'code' => 'min:2|string|required|max:50', + 'applicable_product_ids' => 'array', + 'discount' => [ + 'required_if:discount_type,PERCENTAGE,FIXED', + 'numeric', + 'min:0', + // Custom validation closure + function ($attribute, $value, $fail) { + if ($this->input('discount_type') === PromoCodeDiscountTypeEnum::PERCENTAGE->name + && $value > 100) { + $fail('The discount percentage must be less than or equal to 100%.'); + } + }, + ], + 'expiry_date' => 'date|nullable', + 'max_allowed_usages' => 'nullable|gte:1|max:9999999', + 'discount_type' => [ + 'required', + Rule::in(PromoCodeDiscountTypeEnum::valuesArray()) + ], + ]; + } +} +``` + +### Reusable Validation Traits + +**File**: `backend/app/Http/Request/Event/CreateEventRequest.php` + +```php +class CreateEventRequest extends BaseRequest +{ + use EventRules; // Reusable validation trait + + public function rules(): array + { + return $this->eventRules(); + } + + public function messages(): array + { + return $this->eventMessages(); + } +} +``` + +### Validation Features + +1. **Laravel Validation Rules**: Standard syntax +2. **Custom Closures**: Inline custom validation +3. **Enum Validation**: `Rule::in(Enum::valuesArray())` +4. **Conditional Rules**: `required_if:field,value` +5. **Custom Messages**: Override via `messages()` method +6. **Reusable Traits**: Share logic across requests + +### Automatic Validation + +Request validation happens automatically before `__invoke()` is called. Failed validation returns 422 Unprocessable Entity with error details. + +## Complete Request Flow + +### Example: Create Organizer + +**Route**: +```php +$router->post('/organizers', CreateOrganizerAction::class); +``` + +**Action**: `backend/app/Http/Actions/Organizers/CreateOrganizerAction.php` + +```php +class CreateOrganizerAction extends BaseAction +{ + public function __construct( + private readonly CreateOrganizerHandler $handler + ) {} + + public function __invoke(UpsertOrganizerRequest $request): JsonResponse + { + $organizerData = array_merge( + $request->validated(), + ['account_id' => $this->getAuthenticatedAccountId()] + ); + + $organizer = $this->handler->handle( + organizerData: CreateOrganizerDTO::fromArray($organizerData), + ); + + return $this->resourceResponse( + resource: OrganizerResource::class, + data: $organizer, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} +``` + +**Request/Response Flow**: + +``` +POST /api/organizers +Authorization: Bearer {jwt_token} +Content-Type: application/json + +{ + "name": "My Organization", + "email": "info@myorg.com" +} + +↓ Middleware validates JWT +↓ UpsertOrganizerRequest validates input +↓ CreateOrganizerAction invoked +↓ getAuthenticatedAccountId() retrieves account +↓ CreateOrganizerHandler creates organizer +↓ OrganizerResource transforms domain object + +HTTP/1.1 201 Created +Content-Type: application/json + +{ + "data": { + "id": 123, + "name": "My Organization", + "email": "info@myorg.com", + "account_id": 456, + "created_at": "2025-10-29T..." + } +} +``` + +## Best Practices + +### 1. Always Extend BaseAction + +```php +// ✅ GOOD +class CreateEventAction extends BaseAction +{ + public function __invoke(Request $request): JsonResponse + { + return $this->resourceResponse(...); + } +} + +// ❌ BAD +class CreateEventAction +{ + public function __invoke(Request $request): JsonResponse + { + return response()->json(['data' => ...]); + } +} +``` + +### 2. Use Standardized Response Methods + +```php +// ✅ GOOD +return $this->resourceResponse(EventResource::class, $event, ResponseCodes::HTTP_CREATED); + +// ❌ BAD +return response()->json(['data' => $event], 201); +``` + +### 3. Authorize Before Processing + +```php +public function __invoke(int $eventId, Request $request): JsonResponse +{ + // Authorization first + $this->isActionAuthorized($eventId, EventDomainObject::class); + + // Then processing + $result = $this->handler->handle(...); + + return $this->resourceResponse(...); +} +``` + +### 4. Convert Domain Exceptions + +```php +try { + $result = $this->handler->handle($dto); +} catch (ResourceConflictException $e) { + // Convert to Laravel validation exception + throw ValidationException::withMessages([ + 'code' => $e->getMessage(), + ]); +} catch (DomainException $e) { + // Or use error response + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_BAD_REQUEST, + ); +} +``` + +### 5. Use Type Hints + +```php +public function __invoke( + CreateEventRequest $request, // Type-hinted request + int $eventId // Type-hinted params +): JsonResponse // Return type +{ + $dto = CreateEventDTO::fromArray($request->validated()); + $event = $this->handler->handle($dto); + + return $this->resourceResponse(EventResource::class, $event); +} +``` + +## Exception Handling + +### Custom Exceptions + +```php +class ResourceConflictException extends Exception {} +class EmailTemplateValidationException extends Exception { + public array $validationErrors = []; +} +class OrderValidationException extends Exception {} +``` + +### Handler-Level Exception Handling + +```php +public function handle(UpsertDTO $dto): DomainObject +{ + try { + return $this->repository->create([...]); + } catch (ResourceConflictException $e) { + throw ValidationException::withMessages([ + 'field' => $e->getMessage(), + ]); + } +} +``` + +### Action-Level Exception Handling + +```php +try { + $result = $this->handler->handle($dto); +} catch (UnauthorizedException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_UNAUTHORIZED, + ); +} catch (ValidationException $e) { + // Laravel handles automatically + throw $e; +} +``` + +## Summary + +The Hi.Events HTTP layer provides: + +1. **Invokable Actions**: Single-purpose action classes extending `BaseAction` +2. **Standardized Responses**: Consistent response methods for all endpoints +3. **Robust Authorization**: Entity-level permission checks via `isActionAuthorized()` +4. **JSON API Resources**: Transform domain objects to API responses +5. **RESTful Routing**: Clear separation of public/authenticated routes +6. **Request Validation**: Laravel FormRequest with reusable traits +7. **Clean Architecture**: Clear boundaries between HTTP, Application, and Domain + +**Complete Request Flow**: +``` +HTTP Request + ↓ +Middleware (auth, CORS) + ↓ +Request Validation + ↓ +Action (__invoke) + ↓ +Authorization Check + ↓ +Handler (orchestration) + ↓ +Domain Service (business logic) + ↓ +Repository (data access) + ↓ +Domain Object (returned) + ↓ +JSON Resource (transformation) + ↓ +HTTP Response +``` + +## Related Documentation + +- [Architecture Overview](architecture-overview.md) +- [Domain-Driven Design](domain-driven-design.md) +- [Repository Pattern](repository-pattern.md) +- [Events and Jobs](events-and-jobs.md) diff --git a/backend/docs/architecture-overview.md b/backend/docs/architecture-overview.md new file mode 100644 index 000000000..01e5486f6 --- /dev/null +++ b/backend/docs/architecture-overview.md @@ -0,0 +1,360 @@ +# Hi.Events Backend Architecture Overview + +## Introduction + +Hi.Events is an open-source event management and ticketing platform built with Laravel. The backend implements a **Domain-Driven Design (DDD)** architecture with clear separation of concerns, enabling maintainable, testable, and scalable code. + +## High-Level Architecture + +```mermaid +graph TB + subgraph "HTTP Layer" + A[Actions] --> B[Request Validation] + A --> C[Authorization] + end + + subgraph "Application Layer" + D[Handlers] --> E[DTOs] + D --> F[Transaction Management] + end + + subgraph "Domain Layer" + G[Domain Services] --> H[Domain Objects] + G --> I[Domain Events] + end + + subgraph "Infrastructure Layer" + J[Repositories] --> K[Eloquent Models] + L[External Services] --> M[Stripe/Email/etc] + N[Event Listeners] --> O[Background Jobs] + end + + A --> D + D --> G + G --> J + G --> L + D --> N +``` + +## Architectural Layers + +### 1. HTTP Layer (`app/Http/`) +**Responsibility**: Handle HTTP requests and responses + +- **Actions** (`app/Http/Actions/`) - Invokable controllers +- **Requests** (`app/Http/Request/`) - Validation rules +- **Resources** (`app/Http/Resources/`) - JSON API transformers +- **Middleware** - Authentication, authorization, CORS + +**Key Pattern**: All actions extend `BaseAction` which provides standardized response methods and authorization checks. + +### 2. Application Layer (`app/Services/Application/Handlers/`) +**Responsibility**: Orchestrate domain services and manage use cases + +- **Handlers** - One per use case (e.g., `CreateOrderHandler`, `UpdateEventHandler`) +- **DTOs** - Data Transfer Objects for passing data between layers +- **Transaction Management** - Ensure data consistency +- **Event Dispatching** - Trigger side effects + +**Key Pattern**: Handlers receive DTOs from actions and orchestrate domain services. + +### 3. Domain Layer (`app/Services/Domain/`) +**Responsibility**: Core business logic + +- **Domain Services** - Business logic implementation +- **Domain Objects** (`app/DomainObjects/`) - Type-safe data representations +- **Enums** (`app/DomainObjects/Enums/`, `app/DomainObjects/Status/`) - Domain constants +- **Validators** - Business rule validation + +**Key Pattern**: Services are highly focused (single responsibility) and compose to handle complex operations. + +### 4. Infrastructure Layer +**Responsibility**: External concerns and technical implementations + +- **Repositories** (`app/Repository/`) - Data access abstraction +- **External Services** (`app/Services/Infrastructure/`) - Stripe, email, etc. +- **Event Listeners** (`app/Listeners/`) - React to domain events +- **Background Jobs** (`app/Jobs/`) - Asynchronous processing +- **Database** - Eloquent models and migrations + +**Key Pattern**: Infrastructure depends on domain interfaces, not vice versa. + +## Core Components + +### Domain Objects +Auto-generated immutable representations of database entities. + +- **Generated** via `php artisan generate-domain-objects` +- **Abstract classes** in `app/DomainObjects/Generated/` (read-only) +- **Concrete classes** in `app/DomainObjects/` (customizable) +- Provide type-safe getters/setters and constants for field names + +### DTOs (Data Transfer Objects) +Immutable data containers for passing data between layers. + +- **Modern**: Extend `BaseDataObject` (using Spatie Laravel Data) +- **Legacy**: Extend `BaseDTO` (being phased out) +- Use constructor property promotion with readonly properties +- Type-safe with enums and primitives + +### Repositories +Interface-based data access layer. + +- **40+ base methods** for CRUD operations +- Return domain objects, never Eloquent models +- Support eager loading, pagination, filtering +- Registered in `RepositoryServiceProvider` + +### Events & Jobs +Dual event system for internal workflows and external integrations. + +- **Application Events** (`app/Events/`) - Internal domain logic +- **Infrastructure Events** (`app/Services/Infrastructure/DomainEvents/`) - Webhooks +- **Listeners** (`app/Listeners/`) - React to events, dispatch jobs +- **Jobs** (`app/Jobs/`) - Asynchronous processing with retry logic + +## Request Flow + +```mermaid +sequenceDiagram + participant Client + participant Action + participant Handler + participant Service + participant Repository + participant Database + + Client->>Action: HTTP Request + Action->>Action: Validate Request + Action->>Action: Check Authorization + Action->>Handler: Pass DTO + Handler->>Service: Call Domain Service + Service->>Repository: Query/Persist Data + Repository->>Database: Execute Query + Database-->>Repository: Return Model + Repository-->>Service: Return Domain Object + Service-->>Handler: Return Domain Object + Handler-->>Action: Return Domain Object + Action->>Action: Transform to Resource + Action-->>Client: JSON Response +``` + +## Key Design Patterns + +### 1. Dependency Injection +All services, repositories, and handlers use constructor injection for dependencies. + +```php +class CreateOrderHandler +{ + public function __construct( + private readonly OrderRepositoryInterface $orderRepository, + private readonly OrderManagementService $orderManagementService, + private readonly DatabaseManager $databaseManager, + ) {} +} +``` + +### 2. Repository Pattern +All data access goes through repository interfaces. + +```php +interface OrderRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; +} +``` + +### 3. DTO Pattern +Data passed between layers using immutable DTOs. + +```php +class CreateOrderDTO extends BaseDataObject +{ + public function __construct( + public readonly int $eventId, + public readonly Collection $products, + public readonly ?string $promoCode = null, + ) {} +} +``` + +### 4. Service Layer Pattern +Business logic encapsulated in focused service classes. + +```php +class MarkOrderAsPaidService +{ + public function markOrderAsPaid(int $orderId, int $eventId): OrderDomainObject + { + return DB::transaction(function() { + // Business logic + }); + } +} +``` + +### 5. Event-Driven Architecture +Domain events enable decoupled communication. + +```php +OrderStatusChangedEvent::dispatch($order); +// Triggers: email sending, statistics updates, webhook delivery +``` + +## Multi-Tenancy Architecture + +Hi.Events implements account-based multi-tenancy: + +``` +Account (Tenant) +├── Users (many-to-many with roles) +├── Organizers +│ └── Events +│ ├── Products/Tickets +│ ├── Orders +│ ├── Attendees +│ └── Settings +``` + +- Each account is an independent tenant +- Data isolation through `account_id` foreign keys +- User context: `User::getCurrentAccountId()` + +## Technology Stack + +- **Framework**: Laravel 11 +- **Database**: PostgreSQL with JSONB support +- **Authentication**: JWT (Laravel Passport/Sanctum) +- **Queue**: Laravel Queues (Redis/Database) +- **Cache**: Redis +- **Payment**: Stripe with Stripe Connect +- **Email**: Laravel Mail + +## Best Practices + +### 1. Always Use DTOs +Pass data to handlers using DTOs, not arrays or individual parameters. + +✅ **Good**: +```php +$handler->handle(new CreateOrderDTO(...)); +``` + +❌ **Bad**: +```php +$handler->handle($eventId, $products, $promoCode); +``` + +### 2. Use Domain Object Constants +Reference fields using constants, not strings. + +✅ **Good**: +```php +$repository->findWhere([OrderDomainObjectAbstract::STATUS => 'COMPLETED']); +``` + +❌ **Bad**: +```php +$repository->findWhere(['status' => 'COMPLETED']); +``` + +### 3. Favor Base Repository Methods +Use existing repository methods before creating custom ones. + +✅ **Good**: +```php +$repository->findFirstWhere(['event_id' => $eventId]); +``` + +❌ **Bad**: +```php +$repository->findByEventId($eventId); // Custom method for common pattern +``` + +### 4. Extend BaseAction for HTTP +All HTTP actions should extend `BaseAction` and use its response methods. + +✅ **Good**: +```php +return $this->resourceResponse(OrderResource::class, $order, ResponseCodes::HTTP_CREATED); +``` + +❌ **Bad**: +```php +return response()->json(['data' => $order], 201); +``` + +### 5. Use Enums for Constants +Define domain constants as enums, not arrays or class constants. + +✅ **Good**: +```php +enum OrderStatus { case COMPLETED; case CANCELLED; } +``` + +❌ **Bad**: +```php +class OrderStatus { const COMPLETED = 'completed'; } +``` + +## File Organization + +``` +backend/app/ +├── Console/ # Artisan commands +├── DomainObjects/ # Auto-generated domain objects +│ ├── Generated/ # Don't edit +│ ├── Enums/ # General enums +│ └── Status/ # Status enums +├── Events/ # Application events +├── Exceptions/ # Custom exceptions +├── Http/ +│ ├── Actions/ # Invokable controllers +│ ├── Request/ # Form requests +│ └── Resources/ # JSON API resources +├── Jobs/ # Background jobs +├── Listeners/ # Event listeners +├── Models/ # Eloquent models +├── Repository/ +│ ├── Interfaces/ # Repository contracts +│ └── Eloquent/ # Implementations +├── Services/ +│ ├── Application/ # Application handlers +│ │ └── Handlers/ # Use case handlers +│ ├── Domain/ # Domain services +│ └── Infrastructure/ # External services +└── Validators/ # Validation rules +``` + +## Testing Strategy + +- **Unit Tests** (`tests/Unit/`) - Test handlers and services in isolation +- Use `DatabaseTransactions` trait, not `RefreshDatabase` +- Mock repositories using Mockery +- Test both success and failure paths +- Run: `php artisan test --testsuite=Unit` + +## Performance Considerations + +- **Eager Loading**: Use `loadRelation()` to avoid N+1 queries +- **Indexes**: All foreign keys and frequently queried fields are indexed +- **JSONB**: Use PostgreSQL JSONB for flexible schema +- **Queue**: Offload heavy operations to background jobs +- **Caching**: Cache expensive queries and computed data + +## Security + +- **Authorization**: Entity-level checks via `isActionAuthorized()` +- **Authentication**: JWT tokens with account context +- **Validation**: All inputs validated via FormRequests +- **SQL Injection**: Protected by Eloquent ORM +- **CSRF**: API uses token-based auth (no CSRF) + +## Related Documentation + +- [Domain-Driven Design Patterns](domain-driven-design.md) +- [Database Schema Architecture](database-schema.md) +- [Repository Pattern Guide](repository-pattern.md) +- [Events and Jobs System](events-and-jobs.md) +- [API Patterns and HTTP Layer](api-patterns.md) diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md new file mode 100644 index 000000000..756244d6e --- /dev/null +++ b/backend/docs/database-schema.md @@ -0,0 +1,734 @@ +# Database Schema Architecture + +## Overview + +Hi.Events uses PostgreSQL with a multi-tenant architecture built around accounts. The database schema consists of approximately 57 migration files with core schema defined in `database/migrations/schema.sql`. + +## Core Entity Hierarchy + +```mermaid +graph TB + A[Account] --> B[User] + A --> C[Organizer] + C --> D[Event] + D --> E[Product/Ticket] + D --> F[Order] + F --> G[Attendee] + E --> H[Product Price] + F --> I[Order Item] + D --> J[Questions] + D --> K[Promo Codes] + D --> L[Capacity Assignments] +``` + +## Multi-Tenancy Architecture + +### Account-Based Tenancy + +``` +Account (Tenant) +├── Users (many-to-many via account_users) +│ └── Role-based permissions +├── Account Configuration +│ └── Application fees +├── Taxes and Fees (account-level) +├── Email Templates (account-level) +├── Organizers +│ ├── Organizer Settings +│ ├── Email Templates (organizer-level) +│ └── Events +│ ├── Event Settings +│ ├── Event Statistics +│ ├── Email Templates (event-level) +│ ├── Products (Tickets) +│ ├── Orders +│ ├── Attendees +│ ├── Questions +│ ├── Promo Codes +│ └── Webhooks +``` + +### Key Principles + +1. **Data Isolation**: Each account's data is isolated via `account_id` foreign key +2. **Hierarchical Structure**: Account → Organizer → Event → Products/Orders +3. **User Access**: Users can belong to multiple accounts with different roles +4. **Cascade Deletes**: Parent deletion cascades to children + +## Core Entities + +### 1. Account & User Management + +#### accounts +Top-level tenant entity. + +**Key Fields**: +- `id` - Primary key +- `name` - Account name +- `email` - Account contact email +- `currency_code` - Default currency +- `timezone` - Default timezone +- `stripe_account_id` - Stripe Connect ID +- `stripe_connect_setup_complete` - Onboarding status +- `account_verified_at` - Verification timestamp + +**Model**: `app/Models/Account.php` + +#### users +User authentication and identity. + +**Key Fields**: +- `id` - Primary key +- `email` - Unique email (authentication) +- `password` - Hashed password +- `first_name`, `last_name` - User name +- `timezone` - User timezone +- `email_verified_at` - Email verification +- `status` - User status (ACTIVE/INACTIVE) + +**Features**: +- JWT authentication +- Soft deletes +- Can belong to multiple accounts + +**Model**: `app/Models/User.php` + +#### account_users (Pivot) +Many-to-many relationship with roles. + +**Key Fields**: +- `account_id` - Foreign key to accounts +- `user_id` - Foreign key to users +- `role` - User role (ADMIN, ORGANIZER, etc.) +- `is_account_owner` - Owner flag +- `invited_by_user_id` - Invitation tracking +- `status` - Invitation status +- `last_login_at` - Login tracking + +**Features**: RBAC (Role-Based Access Control) + +#### account_configuration +Account-level configuration for fees. + +**Key Fields**: +- `id` - Primary key +- `name` - Configuration name +- `is_system_default` - System default flag +- `application_fees` - JSONB fee structure + +**Migration**: `database/migrations/2025_02_16_163546_create_account_configuration.php` + +### 2. Event Management + +#### organizers +Event organizers (one account can have multiple). + +**Key Fields**: +- `id` - Primary key +- `account_id` - Foreign key (CASCADE delete) +- `name` - Organizer name +- `email`, `phone`, `website` - Contact info +- `currency` - Default currency +- `timezone` - Default timezone + +**Model**: `app/Models/Organizer.php` + +#### events +Core event entity. + +**Key Fields**: +- `id` - Primary key +- `account_id` - Foreign key (CASCADE delete) +- `organizer_id` - Foreign key +- `user_id` - Creator +- `title`, `description` - Event details +- `start_date`, `end_date` - Event dates +- `status` - EventStatus enum (DRAFT, LIVE, ARCHIVED) +- `location`, `location_details` - JSONB location data +- `currency`, `timezone` - Event-specific settings +- `short_id` - URL-friendly ID +- `category` - Event category + +**Model**: `app/Models/Event.php` + +**Relationships**: +- Has many Products, Orders, Attendees, Questions +- Has one EventSettings, EventStatistics +- Belongs to Account, Organizer, User + +#### event_settings +Detailed event configuration. + +**Key Fields**: +- `event_id` - Primary key (one-to-one) +- `pre_checkout_message`, `post_checkout_message` - Custom messages +- `support_email` - Support contact +- `require_attendee_details` - Attendee form requirement +- `order_timeout_in_minutes` - Reservation timeout +- `homepage_*_color` - Branding colors +- `seo_*` - SEO metadata +- `ticket_design_settings` - JSONB ticket design + +**Features**: Complete control over event UX + +### 3. Products & Pricing + +#### products (formerly tickets) +Tickets/products for events. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key (CASCADE delete) +- `title`, `description` - Product details +- `type` - Product type +- `product_type` - TICKET or OTHER +- `sale_start_date`, `sale_end_date` - Sale window +- `min_per_order`, `max_per_order` - Purchase limits +- `product_category_id` - Category +- `order` - Display order + +**Model**: `app/Models/Product.php` + +**Migration**: Renamed from `tickets` in `database/migrations/2024_09_20_032323_rename_tickets_to_products.php` + +**Relationships**: +- Belongs to Event, ProductCategory +- Has many ProductPrices +- Many-to-many with TaxesAndFees, Questions, CapacityAssignments, CheckInLists + +#### product_prices +Multiple pricing tiers per product. + +**Key Fields**: +- `id` - Primary key +- `product_id` - Foreign key +- `price` - Price amount +- `label` - Price tier name (e.g., "Early Bird", "VIP") +- `sale_start_date`, `sale_end_date` - Time-based pricing +- `initial_quantity_available` - Starting quantity +- `quantity_sold` - Sold count +- `quantity_available` - Remaining +- `order` - Display order + +**Features**: +- Time-based pricing (early bird, etc.) +- Inventory tracking per tier + +#### product_categories +Organize products into categories. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `name`, `description` - Category details +- `order` - Display order + +#### taxes_and_fees +Account-level tax and fee definitions. + +**Key Fields**: +- `id` - Primary key +- `account_id` - Foreign key +- `name` - Tax/fee name +- `type` - TAX or FEE enum +- `calculation_type` - FIXED or PERCENTAGE +- `rate` - Amount/percentage +- `is_active` - Active flag +- `is_default` - Auto-apply to new products + +**Features**: Automatically applies default taxes to new tickets + +### 4. Orders & Transactions + +#### orders +Customer purchases. + +**Key Fields**: +- `id` - Primary key +- `short_id`, `public_id` - URL-friendly IDs +- `event_id` - Foreign key +- `first_name`, `last_name`, `email` - Customer info +- `status` - OrderStatus enum (RESERVED, COMPLETED, etc.) +- `payment_status` - Payment state +- `refund_status` - Refund state +- `payment_gateway`, `payment_provider` - Payment method +- `currency` - Order currency +- `total_before_additions` - Subtotal +- `total_tax`, `total_fee` - Tax/fee totals +- `total_gross` - Grand total +- `total_refunded` - Refund amount +- `promo_code_id` - Applied promo code +- `affiliate_id` - Attribution +- `address` - JSONB address +- `taxes_and_fees_rollup` - JSONB tax breakdown +- `point_in_time_data` - JSONB pricing snapshot +- `reserved_until` - Reservation expiry +- `locale` - Order language + +**Model**: `app/Models/Order.php` + +**Features**: +- GIN trigram indexes on name/email for search +- Point-in-time pricing snapshot +- Order timeout/reservation system + +**Relationships**: +- Belongs to Event, PromoCode, Affiliate +- Has many OrderItems, Attendees, Invoices +- Has one StripePayment, OrderApplicationFee + +#### order_items +Line items in orders. + +**Key Fields**: +- `id` - Primary key +- `order_id` - Foreign key +- `product_id` - Foreign key +- `product_price_id` - Specific price tier +- `quantity` - Item count +- `price` - Unit price +- `price_before_discount` - Pre-discount price +- `total_before_additions` - Subtotal +- `total_tax`, `total_service_fee` - Additions +- `total_gross` - Line total +- `taxes_and_fees_rollup` - JSONB breakdown +- `product_type` - TICKET or OTHER + +**Model**: `app/Models/OrderItem.php` + +**Features**: No timestamps (immutable once created) + +#### stripe_payments +Stripe payment tracking. + +**Key Fields**: +- `id` - Primary key +- `order_id` - Foreign key +- `payment_intent_id` - Stripe payment intent +- `charge_id` - Stripe charge +- `payment_method_id` - Payment method +- `amount_received` - Actual amount +- `connected_account_id` - Stripe Connect account +- `last_error` - JSON error details +- `platform` - Stripe platform used + +**Model**: `app/Models/StripePayment.php` + +#### invoices +Invoice generation for orders. + +**Key Fields**: +- `id` - Primary key +- `order_id` - Foreign key +- `account_id` - Foreign key +- `invoice_number` - Unique invoice number +- `issue_date`, `due_date` - Invoice dates +- `total_amount` - Invoice total +- `status` - Invoice status +- `items` - JSONB line items +- `taxes_and_fees` - JSONB tax breakdown +- `uuid` - Public UUID +- `payment_terms`, `notes` - Invoice details + +**Model**: `app/Models/Invoice.php` + +**Migration**: `database/migrations/2025_01_03_010511_create_invoices_table.php` + +### 5. Attendees & Check-Ins + +#### attendees +Individual ticket holders. + +**Key Fields**: +- `id` - Primary key +- `short_id`, `public_id` - URL-friendly IDs +- `order_id` - Foreign key (CASCADE delete) +- `product_id` - Foreign key (CASCADE delete) +- `product_price_id` - Price tier +- `event_id` - Foreign key (CASCADE delete) +- `first_name`, `last_name`, `email` - Attendee info +- `status` - AttendeeStatus enum +- `locale` - Attendee language +- `notes` - Internal notes + +**Model**: `app/Models/Attendee.php` + +**Features**: +- GIN trigram indexes for search +- Soft deletes + +**Relationships**: +- Belongs to Order, Product, Event +- Has many AttendeeCheckIns, QuestionAnswers + +#### check_in_lists +Multiple check-in lists per event. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `short_id` - URL-friendly ID +- `name`, `description` - List details +- `activates_at`, `expires_at` - Active window + +**Model**: `app/Models/CheckInList.php` + +**Migration**: `database/migrations/2024_08_08_032637_create_check_in_lists_tables.php` + +**Features**: Supports multiple entry points or sessions + +#### attendee_check_ins +Track individual check-in events. + +**Key Fields**: +- `id` - Primary key +- `short_id` - URL-friendly ID +- `check_in_list_id` - Foreign key +- `product_id`, `attendee_id`, `order_id`, `event_id` - References +- `ip_address` - Check-in source + +**Model**: `app/Models/AttendeeCheckIn.php` + +**Features**: Supports multiple check-ins per attendee + +### 6. Questions & Answers + +#### questions +Custom form questions. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `title` - Question text +- `type` - QuestionTypeEnum (TEXT, CHECKBOX, etc.) +- `required` - Required flag +- `options` - JSONB options for multiple choice +- `belongs_to` - ORDER or ATTENDEE level +- `order` - Display order +- `is_hidden` - Hidden flag +- `description` - Help text + +**Model**: `app/Models/Question.php` + +**Relationships**: Many-to-many with Products + +#### question_answers +Store answers to questions. + +**Key Fields**: +- `id` - Primary key +- `question_id` - Foreign key +- `order_id` - Order-level answer +- `attendee_id` - Attendee-level answer +- `product_id` - Product context +- `answer` - JSONB answer data + +**Model**: `app/Models/QuestionAnswer.php` + +#### question_and_answer_views (View) +Materialized view for efficient querying. + +**Model**: `app/Models/QuestionAndAnswerView.php` + +**Purpose**: Pre-joined data for question/answer reporting + +### 7. Capacity Management + +#### capacity_assignments +Manage capacity limits. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `name` - Capacity name +- `capacity` - Total capacity +- `used_capacity` - Used amount +- `applies_to` - EVENT or PRODUCTS +- `status` - Active status + +**Model**: `app/Models/CapacityAssignment.php` + +**Migration**: `database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php` + +**Features**: Share capacity across multiple products + +### 8. Promotions & Marketing + +#### promo_codes +Discount codes. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `code` - Promo code (indexed) +- `discount` - Discount amount +- `discount_type` - NONE, FIXED, PERCENTAGE +- `applicable_product_ids` - JSONB product restrictions +- `expiry_date` - Expiration +- `max_allowed_usages` - Usage limit +- `attendee_usage_count`, `order_usage_count` - Usage tracking + +**Model**: `app/Models/PromoCode.php` + +**Features**: +- Product-specific codes +- Usage tracking +- JSONB index on applicable_product_ids + +#### affiliates +Affiliate tracking. + +**Key Fields**: +- `id` - Primary key +- `event_id`, `account_id` - Foreign keys +- `name` - Affiliate name +- `code` - Unique tracking code (indexed) +- `email` - Contact email +- `total_sales` - Sale count +- `total_sales_gross` - Revenue +- `status` - AffiliateStatus enum + +**Model**: `app/Models/Affiliate.php` + +**Features**: Attribution tracking for marketing + +### 9. Communication + +#### email_templates +Customizable email templates. + +**Key Fields**: +- `id` - Primary key +- `account_id`, `organizer_id`, `event_id` - Three-level hierarchy +- `template_type` - Template type enum +- `subject`, `body` - Email content +- `cta` - JSON call-to-action +- `engine` - liquid (Liquid template engine) +- `is_active` - Active flag + +**Model**: `app/Models/EmailTemplate.php` + +**Migration**: `database/migrations/2025_08_28_100704_create_email_templates_table.php` + +**Features**: Account → Organizer → Event override hierarchy + +#### messages +Bulk messaging to attendees. + +**Key Fields**: +- `id` - Primary key +- `event_id` - Foreign key +- `subject`, `message` - Message content +- `type` - Message type +- `recipient_ids`, `product_ids`, `attendee_ids` - JSONB targeting +- `sent_by_user_id` - Sender +- `status` - Send status +- `sent_at` - Sent timestamp + +**Model**: `app/Models/Message.php` + +### 10. Analytics & Statistics + +#### event_statistics +Aggregate statistics per event. + +**Key Fields**: +- `event_id` - Primary key (one-to-one) +- `unique_views`, `total_views` - View tracking +- `sales_total_gross`, `sales_total_before_additions` - Revenue +- `total_tax`, `total_fee`, `total_refunded` - Breakdown +- `products_sold` - Sold count +- `orders_created`, `orders_cancelled` - Order metrics +- `attendees_registered` - Attendee count +- `version` - Optimistic locking + +**Model**: `app/Models/EventStatistic.php` + +**Features**: Optimistic locking via version field + +#### event_daily_statistics +Daily breakdown. + +**Key Fields**: Same as event_statistics plus `date` + +**Model**: `app/Models/EventDailyStatistic.php` + +### 11. Webhooks & Integration + +#### webhooks +Outgoing webhook configuration. + +**Key Fields**: +- `id` - Primary key +- `event_id`, `account_id`, `user_id` - Foreign keys +- `url` - Webhook URL +- `event_types` - JSONB subscribed events +- `status` - WebhookStatus enum +- `secret` - Webhook secret +- `last_response_code`, `last_response_body` - Delivery tracking +- `last_triggered_at` - Last trigger time + +**Model**: `app/Models/Webhook.php` + +**Migration**: `database/migrations/2025_02_09_074739_create_webhooks_table.php` + +#### webhook_logs +Audit log for deliveries. + +**Key Fields**: +- `webhook_id` - Foreign key +- `payload` - JSONB payload sent +- `event_type` - Event type +- `response_code`, `response_body` - Response tracking + +**Model**: `app/Models/WebhookLog.php` + +### 12. Media & Assets + +#### images +Polymorphic image storage. + +**Key Fields**: +- `id` - Primary key +- `entity_id`, `entity_type` - Polymorphic relation +- `account_id` - Foreign key +- `type` - Image type +- `filename`, `disk`, `path` - Storage details +- `size`, `mime_type` - File metadata + +**Model**: `app/Models/Image.php` + +**Features**: Attach to any entity via polymorphic relationship + +## Key Architectural Patterns + +### 1. Soft Deletes +Nearly all entities use soft deletes via `deleted_at` timestamp. + +**Benefits**: +- Data recovery +- Audit trails +- Referential integrity + +### 2. Timestamps +All tables have `created_at` and `updated_at` (except order_items). + +### 3. Foreign Key Cascades + +**Common cascade behaviors**: +```sql +-- Event deletion cascades to children +ALTER TABLE products ADD CONSTRAINT FOREIGN KEY (event_id) + REFERENCES events(id) ON DELETE CASCADE; + +-- Order deletion cascades to attendees +ALTER TABLE attendees ADD CONSTRAINT FOREIGN KEY (order_id) + REFERENCES orders(id) ON DELETE CASCADE; +``` + +### 4. JSONB for Flexible Data + +**Extensive use of PostgreSQL JSONB**: +- `orders.point_in_time_data` - Pricing snapshot +- `orders.taxes_and_fees_rollup` - Tax breakdown +- `orders.address` - Billing/shipping address +- `events.location_details` - Structured location +- `questions.options` - Question choices +- `promo_codes.applicable_product_ids` - Product restrictions +- `invoices.items` - Invoice line items + +**Benefits**: Schema flexibility without migrations + +### 5. GIN Trigram Indexes + +For fast full-text search: + +```sql +CREATE INDEX orders_first_name_trgm ON orders + USING gin(first_name gin_trgm_ops); +``` + +**Tables**: orders, attendees +**Columns**: first_name, last_name, email, public_id + +**PostgreSQL Extension**: pg_trgm (trigram matching) + +### 6. Short IDs & Public IDs + +User-facing entities use URL-friendly IDs: +- `short_id` - Short identifier +- `public_id` - Public identifier + +**Purpose**: Obfuscate internal IDs, friendly URLs + +### 7. Optimistic Locking + +Event statistics use version field: + +```sql +UPDATE event_statistics +SET sales_total = sales_total + 100, version = version + 1 +WHERE event_id = 123 AND version = 5; +``` + +**Prevents**: Race conditions in high-concurrency updates + +### 8. Point-in-Time Data + +Orders preserve pricing at creation time: + +```sql +orders.point_in_time_data = { + "products": [...], + "event_settings": {...}, + "promo_code": {...} +} +``` + +**Benefits**: Historical accuracy after event changes + +## Database Technology + +### PostgreSQL-Specific Features + +1. **JSONB**: Native JSON with indexing +2. **pg_trgm**: Trigram matching for search +3. **Generated Columns**: Auto-incrementing IDs +4. **GIN Indexes**: For JSONB and text search +5. **Database Views**: Complex query optimization +6. **CHECK Constraints**: Enum validation + +### Schema Evolution + +- **57+ migrations**: Iterative development +- **Major refactors**: tickets → products (2024) +- **Recent additions**: email templates, webhooks, invoices (2025) + +## Entity Relationship Summary + +```mermaid +erDiagram + Account ||--o{ User : "has many (via pivot)" + Account ||--o{ Organizer : "has many" + Account ||--o{ TaxAndFee : "has many" + Organizer ||--o{ Event : "has many" + Event ||--o{ Product : "has many" + Event ||--o{ Order : "has many" + Event ||--o{ Question : "has many" + Event ||--|| EventSettings : "has one" + Product ||--o{ ProductPrice : "has many" + Product }o--o{ TaxAndFee : "many-to-many" + Product }o--o{ Question : "many-to-many" + Order ||--o{ OrderItem : "has many" + Order ||--o{ Attendee : "has many" + Order ||--o| StripePayment : "has one" + Order ||--o{ Invoice : "has many" + Attendee ||--o{ AttendeeCheckIn : "has many" + Attendee ||--o{ QuestionAnswer : "has many" +``` + +## Related Documentation + +- [Architecture Overview](architecture-overview.md) +- [Repository Pattern](repository-pattern.md) +- [Domain-Driven Design](domain-driven-design.md) diff --git a/backend/docs/domain-driven-design.md b/backend/docs/domain-driven-design.md new file mode 100644 index 000000000..f1e5d9b81 --- /dev/null +++ b/backend/docs/domain-driven-design.md @@ -0,0 +1,734 @@ +# Domain-Driven Design in Hi.Events + +## Overview + +Hi.Events implements a clean DDD architecture with clear separation between Application Handlers, Domain Services, and Infrastructure Services. This document explains the patterns, responsibilities, and best practices for each layer. + +## Architecture Layers + +```mermaid +graph LR + A[HTTP Action] --> B[Application Handler] + B --> C[Domain Service] + C --> D[Repository Interface] + D --> E[Eloquent Implementation] + B --> F[Infrastructure Service] + C --> G[Domain Events] +``` + +## 1. Application Layer + +**Location**: `backend/app/Services/Application/Handlers/` + +### Purpose +Orchestration layer that coordinates domain services and handles cross-cutting concerns. + +### Responsibilities +- Receive DTOs from HTTP Actions +- Orchestrate multiple domain services +- Manage database transactions +- Handle logging and error management +- Dispatch domain events +- Transform domain objects back to DTOs/responses + +### Handler Pattern + +Each handler: +- Has a single `handle()` method +- Accepts a DTO as input +- Returns a domain object or DTO +- Uses dependency injection + +**Example**: `backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php` + +```php +class MarkOrderAsPaidHandler +{ + public function __construct( + private readonly MarkOrderAsPaidService $markOrderAsPaidService, + private readonly LoggerInterface $logger, + ) {} + + public function handle(MarkOrderAsPaidDTO $dto): OrderDomainObject + { + $this->logger->info(__('Marking order as paid'), [ + 'order_id' => $dto->orderId, + 'event_id' => $dto->eventId, + ]); + + return $this->markOrderAsPaidService->markOrderAsPaid( + $dto->orderId, + $dto->eventId, + ); + } +} +``` + +### Key Characteristics +- **Thin orchestration** - Delegate logic to services +- **Transaction boundaries** - Wrap multi-step operations +- **Event dispatching** - Trigger side effects +- **Error transformation** - Convert domain exceptions + +### Naming Convention +- Pattern: `{Action}{Entity}Handler` +- Examples: `CreateOrderHandler`, `UpdateEventHandler`, `DeleteProductHandler` + +## 2. Domain Services Layer + +**Location**: `backend/app/Services/Domain/` + +### Purpose +Core business logic implementation organized by domain concepts. + +### Key Domain Areas +- **Order**: Order management, payment processing, cancellation +- **Product**: Product creation, validation, pricing +- **Event**: Event creation, statistics, duplication +- **Auth**: Login, password reset, authentication +- **Payment**: Stripe integration, refunds, webhooks +- **Attendee**: Ticket sending, check-ins +- **Tax**: Tax/fee calculations +- **PromoCode**: Validation and application + +### Service Characteristics + +```php +class CreateProductService +{ + public function __construct( + private readonly ProductRepositoryInterface $productRepository, + private readonly TaxAndProductAssociationService $taxService, + private readonly ProductPriceCreateService $priceCreateService, + private readonly HtmlPurifierService $purifier, + private readonly DomainEventDispatcherService $eventDispatcher, + ) {} + + public function createProduct( + ProductDomainObject $product, + Collection $prices + ): ProductDomainObject { + return DB::transaction(function() use ($product, $prices) { + // 1. Persist product + $persistedProduct = $this->productRepository->create([ + ProductDomainObjectAbstract::TITLE => $product->getTitle(), + // ... + ]); + + // 2. Associate taxes + $this->taxService->associateTaxesAndFees($persistedProduct->getId()); + + // 3. Create prices + $this->priceCreateService->createPrices($persistedProduct, $prices); + + // 4. Dispatch event + $this->eventDispatcher->dispatch( + new ProductEvent( + type: DomainEventType::PRODUCT_CREATED, + productId: $persistedProduct->getId() + ) + ); + + return $persistedProduct; + }); + } +} +``` + +### Domain Service Guidelines + +**DO**: +- Encapsulate business logic +- Focus on single responsibility +- Compose other domain services +- Return domain objects or specialized DTOs +- Throw domain-specific exceptions +- Dispatch domain events + +**DON'T**: +- Handle HTTP concerns +- Know about controllers or actions +- Deal with authentication/authorization directly +- Format responses for API + +### Naming Conventions +- Action-oriented: `Create*Service`, `Update*Service`, `Delete*Service` +- Process-oriented: `*ProcessingService`, `*CalculationService` +- Utility-oriented: `*ValidationService`, `*ManagementService` + +## 3. Data Transfer Objects (DTOs) + +**Location**: `backend/app/Services/Application/Handlers/*/DTO/` + +### Modern Pattern: BaseDataObject + +**Always use** `BaseDataObject` (Spatie Laravel Data) for new DTOs. + +```php +class UpsertProductDTO extends BaseDataObject +{ + public function __construct( + public readonly int $eventId, + public readonly string $title, + public readonly ?string $description, + public readonly ProductType $type, + /** + * @var Collection + */ + public readonly Collection $prices, + public readonly ?int $productCategoryId = null, + public readonly ?int $maxPerOrder = null, + ) {} +} +``` + +### Key Features +- **Constructor promotion**: Concise syntax +- **Readonly properties**: Immutability +- **Type safety**: Enums and primitives +- **Optional parameters**: With defaults +- **Nested DTOs**: Using `Collection` or `#[CollectionOf]` + +### Legacy Pattern: BaseDTO + +**Deprecated** - Do not use for new code. + +```php +// DON'T DO THIS - BaseDTO is deprecated +class OldDTO extends BaseDTO { } + +// DO THIS INSTEAD +class NewDTO extends BaseDataObject { } +``` + +### DTO Best Practices + +1. **Always extend BaseDataObject** +```php +class CreateWebhookDTO extends BaseDataObject { } +``` + +2. **Use readonly properties** +```php +public readonly string $url, +public readonly WebhookStatus $status, +``` + +3. **Integrate enums directly** +```php +public readonly EmailTemplateEngine $engine = EmailTemplateEngine::LIQUID, +``` + +4. **Document collections** +```php +/** + * @var Collection + */ +public readonly Collection $products, +``` + +## 4. Domain Objects + +**Location**: `backend/app/DomainObjects/` + +### What Are Domain Objects? + +Auto-generated, immutable data representations that mirror the database schema. + +### Generation Process + +```bash +php artisan generate-domain-objects +``` + +This command: +1. Reads database schema using Doctrine DBAL +2. Generates abstract classes in `Generated/` folder +3. Generates concrete classes (only if they don't exist) + +### Structure + +**Abstract Class** (`Generated/EventDomainObjectAbstract.php`): +```php +abstract class EventDomainObjectAbstract extends AbstractDomainObject +{ + // Constants for field names + final public const ID = 'id'; + final public const TITLE = 'title'; + final public const STATUS = 'status'; + + // Typed properties + protected int $id; + protected string $title; + protected ?string $status = null; + + // Getters and setters + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } +} +``` + +**Concrete Class** (`EventDomainObject.php`): +```php +class EventDomainObject extends EventDomainObjectAbstract +{ + private ?Collection $products = null; + + // Custom business logic + public function isEventInPast(): bool + { + return $this->end_date && $this->end_date < now(); + } + + // Relationship accessors + public function getProducts(): ?Collection + { + return $this->products; + } +} +``` + +### Usage Pattern + +```php +// Use constants for field names +$order = $orderRepository->create([ + OrderDomainObjectAbstract::EVENT_ID => $eventId, + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::TOTAL_GROSS => $total, +]); + +// Use getters +$orderId = $order->getId(); +$status = $order->getStatus(); +``` + +### Best Practices + +**DO**: +- Use constants for field names +- Add custom logic to concrete classes +- Use getters/setters for access + +**DON'T**: +- Edit abstract classes in `Generated/` folder +- Access properties directly (use getters) +- Forget to regenerate after schema changes + +## 5. Enums + +**Locations**: +- `backend/app/DomainObjects/Status/` - Status enums +- `backend/app/DomainObjects/Enums/` - General enums + +### Status Enums + +For entity lifecycle states: + +```php +enum OrderStatus +{ + use BaseEnum; + + case RESERVED; + case CANCELLED; + case COMPLETED; + case AWAITING_OFFLINE_PAYMENT; + case ABANDONED; + + public static function getHumanReadableStatus(string $status): string + { + return match ($status) { + self::RESERVED->name => __('Reserved'), + self::COMPLETED->name => __('Completed'), + // ... + }; + } +} +``` + +**Examples**: +- `OrderStatus` +- `EventStatus` (DRAFT, LIVE, ARCHIVED) +- `AttendeeStatus` +- `AffiliateStatus` + +### General Enums + +For domain constants and types: + +```php +enum QuestionTypeEnum +{ + use BaseEnum; + + case ADDRESS; + case PHONE; + case SINGLE_LINE_TEXT; + case CHECKBOX; + case RADIO; + case DROPDOWN; + + public static function getMultipleChoiceTypes(): array + { + return [ + self::CHECKBOX, + self::MULTI_SELECT_DROPDOWN, + ]; + } +} +``` + +**Examples**: +- `TaxType` (TAX, FEE) +- `PromoCodeDiscountTypeEnum` +- `ProductPriceType` +- `MessageTypeEnum` + +### BaseEnum Trait + +Provides utility methods: + +```php +trait BaseEnum +{ + public function getName(): string + { + return $this->name; + } + + public static function valuesArray(): array + { + $values = []; + foreach (self::cases() as $enum) { + $values[] = $enum->value ?? $enum->name; + } + return $values; + } +} +``` + +### Usage in Validation + +```php +'status' => [ + 'required', + Rule::in(OrderStatus::valuesArray()) +] +``` + +## 6. DTO Flow Pattern + +```mermaid +sequenceDiagram + participant Action + participant Handler + participant Service + participant Repository + + Action->>Handler: Pass DTO + Note over Action,Handler: CreateOrderDTO + + Handler->>Service: Extract data, call service + Note over Handler,Service: Pass primitives/domain objects + + Service->>Repository: Persist data + Note over Service,Repository: Use domain object constants + + Repository-->>Service: Return Domain Object + Service-->>Handler: Return Domain Object + Handler-->>Action: Return Domain Object + + Action->>Action: Transform to Resource + Note over Action: OrderResource::make($order) +``` + +### Complete Example: Webhook Creation + +**Step 1: Action creates DTO** +```php +$dto = CreateWebhookDTO::from([ + 'url' => $request->input('url'), + 'eventTypes' => $request->input('event_types'), + 'eventId' => $eventId, + 'accountId' => $this->getAuthenticatedAccountId(), + 'status' => WebhookStatus::ACTIVE, +]); +``` + +**Step 2: Handler receives DTO** +```php +public function handle(CreateWebhookDTO $dto): WebhookDomainObject +{ + return $this->databaseManager->transaction( + fn() => $this->createWebhook($dto) + ); +} +``` + +**Step 3: Handler transforms DTO to Domain Object** +```php +private function createWebhook(CreateWebhookDTO $dto): WebhookDomainObject +{ + $webhookDomainObject = (new WebhookDomainObject()) + ->setUrl($dto->url) + ->setEventTypes($dto->eventTypes) + ->setStatus($dto->status->value); + + return $this->createWebhookService->createWebhook($webhookDomainObject); +} +``` + +**Step 4: Service uses Domain Object** +```php +public function createWebhook(WebhookDomainObject $webhookDO): WebhookDomainObject +{ + $webhook = $this->webhookRepository->create([ + WebhookDomainObjectAbstract::URL => $webhookDO->getUrl(), + WebhookDomainObjectAbstract::STATUS => $webhookDO->getStatus(), + WebhookDomainObjectAbstract::SECRET => Str::random(32), + ]); + + $this->logger->info('Created webhook', ['webhook' => $webhook->toArray()]); + + return $webhook; +} +``` + +## 7. Transaction Management + +### Handler-Level Transactions + +Most common pattern - handlers manage transactions: + +```php +public function handle(CreateOrderDTO $dto): OrderDomainObject +{ + return $this->databaseManager->transaction(function () use ($dto) { + $event = $this->eventRepository->findById($dto->eventId); + $order = $this->orderService->createOrder($event); + $items = $this->orderItemService->createItems($order, $dto->products); + + return $this->orderService->updateTotals($order, $items); + }); +} +``` + +### Service-Level Transactions + +For complex domain services: + +```php +public function markOrderAsPaid(int $orderId, int $eventId): OrderDomainObject +{ + return DB::transaction(function() use ($orderId, $eventId) { + $order = $this->orderRepository->findById($orderId); + + // Multiple operations atomically + $this->updateOrderStatus($order); + $this->updateAttendeeStatuses($order); + $this->updateProductQuantities($order); + $this->createInvoice($order); + + // Events dispatched after commit + OrderStatusChangedEvent::dispatch($order); + + return $order; + }); +} +``` + +### Transaction Best Practices + +1. **Dispatch events after transaction commits** +```php +DB::transaction(function() { + // DB operations +}); +OrderEvent::dispatch($order); // After commit +``` + +2. **Keep transactions focused** +```php +// DO: Single logical unit of work +DB::transaction(fn() => $this->createOrderWithItems()); + +// DON'T: Unrelated operations +DB::transaction(function() { + $this->createOrder(); + $this->sendEmail(); // Side effect, should be async +}); +``` + +3. **Handle failures appropriately** +```php +try { + DB::transaction(function() { + // Operations + }); +} catch (DomainException $e) { + $this->logger->error('Transaction failed', ['error' => $e]); + throw $e; +} +``` + +## 8. Service Composition + +Services compose other services to handle complex operations: + +```php +class CompleteOrderHandler +{ + public function __construct( + private readonly OrderRepositoryInterface $orderRepository, + private readonly AttendeeCreationService $attendeeService, + private readonly QuestionAnswerService $questionService, + private readonly ProductQuantityUpdateService $quantityService, + private readonly AffiliateTrackingService $affiliateService, + private readonly DatabaseManager $db, + ) {} + + public function handle(string $orderShortId, CompleteOrderDTO $dto): OrderDomainObject + { + return $this->db->transaction(function() use ($orderShortId, $dto) { + $order = $this->orderRepository->findByShortId($orderShortId); + + // Compose multiple services + $this->attendeeService->createAttendees($order, $dto->products); + $this->questionService->saveAnswers($order, $dto->answers); + $this->quantityService->updateQuantities($order); + $this->affiliateService->trackSale($order); + + return $order; + }); + } +} +``` + +## 9. Exception Handling + +### Domain Exceptions + +Create custom exceptions for domain errors: + +```php +class ResourceConflictException extends Exception {} +class EmailTemplateValidationException extends Exception { + public array $validationErrors = []; +} +class OrderValidationException extends Exception {} +``` + +### Handler Exception Handling + +```php +public function handle(UpsertAffiliateDTO $dto): AffiliateDomainObject +{ + try { + return $this->affiliateRepository->create([...]); + } catch (ResourceConflictException $e) { + throw ValidationException::withMessages([ + 'code' => $e->getMessage(), + ]); + } +} +``` + +### Action Exception Handling + +```php +try { + $result = $this->handler->handle($dto); +} catch (EmailTemplateValidationException $e) { + return $this->errorResponse( + message: 'Validation failed', + statusCode: ResponseCodes::HTTP_BAD_REQUEST, + errors: $e->validationErrors, + ); +} +``` + +## 10. Validation Patterns + +### Three Levels of Validation + +1. **HTTP Layer**: Request validation +```php +class CreateProductRequest extends BaseRequest +{ + public function rules(): array + { + return [ + 'title' => 'required|string|max:255', + 'type' => ['required', Rule::in(ProductType::valuesArray())], + ]; + } +} +``` + +2. **Handler Layer**: DTO structure validation +```php +class CreateProductDTO extends BaseDataObject +{ + public function __construct( + public readonly string $title, // Type validation + public readonly ProductType $type, // Enum validation + ) {} +} +``` + +3. **Domain Layer**: Business rule validation +```php +class OrderValidationService +{ + public function validateOrderCanBeCompleted(OrderDomainObject $order): void + { + if ($order->getStatus() === OrderStatus::COMPLETED->name) { + throw new OrderValidationException('Order already completed'); + } + + if ($order->isExpired()) { + throw new OrderValidationException('Order has expired'); + } + } +} +``` + +## Best Practices Summary + +### Handlers Should +- Orchestrate domain services +- Manage transactions at the highest level +- Handle logging and metrics +- Transform between DTOs and domain objects +- Dispatch events +- Handle cross-cutting concerns + +### Domain Services Should +- Encapsulate business logic +- Be highly focused (single responsibility) +- Depend on repositories via interfaces +- Compose other domain services +- Return domain objects or specialized DTOs +- Throw domain-specific exceptions + +### DTOs Should +- Be immutable (readonly properties) +- Extend BaseDataObject (not BaseDTO) +- Use constructor property promotion +- Integrate enums for type safety +- Document collections with PHPDoc + +### Domain Objects Should +- Be auto-generated from schema +- Provide constants for field names +- Have custom logic in concrete classes +- Never be edited in Generated/ folder diff --git a/backend/docs/events-and-jobs.md b/backend/docs/events-and-jobs.md new file mode 100644 index 000000000..f8133a222 --- /dev/null +++ b/backend/docs/events-and-jobs.md @@ -0,0 +1,860 @@ +# Events and Background Jobs Architecture + +## Overview + +Hi.Events implements a dual-layer event architecture combining Laravel's event system with custom domain events. This enables clean separation between internal workflows (application events) and external integrations (infrastructure events). + +```mermaid +graph LR + A[Domain Service] --> B[Application Event] + A --> C[Infrastructure Event] + B --> D[Event Listener] + C --> E[Webhook Listener] + D --> F[Background Job] + E --> F +``` + +## Architecture Components + +### 1. Application Events + +**Location**: `backend/app/Events/` + +Used for internal domain logic and business workflows. + +#### OrderStatusChangedEvent + +**File**: `backend/app/Events/OrderStatusChangedEvent.php` + +```php +class OrderStatusChangedEvent +{ + use Dispatchable; + + public function __construct( + public OrderDomainObject $order, + public bool $sendEmails = true, + public bool $createInvoice = false, + ) {} +} +``` + +**Purpose**: Triggered whenever order status changes (payment received, completion, cancellation). + +**Listeners**: +- `SendOrderDetailsEmailListener` - Sends order confirmation +- `CreateInvoiceListener` - Generates invoice +- `UpdateEventStatsListener` - Updates statistics + +**Usage**: +```php +OrderStatusChangedEvent::dispatch($order, sendEmails: true, createInvoice: true); +``` + +#### EventUpdateEvent + +**File**: `backend/app/Events/EventUpdateEvent.php` + +```php +class EventUpdateEvent +{ + use Dispatchable; + + public function __construct( + private readonly EventDomainObject $event, + ) {} +} +``` + +**Purpose**: Triggered when event details are updated. + +**Usage**: +```php +EventUpdateEvent::dispatch($event); +``` + +### 2. Infrastructure Domain Events + +**Location**: `backend/app/Services/Infrastructure/DomainEvents/` + +Used for external webhook integrations and system-level events. + +#### Event Types + +**File**: `backend/app/Services/Infrastructure/DomainEvents/DomainEventType.php` + +```php +enum DomainEventType: string +{ + // Product events + case PRODUCT_CREATED = 'product.created'; + case PRODUCT_UPDATED = 'product.updated'; + case PRODUCT_DELETED = 'product.deleted'; + + // Order events + case ORDER_CREATED = 'order.created'; + case ORDER_UPDATED = 'order.updated'; + case ORDER_MARKED_AS_PAID = 'order.marked_as_paid'; + case ORDER_REFUNDED = 'order.refunded'; + case ORDER_CANCELLED = 'order.cancelled'; + + // Attendee events + case ATTENDEE_CREATED = 'attendee.created'; + case ATTENDEE_UPDATED = 'attendee.updated'; + case ATTENDEE_CANCELLED = 'attendee.cancelled'; + + // Check-in events + case CHECKIN_CREATED = 'checkin.created'; + case CHECKIN_DELETED = 'checkin.deleted'; +} +``` + +#### Event Classes + +**Base Event**: `backend/app/Services/Infrastructure/DomainEvents/BaseDomainEvent.php` + +```php +abstract class BaseDomainEvent +{ + use Dispatchable; +} +``` + +**Concrete Events**: All in `backend/app/Services/Infrastructure/DomainEvents/Events/` + +**OrderEvent**: +```php +class OrderEvent extends BaseDomainEvent +{ + public function __construct( + public DomainEventType $type, + public int $orderId, + ) {} +} +``` + +**ProductEvent**: +```php +class ProductEvent extends BaseDomainEvent +{ + public function __construct( + public DomainEventType $type, + public int $productId, + ) {} +} +``` + +**AttendeeEvent**: +```php +class AttendeeEvent extends BaseDomainEvent +{ + public function __construct( + public DomainEventType $type, + public int $attendeeId, + ) {} +} +``` + +**CheckinEvent**: +```php +class CheckinEvent extends BaseDomainEvent +{ + public function __construct( + public DomainEventType $type, + public int $attendeeCheckinId, + ) {} +} +``` + +#### Domain Event Dispatcher + +**File**: `backend/app/Services/Infrastructure/DomainEvents/DomainEventDispatcherService.php` + +```php +class DomainEventDispatcherService +{ + public function dispatch(BaseDomainEvent $event): void + { + try { + Event::dispatch($event); + } catch (Exception $e) { + $this->logger->error('Failed to dispatch domain event', [ + 'event' => get_class($event), + 'error' => $e->getMessage(), + ]); + throw $e; + } + } +} +``` + +**Usage**: +```php +$this->domainEventDispatcher->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $order->getId() + ) +); +``` + +## 3. Event Listeners + +**Location**: `backend/app/Listeners/` + +Listeners react to events and coordinate side effects. + +### Order Listeners + +#### SendOrderDetailsEmailListener + +**File**: `backend/app/Listeners/Order/SendOrderDetailsEmailListener.php` + +```php +class SendOrderDetailsEmailListener +{ + public function handle(OrderStatusChangedEvent $event): void + { + if (!$event->sendEmails) { + return; + } + + dispatch(new SendOrderDetailsEmailJob($event->order)); + } +} +``` + +**Pattern**: Checks event flags and dispatches job if appropriate. + +#### CreateInvoiceListener + +**File**: `backend/app/Listeners/Order/CreateInvoiceListener.php` + +```php +class CreateInvoiceListener +{ + public function __construct( + private readonly InvoiceCreateService $invoiceCreateService + ) {} + + public function handle(OrderStatusChangedEvent $event): void + { + if (!$event->createInvoice) { + return; + } + + $order = $event->order; + + if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name + && $order->getStatus() !== OrderStatus::COMPLETED->name) { + return; + } + + $this->invoiceCreateService->createInvoiceForOrder($order->getId()); + } +} +``` + +**Pattern**: Uses domain service directly (synchronous operation). + +### Event Listeners + +#### UpdateEventStatsListener + +**File**: `backend/app/Listeners/Event/UpdateEventStatsListener.php` + +```php +class UpdateEventStatsListener +{ + public function handle(OrderStatusChangedEvent $event): void + { + if (!$event->order->isOrderCompleted()) { + return; + } + + dispatch(new UpdateEventStatisticsJob($event->order)); + } +} +``` + +**Pattern**: Validates conditions before dispatching job. + +### Webhook Listener + +#### WebhookEventListener + +**File**: `backend/app/Listeners/Webhook/WebhookEventListener.php` + +```php +class WebhookEventListener +{ + public function handle(BaseDomainEvent $event): void + { + $queueName = $this->config->get('queue.webhook_queue_name'); + + switch (get_class($event)) { + case OrderEvent::class: + DispatchOrderWebhookJob::dispatch( + orderId: $event->orderId, + eventType: $event->type, + )->onQueue($queueName); + break; + + case AttendeeEvent::class: + DispatchAttendeeWebhookJob::dispatch( + attendeeId: $event->attendeeId, + eventType: $event->type, + )->onQueue($queueName); + break; + + case ProductEvent::class: + DispatchProductWebhookJob::dispatch( + productId: $event->productId, + eventType: $event->type, + )->onQueue($queueName); + break; + + case CheckinEvent::class: + DispatchCheckInWebhookJob::dispatch( + attendeeCheckInId: $event->attendeeCheckinId, + eventType: $event->type, + )->onQueue($queueName); + break; + } + } +} +``` + +**Pattern**: Routes infrastructure events to appropriate webhook jobs on dedicated queue. + +## 4. Background Jobs + +**Location**: `backend/app/Jobs/` + +Jobs implement `ShouldQueue` for asynchronous processing. + +### Order Jobs + +#### SendOrderDetailsEmailJob + +**File**: `backend/app/Jobs/Order/SendOrderDetailsEmailJob.php` + +```php +class SendOrderDetailsEmailJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public int $tries = 3; + + public function __construct( + private readonly OrderDomainObject $order + ) {} + + public function handle(SendOrderDetailsService $service): void + { + $service->sendOrderSummaryAndTicketEmails($this->order); + } +} +``` + +**Features**: +- 3 retry attempts +- Automatically queued +- Serializes domain objects + +### Webhook Jobs + +All webhook jobs follow similar pattern: + +**File**: `backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php` + +```php +class DispatchOrderWebhookJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct( + public int $orderId, + public DomainEventType $eventType, + ) {} + + public function handle(WebhookDispatchService $webhookDispatchService): void + { + $webhookDispatchService->dispatchOrderWebhook( + eventType: $this->eventType, + orderId: $this->orderId, + ); + } +} +``` + +**Other Webhook Jobs**: +- `DispatchAttendeeWebhookJob` +- `DispatchProductWebhookJob` +- `DispatchCheckInWebhookJob` + +### Event Jobs + +#### UpdateEventStatisticsJob + +**File**: `backend/app/Jobs/Event/UpdateEventStatisticsJob.php` + +```php +class UpdateEventStatisticsJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public int $tries = 5; + public int $backoff = 10; // seconds + + public function __construct( + private readonly OrderDomainObject $order + ) {} + + public function handle(EventStatisticsIncrementService $service): void + { + $service->incrementForOrder($this->order); + } + + public function failed(Throwable $exception): void + { + logger()?->error('Failed to update event statistics', [ + 'order' => $this->order->toArray(), + 'exception' => $exception, + ]); + } +} +``` + +**Features**: +- 5 retry attempts +- 10-second exponential backoff +- Custom failure handler + +**Other Event Jobs**: +- `SendEventEmailJob` - Bulk emails to attendees +- `SendMessagesJob` - Batch message processing +- `UpdateEventPageViewsJob` - Page view tracking + +### Question Jobs + +**File**: `backend/app/Jobs/Question/ExportAnswersJob.php` + +Exports survey/question responses. + +## 5. Event Registration + +**File**: `backend/app/Providers/EventServiceProvider.php` + +```php +class EventServiceProvider extends ServiceProvider +{ + private static array $domainEventMap = [ + WebhookEventListener::class => [ + ProductEvent::class, + OrderEvent::class, + AttendeeEvent::class, + CheckinEvent::class, + ], + ]; + + public function boot(): void + { + $this->registerDomainEventListeners(); + } + + private function registerDomainEventListeners(): void + { + foreach (self::$domainEventMap as $listener => $events) { + foreach ($events as $event) { + Event::listen($event, [$listener, 'handle']); + } + } + } + + public function shouldDiscoverEvents(): bool + { + return true; // Auto-discovers Order/Event listeners + } +} +``` + +**Registration Strategy**: +- **Manual registration**: Infrastructure domain events → webhook listener +- **Auto-discovery**: Application events → Order/Event listeners (convention-based) + +## Event Flow Examples + +### Example 1: Order Payment Success + +**Trigger**: Stripe payment intent succeeds + +**File**: `backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php` + +```mermaid +sequenceDiagram + participant Stripe + participant Handler + participant DB + participant AppEvent + participant InfraEvent + participant Listener + participant Job + + Stripe->>Handler: Payment Intent Succeeded + Handler->>DB: Update order status + Handler->>DB: Update attendee statuses + Handler->>DB: Update product quantities + Handler->>DB: Commit transaction + + Handler->>AppEvent: OrderStatusChangedEvent + Handler->>InfraEvent: OrderEvent (ORDER_CREATED) + + AppEvent->>Listener: SendOrderDetailsEmailListener + Listener->>Job: SendOrderDetailsEmailJob + + AppEvent->>Listener: UpdateEventStatsListener + Listener->>Job: UpdateEventStatisticsJob + + InfraEvent->>Listener: WebhookEventListener + Listener->>Job: DispatchOrderWebhookJob +``` + +**Flow**: + +1. **Payment Handler** processes Stripe webhook +```php +public function handleEvent(PaymentIntent $paymentIntent): void +{ + $this->databaseManager->transaction(function () use ($paymentIntent) { + $updatedOrder = $this->updateOrderStatuses($stripePayment); + $this->updateAttendeeStatuses($updatedOrder); + $this->quantityUpdateService->updateQuantities($updatedOrder); + + // Dispatch application event + OrderStatusChangedEvent::dispatch($updatedOrder); + + // Dispatch infrastructure event + $this->domainEventDispatcherService->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $updatedOrder->getId() + ) + ); + }); +} +``` + +2. **Application Event** triggers 3 listeners: + - Email listener → Email job + - Invoice listener → Invoice creation (sync) + - Statistics listener → Statistics job + +3. **Infrastructure Event** triggers: + - Webhook listener → Webhook job (on dedicated queue) + +4. **Background Jobs** execute asynchronously: + - Email (3 retries) + - Statistics (5 retries, 10s backoff) + - Webhook delivery + +### Example 2: Manual Order Completion + +**Trigger**: User completes order form (no payment required) + +```php +public function handle(string $orderShortId, CompleteOrderDTO $dto): OrderDomainObject +{ + $updatedOrder = DB::transaction(function () use ($orderShortId, $dto) { + $order = $this->getOrder($orderShortId); + $updatedOrder = $this->updateOrder($order, $dto); + $this->createAttendees($dto->products, $order); + + if (!$order->isPaymentRequired()) { + $this->quantityUpdateService->updateQuantities($updatedOrder); + } + + return $updatedOrder; + }); + + // Dispatch after transaction commits + OrderStatusChangedEvent::dispatch($updatedOrder); + + if ($updatedOrder->isOrderCompleted()) { + $this->domainEventDispatcherService->dispatch( + new OrderEvent( + type: DomainEventType::ORDER_CREATED, + orderId: $updatedOrder->getId(), + ) + ); + } + + return $updatedOrder; +} +``` + +**Key Pattern**: Events dispatched **after** transaction commits. + +## Architectural Patterns + +### 1. Dual Event System + +**Why Two Systems?** + +``` +Application Events (app/Events/) +├── Internal workflows +├── Business logic triggers +├── Can be sync or async +└── Examples: OrderStatusChangedEvent, EventUpdateEvent + +Infrastructure Events (Services/Infrastructure/DomainEvents/) +├── External integrations +├── Webhook delivery +├── Always async +├── Isolated failure domain +└── Examples: OrderEvent, ProductEvent, AttendeeEvent +``` + +### 2. Listener Responsibilities + +Three distinct patterns: + +#### Job Dispatcher (Most Common) + +```php +public function handle(OrderStatusChangedEvent $event): void +{ + dispatch(new SendOrderDetailsEmailJob($event->order)); +} +``` + +#### Direct Service Call (Synchronous) + +```php +public function handle(OrderStatusChangedEvent $event): void +{ + $this->invoiceCreateService->createInvoiceForOrder($event->order->getId()); +} +``` + +#### Router/Multiplexer + +```php +public function handle(BaseDomainEvent $event): void +{ + switch (get_class($event)) { + case OrderEvent::class: + DispatchOrderWebhookJob::dispatch(...)->onQueue('webhooks'); + break; + } +} +``` + +### 3. Job Design Patterns + +**Retry Strategy**: +```php +// User-facing operations - fail fast +public int $tries = 3; + +// Background statistics - eventual consistency OK +public int $tries = 5; +public int $backoff = 10; // seconds +``` + +**Failure Handling**: +```php +public function failed(Throwable $exception): void +{ + logger()->error('Job failed', [ + 'job' => static::class, + 'data' => $this->data, + 'exception' => $exception, + ]); +} +``` + +**Serialization**: +```php +// Domain objects auto-serialized +public function __construct( + private readonly OrderDomainObject $order // SerializesModels trait +) {} + +// Or use primitive IDs for smaller payload +public function __construct( + public int $orderId, + public DomainEventType $eventType, +) {} +``` + +### 4. Transaction Boundaries + +**Critical Pattern**: Events dispatched **after** DB transaction commits. + +```php +// ✅ GOOD - Events after commit +DB::transaction(function() { + // DB operations +}); +OrderStatusChangedEvent::dispatch($order); + +// ❌ BAD - Events inside transaction +DB::transaction(function() { + // DB operations + OrderStatusChangedEvent::dispatch($order); // May see uncommitted data +}); +``` + +**Benefits**: +- Listeners see committed data +- Prevents duplicate processing on rollback +- Maintains referential integrity + +### 5. Event Discovery vs Manual Registration + +**Auto-discovered** (convention: `app/Listeners/{Domain}/{EventName}Listener.php`): +``` +app/Listeners/Order/SendOrderDetailsEmailListener.php → OrderStatusChangedEvent +app/Listeners/Event/UpdateEventStatsListener.php → OrderStatusChangedEvent +``` + +**Manually registered** (cross-cutting concerns): +```php +private static array $domainEventMap = [ + WebhookEventListener::class => [ + ProductEvent::class, + OrderEvent::class, + AttendeeEvent::class, + ], +]; +``` + +## Best Practices + +### 1. Choose the Right Event Type + +**Use Application Events when**: +- Internal workflow coordination +- Multiple side effects needed +- May be sync or async +- Example: Order status changes → emails, invoices, stats + +**Use Infrastructure Events when**: +- External integrations +- Webhook delivery +- Always async +- Isolated failure domain +- Example: Notify external systems of order creation + +### 2. Keep Listeners Thin + +```php +// ✅ GOOD - Listener dispatches job +public function handle(OrderStatusChangedEvent $event): void +{ + if ($event->sendEmails) { + dispatch(new SendOrderDetailsEmailJob($event->order)); + } +} + +// ❌ BAD - Business logic in listener +public function handle(OrderStatusChangedEvent $event): void +{ + $email = new OrderConfirmationEmail($event->order); + Mail::send($email); // Blocking operation +} +``` + +### 3. Use Conditional Dispatch + +```php +// Conditional based on event state +if ($event->sendEmails) { + dispatch(new SendEmailJob($order)); +} + +// Conditional based on domain object +if ($order->isOrderCompleted()) { + dispatch(new UpdateStatisticsJob($order)); +} +``` + +### 4. Queue Separation + +```php +// High-priority user-facing +SendOrderDetailsEmailJob::dispatch($order)->onQueue('emails'); + +// Lower-priority background +UpdateEventStatisticsJob::dispatch($order)->onQueue('stats'); + +// Separate failure domain +DispatchOrderWebhookJob::dispatch(...)->onQueue('webhooks'); +``` + +### 5. Error Handling + +```php +class UpdateEventStatisticsJob implements ShouldQueue +{ + public int $tries = 5; + public int $backoff = 10; + + public function handle(EventStatisticsService $service): void + { + $service->incrementForOrder($this->order); + } + + public function failed(Throwable $exception): void + { + logger()->error('Statistics update failed', [ + 'order_id' => $this->order->getId(), + 'error' => $exception->getMessage(), + ]); + + // Optionally: send alert, log to external service, etc. + } +} +``` + +## Summary + +Hi.Events implements a sophisticated event-driven architecture: + +**Components**: +- 3 application events (internal workflows) +- 4 infrastructure event classes + 13 event types (webhooks) +- 10 background jobs (async processing) +- 7 event listeners (orchestration) + +**Strengths**: +- Clear separation of concerns (internal vs external events) +- Transactional safety (events after commits) +- Extensible via auto-discovery +- Robust retry/failure handling +- Queue isolation prevents cascading failures + +**Total Architecture**: +``` +Domain Service → Event Dispatch + ↓ + ┌────────────┴────────────┐ + ↓ ↓ +Application Event Infrastructure Event + ↓ ↓ +Event Listener Webhook Listener + ↓ ↓ +Background Job Webhook Job + ↓ ↓ + Send Email External System + Update Stats + Generate Invoice +``` + +## Related Documentation + +- [Architecture Overview](architecture-overview.md) +- [Domain-Driven Design](domain-driven-design.md) +- [Repository Pattern](repository-pattern.md) diff --git a/backend/docs/repository-pattern.md b/backend/docs/repository-pattern.md new file mode 100644 index 000000000..e523d29a5 --- /dev/null +++ b/backend/docs/repository-pattern.md @@ -0,0 +1,604 @@ +# Repository Pattern in Hi.Events + +## Overview + +Hi.Events uses a clean implementation of the Repository Pattern that provides abstraction between the domain layer and data persistence. All repositories follow interface-based design and return domain objects instead of Eloquent models. + +## Architecture + +```mermaid +graph TB + A[Handler] --> B[Repository Interface] + B --> C[Eloquent Repository Implementation] + C --> D[Eloquent Model] + D --> E[Database] + C --> F[Domain Object] + F --> A +``` + +## Core Components + +### 1. Base Repository Interface + +**Location**: `backend/app/Repository/Interfaces/RepositoryInterface.php` + +Provides 40+ methods for common CRUD operations: + +#### Core CRUD Methods + +```php +// Create +public function create(array $attributes): DomainObjectInterface; +public function insert(array $inserts): bool; // Bulk insert + +// Read +public function all(array $columns = ['*']): Collection; +public function findById(int $id, array $columns = ['*']): DomainObjectInterface; +public function findFirst(int $id, array $columns = ['*']): ?DomainObjectInterface; +public function findWhere(array $where, array $columns = ['*']): Collection; +public function findFirstWhere(array $where, array $columns = ['*']): ?DomainObjectInterface; +public function findWhereIn(string $field, array $values): Collection; + +// Update +public function updateFromDomainObject(int $id, DomainObjectInterface $do): DomainObjectInterface; +public function updateFromArray(int $id, array $attributes): DomainObjectInterface; +public function updateWhere(array $attributes, array $where): int; + +// Delete +public function deleteById(int $id): bool; +public function deleteWhere(array $conditions): int; +``` + +#### Pagination & Counting + +```php +public function paginate(int $limit = 20, array $columns = ['*']): LengthAwarePaginator; +public function paginateWhere(array $where, int $limit = 20): LengthAwarePaginator; +public function countWhere(array $conditions): int; +``` + +#### Increment/Decrement + +```php +public function increment(int $id, string $column, int|float $amount = 1): int; +public function decrement(int $id, string $column, int|float $amount = 1): int; +public function incrementWhere(array $where, string $column, int|float $amount = 1): int; +``` + +#### Special Operations + +```php +// Eager loading +public function loadRelation(string|Relationship $relationship): static; + +// Include soft deleted +public function includeDeleted(): static; + +// Required domain object mapping +public function getDomainObject(): string; // Returns FQCN of domain object +``` + +### 2. Base Repository Implementation + +**Location**: `backend/app/Repository/Eloquent/BaseRepository.php` + +Abstract class that implements all interface methods with: +- Automatic model hydration to domain objects +- Flexible where conditions +- Model reset pattern +- Eager loading support + +#### Key Implementation Features + +**Flexible Where Conditions**: + +```php +// Simple key-value +$where = ['event_id' => 1]; + +// Array with operator +$where = [['status', '!=', 'RESERVED']]; + +// Callable for complex queries +$where[] = static function (Builder $builder) { + $builder->where('name', 'like', '%search%') + ->orWhere('email', 'like', '%search%'); +}; +``` + +**Automatic Hydration**: + +```php +private function hydrateDomainObjectFromModel(Model $model): DomainObjectInterface +{ + $domainObject = new ($this->getDomainObject())(); + + foreach ($model->attributesToArray() as $attribute => $value) { + $method = 'set' . ucfirst(Str::camel($attribute)); + if (is_callable([$domainObject, $method])) { + $domainObject->$method($value); + } + } + + // Handle eager-loaded relationships + $this->handleEagerLoads($model, $domainObject); + + return $domainObject; +} +``` + +## Creating a Repository + +### Step 1: Define Interface + +**File**: `backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php` + +```php +query) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->orWhere(AffiliateDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%') + ->orWhere(AffiliateDomainObjectAbstract::CODE, 'ilike', '%' . $params->query . '%') + ->orWhere(AffiliateDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + }; + } + + $this->model = $this->model->orderBy( + column: $params->sort_by ?? AffiliateDomainObject::getDefaultSort(), + direction: $params->sort_direction ?? 'desc', + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } + + public function incrementSales(int $affiliateId, float $amount): void + { + $this->model->where('id', $affiliateId) + ->increment('total_sales', 1, [ + 'total_sales_gross' => $this->db->raw('total_sales_gross + ' . $amount) + ]); + } +} +``` + +### Step 3: Register in Service Provider + +**File**: `backend/app/Providers/RepositoryServiceProvider.php` + +```php +class RepositoryServiceProvider extends ServiceProvider +{ + private static array $interfaceToConcreteMap = [ + AffiliateRepositoryInterface::class => AffiliateRepository::class, + OrderRepositoryInterface::class => OrderRepository::class, + EventRepositoryInterface::class => EventRepository::class, + // ... more mappings + ]; + + public function register(): void + { + foreach (self::$interfaceToConcreteMap as $interface => $concrete) { + $this->app->bind($interface, $concrete); + } + } +} +``` + +## Usage in Handlers + +### Basic Usage + +```php +class CreateAffiliateHandler +{ + public function __construct( + private readonly AffiliateRepositoryInterface $affiliateRepository, + ) {} + + public function handle(int $eventId, int $accountId, UpsertAffiliateDTO $dto): AffiliateDomainObject + { + // Check for existing + $existing = $this->affiliateRepository->findFirstWhere([ + 'event_id' => $eventId, + 'code' => $code, + ]); + + if ($existing) { + throw new ResourceConflictException('Affiliate with this code already exists'); + } + + // Create new affiliate + return $this->affiliateRepository->create([ + 'event_id' => $eventId, + 'account_id' => $accountId, + 'name' => $dto->name, + 'code' => $code, + 'email' => $dto->email, + 'status' => $dto->status->value, + ]); + } +} +``` + +### Using Domain Object Constants + +**Always use constants** for field names: + +```php +// ✅ GOOD - Using constants +$affiliate = $this->affiliateRepository->findFirstWhere([ + AffiliateDomainObjectAbstract::EVENT_ID => $eventId, + AffiliateDomainObjectAbstract::CODE => $code, + AffiliateDomainObjectAbstract::STATUS => AffiliateStatus::ACTIVE->value, +]); + +// ❌ BAD - Using strings +$affiliate = $this->affiliateRepository->findFirstWhere([ + 'event_id' => $eventId, + 'code' => $code, +]); +``` + +### Eager Loading + +```php +$event = $this->eventRepository + ->loadRelation(EventSettingDomainObject::class) + ->loadRelation(EventStatisticDomainObject::class) + ->findById($eventId); + +// Event now has eager-loaded settings and statistics +$settings = $event->getEventSettings(); +$statistics = $event->getEventStatistics(); +``` + +### Complex Queries + +```php +public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator +{ + $where = [ + [OrderDomainObjectAbstract::EVENT_ID, '=', $eventId], + [OrderDomainObjectAbstract::STATUS, '!=', OrderStatus::RESERVED->name], + ]; + + // Complex search with OR conditions + if ($params->query) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where( + DB::raw("(first_name||' '||last_name)"), + 'ilike', + '%' . $params->query . '%' + ) + ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%') + ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%'); + }; + } + + // Dynamic filters + if (!empty($params->filter_fields)) { + $this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields()); + } + + // Sorting + $this->model = $this->model->orderBy( + column: $params->sort_by ?? OrderDomainObject::getDefaultSort(), + direction: $params->sort_direction ?? 'desc', + ); + + return $this->paginateWhere($where, $params->per_page, page: $params->page); +} +``` + +### Relationship Constraints + +```php +public function findOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses +): Collection { + return $this->handleResults( + $this->model + ->whereHas('order_items', static function (Builder $query) use ($productIds) { + $query->whereIn('product_id', $productIds); + }) + ->whereIn('status', $orderStatuses) + ->where('event_id', $eventId) + ->get() + ); +} +``` + +## Best Practices + +### 1. Favor Base Methods Over Custom Methods + +**Prefer existing base methods**: + +```php +// ✅ GOOD - Use base method +$affiliate = $repository->findFirstWhere(['event_id' => $eventId]); + +// ❌ BAD - Custom method for common pattern +$affiliate = $repository->findByEventId($eventId); +``` + +Only create custom methods for **complex, reusable queries**: + +```php +// ✅ GOOD - Complex query worth a custom method +public function findOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses +): Collection; + +// ✅ GOOD - Domain-specific business logic +public function incrementSales(int $affiliateId, float $amount): void; +``` + +### 2. Use Type Hints + +```php +public function handle(int $eventId, UpsertDTO $dto): AffiliateDomainObject +{ + // Handler knows it receives a domain object + $affiliate = $this->repository->create([...]); + + // Type-safe method calls + $affiliateId = $affiliate->getId(); + $status = $affiliate->getStatus(); + + return $affiliate; +} +``` + +### 3. Handle Null Safely + +```php +// findById throws if not found +$affiliate = $this->repository->findById($id); + +// findFirstWhere returns null if not found +$affiliate = $this->repository->findFirstWhere(['code' => $code]); + +if (!$affiliate) { + throw new NotFoundHttpException('Affiliate not found'); +} +``` + +### 4. Use Transactions at Handler Level + +```php +public function handle(CreateOrderDTO $dto): OrderDomainObject +{ + return $this->databaseManager->transaction(function () use ($dto) { + // Multiple repository calls in transaction + $event = $this->eventRepository->findById($dto->eventId); + $order = $this->orderRepository->create([...]); + $items = $this->orderItemRepository->insert([...]); + + return $order; + }); +} +``` + +### 5. Avoid N+1 Queries + +```php +// ✅ GOOD - Eager load relationships +$orders = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(AttendeeDomainObject::class) + ->findWhere(['event_id' => $eventId]); + +// ❌ BAD - N+1 query +$orders = $this->orderRepository->findWhere(['event_id' => $eventId]); +foreach ($orders as $order) { + $items = $order->getOrderItems(); // Lazy load for each order +} +``` + +## Common Repository Patterns + +### Pagination with Filters + +```php +public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator +{ + $where = [['event_id', '=', $eventId]]; + + if ($params->query) { + $where[] = static fn($builder) => $builder->where('name', 'ilike', '%' . $params->query . '%'); + } + + if ($params->filter_fields) { + $this->applyFilterFields($params, DomainObject::getAllowedFilterFields()); + } + + $this->model = $this->model->orderBy( + $params->sort_by ?? 'created_at', + $params->sort_direction ?? 'desc' + ); + + return $this->paginateWhere($where, $params->per_page, page: $params->page); +} +``` + +### Bulk Operations + +```php +// Bulk insert +$this->repository->insert([ + ['name' => 'Item 1', 'event_id' => 1], + ['name' => 'Item 2', 'event_id' => 1], +]); + +// Bulk update +$this->repository->updateWhere( + ['status' => 'CANCELLED'], + ['event_id' => 1, 'payment_status' => 'PENDING'] +); + +// Bulk delete +$this->repository->deleteWhere(['status' => 'EXPIRED']); +``` + +### Increment/Decrement + +```php +// Increment single field +$this->repository->increment($id, 'view_count', 1); + +// Increment with additional updates +$this->model->where('id', $affiliateId) + ->increment('total_sales', 1, [ + 'total_sales_gross' => DB::raw('total_sales_gross + ' . $amount) + ]); +``` + +### Finding by Short ID + +```php +public function findByShortId(string $orderShortId): ?OrderDomainObject +{ + return $this->findFirstByField('short_id', $orderShortId); +} +``` + +## Testing Repositories + +### Unit Testing with Mocks + +```php +use Mockery; +use Tests\TestCase; + +class CreateAffiliateHandlerTest extends TestCase +{ + use DatabaseTransactions; + + public function test_creates_affiliate_successfully(): void + { + $repository = Mockery::mock(AffiliateRepositoryInterface::class); + + $repository->shouldReceive('findFirstWhere') + ->once() + ->with(['event_id' => 1, 'code' => 'TEST']) + ->andReturn(null); + + $repository->shouldReceive('create') + ->once() + ->with([ + 'event_id' => 1, + 'name' => 'Test Affiliate', + 'code' => 'TEST', + ]) + ->andReturn(new AffiliateDomainObject()); + + $handler = new CreateAffiliateHandler($repository); + $result = $handler->handle(1, 1, new UpsertAffiliateDTO(...)); + + $this->assertInstanceOf(AffiliateDomainObject::class, $result); + } +} +``` + +## Repository Method Reference + +### Query Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `all()` | Collection | Get all records | +| `findById()` | DomainObject | Find by ID (throws if not found) | +| `findFirst()` | ?DomainObject | Find by ID (returns null if not found) | +| `findWhere()` | Collection | Find multiple by conditions | +| `findFirstWhere()` | ?DomainObject | Find first by conditions | +| `findWhereIn()` | Collection | Find where column in array | +| `paginate()` | LengthAwarePaginator | Paginate all | +| `paginateWhere()` | LengthAwarePaginator | Paginate with conditions | +| `countWhere()` | int | Count matching records | + +### Mutation Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `create()` | DomainObject | Create single record | +| `insert()` | bool | Bulk insert (no domain objects returned) | +| `updateFromArray()` | DomainObject | Update by ID with array | +| `updateFromDomainObject()` | DomainObject | Update by ID with domain object | +| `updateWhere()` | int | Bulk update matching records | +| `deleteById()` | bool | Delete by ID | +| `deleteWhere()` | int | Bulk delete matching records | + +### Utility Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `increment()` | int | Increment column | +| `decrement()` | int | Decrement column | +| `loadRelation()` | self | Add eager load (chainable) | +| `includeDeleted()` | self | Include soft deleted (chainable) | + +## Related Documentation + +- [Architecture Overview](architecture-overview.md) +- [Domain-Driven Design](domain-driven-design.md) +- [Database Schema](database-schema.md)