diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5e4cdf1c6e..0fe95494e4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,9 +5,11 @@ on: branches: [main, develop] paths: - 'backend/**' + - '.github/workflows/unit-tests.yml' pull_request: paths: - 'backend/**' + - '.github/workflows/unit-tests.yml' jobs: run-tests: @@ -17,6 +19,31 @@ jobs: matrix: php-versions: ['8.2', '8.3', '8.4'] + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: hievents_test + POSTGRES_USER: hievents + POSTGRES_PASSWORD: hievents + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U hievents -d hievents_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + # Job-level env. .env.testing supplies the rest, but the DB host on a CI + # runner is 127.0.0.1 (service container exposes its port on the runner), + # not the docker network alias used locally — override here. + env: + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_DATABASE: hievents_test + DB_USERNAME: hievents + DB_PASSWORD: hievents + steps: - name: Checkout code uses: actions/checkout@v3 @@ -25,7 +52,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, tokenizer + extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, pdo_pgsql, pgsql, tokenizer ini-values: post_max_size=256M, upload_max_filesize=256M coverage: none @@ -47,5 +74,22 @@ jobs: - name: Install dependencies run: cd backend && composer install --prefer-dist --no-progress --no-interaction + - name: Stage .env for testing + # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan + # commands run outside that flow read .env directly. Copy .env.testing + # to .env so both paths see the same config. + run: cp backend/.env.testing backend/.env + + - name: Wait for Postgres + run: | + for i in {1..30}; do + if pg_isready -h 127.0.0.1 -p 5432 -U hievents -d hievents_test; then + exit 0 + fi + sleep 1 + done + echo "Postgres did not become ready in time" >&2 + exit 1 + - name: Run PHPUnit Tests run: cd backend && ./vendor/bin/phpunit tests/Unit --no-coverage diff --git a/CLAUDE.md b/CLAUDE.md index f283207d7f..0b0c572958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,8 @@ cd docker/development - **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead - Unit tests extend Laravel's TestCase, not PHPUnit's TestCase - Use Mockery for mocking +- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` +- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. ### Frontend diff --git a/backend/.env.testing b/backend/.env.testing new file mode 100644 index 0000000000..e559763ef8 --- /dev/null +++ b/backend/.env.testing @@ -0,0 +1,43 @@ +# Auto-loaded by Laravel when APP_ENV=testing (i.e. whenever PHPUnit runs). +# Safe to commit — contains only test-only credentials and fixed test secrets. +# Real secrets must NEVER be added here. + +APP_NAME=Hi.Events +APP_ENV=testing +# Static, test-only AES-256 key. Do not reuse outside tests. +APP_KEY=base64:rasMRv+Gm0oDMcBq+j9MvRgR3a6JYPTZjpRD4rGG2wA= +APP_DEBUG=true +APP_URL=http://localhost +APP_FRONTEND_URL=http://localhost +APP_LOG_QUERIES=false +APP_SAAS_MODE_ENABLED=false + +LOG_CHANNEL=stderr +LOG_LEVEL=debug + +# Database — must end in _test (BaseRepositoryTest enforces this). +# CI exports overrides via the workflow; locally these defaults match the +# docker-compose pgsql service. +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=hievents_test +DB_USERNAME=username +DB_PASSWORD=password + +# Stateless drivers — keep tests hermetic, no external dependencies. +BROADCAST_DRIVER=log +CACHE_DRIVER=array +FILESYSTEM_PUBLIC_DISK=local +FILESYSTEM_PRIVATE_DISK=local +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +SESSION_LIFETIME=120 +MAIL_MAILER=array + +# Fixed test JWT secret — do not reuse outside tests. +JWT_SECRET=test-jwt-secret-not-for-production-use-only-in-tests-aaaaaaaaaa +JWT_ALGO=HS256 + +BCRYPT_ROUNDS=4 +TELESCOPE_ENABLED=false diff --git a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php index 3ce9ebb1dc..fb4670d327 100644 --- a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php @@ -20,6 +20,9 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A final public const DELETED_AT = 'deleted_at'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; + final public const PUBLIC_SHOW_ATTENDEE_NOTES = 'public_show_attendee_notes'; + final public const PUBLIC_SHOW_QUESTION_ANSWERS = 'public_show_question_answers'; + final public const PUBLIC_SHOW_ORDER_DETAILS = 'public_show_order_details'; protected int $id; protected int $event_id; @@ -31,6 +34,9 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A protected ?string $deleted_at = null; protected ?string $created_at = null; protected ?string $updated_at = null; + protected bool $public_show_attendee_notes = true; + protected bool $public_show_question_answers = true; + protected bool $public_show_order_details = true; public function toArray(): array { @@ -45,6 +51,9 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, + 'public_show_attendee_notes' => $this->public_show_attendee_notes ?? null, + 'public_show_question_answers' => $this->public_show_question_answers ?? null, + 'public_show_order_details' => $this->public_show_order_details ?? null, ]; } @@ -157,4 +166,37 @@ public function getUpdatedAt(): ?string { return $this->updated_at; } + + public function setPublicShowAttendeeNotes(bool $public_show_attendee_notes): self + { + $this->public_show_attendee_notes = $public_show_attendee_notes; + return $this; + } + + public function getPublicShowAttendeeNotes(): bool + { + return $this->public_show_attendee_notes; + } + + public function setPublicShowQuestionAnswers(bool $public_show_question_answers): self + { + $this->public_show_question_answers = $public_show_question_answers; + return $this; + } + + public function getPublicShowQuestionAnswers(): bool + { + return $this->public_show_question_answers; + } + + public function setPublicShowOrderDetails(bool $public_show_order_details): self + { + $this->public_show_order_details = $public_show_order_details; + return $this; + } + + public function getPublicShowOrderDetails(): bool + { + return $this->public_show_order_details; + } } diff --git a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php index 660f7a66f5..8e301915fd 100644 --- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const USER_ID = 'user_id'; final public const EVENT_ID = 'event_id'; - final public const ORGANIZER_ID = 'organizer_id'; final public const ACCOUNT_ID = 'account_id'; + final public const ORGANIZER_ID = 'organizer_id'; final public const URL = 'url'; final public const EVENT_TYPES = 'event_types'; final public const LAST_RESPONSE_CODE = 'last_response_code'; @@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $user_id; protected ?int $event_id = null; - protected ?int $organizer_id = null; protected int $account_id; + protected ?int $organizer_id = null; protected string $url; protected array|string $event_types; protected ?int $last_response_code = null; @@ -48,8 +48,8 @@ public function toArray(): array 'id' => $this->id ?? null, 'user_id' => $this->user_id ?? null, 'event_id' => $this->event_id ?? null, - 'organizer_id' => $this->organizer_id ?? null, 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, 'url' => $this->url ?? null, 'event_types' => $this->event_types ?? null, 'last_response_code' => $this->last_response_code ?? null, @@ -96,26 +96,26 @@ public function getEventId(): ?int return $this->event_id; } - public function setOrganizerId(?int $organizer_id): self + public function setAccountId(int $account_id): self { - $this->organizer_id = $organizer_id; + $this->account_id = $account_id; return $this; } - public function getOrganizerId(): ?int + public function getAccountId(): int { - return $this->organizer_id; + return $this->account_id; } - public function setAccountId(int $account_id): self + public function setOrganizerId(?int $organizer_id): self { - $this->account_id = $account_id; + $this->organizer_id = $organizer_id; return $this; } - public function getAccountId(): int + public function getOrganizerId(): ?int { - return $this->account_id; + return $this->organizer_id; } public function setUrl(string $url): self diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php index c9dc08d3ed..594469aa00 100644 --- a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php @@ -33,6 +33,9 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonR productIds: $request->validated('product_ids'), expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), + publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true, + publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true, + publicShowOrderDetails: $request->validated('public_show_order_details') ?? true, ) ); } catch (UnrecognizedProductIdException $exception) { diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php new file mode 100644 index 0000000000..1837320d69 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php @@ -0,0 +1,48 @@ +handler->handle( + shortId: $checkInListShortId, + attendeePublicId: $attendeePublicId, + staffAccountId: $this->resolveStaffAccountId(), + ); + + return $this->resourceResponse( + resource: AttendeeDetailPublicResource::class, + data: $detail, + ); + } + + /** + * The detail endpoint is public but should reveal all attendee fields to authenticated staff + * whose account matches the event's account. Returns null for anonymous / invalid tokens / + * any auth resolution failure — those callers get data filtered by the list's visibility flags. + */ + private function resolveStaffAccountId(): ?int + { + try { + return $this->authUserService->getAuthenticatedAccountId(); + } catch (Throwable) { + return null; + } + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php new file mode 100644 index 0000000000..1bc816a046 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php @@ -0,0 +1,27 @@ +getCheckInListStatsPublicHandler->handle($checkInListShortId); + + return $this->resourceResponse( + resource: CheckInListStatsPublicResource::class, + data: $stats, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php index dceda8c893..59439aac47 100644 --- a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php @@ -34,6 +34,9 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $c expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), id: $checkInListId, + publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true, + publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true, + publicShowOrderDetails: $request->validated('public_show_order_details') ?? true, ) ); } catch (UnrecognizedProductIdException $exception) { diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php index 06372e6760..9ab2d80ead 100644 --- a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php +++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php @@ -11,10 +11,13 @@ public function rules(): array { return [ 'name' => RulesHelper::REQUIRED_STRING, - 'description' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], 'expires_at' => ['nullable', 'date'], 'activates_at' => ['nullable', 'date'], 'product_ids' => ['required', 'array', 'min:1'], + 'public_show_attendee_notes' => ['nullable', 'boolean'], + 'public_show_question_answers' => ['nullable', 'boolean'], + 'public_show_order_details' => ['nullable', 'boolean'], ]; } diff --git a/backend/app/Repository/DTO/CheckInListProductStatDTO.php b/backend/app/Repository/DTO/CheckInListProductStatDTO.php new file mode 100644 index 0000000000..0d2a1fa67b --- /dev/null +++ b/backend/app/Repository/DTO/CheckInListProductStatDTO.php @@ -0,0 +1,17 @@ + */ abstract class BaseRepository implements RepositoryInterface @@ -50,20 +53,18 @@ public function __construct(Application $application, DatabaseManager $db) /** * Returns a FQCL of the model - * - * @return string */ abstract protected function getModel(): string; /** - * @param class-string $domainObjectClass + * @param class-string $domainObjectClass */ protected function validateSortColumn(?string $sortBy, string $domainObjectClass): string { $allowedColumns = array_keys($domainObjectClass::getAllowedSorts()->toArray()); $default = $domainObjectClass::getDefaultSort(); - if ($sortBy === null || !in_array($sortBy, $allowedColumns, true)) { + if ($sortBy === null || ! in_array($sortBy, $allowedColumns, true)) { return $default; } @@ -86,61 +87,63 @@ public function setMaxPerPage(int $maxPerPage): static public function all(array $columns = self::DEFAULT_COLUMNS): Collection { - $models = $this->model->all($columns); - $this->resetModel(); - - return $this->handleResults($models); + return $this->runQuery( + fn () => $this->handleResults($this->model->all($columns)) + ); } public function paginate( - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - $results = $this->model->paginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); - - return $this->handleResults($results); + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $this->model->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } public function paginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ?int $page = null, - ): LengthAwarePaginator - { - $this->applyConditions($where); - $results = $this->model->paginate( - perPage: $this->getPaginationPerPage($limit), - columns: $columns, - page: $page, - ); - $this->resetModel(); + ?int $page = null, + ): LengthAwarePaginator { + return $this->runQuery(function () use ($where, $limit, $columns, $page) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults($this->model->paginate( + perPage: $this->getPaginationPerPage($limit), + columns: $columns, + page: $page, + )); + }); } public function simplePaginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ): Paginator - { - $this->applyConditions($where); - $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); + ): Paginator { + return $this->runQuery(function () use ($where, $limit, $columns) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults( + $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns) + ); + }); } public function paginateEloquentRelation( Relation $relation, - ?int $limit = null, - array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns)); + ?int $limit = null, + array $columns = self::DEFAULT_COLUMNS + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $relation->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } /** @@ -148,101 +151,94 @@ public function paginateEloquentRelation( */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findFirstByField( - string $field, + string $field, ?string $value = null, - array $columns = ['*'] - ): ?DomainObjectInterface - { - $model = $this->model->where($field, '=', $value)->first($columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + array $columns = ['*'] + ): ?DomainObjectInterface { + return $this->runQuery( + fn () => $this->handleSingleResult( + $this->model->where($field, '=', $value)->first($columns) + ) + ); } public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findWhere( array $where, array $columns = self::DEFAULT_COLUMNS, array $orderAndDirections = [], - ): Collection - { - $this->applyConditions($where); + ): Collection { + return $this->runQuery(function () use ($where, $columns, $orderAndDirections) { + $this->applyConditions($where); - if ($orderAndDirections) { foreach ($orderAndDirections as $orderAndDirection) { $this->model = $this->model->orderBy( $orderAndDirection->getOrder(), $orderAndDirection->getDirection() ); } - } - - $model = $this->model->get($columns); - - $this->resetModel(); - return $this->handleResults($model); + return $this->handleResults($this->model->get($columns)); + }); } public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $this->applyConditions($where); - $model = $this->model->first($columns); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns) { + $this->applyConditions($where); - return $this->handleSingleResult($model); + return $this->handleSingleResult($this->model->first($columns)); + }); } public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection { - if ($additionalWhere) { - $this->applyConditions($additionalWhere); - } - - $model = $this->model->whereIn($field, $values)->get($columns); - $this->resetModel(); + return $this->runQuery(function () use ($field, $values, $additionalWhere, $columns) { + if ($additionalWhere) { + $this->applyConditions($additionalWhere); + } - return $this->handleResults($model); + return $this->handleResults($this->model->whereIn($field, $values)->get($columns)); + }); } public function create(array $attributes): DomainObjectInterface { - $model = $this->model->newInstance(collect($attributes)->toArray()); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($attributes) { + $model = $this->model->newInstance(collect($attributes)->toArray()); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function insert(array $inserts): bool { - // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, - // so we need to do it manually - foreach ($inserts as $index => $insert) { - if (!isset($insert['created_at'], $insert['updated_at'])) { - $now = Carbon::now(); - $inserts[$index]['created_at'] = $now; - $inserts[$index]['updated_at'] = $now; + return $this->runQuery(function () use ($inserts) { + // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, + // so we need to do it manually + foreach ($inserts as $index => $insert) { + if (! isset($insert['created_at'], $insert['updated_at'])) { + $now = Carbon::now(); + $inserts[$index]['created_at'] = $now; + $inserts[$index]['updated_at'] = $now; + } } - } - $insert = $this->model->insert($inserts); - $this->resetModel(); - return $insert; + return $this->model->insert($inserts); + }); } public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface @@ -252,93 +248,103 @@ public function updateFromDomainObject(int $id, DomainObjectInterface $domainObj public function updateFromArray(int $id, array $attributes): DomainObjectInterface { - $model = $this->model->findOrFail($id); - $model->fill($attributes); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes) { + $model = $this->model->findOrFail($id); + $model->fill($attributes); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function updateWhere(array $attributes, array $where): int { - $this->applyConditions($where); - $count = $this->model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($attributes, $where) { + $this->applyConditions($where); - return $count; + return $this->model->update($attributes); + }); } public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface { - $model = $this->model->where($where)->findOrFail($id); - $model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes, $where) { + $model = $this->model->where($where)->findOrFail($id); + $model->update($attributes); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function deleteById(int $id): bool { - return $this->model->findOrFail($id)->delete(); + return $this->runQuery( + fn () => (bool) $this->model->findOrFail($id)->delete() + ); } public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null): int { - if ($where) { - $this->applyConditions($where); - } - - $count = $this->model->incrementEach($columns, $additionalUpdates); - $this->resetModel(); + return $this->runQuery(function () use ($columns, $additionalUpdates, $where) { + if ($where) { + $this->applyConditions($where); + } - return $count; + // Eloquent\Builder's __call swallows incrementEach's int return value + // and hands back the Builder, so we route through the underlying + // QueryBuilder to get the affected-row count. + return $this->resolveBaseQuery()->incrementEach($columns, $additionalUpdates); + }); } public function decrementEach(array $where, array $columns, array $extra = []): int { - $this->applyConditions($where); - $count = $this->model->decrementEach($columns, $extra); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns, $extra) { + $this->applyConditions($where); - return $count; + return $this->resolveBaseQuery()->decrementEach($columns, $extra); + }); } public function increment(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)->increment($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->increment($column, $amount) + ); } public function incrementWhere(array $where, string $column, int|float $amount = 1): int { - $this->applyConditions($where); - $count = $this->model->increment($column, $amount); - $this->resetModel(); + return $this->runQuery(function () use ($where, $column, $amount) { + $this->applyConditions($where); - return $count; + return $this->model->increment($column, $amount); + }); } public function decrement(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)?->decrement($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->decrement($column, $amount) + ); } public function deleteWhere(array $conditions): int { - $this->applyConditions($conditions); - $deleted = $this->model->delete(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $deleted; + return $this->model->delete(); + }); } public function countWhere(array $conditions): int { - $this->applyConditions($conditions); - $count = $this->model->count(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $count; + return $this->model->count(); + }); } public function loadRelation(string|Relationship $relationship): static @@ -363,7 +369,7 @@ public function includeDeleted(): static protected function applyConditions(array $where): void { foreach ($where as $field => $value) { - if (is_callable($value) && !is_string($value)) { + if (is_callable($value) && ! is_string($value)) { $this->model = $this->model->where($value); } elseif (is_array($value)) { [$field, $condition, $val] = $value; @@ -406,6 +412,48 @@ protected function initModel(?string $model = null): Model return $this->app->make($model ?: $this->getModel()); } + /** + * Execute a query callback and guarantee per-call state is reset afterwards, + * even if the callback throws. This is the single point at which the in-flight + * builder ($this->model) and the eager-load list ($this->eagerLoads) are cleared. + * + * The callback runs BEFORE reset, so hydration helpers that read $this->eagerLoads + * (e.g. handleEagerLoads()) still see the correct state. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + protected function runQuery(Closure $callback): mixed + { + try { + return $callback(); + } finally { + $this->resetState(); + } + } + + protected function resetState(): void + { + $model = $this->getModel(); + $this->model = new $model; + $this->eagerLoads = []; + } + + /** + * Resolve $this->model (which may be a fresh Model or an Eloquent Builder + * after applyConditions()) to the underlying query builder. Required for + * methods Eloquent\Builder::__call swallows the return value of, e.g. + * incrementEach() / decrementEach(). + */ + private function resolveBaseQuery(): QueryBuilder + { + return $this->model instanceof Builder + ? $this->model->getQuery() + : $this->model->newQuery()->getQuery(); + } + protected function handleResults($results, ?string $domainObjectOverride = null) { $domainObjects = []; @@ -428,10 +476,9 @@ protected function handleResults($results, ?string $domainObjectOverride = null) protected function handleSingleResult( ?BaseModel $model, - ?string $domainObjectOverride = null - ): ?DomainObjectInterface - { - if (!$model) { + ?string $domainObjectOverride = null + ): ?DomainObjectInterface { + if (! $model) { return null; } @@ -442,11 +489,10 @@ protected function applyFilterFields( QueryParamsDTO $params, array $allowedFilterFields = [], ?string $prefix = null, - ): void - { + ): void { if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { $params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) { - if (!in_array($filterField->field, $allowedFilterFields, true)) { + if (! in_array($filterField->field, $allowedFilterFields, true)) { return; } @@ -467,7 +513,7 @@ protected function applyFilterFields( sprintf('Operator %s is not supported', $filterField->operator) ); - $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field; + $field = $prefix ? $prefix.'.'.$filterField->field : $filterField->field; // Special handling for IN operator if ($operator === 'IN') { @@ -491,10 +537,13 @@ protected function applyFilterFields( } } + /** + * @deprecated Use resetState() instead. Kept for backwards compatibility with + * subclass repositories that build custom queries on $this->model. + */ protected function resetModel(): void { - $model = $this->getModel(); - $this->model = new $model(); + $this->resetState(); } private function getPaginationPerPage(?int $perPage): int @@ -503,30 +552,26 @@ private function getPaginationPerPage(?int $perPage): int $perPage = self::DEFAULT_PAGINATE_LIMIT; } - return (int)min($perPage, $this->maxPerPage); + return (int) min($perPage, $this->maxPerPage); } /** - * @param Model $model - * @param string|null $domainObjectOverride A FQCN of a DO - * @param array|null $relationships - * @return DomainObjectInterface + * @param string|null $domainObjectOverride A FQCN of a DO * * @todo use hydrate method from AbstractDomainObject */ private function hydrateDomainObjectFromModel( - Model $model, + Model $model, ?string $domainObjectOverride = null, - ?array $relationships = null, - ): DomainObjectInterface - { + ?array $relationships = null, + ): DomainObjectInterface { /** @var DomainObjectInterface $object */ $object = $domainObjectOverride ?: $this->getDomainObject(); - $object = new $object(); + $object = new $object; foreach ($model->attributesToArray() as $attribute => $value) { - $method = 'set' . ucfirst(Str::camel($attribute)); - if (is_callable(array($object, $method))) { + $method = 'set'.Str::studly($attribute); + if (is_callable([$object, $method])) { try { $object->$method($value); } catch (TypeError $e) { @@ -538,7 +583,7 @@ private function hydrateDomainObjectFromModel( var_export($value, true), $e->getMessage() ), - (int)$e->getCode(), + (int) $e->getCode(), $e ); } @@ -554,24 +599,20 @@ private function hydrateDomainObjectFromModel( /** * This method will handle nested eager loading of relationships * - * @param Model $model - * @param DomainObjectInterface $object - * @param Relationship[]|null $relationships - * - * @return void + * @param Relationship[]|null $relationships */ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?array $relationships): void { $eagerLoads = $relationships ?: $this->eagerLoads; foreach ($eagerLoads as $eagerLoad) { - if (!$model->relationLoaded($eagerLoad->getName())) { + if (! $model->relationLoaded($eagerLoad->getName())) { continue; } $relatedModels = $model->getRelation($eagerLoad->getName()); - $setterMethod = 'set' . Str::studly($eagerLoad->getName()); + $setterMethod = 'set'.Str::studly($eagerLoad->getName()); - if (!is_callable([$object, $setterMethod])) { + if (! is_callable([$object, $setterMethod])) { throw new BadMethodCallException( sprintf( 'Method %s is not callable on %s. Does it exist?', @@ -590,7 +631,7 @@ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ? ); }); $object->$setterMethod($relatedDomainObjects); - } else if ($relatedModels instanceof BaseModel) { + } elseif ($relatedModels instanceof BaseModel) { $relatedDomainObject = $this->hydrateDomainObjectFromModel( $relatedModels, $eagerLoad->getDomainObject(), diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php index 46b37358da..f58c583571 100644 --- a/backend/app/Repository/Eloquent/CheckInListRepository.php +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -8,6 +8,8 @@ use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\CheckInList; use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO; +use HiEvents\Repository\DTO\CheckInListProductStatDTO; +use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -126,6 +128,95 @@ public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collectio ); } + public function getPerProductCheckInStatsById(int $checkInListId): Collection + { + $sql = <<db->select($sql, ['check_in_list_id' => $checkInListId]); + + return collect($rows)->map( + static fn($row) => new CheckInListProductStatDTO( + productId: (int)$row->product_id, + productTitle: $row->product_title, + totalAttendees: (int)$row->total_attendees, + checkedInAttendees: (int)$row->checked_in_attendees, + ) + ); + } + + public function getRecentCheckInsById(int $checkInListId, int $limit): Collection + { + $sql = <<db->select($sql, [ + 'check_in_list_id' => $checkInListId, + 'row_limit' => $limit, + ]); + + return collect($rows)->map( + static fn($row) => new CheckInListRecentCheckInDTO( + attendeePublicId: $row->attendee_public_id, + firstName: $row->first_name ?? '', + lastName: $row->last_name ?? '', + productTitle: $row->product_title, + checkedInAt: (string)$row->checked_in_at, + ) + ); + } + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator { $where = [ diff --git a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php index 787e734a4c..01c308e0d0 100644 --- a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php @@ -5,6 +5,8 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO; +use HiEvents\Repository\DTO\CheckInListProductStatDTO; +use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -23,4 +25,14 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte * @return Collection */ public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection; + + /** + * @return Collection + */ + public function getPerProductCheckInStatsById(int $checkInListId): Collection; + + /** + * @return Collection + */ + public function getRecentCheckInsById(int $checkInListId, int $limit): Collection; } diff --git a/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php new file mode 100644 index 0000000000..2a05063192 --- /dev/null +++ b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php @@ -0,0 +1,76 @@ +attendee; + $order = $attendee->getOrder(); + $product = $attendee->getProduct(); + + $data = [ + 'id' => $attendee->getId(), + 'public_id' => $attendee->getPublicId(), + 'first_name' => $attendee->getFirstName(), + 'last_name' => $attendee->getLastName(), + 'email' => $attendee->getEmail(), + 'status' => $attendee->getStatus(), + 'product_id' => $attendee->getProductId(), + 'product_title' => $product?->getTitle(), + 'check_ins' => $this->currentListCheckIns + ->map(static fn(AttendeeCheckInDomainObject $checkIn) => (new AttendeeCheckInPublicResource($checkIn))->toArray(request())) + ->values() + ->all(), + 'visibility' => [ + 'notes' => $this->showNotes, + 'question_answers' => $this->showQuestionAnswers, + 'order_details' => $this->showOrderDetails, + ], + ]; + + if ($this->showNotes) { + $data['notes'] = $attendee->getNotes(); + } + + if ($this->showQuestionAnswers) { + $data['question_answers'] = array_map( + static fn(QuestionAndAnswerViewDomainObject $qa) => [ + 'question_id' => $qa->getQuestionId(), + 'title' => $qa->getTitle(), + 'answer' => $qa->getAnswer(), + 'belongs_to' => $qa->getBelongsTo(), + ], + $attendee->getQuestionAndAnswerViews()?->all() ?? [], + ); + } + + if ($this->showOrderDetails && $order) { + $data['order'] = [ + 'id' => $order->getId(), + 'public_id' => $order->getPublicId(), + 'short_id' => $order->getShortId(), + 'status' => $order->getStatus(), + 'total_gross' => $order->getTotalGross(), + 'currency' => $order->getCurrency(), + 'first_name' => $order->getFirstName(), + 'last_name' => $order->getLastName(), + 'email' => $order->getEmail(), + 'created_at' => $order->getCreatedAt(), + ]; + } + + return $data; + } +} diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php index 744f947c70..2aba67d303 100644 --- a/backend/app/Resources/CheckInList/CheckInListResource.php +++ b/backend/app/Resources/CheckInList/CheckInListResource.php @@ -22,6 +22,9 @@ public function toArray($request): array 'short_id' => $this->getShortId(), 'total_attendees' => $this->getTotalAttendeesCount(), 'checked_in_attendees' => $this->getCheckedInCount(), + 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(), + 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(), + 'public_show_order_details' => $this->getPublicShowOrderDetails(), $this->mergeWhen($this->getEvent() !== null, fn() => [ 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), diff --git a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php index 7135da87e4..d4f7ee1cfc 100644 --- a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php +++ b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php @@ -23,6 +23,9 @@ public function toArray($request): array 'activates_at' => $this->getActivatesAt(), 'total_attendees' => $this->getTotalAttendeesCount(), 'checked_in_attendees' => $this->getCheckedInCount(), + 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(), + 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(), + 'public_show_order_details' => $this->getPublicShowOrderDetails(), $this->mergeWhen($this->getEvent() !== null, fn() => [ 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), diff --git a/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php new file mode 100644 index 0000000000..ab760f9e57 --- /dev/null +++ b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php @@ -0,0 +1,41 @@ + $this->totalAttendees, + 'checked_in_attendees' => $this->checkedInAttendees, + 'per_product' => array_map( + static fn(CheckInListProductStatDTO $stat) => [ + 'product_id' => $stat->productId, + 'product_title' => $stat->productTitle, + 'total_attendees' => $stat->totalAttendees, + 'checked_in_attendees' => $stat->checkedInAttendees, + ], + $this->perProduct, + ), + 'recent_check_ins' => array_map( + static fn(CheckInListRecentCheckInDTO $checkIn) => [ + 'attendee_public_id' => $checkIn->attendeePublicId, + 'first_name' => $checkIn->firstName, + 'last_name' => $checkIn->lastName, + 'product_title' => $checkIn->productTitle, + 'checked_in_at' => $checkIn->checkedInAt, + ], + $this->recentCheckIns, + ), + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php index 6682b2a346..e46901cd25 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php @@ -25,7 +25,10 @@ public function handle(UpsertCheckInListDTO $listData): CheckInListDomainObject ->setDescription($listData->description) ->setEventId($listData->eventId) ->setExpiresAt($listData->expiresAt) - ->setActivatesAt($listData->activatesAt); + ->setActivatesAt($listData->activatesAt) + ->setPublicShowAttendeeNotes($listData->publicShowAttendeeNotes) + ->setPublicShowQuestionAnswers($listData->publicShowQuestionAnswers) + ->setPublicShowOrderDetails($listData->publicShowOrderDetails); return $this->createCheckInListService->createCheckInList( checkInList: $checkInList, diff --git a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php index 229a93c42f..478347b2b8 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php +++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php @@ -14,6 +14,9 @@ public function __construct( public ?string $expiresAt = null, public ?string $activatesAt = null, public ?int $id = null, + public bool $publicShowAttendeeNotes = true, + public bool $publicShowQuestionAnswers = true, + public bool $publicShowOrderDetails = true, ) { } diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php new file mode 100644 index 0000000000..7ad997b4da --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php @@ -0,0 +1,24 @@ + $currentListCheckIns + */ + public function __construct( + public AttendeeDomainObject $attendee, + public Collection $currentListCheckIns, + public bool $showNotes, + public bool $showQuestionAnswers, + public bool $showOrderDetails, + ) + { + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php new file mode 100644 index 0000000000..1b86d9d673 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php @@ -0,0 +1,93 @@ +checkInListRepository + ->loadRelation(new Relationship(EventDomainObject::class, name: 'event')) + ->findFirstWhere([ + CheckInListDomainObjectAbstract::SHORT_ID => $shortId, + ]); + + if (!$checkInList) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->loadRelation(QuestionAndAnswerViewDomainObject::class) + ->loadRelation(new Relationship(ProductDomainObject::class, name: 'product')) + ->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_ins')) + ->findFirstWhere([ + 'public_id' => $attendeePublicId, + 'event_id' => $checkInList->getEventId(), + ]); + + if (!$attendee) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + $currentListCheckIns = $this->filterCheckInsForList($attendee->getCheckIns(), $checkInList->getId()); + $isStaff = $this->hasStaffAccess($checkInList, $staffAccountId); + + return new PublicAttendeeDetailDTO( + attendee: $attendee, + currentListCheckIns: $currentListCheckIns, + showNotes: $isStaff || $checkInList->getPublicShowAttendeeNotes(), + showQuestionAnswers: $isStaff || $checkInList->getPublicShowQuestionAnswers(), + showOrderDetails: $isStaff || $checkInList->getPublicShowOrderDetails(), + ); + } + + /** + * @return Collection + */ + private function filterCheckInsForList(?Collection $checkIns, int $checkInListId): Collection + { + if ($checkIns === null) { + return new Collection(); + } + + return $checkIns->filter( + static fn(AttendeeCheckInDomainObject $checkIn) => $checkIn->getCheckInListId() === $checkInListId + )->values(); + } + + private function hasStaffAccess(CheckInListDomainObject $checkInList, ?int $staffAccountId): bool + { + if ($staffAccountId === null) { + return false; + } + + $event = $checkInList->getEvent(); + if ($event === null) { + return false; + } + + return $event->getAccountId() === $staffAccountId; + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php new file mode 100644 index 0000000000..a8e830e089 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php @@ -0,0 +1,38 @@ +checkInListRepository->findFirstWhere(['short_id' => $shortId]); + + if (!$checkInList) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + $totals = $this->checkInListRepository->getCheckedInAttendeeCountById($checkInList->getId()); + $perProduct = $this->checkInListRepository->getPerProductCheckInStatsById($checkInList->getId()); + $recent = $this->checkInListRepository->getRecentCheckInsById($checkInList->getId(), self::RECENT_CHECK_INS_LIMIT); + + return new CheckInListStatsDTO( + totalAttendees: $totals->totalAttendeesCount, + checkedInAttendees: $totals->checkedInCount, + perProduct: $perProduct->values()->all(), + recentCheckIns: $recent->values()->all(), + ); + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php index d31d7873c3..acfccc9697 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php @@ -26,7 +26,10 @@ public function handle(UpsertCheckInListDTO $data): CheckInListDomainObject ->setDescription($data->description) ->setEventId($data->eventId) ->setExpiresAt($data->expiresAt) - ->setActivatesAt($data->activatesAt); + ->setActivatesAt($data->activatesAt) + ->setPublicShowAttendeeNotes($data->publicShowAttendeeNotes) + ->setPublicShowQuestionAnswers($data->publicShowQuestionAnswers) + ->setPublicShowOrderDetails($data->publicShowOrderDetails); return $this->updateCheckInlistService->updateCheckInlist( checkInList: $checkInList, diff --git a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php index a4e95f929c..3d3bedf249 100644 --- a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php @@ -45,6 +45,9 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $p ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) : null, CheckInListDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(), ]); $this->checkInListProductAssociationService->addCheckInListToProducts( diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php index 11a441deaf..4fbf26310f 100644 --- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php @@ -43,6 +43,9 @@ public function updateCheckInList(CheckInListDomainObject $checkInList, array $p CheckInListDomainObjectAbstract::ACTIVATES_AT => $checkInList->getActivatesAt() ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) : null, + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(), ], where: [ CheckInListDomainObjectAbstract::ID => $checkInList->getId(), diff --git a/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php new file mode 100644 index 0000000000..4862967304 --- /dev/null +++ b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php @@ -0,0 +1,25 @@ +boolean('public_show_attendee_notes')->default(true); + $table->boolean('public_show_question_answers')->default(true); + $table->boolean('public_show_order_details')->default(true); + }); + } + + public function down(): void + { + Schema::table('check_in_lists', function (Blueprint $table) { + $table->dropColumn('public_show_attendee_notes'); + $table->dropColumn('public_show_question_answers'); + $table->dropColumn('public_show_order_details'); + }); + } +}; diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 8fec6e6b02..0d665f89e7 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -22,7 +22,7 @@ - + diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..250990fbb4 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -42,8 +42,10 @@ use HiEvents\Http\Actions\CheckInLists\Public\CreateAttendeeCheckInPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\DeleteAttendeeCheckInPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeePublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeeDetailPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeesPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListStatsPublicAction; use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\GetColorThemesAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; @@ -535,8 +537,10 @@ function (Router $router): void { // Check-In $router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class); + $router->get('/check-in-lists/{check_in_list_short_id}/stats', GetCheckInListStatsPublicAction::class); $router->get('/check-in-lists/{check_in_list_short_id}/attendees', GetCheckInListAttendeesPublicAction::class); $router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}', GetCheckInListAttendeePublicAction::class); + $router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}/detail', GetCheckInListAttendeeDetailPublicAction::class); $router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class); $router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class); diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php index 2932d4a69d..621ce4dc06 100644 --- a/backend/tests/TestCase.php +++ b/backend/tests/TestCase.php @@ -3,8 +3,45 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { use CreatesApplication; + + protected function setUp(): void + { + parent::setUp(); + + $this->guardAgainstNonTestDatabase(); + } + + /** + * Hard safety net: any test that boots Laravel could (intentionally or not) + * issue queries against the configured database. Refuse to run unless the + * default connection's database name ends in "_test" so a misconfigured + * environment can never touch a dev/staging/prod database. + * + * The check reads config only — it does not open a connection — so tests + * that never touch the database still pay only a constant-time cost and + * never fail because the test database is unreachable. + * + * Marked final so individual tests cannot bypass it. + */ + final protected function guardAgainstNonTestDatabase(): void + { + $defaultConnection = config('database.default'); + $database = config("database.connections.{$defaultConnection}.database"); + + if (! is_string($database) || ! str_ends_with($database, '_test')) { + throw new RuntimeException(sprintf( + 'Refusing to run %s: default database connection "%s" points at "%s", ' + .'which does not end in "_test". Set DB_DATABASE to a *_test database ' + .'(CI uses hievents_test; locally configured via backend/.env.testing).', + static::class, + (string) $defaultConnection, + (string) $database, + )); + } + } } diff --git a/backend/tests/Unit/Repository/BaseRepositoryTest.php b/backend/tests/Unit/Repository/BaseRepositoryTest.php new file mode 100644 index 0000000000..63a77a12e2 --- /dev/null +++ b/backend/tests/Unit/Repository/BaseRepositoryTest.php @@ -0,0 +1,725 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('br_test_widgets', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->nullable(); + $table->string('name'); + $table->string('sku')->nullable(); + $table->integer('quantity')->default(0); + $table->decimal('price', 10, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->repository = $this->app->make(WidgetRepository::class); + $this->categoryRepository = $this->app->make(WidgetCategoryRepository::class); + } + + protected function tearDown(): void + { + Schema::dropIfExists('br_test_widgets'); + Schema::dropIfExists('br_test_widget_categories'); + + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function makeCategory(string $name = 'Default'): WidgetCategoryModel + { + $category = new WidgetCategoryModel; + $category->name = $name; + $category->save(); + + return $category; + } + + private function makeWidget(array $overrides = []): WidgetModel + { + $widget = new WidgetModel; + $widget->fill(array_merge([ + 'name' => 'Widget '.uniqid('', true), + 'sku' => 'SKU-'.uniqid('', true), + 'quantity' => 10, + 'price' => 9.99, + 'is_active' => true, + 'category_id' => null, + ], $overrides)); + $widget->save(); + + return $widget; + } + + // ───────────────────────────────────────────────────────────────────────── + // create / insert + // ───────────────────────────────────────────────────────────────────────── + + public function test_create_inserts_a_row_and_hydrates_a_domain_object(): void + { + $widget = $this->repository->create([ + 'name' => 'Sprocket', + 'sku' => 'SP-001', + 'quantity' => 5, + 'price' => 12.50, + 'is_active' => true, + ]); + + $this->assertInstanceOf(WidgetDomainObject::class, $widget); + $this->assertNotNull($widget->getId()); + $this->assertSame('Sprocket', $widget->getName()); + $this->assertSame(5, $widget->getQuantity()); + $this->assertSame(12.50, $widget->getPrice()); + $this->assertTrue($widget->getIsActive()); + + $this->assertDatabaseHas('br_test_widgets', ['sku' => 'SP-001']); + } + + public function test_insert_bulk_inserts_rows_and_autofills_timestamps(): void + { + $result = $this->repository->insert([ + ['name' => 'A', 'sku' => 'A-1', 'quantity' => 1, 'price' => 1, 'is_active' => true], + ['name' => 'B', 'sku' => 'B-1', 'quantity' => 2, 'price' => 2, 'is_active' => true], + ]); + + $this->assertTrue($result); + $this->assertSame(2, WidgetModel::query()->count()); + // both rows should have timestamps populated by the base repository + $this->assertSame(0, WidgetModel::query()->whereNull('created_at')->count()); + $this->assertSame(0, WidgetModel::query()->whereNull('updated_at')->count()); + } + + public function test_insert_preserves_caller_supplied_timestamps(): void + { + $supplied = '2020-01-01 00:00:00'; + + $this->repository->insert([ + [ + 'name' => 'A', + 'sku' => 'A-1', + 'quantity' => 1, + 'price' => 1, + 'is_active' => true, + 'created_at' => $supplied, + 'updated_at' => $supplied, + ], + ]); + + $this->assertSame(1, WidgetModel::query()->where('created_at', $supplied)->count()); + } + + // ───────────────────────────────────────────────────────────────────────── + // findById / findFirst / findFirstByField / findFirstWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_by_id_returns_hydrated_domain_object(): void + { + $widget = $this->makeWidget(['name' => 'Cog']); + + $found = $this->repository->findById($widget->id); + + $this->assertInstanceOf(WidgetDomainObject::class, $found); + $this->assertSame($widget->id, $found->getId()); + $this->assertSame('Cog', $found->getName()); + } + + public function test_find_by_id_throws_when_missing(): void + { + $this->expectException(ModelNotFoundException::class); + $this->repository->findById(999_999); + } + + public function test_find_first_returns_domain_object_when_present(): void + { + $widget = $this->makeWidget(['name' => 'Hinge']); + + $found = $this->repository->findFirst($widget->id); + + $this->assertNotNull($found); + $this->assertSame('Hinge', $found->getName()); + } + + public function test_find_first_by_field_returns_match(): void + { + $this->makeWidget(['sku' => 'UNIQ-1']); + + $found = $this->repository->findFirstByField('sku', 'UNIQ-1'); + + $this->assertNotNull($found); + $this->assertSame('UNIQ-1', $found->getSku()); + } + + public function test_find_first_by_field_returns_null_when_no_match(): void + { + $found = $this->repository->findFirstByField('sku', 'does-not-exist'); + + $this->assertNull($found); + } + + public function test_find_first_where_returns_first_matching_row(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => false]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + + $found = $this->repository->findFirstWhere(['is_active' => true]); + + $this->assertNotNull($found); + $this->assertSame('B', $found->getName()); + } + + public function test_find_first_where_returns_null_when_no_match(): void + { + $this->makeWidget(['is_active' => true]); + + $this->assertNull($this->repository->findFirstWhere(['is_active' => false])); + } + + // ───────────────────────────────────────────────────────────────────────── + // findWhere / findWhereIn / all / countWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_where_returns_collection_of_domain_objects(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => true]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + $this->makeWidget(['name' => 'C', 'is_active' => false]); + + $results = $this->repository->findWhere(['is_active' => true]); + + $this->assertInstanceOf(Collection::class, $results); + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $results); + } + + public function test_find_where_orders_results_using_order_and_directions(): void + { + $this->makeWidget(['name' => 'B']); + $this->makeWidget(['name' => 'A']); + $this->makeWidget(['name' => 'C']); + + $results = $this->repository->findWhere( + where: [], + orderAndDirections: [new OrderAndDirection('name', 'asc')], + ); + + $names = $results->map(fn (WidgetDomainObject $w) => $w->getName())->all(); + $this->assertSame(['A', 'B', 'C'], $names); + } + + public function test_find_where_in_filters_by_inclusion_with_additional_where(): void + { + $w1 = $this->makeWidget(['name' => 'X', 'is_active' => true]); + $w2 = $this->makeWidget(['name' => 'Y', 'is_active' => false]); + $this->makeWidget(['name' => 'Z', 'is_active' => true]); + + $results = $this->repository->findWhereIn( + field: 'id', + values: [$w1->id, $w2->id], + additionalWhere: ['is_active' => true], + ); + + $this->assertCount(1, $results); + $this->assertSame('X', $results->first()->getName()); + } + + public function test_all_returns_every_row(): void + { + $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $this->assertCount(3, $this->repository->all()); + } + + public function test_count_where_counts_matching_rows(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $this->assertSame(2, $this->repository->countWhere(['is_active' => true])); + $this->assertSame(3, $this->repository->countWhere([])); + } + + // ───────────────────────────────────────────────────────────────────────── + // applyConditions DSL + // ───────────────────────────────────────────────────────────────────────── + + public function test_apply_conditions_supports_in_operator(): void + { + $a = $this->makeWidget(); + $b = $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'in', [$a->id, $b->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_not_in_operator(): void + { + $a = $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'not in', [$a->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_not_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'not null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_comparison_operators(): void + { + $this->makeWidget(['quantity' => 5]); + $this->makeWidget(['quantity' => 10]); + $this->makeWidget(['quantity' => 15]); + + $this->assertCount(2, $this->repository->findWhere([['quantity', '>=', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '<', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '=', 15]])); + } + + public function test_apply_conditions_treats_simple_pairs_as_equality(): void + { + $this->makeWidget(['name' => 'foo']); + $this->makeWidget(['name' => 'bar']); + + $results = $this->repository->findWhere(['name' => 'foo']); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_callable_value(): void + { + $this->makeWidget(['name' => 'foo', 'is_active' => true]); + $this->makeWidget(['name' => 'bar', 'is_active' => true]); + $this->makeWidget(['name' => 'foo', 'is_active' => false]); + + $results = $this->repository->findWhere([ + 'name' => 'foo', + // closure-as-value path through applyConditions + fn ($q) => $q->where('is_active', true), + ]); + + $this->assertCount(1, $results); + } + + // ───────────────────────────────────────────────────────────────────────── + // update / delete + // ───────────────────────────────────────────────────────────────────────── + + public function test_update_from_array_persists_changes_and_returns_fresh_object(): void + { + $widget = $this->makeWidget(['name' => 'old', 'quantity' => 1]); + + $updated = $this->repository->updateFromArray($widget->id, [ + 'name' => 'new', + 'quantity' => 99, + ]); + + $this->assertSame('new', $updated->getName()); + $this->assertSame(99, $updated->getQuantity()); + $this->assertDatabaseHas('br_test_widgets', ['id' => $widget->id, 'name' => 'new']); + } + + public function test_update_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $affected = $this->repository->updateWhere( + attributes: ['name' => 'renamed'], + where: ['is_active' => true], + ); + + $this->assertSame(2, $affected); + $this->assertSame(2, WidgetModel::query()->where('name', 'renamed')->count()); + } + + public function test_update_by_id_where_updates_when_predicate_matches(): void + { + $widget = $this->makeWidget(['is_active' => true, 'name' => 'old']); + + $updated = $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => true], + ); + + $this->assertSame('new', $updated->getName()); + } + + public function test_update_by_id_where_throws_when_predicate_does_not_match(): void + { + $widget = $this->makeWidget(['is_active' => true]); + + $this->expectException(ModelNotFoundException::class); + $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => false], + ); + } + + public function test_delete_by_id_soft_deletes_the_row(): void + { + $widget = $this->makeWidget(); + + $this->assertTrue($this->repository->deleteById($widget->id)); + $this->assertSoftDeleted('br_test_widgets', ['id' => $widget->id]); + } + + public function test_delete_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $deleted = $this->repository->deleteWhere(['is_active' => true]); + + $this->assertSame(2, $deleted); + } + + // ───────────────────────────────────────────────────────────────────────── + // increment / decrement + // ───────────────────────────────────────────────────────────────────────── + + public function test_increment_bumps_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->increment($widget->id, 'quantity', 3); + + $this->assertSame(13, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_supports_float_amount(): void + { + $widget = $this->makeWidget(['price' => 10.00]); + + $this->repository->increment($widget->id, 'price', 2.50); + + $this->assertSame(12.50, (float) WidgetModel::query()->find($widget->id)->price); + } + + public function test_decrement_lowers_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->decrement($widget->id, 'quantity', 4); + + $this->assertSame(6, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_where_bumps_matching_rows(): void + { + $a = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $b = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $c = $this->makeWidget(['quantity' => 1, 'is_active' => false]); + + $this->repository->incrementWhere(['is_active' => true], 'quantity', 5); + + $this->assertSame(6, (int) WidgetModel::query()->find($a->id)->quantity); + $this->assertSame(6, (int) WidgetModel::query()->find($b->id)->quantity); + $this->assertSame(1, (int) WidgetModel::query()->find($c->id)->quantity); + } + + public function test_increment_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 1, 'price' => 1.00]); + + $this->repository->incrementEach( + columns: ['quantity' => 2, 'price' => 3.00], + where: ['id' => $widget->id], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(3, (int) $fresh->quantity); + $this->assertSame(4.00, (float) $fresh->price); + } + + public function test_decrement_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 10, 'price' => 10.00]); + + $this->repository->decrementEach( + where: ['id' => $widget->id], + columns: ['quantity' => 2, 'price' => 1.00], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(8, (int) $fresh->quantity); + $this->assertSame(9.00, (float) $fresh->price); + } + + // ───────────────────────────────────────────────────────────────────────── + // Pagination + // ───────────────────────────────────────────────────────────────────────── + + public function test_paginate_returns_a_length_aware_paginator(): void + { + for ($i = 0; $i < 5; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->paginate(limit: 2); + + $this->assertInstanceOf(LengthAwarePaginator::class, $page); + $this->assertSame(5, $page->total()); + $this->assertCount(2, $page->items()); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $page->items()); + } + + public function test_paginate_where_filters_then_paginates(): void + { + for ($i = 0; $i < 3; $i++) { + $this->makeWidget(['is_active' => true]); + } + $this->makeWidget(['is_active' => false]); + + $page = $this->repository->paginateWhere(['is_active' => true], limit: 2); + + $this->assertSame(3, $page->total()); + $this->assertCount(2, $page->items()); + } + + public function test_simple_paginate_where_returns_a_simple_paginator(): void + { + for ($i = 0; $i < 4; $i++) { + $this->makeWidget(['is_active' => true]); + } + + $page = $this->repository->simplePaginateWhere(['is_active' => true], limit: 2); + + $this->assertInstanceOf(Paginator::class, $page); + $this->assertCount(2, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Eager loading + // ───────────────────────────────────────────────────────────────────────── + + public function test_load_relation_hydrates_a_belongs_to_relation(): void + { + $category = $this->makeCategory('Tools'); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widget->id); + + $this->assertNotNull($found->getCategory()); + $this->assertInstanceOf(WidgetCategoryDomainObject::class, $found->getCategory()); + $this->assertSame('Tools', $found->getCategory()->getName()); + } + + public function test_load_relation_hydrates_a_has_many_relation_as_a_collection(): void + { + $category = $this->makeCategory('Bolts'); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M3']); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M4']); + + $found = $this->categoryRepository + ->loadRelation(new Relationship(WidgetDomainObject::class, name: 'widgets')) + ->findById($category->id); + + $this->assertInstanceOf(Collection::class, $found->getWidgets()); + $this->assertCount(2, $found->getWidgets()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Soft deletes / includeDeleted + // ───────────────────────────────────────────────────────────────────────── + + public function test_include_deleted_returns_soft_deleted_rows(): void + { + $widget = $this->makeWidget(); + $this->repository->deleteById($widget->id); + + $this->assertNull($this->repository->findFirstWhere(['id' => $widget->id])); + + $found = $this->repository->includeDeleted()->findFirstWhere(['id' => $widget->id]); + $this->assertNotNull($found); + $this->assertSame($widget->id, $found->getId()); + } + + // ───────────────────────────────────────────────────────────────────────── + // State reset (the actual point of the refactor) + // ───────────────────────────────────────────────────────────────────────── + + public function test_consecutive_finds_do_not_leak_where_clauses(): void + { + $a = $this->makeWidget(['is_active' => true]); + $b = $this->makeWidget(['is_active' => false]); + + // First call applies a where(is_active, true) + $first = $this->repository->findWhere(['is_active' => true]); + $this->assertCount(1, $first); + + // Second call must NOT inherit the previous where clause + $second = $this->repository->findWhere([]); + $this->assertCount(2, $second, 'Second findWhere([]) inherited state from the previous query'); + } + + public function test_eager_loads_are_reset_between_queries(): void + { + $category = $this->makeCategory('Cat'); + $widgetA = $this->makeWidget(['category_id' => $category->id]); + $widgetB = $this->makeWidget(['category_id' => $category->id]); + + $first = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widgetA->id); + $this->assertNotNull($first->getCategory()); + + // After the call, eagerLoads MUST be cleared. Previously this was a bug — + // resetModel() reset the builder but left $eagerLoads populated, so the + // array would grow unboundedly across calls on the same instance. + $this->assertSame([], $this->repository->exposeEagerLoads()); + + // A subsequent call without loadRelation() must produce an unhydrated relation. + $second = $this->repository->findById($widgetB->id); + $this->assertNull($second->getCategory()); + } + + public function test_state_is_reset_even_when_the_query_throws(): void + { + $this->makeWidget(['is_active' => true]); + + try { + // findById on a missing id throws ModelNotFoundException — but only + // AFTER the loadRelation call has registered an eager load and added + // a where clause. + $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById(999_999); + $this->fail('Expected ModelNotFoundException'); + } catch (ModelNotFoundException) { + // expected + } + + // The next call on the same repository instance must start clean. + $this->assertSame([], $this->repository->exposeEagerLoads()); + $this->assertFalse($this->repository->exposeBuilderHasWheres()); + } + + public function test_set_max_per_page_caps_pagination_size(): void + { + for ($i = 0; $i < 10; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->setMaxPerPage(3)->paginate(limit: 100); + + $this->assertCount(3, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Hydration edge cases + // ───────────────────────────────────────────────────────────────────────── + + public function test_hydration_calls_setters_via_studly_case(): void + { + // category_id is a snake_case column → setCategoryId on the domain object + $category = $this->makeCategory(); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository->findById($widget->id); + + $this->assertSame($category->id, $found->getCategoryId()); + } + + public function test_hydration_silently_skips_columns_with_no_setter(): void + { + // No setter exists on WidgetDomainObject for an unknown column. + // Add a column on the fly via raw SQL so the model picks it up. + Schema::table('br_test_widgets', function (Blueprint $table) { + $table->string('mystery_field')->nullable(); + }); + + $widget = $this->makeWidget(); + WidgetModel::query()->where('id', $widget->id)->update(['mystery_field' => 'something']); + + // Should not throw — the silent-skip behaviour is documented. + $found = $this->repository->findById($widget->id); + $this->assertNotNull($found); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php new file mode 100644 index 0000000000..9a70807625 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryDomainObject.php @@ -0,0 +1,65 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setWidgets(?Collection $widgets): self + { + $this->widgets = $widgets; + + return $this; + } + + public function getWidgets(): ?Collection + { + return $this->widgets; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php new file mode 100644 index 0000000000..a648fa2b1e --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryModel.php @@ -0,0 +1,23 @@ +hasMany(WidgetModel::class, 'category_id'); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php new file mode 100644 index 0000000000..15de1c05bf --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetCategoryRepository.php @@ -0,0 +1,23 @@ + + */ +class WidgetCategoryRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetCategoryModel::class; + } + + public function getDomainObject(): string + { + return WidgetCategoryDomainObject::class; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php b/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php new file mode 100644 index 0000000000..e4dd4da2c0 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetDomainObject.php @@ -0,0 +1,199 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setCategoryId(?int $category_id): self + { + $this->category_id = $category_id; + + return $this; + } + + public function getCategoryId(): ?int + { + return $this->category_id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setSku(?string $sku): self + { + $this->sku = $sku; + + return $this; + } + + public function getSku(): ?string + { + return $this->sku; + } + + public function setQuantity(?int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setPrice(float|int|null $price): self + { + $this->price = $price === null ? null : (float) $price; + + return $this; + } + + public function getPrice(): ?float + { + return $this->price; + } + + public function setIsActive(?bool $is_active): self + { + $this->is_active = $is_active; + + return $this; + } + + public function getIsActive(): ?bool + { + return $this->is_active; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCategory(?WidgetCategoryDomainObject $category): self + { + $this->category = $category; + + return $this; + } + + public function getCategory(): ?WidgetCategoryDomainObject + { + return $this->category; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'category_id' => $this->category_id, + 'name' => $this->name, + 'sku' => $this->sku, + 'quantity' => $this->quantity, + 'price' => $this->price, + 'is_active' => $this->is_active, + 'description' => $this->description, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'deleted_at' => $this->deleted_at, + ]; + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetModel.php b/backend/tests/Unit/Repository/Fixtures/WidgetModel.php new file mode 100644 index 0000000000..9be9e0eaa6 --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetModel.php @@ -0,0 +1,43 @@ + 'boolean', + 'quantity' => 'integer', + 'price' => 'float', + ]; + } + + public function category(): BelongsTo + { + return $this->belongsTo(WidgetCategoryModel::class, 'category_id'); + } +} diff --git a/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php b/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php new file mode 100644 index 0000000000..1cf6c436fb --- /dev/null +++ b/backend/tests/Unit/Repository/Fixtures/WidgetRepository.php @@ -0,0 +1,44 @@ + + */ +class WidgetRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetModel::class; + } + + public function getDomainObject(): string + { + return WidgetDomainObject::class; + } + + /** + * Test hooks: expose protected state so we can assert reset behaviour + * without resorting to reflection. + */ + public function exposeEagerLoads(): array + { + return $this->eagerLoads; + } + + public function exposeBuilderHasWheres(): bool + { + $base = $this->model->getQuery(); + + return ! empty($base->wheres); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php new file mode 100644 index 0000000000..838750adbf --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php @@ -0,0 +1,169 @@ +checkInListRepository = m::mock(CheckInListRepositoryInterface::class); + $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class); + + $this->handler = new GetCheckInListAttendeeDetailPublicHandler( + $this->attendeeRepository, + $this->checkInListRepository + ); + } + + public function testHandleThrowsNotFoundIfCheckInListMissing(): void + { + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id', 'A-123', null); + } + + public function testHandleThrowsNotFoundIfAttendeeMissing(): void + { + $checkInList = $this->buildList(eventId: 5); + + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($checkInList); + + $this->attendeeRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['public_id' => 'A-123', 'event_id' => 5]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id', 'A-123', null); + } + + public function testAnonymousRequestRespectsListVisibilityFlags(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: true, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', null); + + $this->assertFalse($result->showNotes); + $this->assertTrue($result->showQuestionAnswers); + $this->assertFalse($result->showOrderDetails); + } + + public function testAuthenticatedStaffBypassesVisibilityFlags(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: false, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 77); + + $this->assertTrue($result->showNotes); + $this->assertTrue($result->showQuestionAnswers); + $this->assertTrue($result->showOrderDetails); + } + + public function testAuthenticatedUserFromDifferentAccountStillFiltered(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: false, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 88); + + $this->assertFalse($result->showNotes); + $this->assertFalse($result->showQuestionAnswers); + $this->assertFalse($result->showOrderDetails); + } + + private function buildList( + int $eventId = 5, + int $accountId = 1, + bool $showNotes = true, + bool $showQuestions = true, + bool $showOrderDetails = true, + ): CheckInListDomainObject { + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn($accountId); + + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(1); + $checkInList->shouldReceive('getEventId')->andReturn($eventId); + $checkInList->shouldReceive('getEvent')->andReturn($event); + $checkInList->shouldReceive('getPublicShowAttendeeNotes')->andReturn($showNotes); + $checkInList->shouldReceive('getPublicShowQuestionAnswers')->andReturn($showQuestions); + $checkInList->shouldReceive('getPublicShowOrderDetails')->andReturn($showOrderDetails); + return $checkInList; + } + + private function setupRepos(CheckInListDomainObject $checkInList, $attendee): void + { + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->andReturn($checkInList); + + $this->attendeeRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->andReturn($attendee); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php new file mode 100644 index 0000000000..240be9ed58 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php @@ -0,0 +1,112 @@ +checkInListRepository = m::mock(CheckInListRepositoryInterface::class); + + $this->handler = new GetCheckInListStatsPublicHandler( + $this->checkInListRepository + ); + } + + public function testHandleThrowsNotFoundIfCheckInListMissing(): void + { + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['short_id' => 'short-id']) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id'); + } + + public function testHandleReturnsStatsDTO(): void + { + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(42); + + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['short_id' => 'short-id']) + ->andReturn($checkInList); + + $this->checkInListRepository + ->shouldReceive('getCheckedInAttendeeCountById') + ->once() + ->with(42) + ->andReturn(new CheckedInAttendeesCountDTO( + checkInListId: 42, + checkedInCount: 5, + totalAttendeesCount: 20, + )); + + $productStats = collect([ + new CheckInListProductStatDTO( + productId: 1, + productTitle: 'VIP', + totalAttendees: 10, + checkedInAttendees: 3, + ), + new CheckInListProductStatDTO( + productId: 2, + productTitle: 'General', + totalAttendees: 10, + checkedInAttendees: 2, + ), + ]); + + $this->checkInListRepository + ->shouldReceive('getPerProductCheckInStatsById') + ->once() + ->with(42) + ->andReturn($productStats); + + $recentCheckIns = collect([ + new CheckInListRecentCheckInDTO( + attendeePublicId: 'A-AAAAAAAA', + firstName: 'Alice', + lastName: 'Smith', + productTitle: 'VIP', + checkedInAt: '2026-04-20T10:00:00Z', + ), + ]); + + $this->checkInListRepository + ->shouldReceive('getRecentCheckInsById') + ->once() + ->with(42, 20) + ->andReturn($recentCheckIns); + + $stats = $this->handler->handle('short-id'); + + $this->assertSame(20, $stats->totalAttendees); + $this->assertSame(5, $stats->checkedInAttendees); + $this->assertCount(2, $stats->perProduct); + $this->assertSame('VIP', $stats->perProduct[0]->productTitle); + $this->assertSame(3, $stats->perProduct[0]->checkedInAttendees); + $this->assertCount(1, $stats->recentCheckIns); + $this->assertSame('Alice', $stats->recentCheckIns[0]->firstName); + } +} diff --git a/docker/development/docker-compose.dev.yml b/docker/development/docker-compose.dev.yml index 1c605156cd..ba5683104d 100644 --- a/docker/development/docker-compose.dev.yml +++ b/docker/development/docker-compose.dev.yml @@ -109,8 +109,11 @@ services: POSTGRES_DB: '${DB_DATABASE}' POSTGRES_USER: '${DB_USERNAME}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + TEST_DB_NAME: '${TEST_DB_NAME:-hievents_test}' volumes: - 'app-pgsql:/var/lib/postgresql/data' + # Init scripts run once on a fresh data volume — creates hievents_test. + - './pgsql-init:/docker-entrypoint-initdb.d:ro' networks: - app healthcheck: diff --git a/docker/development/pgsql-init/01-create-test-db.sh b/docker/development/pgsql-init/01-create-test-db.sh new file mode 100755 index 0000000000..0fa09e4cdd --- /dev/null +++ b/docker/development/pgsql-init/01-create-test-db.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Postgres entrypoint init script — runs once on a fresh data volume. +# Creates the hievents_test database used by the test suite (the BaseRepositoryTest +# guard refuses to run against any database whose name does not end in _test). +# +# Idempotent: existing test DBs are left alone. + +set -euo pipefail + +TEST_DB="${TEST_DB_NAME:-hievents_test}" + +psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + SELECT 'CREATE DATABASE ${TEST_DB} OWNER ${POSTGRES_USER}' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${TEST_DB}')\gexec +EOSQL + +echo "Test database '${TEST_DB}' is ready." diff --git a/docker/development/start-dev.sh b/docker/development/start-dev.sh index 599ae442f8..53a3289a07 100755 --- a/docker/development/start-dev.sh +++ b/docker/development/start-dev.sh @@ -5,36 +5,95 @@ CERTS_FLAG="$1" RED='\033[0;31m' GREEN='\033[0;32m' -BG_BLACK='\033[40m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +DIM='\033[2m' NC='\033[0m' # No Color CERTS_DIR="./certs" -echo -e "${GREEN}${BG_BLACK}Installing Hi.Events...${NC}" +print_banner() { + echo "" + echo -e "${CYAN}${BOLD} ╔═══════════════════════════════════════════╗${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ║ ${MAGENTA}Hi.Events Dev Launcher${CYAN} ║${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ╚═══════════════════════════════════════════╝${NC}" + echo "" +} + +step() { + echo -e "${BLUE}${BOLD}▶${NC} ${BOLD}$1${NC}" +} + +info() { + echo -e " ${DIM}$1${NC}" +} + +ok() { + echo -e " ${GREEN}✓${NC} $1" +} + +warn() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +fail() { + echo -e " ${RED}✗${NC} $1" +} + +# Prompt yes/no. $1 = question, $2 = default ("y" or "n") +ask_yes_no() { + local prompt="$1" + local default="$2" + local hint + if [ "$default" = "y" ]; then + hint="${BOLD}Y${NC}/n" + else + hint="y/${BOLD}N${NC}" + fi + while true; do + echo -ne "${YELLOW}?${NC} ${BOLD}$prompt${NC} [$hint] " + read -r reply + reply="${reply:-$default}" + case "$reply" in + [Yy]*) return 0 ;; + [Nn]*) return 1 ;; + *) echo -e " ${DIM}Please answer y or n.${NC}" ;; + esac + done +} + +print_banner mkdir -p "$CERTS_DIR" generate_unsigned_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then - echo -e "${GREEN}Generating unsigned SSL certificates...${NC}" - openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" + step "Generating unsigned SSL certificates" + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" > /dev/null 2>&1 + ok "Certificates generated" else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } generate_signed_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then if ! command -v mkcert &> /dev/null; then - echo -e "${RED}mkcert is not installed.${NC}" - echo "Please install mkcert by following the instructions at: https://github.com/FiloSottile/mkcert#installation" - echo "Alternatively, you can generate unsigned certificates by using '--certs=unsigned' or omitting the --certs flag." + fail "mkcert is not installed." + info "Install via https://github.com/FiloSottile/mkcert#installation" + info "Or use unsigned certs: '--certs=unsigned' (or omit --certs)" exit 1 else - echo -e "${GREEN}Generating signed SSL certificates with mkcert...${NC}" - mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 + step "Generating signed SSL certificates with mkcert" + mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 > /dev/null 2>&1 + ok "Certificates generated" fi else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } @@ -47,33 +106,72 @@ case "$CERTS_FLAG" in ;; esac -$COMPOSE_CMD up -d +echo "" +step "Setup options" -if [ $? -ne 0 ]; then - echo -e "${RED}Failed to start services with docker-compose.${NC}" - exit 1 +WIPE_DB=false +if ask_yes_no "Wipe the database and start fresh?" "n"; then + WIPE_DB=true + warn "Database will be wiped on startup" +else + info "Keeping existing database" fi -echo -e "${GREEN}Running composer install in the backend service...${NC}" +REINSTALL_DEPS=true +if ask_yes_no "Reinstall frontend dependencies (yarn install)?" "y"; then + REINSTALL_DEPS=true + info "Frontend image will be rebuilt with fresh deps" +else + REINSTALL_DEPS=false + info "Skipping frontend dependency reinstall" +fi + +echo "" + +if [ "$WIPE_DB" = true ]; then + step "Tearing down existing containers and volumes" + $COMPOSE_CMD down -v > /dev/null 2>&1 + ok "Containers and volumes removed" +elif [ "$REINSTALL_DEPS" = true ]; then + step "Removing frontend container to refresh node_modules" + $COMPOSE_CMD rm -sfv frontend > /dev/null 2>&1 + ok "Frontend container removed" +fi + +if [ "$REINSTALL_DEPS" = true ]; then + step "Rebuilding frontend image (running yarn install)" + if ! $COMPOSE_CMD build frontend; then + fail "Frontend image build failed" + exit 1 + fi + ok "Frontend image rebuilt" +fi + +step "Starting services" +if ! $COMPOSE_CMD up -d; then + fail "Failed to start services with docker compose." + exit 1 +fi +ok "Services started" -$COMPOSE_CMD exec -T backend composer install \ +step "Running composer install in the backend service" +if ! $COMPOSE_CMD exec -T backend composer install \ --ignore-platform-reqs \ --no-interaction \ --optimize-autoloader \ - --prefer-dist - -if [ $? -ne 0 ]; then - echo -e "${RED}Composer install failed within the backend service.${NC}" + --prefer-dist; then + fail "Composer install failed within the backend service." exit 1 fi +ok "Composer dependencies installed" -echo -e "${GREEN}Waiting for the database to be ready...${NC}" -while ! $COMPOSE_CMD logs pgsql | grep "ready to accept connections" > /dev/null; do - echo -n '.' - sleep 1 +step "Waiting for the database to be ready" +while ! $COMPOSE_CMD logs pgsql 2>/dev/null | grep "ready to accept connections" > /dev/null; do + echo -n '.' + sleep 1 done - -echo -e "\n${GREEN}Database is ready. Proceeding with migrations...${NC}" +echo "" +ok "Database is ready" if [ ! -f ./../../backend/.env ]; then $COMPOSE_CMD exec backend cp .env.example .env @@ -83,17 +181,40 @@ if [ ! -f ./../../frontend/.env ]; then $COMPOSE_CMD exec frontend cp .env.example .env fi +step "Running migrations and setup" $COMPOSE_CMD exec backend php artisan key:generate $COMPOSE_CMD exec backend php artisan migrate $COMPOSE_CMD exec backend chmod -R 775 /var/www/html/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer $COMPOSE_CMD exec backend php artisan storage:link if [ $? -ne 0 ]; then - echo -e "${RED}Migrations failed.${NC}" + fail "Migrations failed." exit 1 fi +ok "Migrations complete" + +echo "" +step "Background workers" + +if ask_yes_no "Start the queue worker?" "y"; then + $COMPOSE_CMD exec -d backend php artisan queue:work --queue=default,webhook-queue --sleep=3 --tries=3 --timeout=60 + ok "Queue worker started (detached)" +else + info "Skipped queue worker — start it later with:" + info "$COMPOSE_CMD exec backend php artisan queue:work" +fi + +if ask_yes_no "Start the scheduler?" "y"; then + $COMPOSE_CMD exec -d backend php artisan schedule:work + ok "Scheduler started (detached)" +else + info "Skipped scheduler — start it later with:" + info "$COMPOSE_CMD exec backend php artisan schedule:work" +fi -echo -e "${GREEN}Hi.Events is now running at:${NC} https://localhost:8443" +echo "" +echo -e "${GREEN}${BOLD} 🎉 Hi.Events is now running at:${NC} ${CYAN}${BOLD}https://localhost:8443${NC}" +echo "" case "$(uname -s)" in Darwin) open https://localhost:8443/auth/register ;; diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts index 709121e915..6fd7fe8ca5 100644 --- a/frontend/src/api/check-in.client.ts +++ b/frontend/src/api/check-in.client.ts @@ -1,7 +1,9 @@ import {publicApi} from "./public-client"; import { Attendee, + AttendeeDetailPublic, CheckInList, + CheckInListStats, GenericDataResponse, GenericPaginatedResponse, IdParam, PublicCheckIn, @@ -14,6 +16,10 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}`); return response.data; }, + getCheckInListStats: async (checkInListShortId: IdParam) => { + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/stats`); + return response.data; + }, getCheckInListAttendees: async (checkInListShortId: IdParam, pagination: QueryFilters) => { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination)); return response.data; @@ -22,6 +28,10 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}`); return response.data; }, + getCheckInListAttendeeDetail: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => { + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}/detail`); + return response.data; + }, createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam, action: 'check-in' | 'check-in-and-mark-order-as-paid') => { const response = await publicApi.post>(`/check-in-lists/${checkInListShortId}/check-ins`, { "attendees": [ diff --git a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx b/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx deleted file mode 100644 index 8a005d19af..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {Anchor, Button} from "@mantine/core"; -import {t, Trans} from "@lingui/macro"; -import classes from "./QrScanner.module.scss"; - -interface PermissionDeniedMessageProps { - onRequestPermission: () => void; - onClose: () => void; -} - -export const PermissionDeniedMessage = ({ - onRequestPermission, - onClose -}: PermissionDeniedMessageProps) => { - return ( -
- - Camera permission was denied. Request - Permission again, - or if this doesn't work, - you will need to grant - this page access to your camera in your browser settings. - - -
- -
-
- ); -}; \ No newline at end of file diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss deleted file mode 100644 index 33878f6a84..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss +++ /dev/null @@ -1,100 +0,0 @@ -@use "../../../styles/mixins"; - -@keyframes colorfulBorder { - 0% { - border-color: #ffffff50; - } - 50% { - border-color: #00000050; - } - 100% { - border-color: #ffffff50; - } -} - -.videoContainer { - position: relative; - display: flex; - justify-content: center; - align-items: center; - - .permissionMessage { - position: absolute; - width: 100vw; - padding: 20px; - text-align: center; - background-color: #000000; - color: #fff; - z-index: 3; - - a { - color: #dddddd; - text-decoration: underline; - } - } - - .flashToggle { - position: absolute; - top: 20px; - left: 20px; - z-index: 2; - } - - .soundToggle { - position: absolute; - bottom: 20px; - left: 20px; - z-index: 2; - } - - .closeButton { - position: absolute; - top: 20px; - right: 20px; - z-index: 2; - } - - .switchCameraButton { - position: absolute; - bottom: 20px; - right: 20px; - z-index: 2; - } - - //scanner overlay is a square div that scales as the browser window scales - .scannerOverlay { - width: 60vw; - height: 60vw; - border: 5px solid #ffffff50; - position: absolute; - animation: colorfulBorder 10s infinite; - border-radius: 10px; - outline: solid 50vmax rgb(71 46 120 / 50%); - transition: outline-color 0.2s ease-out; - min-width: 200px; - min-height: 200px; - - @include mixins.respond-above(md) { - width: 40vw; - height: 40vw; - } - } - - .scannerOverlay.success { - outline: solid 50vmax rgb(80 148 80 / 75%); - } - - .scannerOverlay.failure { - outline: solid 50vmax rgb(193 72 72 / 75%); - } - - .scannerOverlay.checkingIn { - outline: solid 50vmax rgb(172 158 85 / 60%); - } - - video { - width: 100vw !important; - height: 100vh !important; - object-fit: cover; - } -} diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx deleted file mode 100644 index 121fde9e31..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; -import QrScanner from 'qr-scanner'; -import {useDebouncedValue} from '@mantine/hooks'; -import classes from './QrScanner.module.scss'; -import {showError} from "../../../utilites/notifications.tsx"; -import {t} from "@lingui/macro"; -import {QrScannerControls} from './QrScannerControls'; -import {PermissionDeniedMessage} from './PermissionDeniedMessage'; - -interface QRScannerComponentProps { - onAttendeeScanned: (attendeePublicId: string) => void; - onClose: () => void; - isSoundOn?: boolean; -} - -export const QRScannerComponent = (props: QRScannerComponentProps) => { - const videoRef = useRef(null); - const qrScannerRef = useRef(null); - const [permissionGranted, setPermissionGranted] = useState(false); - const [permissionDenied, setPermissionDenied] = useState(false); - const [isCheckingIn, setIsCheckingIn] = useState(false); - const [isFlashAvailable, setIsFlashAvailable] = useState(false); - const [isFlashOn, setIsFlashOn] = useState(false); - const [cameraList, setCameraList] = useState(); - const [processedAttendeeIds, setProcessedAttendeeIds] = useState([]); - const latestProcessedAttendeeIdsRef = useRef([]); - - const [currentAttendeeId, setCurrentAttendeeId] = useState(null); - const [debouncedAttendeeId] = useDebouncedValue(currentAttendeeId, 1000); - const [isScanFailed, setIsScanFailed] = useState(false); - const [isScanSucceeded, setIsScanSucceeded] = useState(false); - - const scanSuccessAudioRef = useRef(null); - const scanErrorAudioRef = useRef(null); - const scanInProgressAudioRef = useRef(null); - - const [isSoundOn, setIsSoundOn] = useState(() => { - // Use the prop value if provided, otherwise fallback to unified storage - if (props.isSoundOn !== undefined) { - return props.isSoundOn; - } - const storedIsSoundOn = localStorage.getItem("scannerSoundOn"); - return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn); - }); - - // Sync with prop changes - useEffect(() => { - if (props.isSoundOn !== undefined) { - setIsSoundOn(props.isSoundOn); - } - }, [props.isSoundOn]); - - useEffect(() => { - // Only save to localStorage if not controlled by props - if (props.isSoundOn === undefined) { - localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn)); - } - }, [isSoundOn, props.isSoundOn]); - - useEffect(() => { - latestProcessedAttendeeIdsRef.current = processedAttendeeIds; - }, [processedAttendeeIds]); - - const startScanner = async () => { - try { - await navigator.mediaDevices.getUserMedia({video: true}); - setPermissionGranted(true); - if (videoRef.current) { - qrScannerRef.current = new QrScanner(videoRef.current, (result) => { - setCurrentAttendeeId(result.data); - }, { - maxScansPerSecond: 1, - }); - qrScannerRef.current.start(); - } - } catch (error) { - setPermissionDenied(true); - console.error(error); - } - }; - - useEffect(() => { - if (debouncedAttendeeId) { - const latestProcessedAttendeeIds = latestProcessedAttendeeIdsRef.current; - const alreadyScanned = latestProcessedAttendeeIds.includes(debouncedAttendeeId); - - if (isScanSucceeded || isScanFailed) { - return; - } - - if (alreadyScanned) { - showError(t`You already scanned this ticket`); - - setIsScanFailed(true); - setInterval(() => setIsScanFailed(false), 500); - if (isSoundOn && scanErrorAudioRef.current) { - scanErrorAudioRef.current.play(); - } - - return; - } - - if (!isCheckingIn && !alreadyScanned) { - setIsCheckingIn(true); - if (isSoundOn && scanInProgressAudioRef.current) { - scanInProgressAudioRef.current.play(); - } - - props.onAttendeeScanned(debouncedAttendeeId); - setIsCheckingIn(false); - setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]); - setCurrentAttendeeId(null); - - setIsScanSucceeded(true); - setInterval(() => setIsScanSucceeded(false), 500); - if (isSoundOn && scanSuccessAudioRef.current) { - scanSuccessAudioRef.current.play(); - } - } - } - }, [debouncedAttendeeId]); - - const stopScanner = () => { - if (qrScannerRef.current) { - qrScannerRef.current.stop(); - qrScannerRef.current.destroy(); - qrScannerRef.current = null; - } - }; - - const handleClose = () => { - stopScanner(); - props.onClose(); - }; - - const handleFlashToggle = () => { - if (!isFlashAvailable) { - showError(t`Flash is not available on this device`); - return; - } - if (qrScannerRef.current) { - if (isFlashOn) { - qrScannerRef.current.turnFlashOff(); - } else { - qrScannerRef.current.turnFlashOn(); - } - setIsFlashOn(!isFlashOn); - } - }; - - const handleSoundToggle = () => { - setIsSoundOn(!isSoundOn); - }; - - const requestPermission = async () => { - setPermissionDenied(false); - await startScanner(); - }; - - const updateFlashAvailability = async () => { - if (qrScannerRef.current) { - const hasFlash = await qrScannerRef.current.hasFlash(); - setIsFlashAvailable(hasFlash); - } - }; - - useEffect(() => { - startScanner().then(() => { - updateFlashAvailability().catch(console.error); - QrScanner.listCameras(true) - .then(cameras => setCameraList(cameras)); - }); - - return () => { - if (permissionGranted) { - stopScanner(); - } - }; - }, []); - - const handleCameraSelection = (camera: QrScanner.Camera) => { - return qrScannerRef.current?.setCamera(camera.id) - .then(() => updateFlashAvailability().catch(console.error)); - }; - - return ( -
- {permissionDenied && ( - - )} - - - - - -
+ } + size="md" + centered + radius="md" + padding={24} + > +
+ {description} +
+ + + ); +}; diff --git a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx index a0d5584f2b..a4cfe7aff4 100644 --- a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx +++ b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx @@ -1,8 +1,9 @@ import {Modal, Progress} from "@mantine/core"; -import {Trans} from "@lingui/macro"; +import {t, Trans} from "@lingui/macro"; import Truncate from "../Truncate"; import {CheckInList} from "../../../types.ts"; -import classes from "../../layouts/CheckIn/CheckIn.module.scss"; +import {PoweredByFooter} from "../PoweredByFooter"; +import {getConfig} from "../../../utilites/config"; interface CheckInInfoModalProps { isOpen: boolean; @@ -11,53 +12,97 @@ interface CheckInInfoModalProps { } export const CheckInInfoModal = ({ - isOpen, - checkInList, - onClose -}: CheckInInfoModalProps) => { + isOpen, + checkInList, + onClose, + }: CheckInInfoModalProps) => { if (!checkInList) return null; - + + const total = checkInList.total_attendees; + const checkedIn = checkInList.checked_in_attendees; + const percent = total > 0 ? (checkedIn / total) * 100 : 0; + const appName = getConfig("VITE_APP_NAME", "Hi.Events"); + const logoSrc = getConfig("VITE_APP_LOGO_LIGHT", "/logos/hi-events-text-light.svg"); + return ( - } + size="md" + centered + radius="md" + padding={24} + transitionProps={{transition: "fade", duration: 200}} > - - - - - - - - -
-
- <> -

- - {`${checkInList.checked_in_attendees}/${checkInList.total_attendees}`} checked in - -

+
+
+ {t`Progress`} +
+
+ {`${checkedIn} / ${total}`} checked in +
+ +
- - + {checkInList.description && ( +
+
+ {t`Staff instructions`}
- - {checkInList.description && ( -
- {checkInList.description} -
- )} + {checkInList.description}
- - + )} + +
+ {t`${appName} + +
+ ); }; diff --git a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx b/frontend/src/components/common/CheckIn/HidScannerStatus.tsx deleted file mode 100644 index 4cc250f7be..0000000000 --- a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {Button} from "@mantine/core"; -import {IconScan, IconX} from "@tabler/icons-react"; -import {t} from "@lingui/macro"; -import {showSuccess} from "../../../utilites/notifications.tsx"; - -interface HidScannerStatusProps { - isActive: boolean; - pageHasFocus: boolean; - onDisable: () => void; -} - -export const HidScannerStatus = ({ - isActive, - pageHasFocus, - onDisable -}: HidScannerStatusProps) => { - if (!isActive) return null; - - return ( -
-
- - - {pageHasFocus - ? 'USB Scanner Active - Ready to Scan' - : 'USB Scanner Paused - Click anywhere to resume scanning'} - -
- -
- ); -}; diff --git a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx b/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx deleted file mode 100644 index 3ffe4c65eb..0000000000 --- a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {Button, Modal, Stack} from "@mantine/core"; -import {IconCamera, IconScan} from "@tabler/icons-react"; -import {t} from "@lingui/macro"; -import {showSuccess} from "../../../utilites/notifications.tsx"; - -interface ScannerSelectionModalProps { - isOpen: boolean; - isHidScannerActive: boolean; - onClose: () => void; - onCameraSelect: () => void; - onHidScannerSelect: () => void; -} - -export const ScannerSelectionModal = ({ - isOpen, - isHidScannerActive, - onClose, - onCameraSelect, - onHidScannerSelect -}: ScannerSelectionModalProps) => { - return ( - - - - - - - - ); -}; \ No newline at end of file diff --git a/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss new file mode 100644 index 0000000000..d0a974433d --- /dev/null +++ b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss @@ -0,0 +1,121 @@ +.advancedToggle { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: var(--hi-primary); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 6px 8px; + margin: 4px 0 12px -8px; + border-radius: 6px; + transition: background 140ms ease; + + &:hover { + background: color-mix(in srgb, var(--hi-primary) 8%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--hi-primary); + outline-offset: 1px; + } + + .chevron { + transition: transform 180ms ease; + + &.chevronOpen { + transform: rotate(90deg); + } + } +} + +.visibilitySection { + margin-top: 20px; + padding: 16px; + background: var(--hi-color-gray); + border-radius: 12px; + border: 1px solid var(--hi-color-gray-2); +} + +.visibilityHeader { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 14px; +} + +.visibilityIcon { + width: 32px; + height: 32px; + border-radius: 8px; + background: #fff; + border: 1px solid var(--hi-color-gray-2); + display: flex; + align-items: center; + justify-content: center; + color: var(--hi-color-gray-dark); + flex-shrink: 0; +} + +.visibilityTitle { + font-size: 14px; + font-weight: 700; + color: var(--hi-text); + line-height: 1.3; + letter-spacing: -0.01em; +} + +.visibilityHint { + font-size: 12px; + color: var(--hi-color-gray-dark); + line-height: 1.4; + margin-top: 2px; +} + +.visibilityRows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.visibilityRow { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: #fff; + border: 1px solid var(--hi-color-gray-2); + border-radius: 10px; +} + +.visibilityRowIcon { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--hi-color-gray); + display: flex; + align-items: center; + justify-content: center; + color: var(--hi-color-gray-dark); + flex-shrink: 0; +} + +.visibilityRowMain { + flex: 1; + min-width: 0; +} + +.visibilityRowLabel { + font-size: 13px; + font-weight: 600; + color: var(--hi-text); +} + +.visibilityRowDesc { + font-size: 12px; + color: var(--hi-color-gray-dark); + margin-top: 1px; +} diff --git a/frontend/src/components/forms/CheckInListForm/index.tsx b/frontend/src/components/forms/CheckInListForm/index.tsx index 7b57c56222..5c45fed9ea 100644 --- a/frontend/src/components/forms/CheckInListForm/index.tsx +++ b/frontend/src/components/forms/CheckInListForm/index.tsx @@ -1,17 +1,36 @@ -import {Alert, Textarea, TextInput} from "@mantine/core"; -import {t} from "@lingui/macro"; +import {Collapse, Switch, Textarea, TextInput} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; import {UseFormReturnType} from "@mantine/form"; import {CheckInListRequest, ProductCategory, ProductType} from "../../../types.ts"; import {InputGroup} from "../../common/InputGroup"; import {ProductSelector} from "../../common/ProductSelector"; -import {useEffect, useMemo} from "react"; -import {IconInfoCircle} from "@tabler/icons-react"; +import {Callout} from "../../common/Callout"; +import {useEffect, useMemo, useState} from "react"; +import { + IconChevronRight, + IconClipboardText, + IconEye, + IconMessageCircleQuestion, + IconReceipt2, +} from "@tabler/icons-react"; +import classes from "./CheckInListForm.module.scss"; interface CheckInListFormProps { form: UseFormReturnType; productCategories: ProductCategory[]; } +const hasAdvancedValuesSet = (form: UseFormReturnType): boolean => { + return !!( + form.values.description + || form.values.activates_at + || form.values.expires_at + || form.values.public_show_attendee_notes === false + || form.values.public_show_question_answers === false + || form.values.public_show_order_details === false + ); +}; + export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) => { const tickets = useMemo(() => { return productCategories @@ -19,6 +38,9 @@ export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) .filter(product => product.product_type === ProductType.Ticket); }, [productCategories]); + // Open advanced panel automatically if editing a list that already uses any of those options. + const [showAdvanced, setShowAdvanced] = useState(() => hasAdvancedValuesSet(form)); + useEffect(() => { if (tickets.length === 1 && (!form.values.product_ids || form.values.product_ids.length === 0)) { form.setFieldValue('product_ids', [String(tickets[0].id)]); @@ -27,9 +49,11 @@ export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) return ( <> - } color="blue" variant="light"> - {t`Check-in lists let you control entry across days, areas, or ticket types. You can share a secure check-in link with staff — no account required.`} - + + + Split check-in across days, areas, or ticket types. Share the link with staff — no account needed on their end. + + -