diff --git a/.ai/guidelines/domain/02-module-architecture.blade.php b/.ai/guidelines/domain/02-module-architecture.blade.php index 66736cd0b..c13412d40 100644 --- a/.ai/guidelines/domain/02-module-architecture.blade.php +++ b/.ai/guidelines/domain/02-module-architecture.blade.php @@ -101,9 +101,47 @@ public function boot(): void @endverbatim +## Version constraints — mandatory `^1.0.0` style + +Every intra-repo `he4rt/*` module dependency (in the root `composer.json` and in any +module's `composer.json`) MUST be declared with the caret style `^1.0.0`. Never use +loose constraints like `>=1`, `*`, `dev-main`, or a truncated `^1.0`. + +@verbatim + +{ + "require": { + // GOOD — caret with full three-part version: + "he4rt/identity": "^1.0.0", + "he4rt/moderation": "^1.0.0", + + // BAD — loose or truncated constraints: + "he4rt/identity": ">=1", + "he4rt/moderation": "^1.0", + "he4rt/economy": "*" + } +} + +@endverbatim + +This keeps every module pinned to a predictable, SemVer-compatible range and avoids +accidental major upgrades. When you add a new module dependency, match this style. + ## Dependency rules - **Domain** modules never import from Presentation or Integration. - **Integration** modules may depend on Domain (e.g., Identity for user resolution). - **Presentation** imports from Domain and Integration, never the reverse. - Check `CONTEXT-MAP.md` for cross-context constraints. + +## Registering a new module — mandatory label sync + +Every `app-modules//` has exactly one `mod:` triage label, and vice +versa. When you scaffold a **new** module you MUST, in the same change: + +1. **Add a row** to the module table in `workflow/triage-labels` (`mod:` → ``). +2. **Create the label on GitHub** so the issue tracker can use it: + `gh label create "mod:" --color "c2e0c6" --description ""` + +Do not leave the guideline table and the live GitHub labels out of sync — an issue +that can't be tagged with its module's label means triage and routing break. diff --git a/.ai/guidelines/domain/06-typed-json-casts.blade.php b/.ai/guidelines/domain/06-typed-json-casts.blade.php new file mode 100644 index 000000000..46c8f77d1 --- /dev/null +++ b/.ai/guidelines/domain/06-typed-json-casts.blade.php @@ -0,0 +1,55 @@ +@php + /** @var \Laravel\Boost\Install\GuidelineAssist $assist */ +@endphp + +# Typed JSON Casts — Ban the Loose Array Cast + +**Priority: HIGH.** Every JSON/jsonb column with a known or semi-structured shape MUST be +cast to a typed value object. A loose `'metadata' => 'array'` is an untyped, unvalidated, +refactor-hostile blind spot: PHPStan collapses to `mixed`, malformed payloads become +silent missing keys, and renaming a key is find-and-pray. + +The same rule applies beyond casts: **prefer DTOs/VOs over associative arrays and +`stdClass` anywhere a shape is predictable** — command payloads, event properties, +integration Response Objects (Saloon), service returns. + +## Banned casts (mechanically enforced) + +Banned inside any model's `casts()`/`$casts`: `'array'`, `'json'`, `'object'`, +`'collection'`, `encrypted:array|collection|object`, `AsArrayObject`, `AsCollection`, +`AsEnumArrayObject`, `AsEnumCollection`, `AsEncryptedArrayObject`, +`AsEncryptedCollection`, and `SchemalessAttributes`. + +The gate is `tests/Unit/NoLooseArrayCastsTest.php` — it reflects over every concrete +model (`app/Models` + `app-modules/*/src`), reads the real merged `getCasts()`, and fails +on any banned cast not explicitly allowlisted there. The allowlist is the documented +escape hatch for **genuinely polymorphic** JSON (each entry carries a reason); keep it +trending toward empty. + +## The pattern + + +// BEFORE — blind +'metadata' => 'array', +$model->metadata['last_projects_sync_at'] ?? null; // mixed · magic string + +// AFTER — typed VO cast (see AsCredentials in the identity module for a live example) +'metadata' => AsIdentityMetadata::class, +$model->metadata->lastSyncAt(Capability::Projects); // ?CarbonImmutable · verified + + +The VO owns shape, validation and accessors (`fromArray()` / `toArray()` / immutable +`withX()` copies). The cast is only the JSON ↔ VO bridge. + +## Rules of thumb + +- **Key/format conventions live on ONE owner** (usually the enum: + `$capability->lastSyncedAtKey()`) — never reverse-parse strings to recover a key. +- **The cast generic is a contract for the setter too**: declare + `CastsAttributes>` so legacy array assignment stays + a reachable, typed branch (`match (true)`). +- `json_decode` returns `array`, never your shape — guard keys or + iterate `EnumClass::cases()` instead of touching arbitrary keys. +- Genuinely polymorphic/freeform JSON → allowlist it **with a reason** and revisit. +- Never introduce `stdClass` in domain code; defensive layers (e.g. the kernel's + `CanonicalJson`) may normalize it, but no domain object produces one. diff --git a/.ai/guidelines/domain/07-enum-filament-contracts.blade.php b/.ai/guidelines/domain/07-enum-filament-contracts.blade.php new file mode 100644 index 000000000..f0b1bf397 --- /dev/null +++ b/.ai/guidelines/domain/07-enum-filament-contracts.blade.php @@ -0,0 +1,101 @@ +@php +/** @var \Laravel\Boost\Install\GuidelineAssist $assist */ +@endphp + +# Enums — Implement the Filament Contracts Immediately + +**Priority: HIGH.** Every time you create a **new** backed enum, implement the Filament +"enum trick" contracts in the **same change** — never as a follow-up pass. Retrofitting +later means touching every enum (and every call site that assumed a bare `->value`) twice. + +Domain enums implementing Filament contracts is the established precedent here (the +moderation enums — `CaseStatus`, `Severity`, `AppealStatus`, `ModerationType`, … — all do +it). The panel leans on these contracts for badges, table/infolist columns, `SelectFilter` +options, select descriptions and state-gated UI. + +## Mandatory contracts + +| Contract | Method | Always? | +|----------|--------|---------| +| `Filament\Support\Contracts\HasLabel` | `getLabel(): string` | Yes | +| `Filament\Support\Contracts\HasColor` | `getColor()` | Yes | +| `Filament\Support\Contracts\HasDescription` | `getDescription(): ?string` | Yes | +| `Filament\Support\Contracts\HasIcon` | `getIcon()` | Only when an icon is genuinely meaningful | + +Every getter is a `match ($this)` over **all** cases — no `default` arm, so adding a case +is a compile-time reminder to fill in every trick. + +## HasColor — ordered enums ramp light → red + +When the enum encodes an **ordered scale** (access level, seniority, severity, risk, SLA +health), `getColor()` MUST form a monotonic light→red heat ramp: the least/lowest case gets +the lightest color, the most/highest gets `danger` (red). The six semantic strings +(`gray`, `info`, `primary`, `success`, `warning`, `danger`) cover short scales; for a longer +monotonic gradient use the `Filament\Support\Colors\Color` palette. + +For an **unordered** enum (e.g. document kinds), pick distinct semantic colors per case — +the ramp rule does not apply. + +## The pattern + + +use Filament\Support\Colors\Color; +use Filament\Support\Contracts\HasColor; +use Filament\Support\Contracts\HasDescription; +use Filament\Support\Contracts\HasIcon; +use Filament\Support\Contracts\HasLabel; +use Filament\Support\Icons\Heroicon; + +enum AccessTier: string implements HasColor, HasDescription, HasIcon, HasLabel +{ + case None = 'none'; + case Analyst = 'analyst'; + case Admin = 'admin'; + + public function getLabel(): string + { + return match ($this) { + self::None => 'No access', + self::Analyst => 'Analyst', + self::Admin => 'Administration', + }; + } + + // Ordered scale → light (no access) ramps to red (apex authority). + public function getColor(): string|array + { + return match ($this) { + self::None => 'gray', + self::Analyst => Color::Blue, + self::Admin => Color::Red, + }; + } + + public function getDescription(): ?string + { + return match ($this) { + self::None => 'Notification audience — no panel access', + self::Analyst => 'Day-to-day operator: triage, review, moderation', + self::Admin => 'Final authority: configures the panel, decides cases', + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::None => Heroicon::OutlinedNoSymbol, + self::Analyst => Heroicon::OutlinedMagnifyingGlass, + self::Admin => Heroicon::OutlinedScale, + }; + } +} + + +## Verification + +Before marking an enum task done, confirm: + +1. The enum `implements HasColor, HasDescription, HasLabel` (and `HasIcon` when meaningful). +2. Every getter `match`es **all** cases with no `default`. +3. Ordered enums ramp light → `danger`; the apex case is red. +4. `{{ $assist->binCommand('pint') }}` and PHPStan pass. diff --git a/.ai/guidelines/workflow/01-issue-tracker.blade.php b/.ai/guidelines/workflow/01-issue-tracker.blade.php index ff1b4571e..55cb021c4 100644 --- a/.ai/guidelines/workflow/01-issue-tracker.blade.php +++ b/.ai/guidelines/workflow/01-issue-tracker.blade.php @@ -4,7 +4,58 @@ # Issue tracker: GitHub -Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI for all operations. +Issues and PRDs for this repo live as GitHub issues on `he4rt/heartdevs.com`. Use the +`gh` CLI for all operations — `gh` infers the repo from `git remote -v`, so never +hard-code the `owner/repo`. + +## When a skill says "publish to the issue tracker" + +Create a real GitHub issue with `gh`, applying the triage/type/module/difficulty +labels from `workflow/triage-labels`, then show the user the created issue URL: + +@verbatim + +gh issue create \ + --title "(): " \ + --body "" \ + --label "type:feat,mod:panel-admin,needs-triage" + +@endverbatim + +Use a heredoc for multi-line bodies. + +## When a skill says "fetch the relevant ticket" + +Read it from GitHub rather than asking the user to paste it. Always include +`--comments` — the resolution usually lives in the thread (reporter clarifications, +the answer to a `needs-info` question, "dupe of #50"), not the opening post: + +@verbatim + +gh issue view --comments +gh issue list --state open --label "needs-triage" + +@endverbatim + +## Pull requests as a triage surface + +The triage queue is **GitHub Issues** — `/triage` does not pull pull requests; PRs go +through normal code review, not triage. + +Because issues and PRs share one number space, a bare `#42` may be either — resolve +with `gh pr view 42` and fall back to `gh issue view 42`. + +## Triage labels + +The taxonomy in `workflow/triage-labels` maps to **live GitHub labels**. Apply them +with `gh issue edit --add-label "..."`. If a label does not exist yet, +create it first: + +@verbatim + +gh label create "mod:" --description "" --color "c2e0c6" + +@endverbatim ## Conventions @@ -14,13 +65,6 @@ - **Comment on an issue**: `gh issue comment --body "..."` - **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` - **Close**: `gh issue close --comment "..."` +- Confirm with the user before **bulk** operations or **closing/deleting** issues — those are outward-facing and harder to undo. Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. - -## When a skill says "publish to the issue tracker" - -Create a GitHub issue. - -## When a skill says "fetch the relevant ticket" - -Run `gh issue view --comments`. diff --git a/.ai/guidelines/workflow/04-branch-naming.blade.php b/.ai/guidelines/workflow/04-branch-naming.blade.php new file mode 100644 index 000000000..7abf6caea --- /dev/null +++ b/.ai/guidelines/workflow/04-branch-naming.blade.php @@ -0,0 +1,51 @@ +@php +/** @var \Laravel\Boost\Install\GuidelineAssist $assist */ +@endphp + +# Branch Naming & Git Workflow + +Agents MUST create branches using the canonical prefixes below. CI is a **merge +gate** defined in `.github/workflows/continuous-integration.yml`: it runs on a +pull request only when the PR's **base** (target) branch matches one of these +patterns. A branch that becomes a PR target outside this convention gets **no +pipeline**. + +## Canonical branch prefixes + +| Prefix | Use for | Commit type | +| ------------------------ | ------------------------------------------- | ----------- | +| `feature/` | New features | `feat` | +| `bugfix/` | Bug fixes | `fix` | +| `chore/` | Maintenance, dependencies, tooling, config, CI | `chore` | +| `story/-` | Issue-driven work (a GitHub issue is open) | fits the work | + +- `` is lowercase kebab-case, e.g. `feature/public-profile-page`. +- `story/` embeds the issue number, e.g. `story/142-oauth-account-merge`. +- Prefixes map to the `type:` labels documented in the Triage Labels guideline. + +## Base / integration branches + +Every PR targets one of these bases — the patterns CI is gated on: + +| Base pattern | Meaning | +| ----------------------------------------------- | ------------------------------------------------------- | +| `main` | Default line | +| `.x` | Versioned release lines (`1.x`, `2.x`) — Laravel style | +| `feature/**` `bugfix/**` `chore/**` `story/**` | Long-lived integration branches that receive sub-PRs | + +Long-lived integration branches use the **same prefixes**. Sub-work is PR'd into +them (e.g. `story/142-oauth-account-merge → feature/identity`); the integration +branch later merges into `main` or a `.x` line. + +## Rules for agents + +- Branch off the correct base and name it with a canonical prefix. Never push + work directly to `main` or a `.x` line. +- Commit subjects follow conventional commits with the module as scope + (`(): ...`) — see the Title Convention in the Triage Labels + guideline. +- CI runs on PRs only; draft PRs are skipped. Open a non-draft PR — or mark it + ready for review — to trigger the pipeline. +- Keep this table in sync with the `pull_request.branches` filter in + `.github/workflows/continuous-integration.yml`; changing one without the other + drifts the merge gate. diff --git a/.env.testing b/.env.testing.example similarity index 73% rename from .env.testing rename to .env.testing.example index a1077969b..88c9040eb 100644 --- a/.env.testing +++ b/.env.testing.example @@ -1,6 +1,6 @@ -APP_NAME=Laravel -APP_ENV=local -APP_KEY=base64:vCE9NeWv1bkp19JvhTCeMk+qPEC52Vim7shJUljveCg= +APP_NAME=Heartdevs +APP_ENV=testing +APP_KEY= APP_DEBUG=true APP_URL=http://localhost APP_DOMAIN=localhost @@ -12,6 +12,9 @@ APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US +# Faster hashing for the test suite +BCRYPT_ROUNDS=4 + DEBUGBAR_ENABLED=false TELESCOPE_ENABLED=false PULSE_ENABLED=false @@ -28,6 +31,11 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# --- Test database — per-user. Copied to .env.testing, which is UNTRACKED. +# No host/port/credentials are shipped: the test DB location is +# infra-specific. Point these at YOUR Postgres (docker-compose, Lerd, +# or a custom stack). Lerd users: find the mapped port via the Lerd +# web UI or `lerd services`. Edit freely; this file never shows in git. --- DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 diff --git a/.github/workflows/_pest.yml b/.github/workflows/_pest.yml index 6023e3c99..3f8ea4983 100644 --- a/.github/workflows/_pest.yml +++ b/.github/workflows/_pest.yml @@ -23,14 +23,18 @@ permissions: {} jobs: tests: - name: Run + name: 'Shard ${{ matrix.shard }}/2' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-tests + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-tests-${{ matrix.shard }} cancel-in-progress: true + strategy: + fail-fast: false + matrix: + shard: [1, 2] services: postgres: @@ -43,9 +47,10 @@ jobs: - 5432/tcp options: >- --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 + --health-interval 3s + --health-timeout 3s + --health-retries 10 + --health-start-period 5s steps: - name: Checkout code @@ -60,7 +65,7 @@ jobs: php-version: ${{ inputs.PHP_VERSION }} extensions: ${{ inputs.PHP_EXTENSIONS }} ini-values: ${{ inputs.PHP_INI_PROPERTIES }} - coverage: xdebug + coverage: none tools: composer:v2 - name: Setup Problem Matches @@ -91,9 +96,14 @@ jobs: - name: Build Assets run: npm run build + - name: Prepare test env + run: | + composer run-script post-root-package-install + composer run-script key:generate + - name: Run Tests env: - XDEBUG_MODE: coverage DB_PORT: ${{ job.services.postgres.ports['5432'] }} + SHARD: ${{ matrix.shard }} run: | - vendor/bin/pest --ci --parallel + vendor/bin/pest --ci --parallel --shard="${SHARD}"/2 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index b60b14f5a..80840536f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -2,7 +2,13 @@ name: Continuous Integration on: pull_request: - branches: [4.x, feat/squads] + branches: + - '*.x' + - 'feature/**' + - 'feat/**' + - 'bugfix/**' + - 'chore/**' + - 'story/**' types: [opened, reopened, synchronize, ready_for_review] concurrency: @@ -124,3 +130,24 @@ jobs: PHP_EXTENSIONS: ${{ needs.setup.outputs.PHP_EXTENSIONS }} PHP_INI_PROPERTIES: ${{ needs.setup.outputs.PHP_INI_PROPERTIES }} PHP_VERSION: ${{ needs.setup.outputs.PHP_VERSION }} + + gate: + name: CI Gate + needs: [setup, pint, rector, phpstan, pest] + if: ${{ always() && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Require every check to succeed + env: + RESULTS: ${{ join(needs.*.result, ', ') }} + run: | + echo "Dependency results: ${RESULTS}" + for result in ${RESULTS//,/ }; do + if [ "${result}" != "success" ]; then + echo "::error::CI did not pass cleanly (${RESULTS})." + exit 1 + fi + done + echo "All CI checks passed." diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 37c05df68..4b6ffa900 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -5,6 +5,10 @@ on: pull_request_target: branches: [4.x] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: {} jobs: diff --git a/.gitignore b/.gitignore index 34d94f13b..abdcf3a55 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env .env.backup .env.production +.env.testing .phpactor.json .phpstorm.meta.php .phpunit.result.cache @@ -41,6 +42,10 @@ yarn-error.log tests/Browser/Screenshots /.composer +# Lerd +.env.before_lerd +.lerd.yaml + # === AI Agents === # Laravel Boost diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 687fea251..5c94ba28a 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -15,6 +15,8 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un | Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) | | Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL | | Identity | `app-modules/identity/` | Users, tenants, external identities, authentication | +| Events | `app-modules/events/` | Event participation lifecycle — enrollment, check-in, attendance, XP dispatch | +| Gamification | `app-modules/gamification/` | Character progression — XP, levels, badges, seasons, daily bonuses | | Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing | | Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks | | Integration GitHub | `app-modules/integration-github/` | GitHub transport (REST via Saloon), OAuth, community contribution ingestion (backfill + webhooks) + event lake | @@ -27,10 +29,24 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un ┌─────────────────┐ ┌──────────────────────┐ │ Bot Discord │ │ Integration Discord │ │ (runtime/ws) │────────▶│ (transport/rest) │ -└────────┬────────┘ └──────────┬───────────┘ - │ │ - │ listens to events │ provides DiscordConnector - ▼ │ +└───┬─────────┬───┘ └──────────┬───────────┘ + │ │ │ + │ │ dispatches │ provides DiscordConnector + │ │ CheckInRequested │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Events │ │ + │ │ (participation) │────────────┼───── publishes domain events + │ └────────┬────────┘ │ │ + │ │ │ ▼ + │ │ reads users │ ┌─────────────────┐ + │ │ │ │ Gamification │ + │ │ │ │ (XP/levels) │ + │ │ │ └────────┬────────┘ + │ │ │ │ + │ listens │ │ │ reads users + │ to events │ │ │ + ▼ ▼ │ ▼ ┌─────────────────┐ │ │ Moderation │◀───────────────────┘ │ (domain core) │ @@ -46,10 +62,12 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un ### Dependency rules - **Moderation** is platform-agnostic. It never imports from `bot-discord`, `integration-discord`, or `integration-twitch`. -- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport). +- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport) and Events (dispatches check-in domain events).. - **Integration Discord** depends on Identity (OAuth user resolution). It never imports from Moderation. - **Integration Twitch** depends on Identity (OAuth user resolution, ExternalIdentity for tenant linking). It never imports from Moderation, Integration Discord, or Bot Discord. - **Integration GitHub** depends on Identity (OAuth user resolution; future `Character` seam via `ExternalIdentity`). It never imports from Activity, Economy, Moderation or any Bot/runtime module — it only emits the `GithubContributionRecorded` domain event. The community presentation (in `portal`) and the allowlist admin UI (in `panel-admin`) depend on it, never the reverse. - **Identity** has no upstream dependencies on other contexts listed here. +- **Events** depends on Identity (reads Users and Tenants). Publishes domain events consumed by Gamification. +- **Gamification** depends on Identity (Character belongs to User). Listens to Events domain events for XP. - **Onboarding** depends on Identity (User, tenant scoping, GitHub `ExternalIdentity` link) and listens to `integration-github`'s `GithubPullRequestApproved` domain event (reads the `challenge` repos in the allowlist). It never imports from `squads` — `squads` is a consumer of its completion gate, never the reverse. - **Squads** depends on Onboarding (reads the `Squads`-completion gate, "APTO") and Identity (users/tenants). It never imports from presentation; the panels depend on it. diff --git a/Makefile b/Makefile index 7aa994b5d..32bb6529c 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ test-feature: ## Run feature tests .PHONY: setup-test-db setup-test-db: ## Create the testing database for running tests - @PGHOST=localhost PGUSER=postgres PGPASSWORD=postgres createdb test_he4rtbot 2>/dev/null || echo "Database test_he4rtbot already exists" + @php artisan migrate --env=testing --no-interaction --force .PHONY: migrate-fresh migrate-fresh: ## Run migrations and seed the database @@ -85,12 +85,7 @@ dev: ## Start the server .PHONY: setup setup: ## Setup the project - @composer install - @npm install - @composer run-script post-root-package-install - @composer run-script post-create-project-cmd - @php artisan key:generate --ansi - @php artisan storage:link --ansi + @composer run-script setup .PHONY: import-db import-db: ## Import a PostgreSQL dump file (usage: make import-db file=path/to/dump) diff --git a/Taskfile.yml b/Taskfile.yml index d18fac78f..a7b580957 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -112,9 +112,4 @@ tasks: desc: Setup the project aliases: [s] cmds: - - 'composer install' - - 'npm install' - - 'composer run post-root-package-install' - - 'composer run post-create-project-cmd' - - 'php artisan key:generate --ansi' - - 'php artisan storage:link --ansi' + - 'composer run setup' diff --git a/app-modules/activity/database/factories/.gitkeep b/app-modules/activity/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/activity/database/migrations/.gitkeep b/app-modules/activity/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/activity/src/ActivityServiceProvider.php b/app-modules/activity/src/ActivityServiceProvider.php index 3e9fb4b56..d79f3f9f9 100644 --- a/app-modules/activity/src/ActivityServiceProvider.php +++ b/app-modules/activity/src/ActivityServiceProvider.php @@ -8,8 +8,10 @@ use He4rt\Activity\Moderation\Models\ModerationEvent; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Listeners\PublishModerationToTimeline; +use He4rt\Activity\Timeline\Listeners\ReassignTimelineOwnership; use He4rt\Activity\Timeline\Timeline; use He4rt\Activity\Voice\Models\Voice; +use He4rt\Identity\Auth\Events\AccountsMerged; use He4rt\Moderation\Enforcement\ActionExecuted; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Event; @@ -36,5 +38,6 @@ public function boot(): void ]); Event::listen(ActionExecuted::class, [PublishModerationToTimeline::class, 'handle']); + Event::listen(AccountsMerged::class, [ReassignTimelineOwnership::class, 'handle']); } } diff --git a/app-modules/activity/src/Moderation/Models/ModerationEvent.php b/app-modules/activity/src/Moderation/Models/ModerationEvent.php index 642f3ff39..950399fd4 100644 --- a/app-modules/activity/src/Moderation/Models/ModerationEvent.php +++ b/app-modules/activity/src/Moderation/Models/ModerationEvent.php @@ -31,7 +31,7 @@ * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ -#[ObservedBy(ModerationEventObserver::class)] +#[ObservedBy(classes: ModerationEventObserver::class)] #[Table(name: 'moderation_events')] final class ModerationEvent extends Model { diff --git a/app-modules/activity/src/Timeline/Listeners/ReassignTimelineOwnership.php b/app-modules/activity/src/Timeline/Listeners/ReassignTimelineOwnership.php new file mode 100644 index 000000000..607acdc3a --- /dev/null +++ b/app-modules/activity/src/Timeline/Listeners/ReassignTimelineOwnership.php @@ -0,0 +1,23 @@ +where('user_id', $event->mergedId) + ->update(['user_id' => $event->survivorId]); + } +} diff --git a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php index 328d470c2..fdbd43483 100644 --- a/app-modules/activity/src/Timeline/Queries/TimelineFeed.php +++ b/app-modules/activity/src/Timeline/Queries/TimelineFeed.php @@ -19,6 +19,7 @@ public function builder(): Builder return Timeline::query() ->where('tenant_id', $this->tenantId) ->where('is_ignored', operator: false) + ->whereHas('user') ->whereNull('parent_id')->latest(); } } diff --git a/app-modules/activity/src/Voice/Models/Voice.php b/app-modules/activity/src/Voice/Models/Voice.php index 2cd63492e..1329435fd 100644 --- a/app-modules/activity/src/Voice/Models/Voice.php +++ b/app-modules/activity/src/Voice/Models/Voice.php @@ -29,7 +29,7 @@ // `state` is a plain string: the voice experience ticker writes // VoiceStatesEnum values (muted/unmuted/disabled) and the historical // ETL import writes joined/left as raw strings. -#[UseFactory(VoiceFactory::class)] +#[UseFactory(factoryClass: VoiceFactory::class)] #[Table(name: 'voice_messages')] final class Voice extends Model { diff --git a/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php b/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php index 5b008a4a1..65f60a4a3 100644 --- a/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php +++ b/app-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.php @@ -84,6 +84,31 @@ ->and($result->first()->id)->toBe($visible->id); }); +test('excludes posts whose author no longer exists', function (): void { + $tenant = Tenant::factory()->create(); + $author = User::factory()->create(); + $ghost = User::factory()->create(); + + $visible = Timeline::factory()->for($author)->create([ + 'tenant_id' => $tenant->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => PostEntry::factory()->create()->id, + ]); + + Timeline::factory()->for($ghost)->create([ + 'tenant_id' => $tenant->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => PostEntry::factory()->create()->id, + ]); + + $ghost->delete(); + + $result = new TimelineFeed($tenant->id)->builder()->simplePaginate(15); + + expect($result)->toHaveCount(1) + ->and($result->first()->id)->toBe($visible->id); +}); + test('does not show posts from other tenants', function (): void { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); diff --git a/app-modules/community/database/factories/.gitkeep b/app-modules/community/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/community/database/migrations/.gitkeep b/app-modules/community/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/docs/src/Console/Commands/CacheDocsCommand.php b/app-modules/docs/src/Console/Commands/CacheDocsCommand.php index 47f25415a..6e8ccb02d 100644 --- a/app-modules/docs/src/Console/Commands/CacheDocsCommand.php +++ b/app-modules/docs/src/Console/Commands/CacheDocsCommand.php @@ -13,8 +13,8 @@ * Warms or clears the documentation portal cache. Run `docs:cache` during * deploy to pre-build the navigation tree and rendered pages. */ -#[Description('Warm or clear the documentation portal cache')] -#[Signature('docs:cache {--clear : Clear the cached documentation index instead of warming it}')] +#[Description(description: 'Warm or clear the documentation portal cache')] +#[Signature(signature: 'docs:cache {--clear : Clear the cached documentation index instead of warming it}')] final class CacheDocsCommand extends Command { public function handle(DocumentRegistry $registry): int diff --git a/app-modules/economy/database/factories/.gitkeep b/app-modules/economy/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/economy/database/migrations/.gitkeep b/app-modules/economy/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/events/CONTEXT.md b/app-modules/events/CONTEXT.md new file mode 100644 index 000000000..657b68d29 --- /dev/null +++ b/app-modules/events/CONTEXT.md @@ -0,0 +1,74 @@ +# Events — Bounded Context + +## Purpose + +Manages event participation lifecycle: enrollment, check-in, attendance tracking, and XP reward dispatching. Covers in-person meetups, workshops, and multi-day conferences. + +## Glossary + +| Term | Definition | Not to be confused with | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **Event** | A scheduled gathering (meetup, workshop, or conference) owned by a tenant. Defined by type, date range, location, and an enrollment policy. | "Event" as in Laravel Event (domain event) — use "domain event" for those. | +| **Enrollment** | A single record representing one user's relationship with one event. Progresses through a strict state machine from entry to terminal state. One enrollment per (user, event). | "Registration" — we don't use this term. | +| **Enrollment Policy** | A 1:1 configuration record attached to an event. Defines enrollment method, capacity, check-in method, waitlist behavior, cancellation deadline, XP rewards, and application form schema. | "Event settings" — policy specifically governs participation rules. | +| **Enrollment Method** | How a user enters an event. One of: `rsvp` (1-click), `rsvp_checkin` (1-click + mandatory presence verification), `application` (form submission + organizer approval). | | +| **Check-in** | Verification of physical/virtual presence at an event. One record per (enrollment, date). Methods: `manual`, `numeric_code`, `qr_code`. | "Enrollment" — check-in proves presence, enrollment proves intent. | +| **Numeric Code** | A short-lived code announced by the organizer (projected on screen or spoken in stream). Bound to a specific event date. Has expiration window and optional max uses. Also used by Discord/Twitch bots as check-in trigger. | | +| **QR Token** | A unique token generated per enrollment. Encoded in a QR code on the participant's badge/screen. Reusable across event days — each scan creates a new check-in record for that day. | | +| **Waitlist** | Ordered queue of enrollments when event capacity is full. FIFO promotion when a confirmed participant cancels. | | +| **Attendance Requirement** | Policy rule defining how many days of check-in are needed to achieve `attended` status. Values: `all_days`, `any_day`, `minimum_days(N)`. | | +| **Transition** | An auditable state change on an enrollment. Every transition is recorded with actor, timestamp, and reason. Written by application code (Actions), never by database triggers. | | +| **No-show** | Terminal state for a participant who confirmed but never checked in. Assigned automatically by a scheduled job after the event ends. No systemic consequences in MVP (future: may affect eligibility). | | +| **Application** | An enrollment method where the participant submits a dynamic form (JSONB schema defined in policy) and waits for organizer approval. Results in `pending → confirmed` or `pending → rejected`. | "Submission" (CFP) — application is for participation, submission is for presenting (out of MVP scope). | + +## State Machine — Enrollment + +``` +[entry] + ├─ application → pending + │ ├─ approve() → confirmed + │ ├─ reject() → rejected (TERMINAL) + │ └─ cancel() → cancelled (TERMINAL) + │ + ├─ rsvp/rsvp_checkin + capacity available → confirmed + └─ rsvp/rsvp_checkin + full → waitlisted + ├─ slot_opens() → confirmed + └─ cancel() → cancelled (TERMINAL) + +confirmed + ├─ check_in() → checked_in + ├─ cancel(pre-deadline) → cancelled (TERMINAL) + └─ [job] event_ended → no_show (TERMINAL) + +checked_in + └─ [job] event_ended + attendance_requirement met → attended (TERMINAL · SUCCESS) +``` + +**States:** `pending`, `confirmed`, `waitlisted`, `checked_in`, `attended`, `cancelled`, `rejected`, `no_show` + +**Terminal states:** `attended` (success), `cancelled`, `rejected`, `no_show` + +## Actors + +| Actor | Panel | Capabilities | +| --------------- | ---------------- | ---------------------------------------------------------------------------------------------------- | +| **Organizer** | Admin (`/admin`) | Creates events, configures policies, approves/rejects applications, manual check-in, status override | +| **Participant** | App (`/app`) | Enrolls (RSVP/application), checks in (code/QR), cancels, views own events | + +## Module Boundaries + +- **Events → Gamification**: Events dispatches domain events (`EnrollmentConfirmed`, `ParticipantCheckedIn`, `ParticipantAttended`). Gamification listens and awards XP. Events does not know how XP works. +- **Bot Discord → Events**: Bot dispatches domain events (e.g., `CheckInRequested`). Events module listens and processes. Bot is transport, Events owns the rules. +- **Events → Identity**: Events reads User and Tenant models. No writes to Identity. + +## Out of Scope (MVP) + +- Networking between participants +- Referral / invite links +- Magic link and geolocation check-in +- Sponsors association +- Timeline / activity feed (listeners ready, no consumer yet) +- Call for Papers / Submissions (CFP) +- Agenda / schedule display +- Paid events / payment integration +- No-show penalties diff --git a/app-modules/events/database/factories/CheckInCodeFactory.php b/app-modules/events/database/factories/CheckInCodeFactory.php new file mode 100644 index 000000000..084da95a8 --- /dev/null +++ b/app-modules/events/database/factories/CheckInCodeFactory.php @@ -0,0 +1,32 @@ + */ +final class CheckInCodeFactory extends Factory +{ + protected $model = CheckInCode::class; + + public function definition(): array + { + $startsAt = Date::now(); + + return [ + 'event_id' => Event::factory(), + 'event_date' => Date::today(), + 'code' => fake()->numerify('######'), + 'starts_at' => $startsAt, + 'expires_at' => $startsAt->clone()->addHours(2), + 'max_uses' => null, + 'uses_count' => 0, + 'revoked_at' => null, + ]; + } +} diff --git a/app-modules/events/database/factories/CheckInFactory.php b/app-modules/events/database/factories/CheckInFactory.php new file mode 100644 index 000000000..2392e1fec --- /dev/null +++ b/app-modules/events/database/factories/CheckInFactory.php @@ -0,0 +1,28 @@ + */ +final class CheckInFactory extends Factory +{ + protected $model = CheckIn::class; + + public function definition(): array + { + return [ + 'enrollment_id' => Enrollment::factory(), + 'event_date' => Date::today(), + 'method' => fake()->randomElement(CheckInMethod::cases()), + 'payload' => null, + 'checked_in_at' => Date::now(), + ]; + } +} diff --git a/app-modules/events/database/factories/EnrollmentFactory.php b/app-modules/events/database/factories/EnrollmentFactory.php new file mode 100644 index 000000000..d832f045a --- /dev/null +++ b/app-modules/events/database/factories/EnrollmentFactory.php @@ -0,0 +1,35 @@ + */ +final class EnrollmentFactory extends Factory +{ + protected $model = Enrollment::class; + + public function definition(): array + { + return [ + 'event_id' => Event::factory(), + 'user_id' => User::factory(), + 'status' => EnrollmentStatus::Pending, + 'is_public' => true, + 'waitlist_position' => null, + 'application_data' => null, + 'rejection_reason' => null, + 'enrolled_at' => null, + 'confirmed_at' => null, + 'checked_in_at' => null, + 'attended_at' => null, + 'cancelled_at' => null, + ]; + } +} diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php new file mode 100644 index 000000000..96f1ab197 --- /dev/null +++ b/app-modules/events/database/factories/EnrollmentPolicyFactory.php @@ -0,0 +1,53 @@ + */ +final class EnrollmentPolicyFactory extends Factory +{ + protected $model = EnrollmentPolicy::class; + + public function definition(): array + { + $requirement = fake()->randomElement(AttendanceRequirement::cases()); + + return [ + 'event_id' => Event::factory(), + 'enrollment_method' => fake()->randomElement(EnrollmentMethod::cases()), + 'check_in_method' => fake()->randomElement(CheckInMethod::cases()), + 'capacity' => fake()->optional()->numberBetween(10, 200), + 'has_waitlist' => false, + 'attendance_requirement' => $requirement, + 'minimum_days' => $requirement === AttendanceRequirement::MinimumDays ? 1 : null, + 'cancellation_deadline_hours' => null, + 'xp_on_confirmed' => 0, + 'xp_on_checked_in' => 0, + 'xp_on_attended' => 0, + 'application_schema' => null, + ]; + } + + public function rsvp(): static + { + return $this->state(fn (): array => [ + 'enrollment_method' => EnrollmentMethod::Rsvp, + ]); + } + + public function application(?array $schema = null): static + { + return $this->state(fn (): array => [ + 'enrollment_method' => EnrollmentMethod::Application, + 'application_schema' => $schema, + ]); + } +} diff --git a/app-modules/events/database/factories/EnrollmentTransitionFactory.php b/app-modules/events/database/factories/EnrollmentTransitionFactory.php new file mode 100644 index 000000000..6430c49d8 --- /dev/null +++ b/app-modules/events/database/factories/EnrollmentTransitionFactory.php @@ -0,0 +1,30 @@ + */ +final class EnrollmentTransitionFactory extends Factory +{ + protected $model = EnrollmentTransition::class; + + public function definition(): array + { + return [ + 'enrollment_id' => Enrollment::factory(), + 'from_status' => null, + 'to_status' => EnrollmentStatus::Pending, + 'actor_id' => null, + 'triggered_by' => TriggeredBy::User, + 'reason' => null, + 'metadata' => null, + ]; + } +} diff --git a/app-modules/events/database/factories/EventAgendaFactory.php b/app-modules/events/database/factories/EventAgendaFactory.php deleted file mode 100644 index 070249108..000000000 --- a/app-modules/events/database/factories/EventAgendaFactory.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -final class EventAgendaFactory extends Factory -{ - protected $model = EventAgenda::class; - - public function definition(): array - { - return [ - 'tenant_id' => Tenant::factory(), - 'event_id' => EventModel::factory(), - 'start_at' => Date::now(), - 'end_at' => Date::now()->addHour(), - 'created_at' => Date::now(), - 'updated_at' => Date::now(), - ]; - } - - public function forSegment(?EventSegment $segment = null) - { - return $this->state(fn () => [ - 'schedulable_type' => (new EventSegment)->getMorphClass(), - 'schedulable_id' => $segment ?? EventSegment::query()->inRandomOrder()->first()->getKey(), - ]); - } - - public function forTalk(?EventSubmission $talk = null) - { - return $this->state(fn () => [ - 'schedulable_type' => (new EventSubmission)->getMorphClass(), - 'schedulable_id' => $talk ?? EventSubmission::factory(), - ]); - } -} diff --git a/app-modules/events/database/factories/EventFactory.php b/app-modules/events/database/factories/EventFactory.php index b532cf183..ed8ce12ee 100644 --- a/app-modules/events/database/factories/EventFactory.php +++ b/app-modules/events/database/factories/EventFactory.php @@ -4,61 +4,125 @@ namespace He4rt\Events\Database\Factories; -use Exception; -use He4rt\Events\Enums\AttendingStatusEnum; -use He4rt\Events\Enums\EventTypeEnum; -use He4rt\Events\Models\EventModel; +use He4rt\Events\CheckIn\Enums\CheckInMethod; +use He4rt\Events\Enrollment\Enums\AttendanceRequirement; +use He4rt\Events\Enrollment\Enums\EnrollmentMethod; +use He4rt\Events\Enrollment\Models\EnrollmentPolicy; +use He4rt\Events\Event\Enums\EventStatus; +use He4rt\Events\Event\Enums\EventType; +use He4rt\Events\Event\Models\Event; use He4rt\Identity\Tenant\Models\Tenant; -use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Date; -/** - * @extends Factory - */ +/** @extends Factory */ final class EventFactory extends Factory { - protected $model = EventModel::class; + protected $model = Event::class; public function definition(): array { + $startsAt = Date::now()->addDays(fake()->numberBetween(1, 30)); + return [ 'tenant_id' => Tenant::factory(), - 'event_type' => fake()->randomElement(EventTypeEnum::cases()), - 'slug' => fake()->slug(), - 'active' => true, + 'slug' => fake()->unique()->slug(), 'title' => fake()->sentence(4), - 'description' => fake()->text(), - 'event_at' => Date::today(), - 'start_at' => Date::now(), - 'end_at' => Date::today()->endOfDay(), - 'location' => fake()->sentence(3), - 'max_attendees' => fake()->numberBetween(10, 100), - 'attendees_count' => 0, - 'waitlist_count' => 0, - 'created_at' => Date::now(), - 'updated_at' => Date::now(), + 'description' => fake()->optional()->text(), + 'event_type' => fake()->randomElement(EventType::cases()), + 'location' => fake()->optional()->address(), + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addHours(fake()->numberBetween(2, 8)), + 'status' => EventStatus::Draft, ]; } - public function withStatus(AttendingStatusEnum $status = AttendingStatusEnum::Attending): self + public function published(): static + { + return $this->state(fn (): array => [ + 'status' => EventStatus::Published, + ]); + } + + public function upcoming(): static { - return $this->afterCreating(static function (EventModel $model) use ($status): void { - $attendees = User::factory()->count(fake()->numberBetween(3, 10))->create(); - $column = match ($status) { - AttendingStatusEnum::Attending => 'attendees_count', - AttendingStatusEnum::Waitlist => 'waitlist_count', - AttendingStatusEnum::NotAttending => throw new Exception('Event is not attending anymore'), - }; - $model->update([ - $column => $attendees->count(), + $startsAt = Date::now()->addDays(7); + + return $this->state(fn (): array => [ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addHours(3), + ]); + } + + public function past(): static + { + $startsAt = Date::now()->subDays(7); + + return $this->state(fn (): array => [ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addHours(3), + ]); + } + + public function asWorkshop(): static + { + return $this->state(fn (): array => [ + 'event_type' => EventType::Workshop, + 'status' => EventStatus::Published, + ])->afterCreating(static function (Event $event): void { + EnrollmentPolicy::factory()->create([ + 'event_id' => $event->id, + 'enrollment_method' => EnrollmentMethod::RsvpCheckin, + 'check_in_method' => CheckInMethod::NumericCode, + 'capacity' => 50, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'xp_on_confirmed' => 100, + 'xp_on_checked_in' => 200, + 'xp_on_attended' => 500, + ]); + }); + } + + public function asMeetup(): static + { + return $this->state(fn (): array => [ + 'event_type' => EventType::Meetup, + 'status' => EventStatus::Published, + ])->afterCreating(static function (Event $event): void { + EnrollmentPolicy::factory()->create([ + 'event_id' => $event->id, + 'enrollment_method' => EnrollmentMethod::Rsvp, + 'check_in_method' => CheckInMethod::Manual, + 'capacity' => null, + 'has_waitlist' => false, + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'xp_on_confirmed' => 50, + 'xp_on_checked_in' => 100, + 'xp_on_attended' => 250, ]); + }); + } - foreach ($attendees as $user) { - $model->attendees()->attach($user->getKey(), [ - 'status' => $status, - ]); - } + public function asConference(): static + { + return $this->state(fn (): array => [ + 'event_type' => EventType::Conference, + 'status' => EventStatus::Published, + ])->afterCreating(static function (Event $event): void { + EnrollmentPolicy::factory()->create([ + 'event_id' => $event->id, + 'enrollment_method' => EnrollmentMethod::Application, + 'check_in_method' => CheckInMethod::QrCode, + 'capacity' => 200, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + 'minimum_days' => 2, + 'cancellation_deadline_hours' => 48, + 'xp_on_confirmed' => 200, + 'xp_on_checked_in' => 300, + 'xp_on_attended' => 1_000, + ]); }); } } diff --git a/app-modules/events/database/factories/EventSubmissionFactory.php b/app-modules/events/database/factories/EventSubmissionFactory.php deleted file mode 100644 index dbdc2e8e0..000000000 --- a/app-modules/events/database/factories/EventSubmissionFactory.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ -final class EventSubmissionFactory extends Factory -{ - protected $model = EventSubmission::class; - - public function definition(): array - { - return [ - 'tenant_id' => Tenant::factory(), - 'event_id' => EventModel::factory(), - 'user_id' => User::factory(), - 'status' => fake()->randomElement(TalkStatusEnum::cases()), - 'field_type' => fake()->word(), - 'title' => fake()->word(), - 'description' => fake()->text(), - 'created_at' => Date::now(), - 'updated_at' => Date::now(), - ]; - } -} diff --git a/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php b/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php deleted file mode 100644 index a5e5e5b2f..000000000 --- a/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - */ -class EventSubmissionSpeakerFactory extends Factory -{ - protected $model = EventSubmissionSpeaker::class; - - public function definition(): array - { - return [ - 'event_id' => EventModel::factory(), - 'user_id' => User::factory(), - ]; - } -} diff --git a/app-modules/events/database/factories/QrTokenFactory.php b/app-modules/events/database/factories/QrTokenFactory.php new file mode 100644 index 000000000..0950d7bfe --- /dev/null +++ b/app-modules/events/database/factories/QrTokenFactory.php @@ -0,0 +1,25 @@ + */ +final class QrTokenFactory extends Factory +{ + protected $model = QrToken::class; + + public function definition(): array + { + return [ + 'enrollment_id' => Enrollment::factory(), + 'token' => Str::random(64), + 'expires_at' => null, + ]; + } +} diff --git a/app-modules/events/database/factories/SponsorFactory.php b/app-modules/events/database/factories/SponsorFactory.php deleted file mode 100644 index 4f360c65e..000000000 --- a/app-modules/events/database/factories/SponsorFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -final class SponsorFactory extends Factory -{ - protected $model = Sponsor::class; - - public function definition(): array - { - return [ - 'tenant_id' => Tenant::factory(), - 'name' => fake()->name(), - 'homepage_url' => fake()->url(), - 'created_at' => Date::now(), - 'updated_at' => Date::now(), - ]; - } -} diff --git a/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php b/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php index 341443023..4eba8fe63 100644 --- a/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php +++ b/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php @@ -17,4 +17,9 @@ public function up(): void Schema::dropIfExists('sponsors'); Schema::dropIfExists('events'); } + + public function down(): void + { + // Irreversible: removes legacy events module tables replaced by the new schema. + } }; diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php new file mode 100644 index 000000000..215b4e446 --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->foreignUuid('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->string('slug', 120); + $table->string('title', 200); + $table->longText('description')->nullable(); + $table->string('event_type', 20)->comment(EventType::stringifyCases()); + $table->string('location')->nullable(); + $table->timestampTz('starts_at'); + $table->timestampTz('ends_at'); + $table->string('status', 20) + ->comment(EventStatus::stringifyCases()) + ->default(EventStatus::Draft); + $table->timestampsTz(); + $table->unique(['tenant_id', 'slug'], 'idx_events_tenant_slug'); + $table->index(['tenant_id', 'starts_at'], 'idx_events_tenant_window'); + $table->index(['event_type', 'starts_at'], 'idx_events_type_window'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php new file mode 100644 index 000000000..0fbd3d6cc --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php @@ -0,0 +1,40 @@ +uuid('id')->primary(); + $table->foreignUuid('event_id')->unique()->constrained('events')->cascadeOnDelete(); + $table->string('enrollment_method', 20)->comment(EnrollmentMethod::stringifyCases()); + $table->string('check_in_method', 20)->comment(CheckInMethod::stringifyCases()); + $table->unsignedInteger('capacity')->nullable(); + $table->boolean('has_waitlist')->default(value: false); + $table->string('attendance_requirement', 20) + ->comment(AttendanceRequirement::stringifyCases()) + ->default(AttendanceRequirement::AllDays); + $table->unsignedSmallInteger('minimum_days')->nullable(); + $table->unsignedSmallInteger('cancellation_deadline_hours')->nullable(); + $table->unsignedInteger('xp_on_confirmed')->default(0); + $table->unsignedInteger('xp_on_checked_in')->default(0); + $table->unsignedInteger('xp_on_attended')->default(0); + $table->jsonb('application_schema')->nullable(); + $table->timestampsTz(); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_enrollment_policies'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php new file mode 100644 index 000000000..82a66a1ff --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php @@ -0,0 +1,43 @@ +uuid('id')->primary(); + $table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete(); + $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('status', 20) + ->comment(EnrollmentStatus::stringifyCases()) + ->default(EnrollmentStatus::Pending); + $table->boolean('is_public')->default(value: true); + $table->unsignedInteger('waitlist_position')->nullable(); + $table->jsonb('application_data')->nullable(); + $table->string('rejection_reason', 500)->nullable(); + $table->timestampTz('enrolled_at')->nullable(); + $table->timestampTz('confirmed_at')->nullable(); + $table->timestampTz('checked_in_at')->nullable(); + $table->timestampTz('attended_at')->nullable(); + $table->timestampTz('cancelled_at')->nullable(); + $table->timestampsTz(); + + $table->unique(['event_id', 'user_id'], 'idx_enrollments_unique'); + $table->index(['event_id', 'status'], 'idx_enrollments_event_status'); + $table->index(['status', 'waitlist_position'], 'idx_enrollments_waitlist'); + $table->index(['user_id', 'status'], 'idx_enrollments_user_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_enrollments'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php new file mode 100644 index 000000000..937ab0f1a --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php @@ -0,0 +1,35 @@ +uuid('id')->primary(); + $table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete(); + $table->string('from_status', 20)->nullable()->comment(EnrollmentStatus::stringifyCases()); + $table->string('to_status', 20)->comment(EnrollmentStatus::stringifyCases()); + $table->foreignUuid('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('triggered_by', 20)->comment(TriggeredBy::stringifyCases()); + $table->string('reason', 500)->nullable(); + // contexto adicional para audit trail: notas do admin, job ID, código de razão, etc. + $table->jsonb('metadata')->nullable(); + $table->timestampTz('created_at')->useCurrent(); + + $table->index(['enrollment_id', 'created_at'], 'idx_transitions_enrollment_time'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_enrollment_transitions'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php new file mode 100644 index 000000000..bf6ff5fd0 --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete(); + $table->date('event_date'); + $table->string('method', 20)->comment(CheckInMethod::stringifyCases()); + $table->jsonb('payload')->nullable(); + $table->timestampsTz(); + $table->unique(['enrollment_id', 'event_date'], 'idx_check_ins_unique_per_day'); + $table->index('event_date', 'idx_check_ins_date'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_check_ins'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php new file mode 100644 index 000000000..ba7ae6504 --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete(); + $table->date('event_date'); + $table->string('code', 16); + $table->timestampTz('starts_at'); + $table->timestampTz('expires_at'); + $table->unsignedInteger('max_uses')->nullable(); + $table->unsignedInteger('uses_count')->default(0); + $table->timestampsTz(); + + $table->unique(['event_id', 'event_date', 'code'], 'idx_check_in_codes_unique'); + $table->index(['code', 'expires_at'], 'idx_check_in_codes_lookup'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_check_in_codes'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php new file mode 100644 index 000000000..8b24e300a --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php @@ -0,0 +1,28 @@ +uuid('id')->primary(); + $table->foreignUuid('enrollment_id')->unique()->constrained('events_enrollments')->cascadeOnDelete(); + $table->string('token', 64); + $table->timestampTz('expires_at')->nullable(); + $table->timestampsTz(); + + $table->unique('token', 'idx_qr_tokens_token'); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_qr_tokens'); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php new file mode 100644 index 000000000..49ab92908 --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php @@ -0,0 +1,24 @@ +timestampTz('checked_in_at')->nullable()->after('payload'); + }); + } + + public function down(): void + { + Schema::table('events_check_ins', static function (Blueprint $table): void { + $table->dropColumn('checked_in_at'); + }); + } +}; diff --git a/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php new file mode 100644 index 000000000..eab4a3a53 --- /dev/null +++ b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php @@ -0,0 +1,24 @@ +timestampTz('revoked_at')->nullable()->after('uses_count'); + }); + } + + public function down(): void + { + Schema::table('events_check_in_codes', static function (Blueprint $table): void { + $table->dropColumn('revoked_at'); + }); + } +}; diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php new file mode 100644 index 000000000..c42a0b8a3 --- /dev/null +++ b/app-modules/events/database/seeders/EventsSeeder.php @@ -0,0 +1,475 @@ +resolveTenant(); + $participants = $this->resolveParticipants($tenant); + + foreach ($this->matrix() as $spec) { + $event = Event::query()->firstOrCreate( + ['slug' => Str::slug($spec['title'])], + [ + 'tenant_id' => $tenant->id, + 'title' => $spec['title'], + 'description' => $spec['description'], + 'event_type' => $spec['type'], + 'location' => $spec['location'], + 'status' => $spec['status'], + ...$this->schedule($spec['schedule'], $spec['days']), + ], + ); + + if (!$event->wasRecentlyCreated) { + continue; + } + + $this->seedPolicy($event, $spec); + $this->seedEnrollments($event, $spec, $participants); + $this->seedCheckInCodes($event, $spec); + } + } + + /** + * The event matrix. Each row is a self-contained scenario with a + * human-readable title so it is easy to recognise in the panels. + * + * @return list> + */ + private function matrix(): array + { + return [ + // RSVP-only meetup, no capacity, manual check-in, happening soon. + [ + 'title' => 'He4rt Meetup #42 — Carreira em Tech', + 'description' => 'Bate-papo aberto sobre transição de carreira e primeiro emprego em tecnologia.', + 'type' => EventType::Meetup, + 'status' => EventStatus::Published, + 'schedule' => 'upcoming', + 'days' => 1, + 'location' => 'Online — Discord He4rt', + 'enrollment_method' => EnrollmentMethod::Rsvp, + 'check_in_method' => CheckInMethod::Manual, + 'capacity' => null, + 'has_waitlist' => false, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'minimum_days' => null, + 'cancellation_deadline_hours' => null, + 'xp' => [50, 0, 100], + 'enrollments' => [ + EnrollmentStatus::Confirmed->value => 8, + EnrollmentStatus::Pending->value => 2, + EnrollmentStatus::Cancelled->value => 1, + ], + ], + + // The primary numeric-code target: ongoing single-day workshop. + [ + 'title' => 'Workshop: Rust do Zero ao Deploy', + 'description' => 'Mão na massa construindo e publicando uma API em Rust em uma tarde.', + 'type' => EventType::Workshop, + 'status' => EventStatus::Published, + 'schedule' => 'ongoing', + 'days' => 1, + 'location' => 'Sede He4rt — Sala 1', + 'enrollment_method' => EnrollmentMethod::RsvpCheckin, + 'check_in_method' => CheckInMethod::NumericCode, + 'capacity' => 50, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + 'cancellation_deadline_hours' => 24, + 'xp' => [100, 200, 500], + 'enrollments' => [ + EnrollmentStatus::Confirmed->value => 10, + EnrollmentStatus::CheckedIn->value => 6, + EnrollmentStatus::Waitlisted->value => 4, + EnrollmentStatus::Cancelled->value => 2, + ], + 'check_in_codes' => true, + ], + + // Small-capacity numeric-code meetup, ongoing, to test waitlist + check-in. + [ + 'title' => 'Mentoria em Grupo: Primeiro Emprego', + 'description' => 'Sessão de mentoria com vagas limitadas e lista de espera.', + 'type' => EventType::Meetup, + 'status' => EventStatus::Published, + 'schedule' => 'ongoing', + 'days' => 1, + 'location' => 'Online — Discord He4rt', + 'enrollment_method' => EnrollmentMethod::RsvpCheckin, + 'check_in_method' => CheckInMethod::NumericCode, + 'capacity' => 10, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'minimum_days' => null, + 'cancellation_deadline_hours' => 12, + 'xp' => [30, 80, 150], + 'enrollments' => [ + EnrollmentStatus::CheckedIn->value => 7, + EnrollmentStatus::Confirmed->value => 3, + EnrollmentStatus::Waitlisted->value => 5, + EnrollmentStatus::NoShow->value => 1, + ], + 'check_in_codes' => true, + ], + + // Application-based, multi-day conference with QR check-in, upcoming. + [ + 'title' => 'He4rt Conf 2026', + 'description' => 'Três dias de palestras, trilhas e networking da comunidade He4rt.', + 'type' => EventType::Conference, + 'status' => EventStatus::Published, + 'schedule' => 'upcoming', + 'days' => 3, + 'location' => 'São Paulo — Centro de Convenções', + 'enrollment_method' => EnrollmentMethod::Application, + 'check_in_method' => CheckInMethod::QrCode, + 'capacity' => 200, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + 'minimum_days' => 2, + 'cancellation_deadline_hours' => 48, + 'xp' => [200, 300, 1_000], + 'application_schema' => [ + ['key' => 'why_participate', 'type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true], + ['key' => 'experience_level', 'type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']], + ['key' => 'personal_project', 'type' => 'textarea', 'label' => 'Descreva um projeto pessoal ou open source relevante', 'required' => false], + ['key' => 'attendance_days', 'type' => 'checkbox', 'label' => 'Em quais dias você pode comparecer?', 'required' => false, 'options' => ['Dia 1', 'Dia 2', 'Dia 3']], + ], + 'enrollments' => [ + EnrollmentStatus::Pending->value => 6, + EnrollmentStatus::Confirmed->value => 12, + EnrollmentStatus::Waitlisted->value => 4, + EnrollmentStatus::Rejected->value => 2, + ], + ], + + // Past, completed meetup with attendance recorded. + [ + 'title' => 'Live: PHP 8.5 — O que há de novo', + 'description' => 'Retrospectiva das novidades do PHP 8.5 com exemplos práticos.', + 'type' => EventType::Meetup, + 'status' => EventStatus::Completed, + 'schedule' => 'past', + 'days' => 1, + 'location' => 'Online — YouTube He4rt', + 'enrollment_method' => EnrollmentMethod::Rsvp, + 'check_in_method' => CheckInMethod::Manual, + 'capacity' => null, + 'has_waitlist' => false, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'minimum_days' => null, + 'cancellation_deadline_hours' => null, + 'xp' => [40, 0, 120], + 'enrollments' => [ + EnrollmentStatus::Attended->value => 9, + EnrollmentStatus::NoShow->value => 3, + EnrollmentStatus::Cancelled->value => 2, + ], + ], + + // Upcoming numeric-code workshop with any-day attendance. + [ + 'title' => 'Workshop: Docker para Desenvolvedores', + 'description' => 'Do build à orquestração: containers na prática para o dia a dia.', + 'type' => EventType::Workshop, + 'status' => EventStatus::Published, + 'schedule' => 'upcoming', + 'days' => 1, + 'location' => 'Sede He4rt — Sala 2', + 'enrollment_method' => EnrollmentMethod::RsvpCheckin, + 'check_in_method' => CheckInMethod::NumericCode, + 'capacity' => 40, + 'has_waitlist' => false, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'minimum_days' => null, + 'cancellation_deadline_hours' => 24, + 'xp' => [80, 150, 400], + 'enrollments' => [ + EnrollmentStatus::Confirmed->value => 14, + EnrollmentStatus::Pending->value => 1, + ], + ], + + // Draft (unpublished) workshop — should be invisible to participants. + [ + 'title' => 'Bootcamp Laravel — Turma 1', + 'description' => 'Rascunho do bootcamp intensivo de Laravel (ainda não publicado).', + 'type' => EventType::Workshop, + 'status' => EventStatus::Draft, + 'schedule' => 'upcoming', + 'days' => 5, + 'location' => 'Sede He4rt — Auditório', + 'enrollment_method' => EnrollmentMethod::RsvpCheckin, + 'check_in_method' => CheckInMethod::NumericCode, + 'capacity' => 30, + 'has_waitlist' => true, + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + 'minimum_days' => 4, + 'cancellation_deadline_hours' => 72, + 'xp' => [150, 250, 1_500], + 'enrollments' => [], + ], + + // Cancelled conference — terminal status, still viewable by participants. + [ + 'title' => 'Hackathon He4rt — Edição Inverno', + 'description' => 'Hackathon cancelado por falta de patrocínio (mantido para histórico).', + 'type' => EventType::Conference, + 'status' => EventStatus::Cancelled, + 'schedule' => 'upcoming', + 'days' => 2, + 'location' => 'Online — Discord He4rt', + 'enrollment_method' => EnrollmentMethod::Application, + 'check_in_method' => CheckInMethod::QrCode, + 'capacity' => 100, + 'has_waitlist' => false, + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + 'cancellation_deadline_hours' => 48, + 'xp' => [100, 200, 800], + 'application_schema' => [ + ['key' => 'project_idea', 'type' => 'text', 'label' => 'Qual ideia de projeto você traria para o hackathon?', 'required' => true], + ['key' => 'main_stack', 'type' => 'select', 'label' => 'Stack principal', 'required' => true, 'options' => ['PHP', 'JavaScript', 'Python', 'Go', 'Rust', 'Outra']], + ], + 'enrollments' => [ + EnrollmentStatus::Cancelled->value => 8, + ], + ], + ]; + } + + /** + * @param array $spec + */ + private function seedPolicy(Event $event, array $spec): void + { + EnrollmentPolicy::factory()->create([ + 'event_id' => $event->id, + 'enrollment_method' => $spec['enrollment_method'], + 'check_in_method' => $spec['check_in_method'], + 'capacity' => $spec['capacity'], + 'has_waitlist' => $spec['has_waitlist'], + 'attendance_requirement' => $spec['attendance_requirement'], + 'minimum_days' => $spec['minimum_days'], + 'cancellation_deadline_hours' => $spec['cancellation_deadline_hours'], + 'xp_on_confirmed' => $spec['xp'][0], + 'xp_on_checked_in' => $spec['xp'][1], + 'xp_on_attended' => $spec['xp'][2], + 'application_schema' => $spec['application_schema'] ?? null, + ]); + } + + /** + * @param Collection $participants + * @param array $spec + */ + private function seedEnrollments(Event $event, array $spec, Collection $participants): void + { + $pool = $participants->shuffle()->values(); + $cursor = 0; + $waitlistPosition = 1; + + foreach ($spec['enrollments'] as $statusValue => $count) { + $status = EnrollmentStatus::from($statusValue); + + for ($i = 0; $i < $count; $i++) { + $user = $pool->get($cursor++); + + if ($user === null) { + return; // pool exhausted for this event + } + + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'status' => $status, + 'waitlist_position' => $status === EnrollmentStatus::Waitlisted ? $waitlistPosition++ : null, + 'rejection_reason' => $status === EnrollmentStatus::Rejected ? 'Vagas esgotadas.' : null, + 'application_data' => $this->fakeApplicationData($spec['application_schema'] ?? null), + ...$this->enrollmentTimestamps($status), + ]); + } + } + } + + /** + * Creates a representative set of codes for numeric-code events so every + * branch of the check-in flow (valid / expired / revoked / exhausted) is + * reachable from the admin panel and the participant component. + * + * @param array $spec + */ + private function seedCheckInCodes(Event $event, array $spec): void + { + if (!($spec['check_in_codes'] ?? false)) { + return; + } + + $now = Date::now(); + $today = Date::today(); + + // Memorable, currently-valid code for manual testing. + CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => $today, + 'code' => '424242', + 'starts_at' => $now->clone()->subHour(), + 'expires_at' => $now->clone()->addHours(3), + 'max_uses' => null, + 'uses_count' => 0, + ]); + + // Expired window. + CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => $today, + 'code' => '100001', + 'starts_at' => $now->clone()->subHours(5), + 'expires_at' => $now->clone()->subHour(), + ]); + + // Manually revoked. + CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => $today, + 'code' => '100002', + 'starts_at' => $now->clone()->subHour(), + 'expires_at' => $now->clone()->addHours(3), + 'revoked_at' => $now->clone()->subMinutes(10), + ]); + + // Exhausted (single use already consumed). + CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => $today, + 'code' => '100003', + 'starts_at' => $now->clone()->subHour(), + 'expires_at' => $now->clone()->addHours(3), + 'max_uses' => 1, + 'uses_count' => 1, + ]); + } + + /** + * @return array{starts_at: CarbonInterface, ends_at: CarbonInterface} + */ + private function schedule(string $schedule, int $days): array + { + $start = match ($schedule) { + 'past' => Date::today()->subDays(10), + 'ongoing' => Date::today(), + default => Date::today()->addDays(14), + }; + + return [ + 'starts_at' => $start->clone()->setTime(9, 0), + 'ends_at' => $start->clone()->addDays($days - 1)->setTime(18, 0), + ]; + } + + /** + * @return array + */ + private function enrollmentTimestamps(EnrollmentStatus $status): array + { + $enrolledAt = Date::now()->subDays(3); + $confirmedAt = Date::now()->subDays(2); + $checkedInAt = Date::now()->subDay(); + + return match ($status) { + EnrollmentStatus::Pending, + EnrollmentStatus::Waitlisted => ['enrolled_at' => $enrolledAt], + EnrollmentStatus::Confirmed => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt], + EnrollmentStatus::CheckedIn => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt, 'checked_in_at' => $checkedInAt], + EnrollmentStatus::Attended => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt, 'checked_in_at' => $checkedInAt, 'attended_at' => $checkedInAt], + EnrollmentStatus::NoShow => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt], + EnrollmentStatus::Cancelled => ['enrolled_at' => $enrolledAt, 'cancelled_at' => Date::now()->subDay()], + EnrollmentStatus::Rejected => ['enrolled_at' => $enrolledAt], + }; + } + + /** + * @param array>|null $schema + * @return array|null + */ + private function fakeApplicationData(?array $schema): ?array + { + if ($schema === null) { + return null; + } + + $data = []; + + foreach ($schema as $field) { + $data[$field['key']] = match ($field['type'] ?? 'text') { + 'select' => fake()->randomElement($field['options'] ?? ['Opção A']), + 'checkbox' => blank($field['options']) ? [] : fake()->randomElements($field['options'], fake()->numberBetween(1, count($field['options']))), + 'textarea' => fake()->paragraph(), + default => fake()->sentence(), + }; + } + + return $data; + } + + private function resolveTenant(): Tenant + { + return Tenant::query()->where('slug', 'he4rt')->first() + ?? Tenant::query()->first() + ?? Tenant::factory()->create(['name' => 'He4rt Developers', 'slug' => 'he4rt']); + } + + /** + * @return Collection + */ + private function resolveParticipants(Tenant $tenant): Collection + { + $existing = $tenant->members()->limit(self::PARTICIPANT_POOL)->get(); + $missing = self::PARTICIPANT_POOL - $existing->count(); + + if ($missing > 0) { + $created = User::factory()->count($missing)->create(); + $tenant->members()->attach($created->pluck('id')); + $existing = $existing->concat($created); + } + + return $existing; + } +} diff --git a/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md b/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md new file mode 100644 index 000000000..b699b547e --- /dev/null +++ b/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md @@ -0,0 +1,20 @@ +# ADR-0001: Enrollment state machine as PHP enum, not spatie/laravel-model-states + +## Status + +Accepted — 2026-05-16 + +## Context + +The enrollment lifecycle has 8 states with well-defined transitions. We considered `spatie/laravel-model-states` (dedicated state classes, automatic transition validation) vs. a plain PHP enum with a `canTransitionTo()` method and validation in Actions. + +## Decision + +Use a backed PHP enum (`EnrollmentStatusEnum`) with an explicit `canTransitionTo(self $target): bool` method. Transition side-effects (XP dispatch, waitlist promotion, audit trail) live in Action classes. + +## Consequences + +- **No extra dependency** — one less package to maintain and version-match. +- **All business logic is explicit** — transitions, guards, and side-effects visible in Action code, not hidden in package config/hooks. +- **Trade-off**: if the state graph grows significantly or requires per-tenant customization, we lose the declarative config that `model-states` provides. Acceptable given the graph is static and small. +- **Testing**: state transitions are tested by calling Actions directly, asserting status + transition records. diff --git a/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md b/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md new file mode 100644 index 000000000..64bf7a7bb --- /dev/null +++ b/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md @@ -0,0 +1,24 @@ +# ADR-0002: XP awarded via domain events, not an in-module ledger + +## Status + +Accepted — 2026-05-16 + +## Context + +We considered three approaches for XP integration: + +- (A) Events dispatches domain events, Gamification listens and increments `Character.experience`. +- (B) Events maintains its own `events_xp_rewards` ledger and calls Gamification. +- (C) Centralized ledger in Gamification with `source_type`/`source_id`. + +## Decision + +Option A — fire-and-forget domain events. Events module publishes `EnrollmentConfirmed`, `ParticipantCheckedIn`, `ParticipantAttended` with XP amounts from the enrollment policy. Gamification module subscribes and increments. + +## Consequences + +- **Module boundary respected** — Events has zero knowledge of how XP is stored or calculated. +- **No ledger in Events** — auditability comes from the `events_enrollment_transitions` table (which records every state change) + Gamification's own records. +- **Trade-off**: no single "XP history per event" query without joining across modules. Acceptable for MVP; a cross-module read model can be added later if needed. +- **Idempotency**: the listener must be idempotent (check if XP was already granted for this enrollment+reason). This responsibility moves to Gamification. diff --git a/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md b/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md new file mode 100644 index 000000000..e9be83d2f --- /dev/null +++ b/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md @@ -0,0 +1,26 @@ +# ADR-0003: No database triggers — all business logic explicit in application code + +## Status + +Accepted — 2026-05-16 + +## Context + +The original spec proposed PostgreSQL triggers for: + +- Automatically recording enrollment transitions (audit trail) +- Awarding XP on state changes +- Promoting waitlisted participants + +Triggers are invisible to application code, hard to test, and bypass Laravel's event system. + +## Decision + +All business logic lives in PHP Action classes. No database triggers. The audit trail (`events_enrollment_transitions`) is written explicitly by the Action that performs the transition. + +## Consequences + +- **Testable** — every behavior can be tested via Action unit/feature tests without database-level mocking. +- **Visible** — reading an Action tells you everything that happens on a transition (audit write, event dispatch, waitlist promotion). +- **Debuggable** — no hidden side-effects outside of Laravel's control. +- **Trade-off**: a raw SQL update bypassing Actions would skip audit/XP/promotion. Mitigated by never updating enrollment status outside of Actions (enforced by code review, not by the database). diff --git a/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md b/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md new file mode 100644 index 000000000..1940ca941 --- /dev/null +++ b/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md @@ -0,0 +1,21 @@ +# ADR-0004: Check-ins as separate 1:N table per enrollment + +## Status + +Accepted — 2026-05-16 + +## Context + +Initially considered storing check-in data as columns on the enrollment record (1:1). However, multi-day events (e.g., 2-day conference) require multiple check-ins per enrollment — one per day attended. + +## Decision + +`events_check_ins` is a separate table with a many-to-one relationship to enrollments. Each record represents one check-in on one specific date. The enrollment policy's `attendance_requirement` (`all_days` | `any_day` | `minimum_days(N)`) determines how many check-ins are needed for the `attended` terminal state. + +## Consequences + +- **Multi-day supported** — conferences spanning N days work naturally. +- **Event days derived from date range** — no separate `events_days` table. System validates `check_in_date BETWEEN event.starts_at::date AND event.ends_at::date`. +- **QR token reuse** — one QR per enrollment, each scan on a different day creates a new check-in record. +- **Numeric codes bound to date** — `events_check_in_codes.event_date` ensures codes are day-specific. +- **Trade-off**: if an event skips a day (Friday + Sunday, no Saturday), the system can't distinguish "valid day" from "gap day" without manual intervention. Acceptable for current use cases (consecutive days). diff --git a/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md b/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md new file mode 100644 index 000000000..3cc600172 --- /dev/null +++ b/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md @@ -0,0 +1,23 @@ +# ADR-0005: Bot communicates with Events via domain events, not REST API + +## Status + +Accepted — 2026-05-16 + +## Context + +Discord/Twitch bots need to trigger check-ins (e.g., user types `!checkin 4829` in chat). Two options: + +- (A) Bot calls a REST endpoint on the Events module. +- (B) Bot dispatches a Laravel domain event, Events module listens. + +## Decision + +Option B — Bot dispatches a domain event (e.g., `CheckInRequested`) containing user identifier, event context, and code. Events module has a listener that validates and processes. Bot is pure transport. + +## Consequences + +- **Consistent with existing architecture** — Bot Discord already communicates with Moderation via domain events (see CONTEXT-MAP.md). +- **Module boundaries respected** — Bot doesn't import Events classes or know enrollment logic. +- **Testable in isolation** — Events listener can be tested by dispatching the event directly, without HTTP layer. +- **Trade-off**: no synchronous HTTP response to the bot. Bot must listen for a response event (e.g., `CheckInProcessed`) to reply to the user in chat. Adds async complexity but maintains decoupling. diff --git a/app-modules/events/docs/adr/0006-numeric-code-check-in.md b/app-modules/events/docs/adr/0006-numeric-code-check-in.md new file mode 100644 index 000000000..1109eb7e4 --- /dev/null +++ b/app-modules/events/docs/adr/0006-numeric-code-check-in.md @@ -0,0 +1,54 @@ +# ADR-0006: Numeric code check-in + +## Status + +Accepted — 2026-05-31 + +## Context + +Participants need a check-in method beyond manual organizer intervention. Numeric codes — short-lived digit strings announced by the organizer — allow self-service check-in during live events. The code is bound to a specific event date, has a validity window and optional max uses, and supports bot-triggered check-in via domain events (ADR-0005). + +## Decision + +### Action pattern + +Introduce `NumericCodeCheckInAction` following the same delegation pattern as `ManualCheckInAction`. The new action validates the code (existence, date match, time window, max uses, revoked status), atomically increments `uses_count` on the code record, then delegates to the core `CheckInAction` with `CheckInMethod::NumericCode` and `TriggeredBy::User`. This keeps all core check-in logic (status transition, duplicate detection, domain event dispatch) in the single `CheckInAction`. + +### Soft revoke instead of delete + +Codes receive a nullable `revoked_at` timestamp. Revoked codes fail validation identically to expired codes. This preserves the audit trail — an organizer can see which codes were used before revocation, which aligns with ADR-0003 (no data destruction). + +### Admin UI as RelationManager + +A `CheckInCodesRelationManager` on the Event edit page (alongside the existing `EnrollmentsRelationManager`). No separate Filament resource — codes are always scoped to an event. The form includes a digit length selector (4 or 6), auto-generates a random read-only code, defaults `event_date` to the event's `starts_at` date (organizer can override, must be within event range), and provides a revoke action. + +### Participant UI as dedicated Livewire component + +A `NumericCodeCheckIn` Livewire component embedded in the event detail page. Shown only when the user's enrollment status is `confirmed` or `checked_in`. A text input for the code plus a submit button; validation errors surface as inline messages. + +### Error messages + +Each failure mode gets a distinct error message for clear diagnostics: + +| Condition | Message | HTTP | +| ------------------------------------------------------------- | ------------------------------- | ---- | +| Code not found in database | "Invalid check-in code" | 422 | +| Code found but `event_date` does not match check-in date | "Code is not valid for today" | 422 | +| Code found but `expires_at` has passed or `revoked_at` is set | "Code has expired" | 422 | +| Code found but `uses_count >= max_uses` | "Code has reached maximum uses" | 422 | + +### Code validation order + +1. Existence (code not found → invalid) +2. Date binding (found but wrong date → not valid for today) +3. Expiry/revocation (found, right date, but expired/revoked → expired) +4. Max uses (found, right date, still valid, but exhausted → max uses) +5. Uses increment (found, right date, valid, has capacity → atomically increment and proceed) + +## Consequences + +- **Separate concerns** — code validation is isolated from check-in mechanics; `CheckInAction` remains untouched. +- **Self-service** — participants check in without organizer intervention; scales to large events. +- **Concurrency-safe** — `uses_count` increment is atomic inside a DB transaction with locked rows. +- **Bot compatible** — Discord/Twitch bot can validate codes by dispatching `CheckInRequested` domain events (future). +- **Multi-day** — organizer creates one code per day with the appropriate `event_date`. diff --git a/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md b/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md new file mode 100644 index 000000000..431d29652 --- /dev/null +++ b/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md @@ -0,0 +1,52 @@ +# ADR-0007: Event closure as scheduled job + +## Status + +Accepted — 2026-06-04 + +## Context + +After an event's `ends_at`, enrollments must be resolved to terminal states: `attended` (success, when `attendance_requirement` is satisfied per the policy) or `no_show` (failure, when not). Without automated closure, enrollments stay in `confirmed` or `checked_in` indefinitely, blocking `ParticipantAttended` dispatch and the XP reward that Gamification awards on that event (ADR-0002). Manual organizer action does not scale, and real-time closure on `ends_at` is fragile (clock drift, job queue downtime, multi-day events whose `ends_at` is the final day). + +The closure step is also the natural place to mark `confirmed` participants who never checked in as `no_show`. This is a system-driven consequence, not an organizer decision. + +## Decision + +### Closure is a post-event scheduled sweep + +A scheduled job runs after events end and resolves every non-terminal enrollment to a terminal state. This is decoupled from any real-time trigger on `ends_at` so that queue downtime, clock drift, or off-by-one date math cannot leave enrollments stranded. + +### Per-event job granularity + +`ProcessEventClosureJob` handles one event at a time (constructor takes `string $eventId`). The job implements `ShouldQueue` and `ShouldBeUnique` with `uniqueId = $eventId` and `uniqueFor = 1800` seconds. This means concurrent dispatches for the same event collapse into a single execution, and the lock covers the full retry window (backoff sum ≈ 16 minutes, plus buffer). + +`$backoff = [1, 5, 10]` and `tries = 4` (1 initial + 3 retries). After exhaustion, `failed()` runs and logs the `event_id` plus the exception message. + +### Per-enrollment transactions, not per-event + +`CloseEventAction::handle(Event $event): int` opens one `DB::transaction` per enrollment inside its loop, not one transaction wrapping the entire event. This is a deliberate departure from the "all-or-nothing per operation" default in favour of **partial commits that preserve audit trails on failure**. + +The loop is: + +1. Re-load the enrollment with `lockForUpdate()`. +2. If the enrollment is already terminal (`isTerminal()` returns true — e.g. a previous attempt succeeded, or an admin override landed between attempts), skip it. +3. Resolve the target status: `EnrollmentPolicy::resolveAttendance($enrollment)` for `checked_in` (returns `Attended` or `NoShow`), or `NoShow` directly for `confirmed`. +4. Call the existing `TransitionEnrollmentAction` (ADR-0001, ADR-0003) with `triggered_by = System`. This writes the audit row and updates the status + timestamp atomically. +5. If the target is `Attended`, dispatch `ParticipantAttended` (mirroring `ParticipantCheckedIn` payload shape — IDs, not models). + +Idempotency is therefore structural: the `isTerminal()` re-check + `canTransitionTo()` guard + `lockForUpdate()` make a second run a no-op for already-closed enrollments. + +### Discovery via artisan command + +A `ClosePendingEventsCommand` (`events:close-pending`) queries `Event` where `ends_at < now()` AND has at least one enrollment in `confirmed` or `checked_in`, then dispatches one `ProcessEventClosureJob` per match. Scheduled every 15 minutes in `EventsServiceProvider::boot()` via `Schedule::command(...)->everyFifteenMinutes()->withoutOverlapping()`. + +This is the same pattern `IntegrationDevToServiceProvider` uses for its own scheduled command, so the discovery mechanism is testable (`Artisan::call`) and manually runnable. + +## Consequences + +- **Audit trail on partial failure** — if the 3rd enrollment in a 100-enrollment event throws mid-loop, the first two keep their `EnrollmentTransition` records. The retry can pick up from the 3rd without re-doing work or losing history. +- **Concurrency-safe** — `ShouldBeUnique` prevents two jobs processing the same event, even if the scheduler fires twice in a race. +- **Retry-friendly** — completed enrollments are skipped via the `isTerminal()` re-check, so retries do not re-dispatch `ParticipantAttended` or write duplicate transitions. +- **Worst-case latency** — `ends_at` to closure is at most 15 minutes (one scheduler tick) plus job queue latency. Acceptable for terminal-state assignment, which is not user-facing in real time. +- **Trade-off** — a 3-retry ceiling means persistent infrastructure failures (DB outage spanning >16 minutes) end up in the `failed()` log instead of auto-recovery. The command will redispatch on the next scheduler tick, so eventual closure is still likely without human intervention. +- **Trade-off** — the 15-minute cadence means a `ParticipantAttended` event may fire up to 15 minutes after the event ends. Gamification's XP grant follows the same window. Documented in `EnrollmentPolicy::$xp_on_attended` consumers (Gamification listeners). diff --git a/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md b/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md new file mode 100644 index 000000000..33df28589 --- /dev/null +++ b/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md @@ -0,0 +1,69 @@ +# ADR-0008: Admin status override as separate Action + +## Status + +Accepted — 2026-06-04 + +## Context + +The enrollment state machine (ADR-0001) blocks all transitions out of terminal states. This is correct for the normal lifecycle: an `attended` enrollment should never revert to `checked_in`, and `no_show` should never silently become `attended`. But real-world organizer needs include: + +- A participant marked `no_show` who actually attended (organizer missed a check-in, code expired, manual check-in failed silently). +- A late arrival who confirmed but missed the formal check-in window and the organizer wants to mark them `checked_in` to record the presence. + +These are **admin corrections**, not normal lifecycle transitions. They need a different validation surface, a stronger audit story, and a clear rule that they bypass the state machine for a tightly-scoped set of cases. + +## Decision + +### Override is a separate Action + +Introduce `OverrideEnrollmentStatusAction` as a sibling to `TransitionEnrollmentAction`, not a force-flag on it. The two actions have different validation, different intent, and different side effects. A force flag on `TransitionEnrollmentAction` would have to be threaded through `TransitionEnrollmentDTO`, the transition audit, and any future caller — and would silently widen the surface that can bypass the state machine. + +### Allowed overrides are explicit and narrow + +The Action validates the `(fromStatus, toStatus)` pair against an explicit allowlist: + +| From | To | Use case | +| ----------- | ------------ | ---------------------------------------- | +| `no_show` | `attended` | Organizer corrects a missed check-in | +| `confirmed` | `checked_in` | Late arrival that missed formal check-in | + +Any other pair — including `attended → no_show`, `cancelled → attended`, or any path starting from `attended`/`cancelled`/`rejected` — is rejected with `OverrideEnrollmentStatusException::overrideNotAllowed(from, to)`. + +### Reason is required and recorded + +`OverrideEnrollmentStatusDTO` takes a non-empty `reason` string and rejects empty input with `OverrideEnrollmentStatusException::reasonRequired()`. The reason is written to `EnrollmentTransition::$reason` for accountability, alongside `triggered_by = admin` and `actor_id = $organizer->id`. + +### Stale status is a conflict, not a generic invalid override + +The DTO carries the enrollment status observed by the admin UI. The Action still re-loads the enrollment inside a transaction with a row lock before applying the correction. If the current status no longer matches the observed status, it throws `OverrideEnrollmentStatusException::statusChanged(expected, actual)` instead of `overrideNotAllowed(from, to)`. + +This separates two cases: + +- `overrideNotAllowed` — the requested pair is not part of the correction allowlist. +- `statusChanged` — the requested pair may have been valid when the admin opened the modal, but another process changed the enrollment before save. + +### Override does not re-dispatch domain events + +No `ParticipantAttended` or `ParticipantCheckedIn` is dispatched on override. The override is a correction of the audit trail, not a fresh occurrence. XP implications: + +- `no_show → attended` does not grant XP retroactively. The participant was marked absent by the closure job; admin correction updates the record but the system-level "first time attended" event has already passed. +- `confirmed → checked_in` does not dispatch `ParticipantCheckedIn`. Same reasoning — the check-in did not happen through the normal flow, and the system event has semantic meaning tied to actual check-in mechanics. + +If retroactive XP is ever needed, it is a separate decision (likely a `ParticipantManuallyAttended` event, with its own audience in Gamification). + +### Filament UI is a dedicated Action class + +`OverrideEnrollmentStatusAction` lives under the Event relation manager `Actions/` directory, matching the existing `GenerateCheckInCodeAction` / `RevokeCheckInCodeAction` pattern. It opens a modal with a reason textarea and a status select pre-populated with the allowed targets. The action is visible only when the current status is in the allowlist source set (`no_show`, `confirmed`) — anything else hides the action entirely. + +### No runtime inconsistency checks + +The form and factory enforce that `minimum_days` is set when `attendance_requirement = MinimumDays`. There is no save-time guard on `EnrollmentPolicy` for inconsistent `(requirement, minimum_days)` — this is defense at the edges only. See ADR-0007 for the closure-side evaluator that trusts the data. + +## Consequences + +- **Strong accountability** — every override writes an audit row with the organizer's identity and their stated reason. +- **Tightly scoped bypass** — the state machine is still the gatekeeper for all non-override transitions. The override path is the _only_ way to leave a terminal state, and it is one line in one Action. +- **No XP duplication risk** — by not re-dispatching domain events, an admin override cannot grant XP twice for the same participant-event pair. +- **Trade-off** — overriding a status that has downstream side effects beyond XP (e.g. waitlist promotion triggered by `confirmed → cancelled`) does not replay those side effects. Acceptable for the current scope; flag if a new side-effect is added. +- **Trade-off** — the Filament action is now another class to navigate, but `EnrollmentsRelationManager` stays focused on table composition while action-specific form and callback wiring live next to the other Event relation manager actions. diff --git a/app-modules/events/lang/en/check_in.php b/app-modules/events/lang/en/check_in.php new file mode 100644 index 000000000..0966e9c45 --- /dev/null +++ b/app-modules/events/lang/en/check_in.php @@ -0,0 +1,22 @@ + 'Only confirmed or checked-in enrollments can be checked in.', + 'check_in_outside_event_date_range' => 'Check-in date must be within the event date range.', + 'already_checked_in_for_date' => 'This enrollment has already checked in for this date.', + 'invalid_check_in_actor' => 'Manual check-in requires the organizer user id.', + 'qr_token_not_found' => 'QR token not found or does not belong to this event.', + 'qr_token_expired' => 'This QR token has expired.', + 'invalid_check_in_code' => 'Invalid check-in code.', + 'invalid_check_in_code_format' => 'Enter a 4 or 6 digit check-in code.', + 'check_in_code_expired' => 'Code has expired.', + 'check_in_code_exhausted' => 'Code has reached maximum uses.', + 'check_in_code_wrong_date' => 'Code is not valid for today.', + 'check_in_code_rate_limited' => 'Too many attempts. Try again in :seconds seconds.', + 'bot_user_not_linked' => 'Your account is not linked to this platform.', + 'bot_no_active_enrollment' => 'No active enrollment found for an event happening today.', + 'bot_multiple_active_events' => 'More than one event uses this code today. Please check in through the panel.', + 'bot_check_in_success' => 'Check-in confirmed. See you there!', +]; diff --git a/app-modules/events/lang/en/enums.php b/app-modules/events/lang/en/enums.php new file mode 100644 index 000000000..22a3c5871 --- /dev/null +++ b/app-modules/events/lang/en/enums.php @@ -0,0 +1,53 @@ + [ + 'draft' => 'Draft', + 'published' => 'Published', + 'completed' => 'Completed', + 'cancelled' => 'Cancelled', + ], + + 'event_type' => [ + 'meetup' => 'Meetup', + 'workshop' => 'Workshop', + 'conference' => 'Conference', + ], + + 'enrollment_method' => [ + 'rsvp' => 'RSVP', + 'rsvp_checkin' => 'RSVP + Check-in', + 'application' => 'Application', + ], + + 'attendance_requirement' => [ + 'all_days' => 'All Days', + 'any_day' => 'Any Day', + 'minimum_days' => 'Minimum Days', + ], + + 'enrollment_status' => [ + 'pending' => 'Pending', + 'confirmed' => 'Confirmed', + 'waitlisted' => 'Waitlisted', + 'checked_in' => 'Checked In', + 'attended' => 'Attended', + 'cancelled' => 'Cancelled', + 'rejected' => 'Rejected', + 'no_show' => 'No Show', + ], + + 'check_in_method' => [ + 'manual' => 'Manual', + 'numeric_code' => 'Numeric Code', + 'qr_code' => 'QR Code', + ], + + 'triggered_by' => [ + 'user' => 'User', + 'admin' => 'Admin', + 'system' => 'System', + ], +]; diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php new file mode 100644 index 000000000..b3f40e330 --- /dev/null +++ b/app-modules/events/lang/en/exceptions.php @@ -0,0 +1,18 @@ + 'Cannot transition enrollment from :from to :to.', + 'already_enrolled' => 'You are already enrolled in this event.', + 'event_past' => 'This event has already started and is no longer accepting enrollments.', + 'event_not_active' => 'This event is not available for enrollment.', + 'invalid_enrollment_method' => 'This event requires application enrollment, not RSVP.', + 'event_full' => 'This event has reached maximum capacity and does not have a waitlist.', + 'response_message_not_implemented' => 'Response message is not implemented for enrollment status: :status.', + 'override_reason_required' => 'A reason is required when overriding an enrollment status.', + 'override_not_allowed' => 'Override from :from to :to is not allowed.', + 'override_status_changed' => 'Enrollment status changed from :expected to :actual before the override was saved. Review the current status and try again.', + 'enrollment_not_pending' => 'This enrollment is not pending and cannot be approved or rejected.', + 'application_data_invalid' => 'The submitted application data is invalid or missing required answers.', +]; diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php new file mode 100644 index 000000000..a45bb3d2a --- /dev/null +++ b/app-modules/events/lang/en/pages.php @@ -0,0 +1,50 @@ + 'Back to Events', + 'confirm_presence' => 'Confirm Presence', + 'confirm_presence_hint' => 'Confirm your attendance to this event.', + 'confirm_presence_prompt' => 'Are you sure you want to enroll in this event?', + 'confirm_presence_yes' => 'Yes, enroll me', + 'confirm_presence_cancel' => 'Cancel', + 'confirm_presence_success' => 'Your presence has been confirmed!', + 'waitlist_success' => 'You are on the waitlist (position :position).', + 'waitlist_status' => 'You are on the waitlist (position :position).', + 'event_full' => 'This event is full.', + 'enrollment_status_label' => 'Your enrollment status', + 'enrolled_at' => 'Enrolled on :date', + 'no_enrollments' => 'You are not enrolled in any events yet.', + 'no_upcoming_events' => 'No upcoming events available.', + 'my_qr_code' => 'My QR Code', + 'qr_code_hint' => 'Present this QR code to the organizer for check-in.', + 'copy_token' => 'Copy Token', + 'token_copied' => 'Copied!', + 'download_qr' => 'Download QR', + 'checked_in_today' => 'Check-in done today', + 'check_in_history' => 'Check-in History', + 'check_in_history_date' => ':date', + 'no_check_ins_yet' => 'No check-ins recorded yet.', + 'enter_check_in_code_hint' => 'Enter the check-in code announced by the organizer.', + 'check_in' => 'Check In', + 'apply_hint' => 'Fill in the form below to apply for this event.', + 'apply_submit' => 'Submit Application', + 'application_submitted' => 'Your application has been submitted and is pending review.', + 'application_pending_hint' => 'Your application is under review. You will be notified once it is evaluated.', + 'application_your_answers' => 'Your Answers', + 'application_rejected_hint' => 'Your application was not accepted.', + 'application_rejection_reason' => 'Reason: :reason', + 'apply_select_option_placeholder' => 'Select an option', + 'admin_approve_application' => 'Approve', + 'admin_approve_application_modal_heading' => 'Approve Application', + 'admin_approve_application_modal_description' => 'Approve the application from :name?', + 'admin_approve_application_success' => 'Application approved.', + 'admin_reject_application' => 'Reject', + 'admin_reject_application_reason_label' => 'Rejection Reason', + 'admin_reject_application_success' => 'Application rejected.', + 'admin_application_no_data' => 'No application data available.', + 'admin_application_answer_yes' => 'Yes', + 'admin_application_answer_no' => 'No', + 'admin_application_no_answer' => 'No answer', +]; diff --git a/app-modules/events/lang/pt_BR/check_in.php b/app-modules/events/lang/pt_BR/check_in.php new file mode 100644 index 000000000..5c730a408 --- /dev/null +++ b/app-modules/events/lang/pt_BR/check_in.php @@ -0,0 +1,22 @@ + 'Apenas inscrições confirmadas ou com check-in realizado podem receber check-in.', + 'check_in_outside_event_date_range' => 'A data do check-in deve estar dentro do período do evento.', + 'already_checked_in_for_date' => 'Esta inscrição já possui check-in para essa data.', + 'invalid_check_in_actor' => 'Check-in manual exige o ID do usuário organizador.', + 'qr_token_not_found' => 'Token QR não encontrado ou não pertence a este evento.', + 'qr_token_expired' => 'Este token QR expirou.', + 'invalid_check_in_code' => 'Código de check-in inválido.', + 'invalid_check_in_code_format' => 'Informe um código de check-in com 4 ou 6 dígitos.', + 'check_in_code_expired' => 'O código expirou.', + 'check_in_code_exhausted' => 'O código atingiu o limite máximo de usos.', + 'check_in_code_wrong_date' => 'O código não é válido para hoje.', + 'check_in_code_rate_limited' => 'Muitas tentativas. Tente novamente em :seconds segundos.', + 'bot_user_not_linked' => 'Sua conta não está vinculada a esta plataforma.', + 'bot_no_active_enrollment' => 'Nenhuma inscrição ativa encontrada para um evento de hoje.', + 'bot_multiple_active_events' => 'Mais de um evento usa esse código hoje. Faça o check-in pelo painel.', + 'bot_check_in_success' => 'Check-in confirmado. Até lá!', +]; diff --git a/app-modules/events/lang/pt_BR/enums.php b/app-modules/events/lang/pt_BR/enums.php new file mode 100644 index 000000000..9304596d1 --- /dev/null +++ b/app-modules/events/lang/pt_BR/enums.php @@ -0,0 +1,53 @@ + [ + 'draft' => 'Rascunho', + 'published' => 'Publicado', + 'completed' => 'Concluído', + 'cancelled' => 'Cancelado', + ], + + 'event_type' => [ + 'meetup' => 'Meetup', + 'workshop' => 'Workshop', + 'conference' => 'Conferência', + ], + + 'enrollment_method' => [ + 'rsvp' => 'RSVP', + 'rsvp_checkin' => 'RSVP + Check-in', + 'application' => 'Inscrição', + ], + + 'attendance_requirement' => [ + 'all_days' => 'Todos os dias', + 'any_day' => 'Qualquer dia', + 'minimum_days' => 'Dias mínimos', + ], + + 'enrollment_status' => [ + 'pending' => 'Pendente', + 'confirmed' => 'Confirmado', + 'waitlisted' => 'Lista de espera', + 'checked_in' => 'Check-in realizado', + 'attended' => 'Presente', + 'cancelled' => 'Cancelado', + 'rejected' => 'Rejeitado', + 'no_show' => 'Não compareceu', + ], + + 'check_in_method' => [ + 'manual' => 'Manual', + 'numeric_code' => 'Código numérico', + 'qr_code' => 'QR Code', + ], + + 'triggered_by' => [ + 'user' => 'Usuário', + 'admin' => 'Administrador', + 'system' => 'Sistema', + ], +]; diff --git a/app-modules/events/lang/pt_BR/exceptions.php b/app-modules/events/lang/pt_BR/exceptions.php new file mode 100644 index 000000000..9742ca094 --- /dev/null +++ b/app-modules/events/lang/pt_BR/exceptions.php @@ -0,0 +1,18 @@ + 'Não é possível transicionar inscrição de :from para :to.', + 'already_enrolled' => 'Você já está inscrito neste evento.', + 'event_past' => 'Este evento já começou e não aceita mais inscrições.', + 'event_not_active' => 'Este evento não está disponível para inscrição.', + 'invalid_enrollment_method' => 'Esse evento exige inscrição via formulário, não RSVP.', + 'event_full' => 'Este evento atingiu a capacidade máxima e não possui lista de espera.', + 'response_message_not_implemented' => 'Mensagem de resposta não implementada para o status de inscrição: :status.', + 'override_reason_required' => 'É obrigatório informar um motivo ao sobrescrever o status de uma inscrição.', + 'override_not_allowed' => 'Sobrescrita de :from para :to não é permitida.', + 'override_status_changed' => 'O status da inscrição mudou de :expected para :actual antes da sobrescrita ser salva. Revise o status atual e tente novamente.', + 'enrollment_not_pending' => 'Esta inscrição não está pendente e não pode ser aprovada ou rejeitada.', + 'application_data_invalid' => 'Os dados enviados na candidatura são inválidos ou faltam respostas obrigatórias.', +]; diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php new file mode 100644 index 000000000..a26e43e59 --- /dev/null +++ b/app-modules/events/lang/pt_BR/pages.php @@ -0,0 +1,50 @@ + 'Voltar para Eventos', + 'confirm_presence' => 'Confirmar Presença', + 'confirm_presence_hint' => 'Confirme sua presença neste evento.', + 'confirm_presence_prompt' => 'Tem certeza que deseja se inscrever neste evento?', + 'confirm_presence_yes' => 'Sim, me inscrever', + 'confirm_presence_cancel' => 'Cancelar', + 'confirm_presence_success' => 'Sua presença foi confirmada!', + 'waitlist_success' => 'Você está na lista de espera (posição :position).', + 'waitlist_status' => 'Você está na lista de espera (posição :position).', + 'event_full' => 'Este evento está lotado.', + 'enrollment_status_label' => 'Status da sua inscrição', + 'enrolled_at' => 'Inscrito em :date', + 'no_enrollments' => 'Você ainda não está inscrito em nenhum evento.', + 'no_upcoming_events' => 'Nenhum evento disponível no momento.', + 'my_qr_code' => 'Meu QR Code', + 'qr_code_hint' => 'Apresente este QR code ao organizador para realizar o check-in.', + 'copy_token' => 'Copiar Token', + 'token_copied' => 'Copiado!', + 'download_qr' => 'Baixar QR', + 'checked_in_today' => 'Check-in feito hoje', + 'check_in_history' => 'Histórico de Check-ins', + 'check_in_history_date' => ':date', + 'no_check_ins_yet' => 'Nenhum check-in registrado ainda.', + 'enter_check_in_code_hint' => 'Insira o código de check-in informado pelo organizador.', + 'check_in' => 'Fazer Check-in', + 'apply_hint' => 'Preencha o formulário abaixo para se candidatar a este evento.', + 'apply_submit' => 'Enviar Candidatura', + 'application_submitted' => 'Sua candidatura foi enviada e está aguardando avaliação.', + 'application_pending_hint' => 'Sua candidatura está em análise. Você será notificado quando for avaliada.', + 'application_your_answers' => 'Suas Respostas', + 'application_rejected_hint' => 'Sua candidatura não foi aceita.', + 'application_rejection_reason' => 'Motivo: :reason', + 'apply_select_option_placeholder' => 'Selecione uma opção', + 'admin_approve_application' => 'Aprovar', + 'admin_approve_application_modal_heading' => 'Aprovar Candidatura', + 'admin_approve_application_modal_description' => 'Aprovar a candidatura de :name?', + 'admin_approve_application_success' => 'Candidatura aprovada.', + 'admin_reject_application' => 'Rejeitar', + 'admin_reject_application_reason_label' => 'Motivo da Rejeição', + 'admin_reject_application_success' => 'Candidatura rejeitada.', + 'admin_application_no_data' => 'Nenhum dado de candidatura disponível.', + 'admin_application_answer_yes' => 'Sim', + 'admin_application_answer_no' => 'Não', + 'admin_application_no_answer' => 'Sem resposta', +]; diff --git a/app-modules/events/src/CheckIn/Actions/CheckInAction.php b/app-modules/events/src/CheckIn/Actions/CheckInAction.php new file mode 100644 index 000000000..5739fb856 --- /dev/null +++ b/app-modules/events/src/CheckIn/Actions/CheckInAction.php @@ -0,0 +1,105 @@ +loadLockedEnrollment($dto->enrollment); + $event = $enrollment->event; + $normalizedEventDate = Date::parse($dto->eventDate->toDateString())->startOfDay(); + + $this->validateCommonRules($enrollment, $normalizedEventDate); + $isFirstCheckIn = !CheckIn::query() + ->where('enrollment_id', $enrollment->id) + ->exists(); + + try { + $checkIn = CheckIn::query()->create([ + 'enrollment_id' => $enrollment->id, + 'method' => $dto->method, + 'payload' => $dto->payload, + 'event_date' => $normalizedEventDate, + 'checked_in_at' => now(), + ]); + } catch (UniqueConstraintViolationException) { + throw CheckInException::alreadyCheckedInForDate(); + } + + if ($isFirstCheckIn && $enrollment->status === EnrollmentStatus::Confirmed) { + resolve(TransitionEnrollmentAction::class)->handle( + new TransitionEnrollmentDTO( + enrollment: $enrollment, + toStatus: EnrollmentStatus::CheckedIn, + triggeredBy: $dto->triggeredBy, + actorId: $dto->actorUserId, + timestamp: $checkIn->checked_in_at, + ), + ); + } + + $xpRewardOnCheckedIn = (int) ($event->enrollmentPolicy->xp_on_checked_in ?? 0); + + event(new ParticipantCheckedIn( + checkInId: $checkIn->id, + enrollmentId: $enrollment->id, + eventId: $enrollment->event_id, + userId: $enrollment->user_id, + eventDate: $normalizedEventDate->toDateString(), + xpRewardOnCheckedIn: $xpRewardOnCheckedIn, + )); + + return $checkIn->load('enrollment'); + }); + } + + private function loadLockedEnrollment(Enrollment $enrollment): Enrollment + { + return Enrollment::query() + ->with(['event.enrollmentPolicy']) + ->whereKey($enrollment->id) + ->lockForUpdate() + ->firstOrFail(); + } + + private function validateCommonRules(Enrollment $enrollment, CarbonInterface $eventDate): void + { + throw_unless( + in_array($enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true), + CheckInException::invalidCheckInStatus(), + ); + + $event = $enrollment->event; + $eventDateString = $eventDate->toDateString(); + + throw_unless( + $eventDateString >= $event->starts_at->toDateString() && $eventDateString <= $event->ends_at->toDateString(), + CheckInException::checkInOutsideEventDateRange(), + ); + + throw_if( + CheckIn::query() + ->where('enrollment_id', $enrollment->id) + ->whereDate('event_date', $eventDate->toDateString()) + ->exists(), + CheckInException::alreadyCheckedInForDate(), + ); + } +} diff --git a/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php b/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php new file mode 100644 index 000000000..e18bbcf10 --- /dev/null +++ b/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php @@ -0,0 +1,26 @@ +where('enrollment_id', $enrollment->getKey())->first(); + + if ($existing !== null) { + return $existing; + } + + return QrToken::query()->create([ + 'enrollment_id' => $enrollment->getKey(), + 'token' => Str::uuid7()->toString(), + ]); + } +} diff --git a/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php b/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php new file mode 100644 index 000000000..8f82399e1 --- /dev/null +++ b/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php @@ -0,0 +1,28 @@ +handle( + new CheckInDTO( + enrollment: $dto->enrollment, + method: CheckInMethod::Manual, + payload: ['actor_user_id' => $dto->actorUserId], + eventDate: $dto->eventDate, + actorUserId: $dto->actorUserId, + triggeredBy: TriggeredBy::Admin, + ), + ); + } +} diff --git a/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php new file mode 100644 index 000000000..956510842 --- /dev/null +++ b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php @@ -0,0 +1,63 @@ +where('code', $dto->code) + ->where('event_id', $dto->enrollment->event_id) + ->whereDate('event_date', $dto->eventDate->toDateString()) + ->lockForUpdate() + ->first(); + + if ($codeRecord === null) { + $codeExistsForAnotherDate = CheckInCode::query() + ->where('code', $dto->code) + ->where('event_id', $dto->enrollment->event_id) + ->exists(); + + if ($codeExistsForAnotherDate) { + throw CheckInException::checkInCodeWrongDate(); + } + + throw CheckInException::invalidCheckInCode(); + } + + if ($codeRecord->starts_at->isFuture() || $codeRecord->expires_at->isPast() || $codeRecord->revoked_at !== null) { + throw CheckInException::checkInCodeExpired(); + } + + if ($codeRecord->max_uses !== null && $codeRecord->uses_count >= $codeRecord->max_uses) { + throw CheckInException::checkInCodeExhausted(); + } + + $codeRecord->increment('uses_count'); + + return resolve(CheckInAction::class)->handle( + new CheckInDTO( + enrollment: $dto->enrollment, + method: CheckInMethod::NumericCode, + payload: ['code' => $dto->code], + eventDate: $dto->eventDate, + actorUserId: $dto->enrollment->user_id, + triggeredBy: TriggeredBy::User, + ), + ); + }); + } +} diff --git a/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php b/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php new file mode 100644 index 000000000..aceb22a6a --- /dev/null +++ b/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php @@ -0,0 +1,45 @@ +with('enrollment') + ->where('token', $dto->token) + ->first(); + + throw_unless( + $qrToken !== null && $qrToken->enrollment->event_id === $dto->event->id, + CheckInException::qrTokenNotFound(), + ); + + throw_if( + $qrToken->expires_at !== null && $qrToken->expires_at->isPast(), + CheckInException::qrTokenExpired(), + ); + + return resolve(CheckInAction::class)->handle( + new CheckInDTO( + enrollment: $qrToken->enrollment, + method: CheckInMethod::QrCode, + payload: ['token' => $dto->token], + eventDate: $dto->eventDate, + actorUserId: $dto->actorUserId, + triggeredBy: TriggeredBy::Admin, + ), + ); + } +} diff --git a/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php new file mode 100644 index 000000000..becb5dcd6 --- /dev/null +++ b/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php @@ -0,0 +1,25 @@ + $payload + */ + public function __construct( + public Enrollment $enrollment, + public CheckInMethod $method, + public array $payload, + public CarbonInterface $eventDate, + public string $actorUserId, + public TriggeredBy $triggeredBy, + ) {} +} diff --git a/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php new file mode 100644 index 000000000..261a00d3f --- /dev/null +++ b/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php @@ -0,0 +1,20 @@ +actorUserId), CheckInException::invalidCheckInActor()); + } +} diff --git a/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php new file mode 100644 index 000000000..f5967de1e --- /dev/null +++ b/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php @@ -0,0 +1,17 @@ +token), CheckInException::qrTokenNotFound()); + throw_if(blank($this->actorUserId), CheckInException::invalidCheckInActor()); + } +} diff --git a/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php new file mode 100644 index 000000000..3cef5dfa2 --- /dev/null +++ b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php @@ -0,0 +1,15 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::Manual => Color::Gray, + self::NumericCode => Color::Blue, + self::QrCode => Color::Purple, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Manual => Heroicon::HandRaised, + self::NumericCode => Heroicon::InformationCircle, + self::QrCode => Heroicon::QrCode, + }; + } +} diff --git a/app-modules/events/src/CheckIn/Events/CheckInProcessed.php b/app-modules/events/src/CheckIn/Events/CheckInProcessed.php new file mode 100644 index 000000000..6eb0bda51 --- /dev/null +++ b/app-modules/events/src/CheckIn/Events/CheckInProcessed.php @@ -0,0 +1,26 @@ + $channelContext Echoed back from the request for reply routing. + */ + public function __construct( + public IdentityProvider $provider, + public string $externalUserId, + public BotCheckInStatus $status, + public string $message, + public array $channelContext = [], + public ?string $enrollmentId = null, + ) {} +} diff --git a/app-modules/events/src/CheckIn/Events/CheckInRequested.php b/app-modules/events/src/CheckIn/Events/CheckInRequested.php new file mode 100644 index 000000000..db4eb94f0 --- /dev/null +++ b/app-modules/events/src/CheckIn/Events/CheckInRequested.php @@ -0,0 +1,25 @@ + $channelContext Opaque routing data the bot uses to reply to the member. + */ + public function __construct( + public IdentityProvider $provider, + public string $externalUserId, + public string $code, + public string $tenantId, + public ?string $eventId = null, + public array $channelContext = [], + ) {} +} diff --git a/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php b/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php new file mode 100644 index 000000000..8ddddc728 --- /dev/null +++ b/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php @@ -0,0 +1,22 @@ + $seconds]), + Response::HTTP_TOO_MANY_REQUESTS, + ); + } + + public static function botNoActiveEnrollment(): self + { + return new self( + __('events::check_in.bot_no_active_enrollment'), + Response::HTTP_NOT_FOUND, + ); + } + + public static function botMultipleActiveEvents(): self + { + return new self( + __('events::check_in.bot_multiple_active_events'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } +} diff --git a/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php b/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php new file mode 100644 index 000000000..8598a0129 --- /dev/null +++ b/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php @@ -0,0 +1,19 @@ +findOrFail($event->enrollmentId); + + resolve(GenerateQrTokenAction::class)->handle($enrollment); + } +} diff --git a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php new file mode 100644 index 000000000..a3d4afbc9 --- /dev/null +++ b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php @@ -0,0 +1,118 @@ +resolveUserId($event); + $enrollment = $this->resolveEnrollment($event, $userId); + + $checkIn = $this->numericCodeCheckIn->handle( + new NumericCodeCheckInDTO( + enrollment: $enrollment, + code: $event->code, + eventDate: now(), + ), + ); + + $this->dispatchSuccess($event, $checkIn->enrollment_id); + } catch (ExternalIdentityException) { + $this->dispatchError($event, __('events::check_in.bot_user_not_linked')); + } catch (CheckInException $exception) { + $this->dispatchError($event, $exception->getMessage()); + } + } + + private function resolveUserId(CheckInRequested $event): string + { + return $this->findExternalIdentity->handle( + provider: $event->provider->value, + providerId: $event->externalUserId, + tenantId: $event->tenantId, + )->model_id; + } + + private function resolveEnrollment(CheckInRequested $event, string $userId): Enrollment + { + $today = now()->toDateString(); + + $enrollments = Enrollment::query() + ->where('user_id', $userId) + ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]) + ->whereHas('event', static function (Builder $query) use ($event, $today): void { + $query->where('tenant_id', $event->tenantId) + ->where('status', EventStatus::Published) + ->whereDate('starts_at', '<=', $today) + ->whereDate('ends_at', '>=', $today); + }) + ->get(); + + $enrollment = $enrollments->first(); + throw_unless($enrollment, CheckInException::botNoActiveEnrollment()); + + if ($enrollments->count() === 1) { + return $enrollment; + } + + $matchingEventIds = CheckInCode::query() + ->whereIn('event_id', $enrollments->pluck('event_id')) + ->where('code', $event->code) + ->whereDate('event_date', $today) + ->pluck('event_id'); + + $matching = $enrollments->whereIn('event_id', $matchingEventIds); + + $matched = $matching->first(); + throw_unless($matched, CheckInException::invalidCheckInCode()); + throw_if($matching->count() > 1, CheckInException::botMultipleActiveEvents()); + + return $matched; + } + + private function dispatchSuccess(CheckInRequested $event, string $enrollmentId): void + { + event(new CheckInProcessed( + provider: $event->provider, + externalUserId: $event->externalUserId, + status: BotCheckInStatus::Success, + message: __('events::check_in.bot_check_in_success'), + channelContext: $event->channelContext, + enrollmentId: $enrollmentId, + )); + } + + private function dispatchError(CheckInRequested $event, string $message): void + { + event(new CheckInProcessed( + provider: $event->provider, + externalUserId: $event->externalUserId, + status: BotCheckInStatus::Error, + message: $message, + channelContext: $event->channelContext, + )); + } +} diff --git a/app-modules/events/src/CheckIn/Models/CheckIn.php b/app-modules/events/src/CheckIn/Models/CheckIn.php new file mode 100644 index 000000000..0a8bdf37b --- /dev/null +++ b/app-modules/events/src/CheckIn/Models/CheckIn.php @@ -0,0 +1,63 @@ +|null $payload + * @property Carbon|null $checked_in_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ +#[Table(name: 'events_check_ins')] +final class CheckIn extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'enrollment_id', + 'event_date', + 'method', + 'payload', + 'checked_in_at', + ]; + + /** @return BelongsTo */ + public function enrollment(): BelongsTo + { + return $this->belongsTo(Enrollment::class); + } + + protected static function newFactory(): CheckInFactory + { + return CheckInFactory::new(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'event_date' => 'date', + 'method' => CheckInMethod::class, + 'payload' => 'array', + 'checked_in_at' => 'datetime', + ]; + } +} diff --git a/app-modules/events/src/CheckIn/Models/CheckInCode.php b/app-modules/events/src/CheckIn/Models/CheckInCode.php new file mode 100644 index 000000000..e22986c3b --- /dev/null +++ b/app-modules/events/src/CheckIn/Models/CheckInCode.php @@ -0,0 +1,68 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'event_id', + 'event_date', + 'code', + 'starts_at', + 'expires_at', + 'max_uses', + 'uses_count', + 'revoked_at', + ]; + + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + protected static function newFactory(): CheckInCodeFactory + { + return CheckInCodeFactory::new(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'event_date' => 'date', + 'starts_at' => 'datetime', + 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } +} diff --git a/app-modules/events/src/CheckIn/Models/QrToken.php b/app-modules/events/src/CheckIn/Models/QrToken.php new file mode 100644 index 000000000..8b97fed13 --- /dev/null +++ b/app-modules/events/src/CheckIn/Models/QrToken.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'enrollment_id', + 'token', + 'expires_at', + ]; + + /** @return BelongsTo */ + public function enrollment(): BelongsTo + { + return $this->belongsTo(Enrollment::class); + } + + protected static function newFactory(): QrTokenFactory + { + return QrTokenFactory::new(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } +} diff --git a/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php b/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php new file mode 100644 index 000000000..971627e74 --- /dev/null +++ b/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php @@ -0,0 +1,19 @@ +getMessage()); + } + } +} diff --git a/app-modules/events/src/Closure/Actions/CloseEventAction.php b/app-modules/events/src/Closure/Actions/CloseEventAction.php new file mode 100644 index 000000000..ed104df33 --- /dev/null +++ b/app-modules/events/src/Closure/Actions/CloseEventAction.php @@ -0,0 +1,76 @@ +enrollments() + ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]) + ->eachById(function (Enrollment $enrollment) use (&$attendances): void { + $attendances += $this->closeOne($enrollment); + }); + + return $attendances; + } + + /** + * @throws Throwable + */ + private function closeOne(Enrollment $enrollment): int + { + return DB::transaction(static function () use ($enrollment): int { + $locked = Enrollment::query() + ->with('event.enrollmentPolicy') + ->whereKey($enrollment->id) + ->lockForUpdate() + ->first(); + + if ($locked === null || $locked->status->isTerminal()) { + return 0; + } + + $toStatus = $locked->status === EnrollmentStatus::CheckedIn + ? $locked->event->enrollmentPolicy->resolveAttendance($locked) + : EnrollmentStatus::NoShow; + + resolve(TransitionEnrollmentAction::class)->handle( + new TransitionEnrollmentDTO( + enrollment: $locked, + toStatus: $toStatus, + triggeredBy: TriggeredBy::System, + ), + ); + + if ($toStatus === EnrollmentStatus::Attended) { + $xpReward = ($locked->event->enrollmentPolicy->xp_on_attended ?? 0); + + event(new ParticipantAttended( + enrollmentId: $locked->id, + eventId: $locked->event_id, + userId: $locked->user_id, + xpRewardOnAttended: $xpReward, + )); + + return 1; + } + + return 0; + }); + } +} diff --git a/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php new file mode 100644 index 000000000..5ddadb5ec --- /dev/null +++ b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php @@ -0,0 +1,85 @@ + */ + private const array ALLOWED_OVERRIDES = [ + ['from' => EnrollmentStatus::NoShow, 'to' => EnrollmentStatus::Attended], + ['from' => EnrollmentStatus::Confirmed, 'to' => EnrollmentStatus::CheckedIn], + ]; + + /** @return list */ + public static function allowedTargetsFor(EnrollmentStatus $from): array + { + $targets = []; + + foreach (self::ALLOWED_OVERRIDES as $pair) { + if ($pair['from'] === $from) { + $targets[] = $pair['to']; + } + } + + return $targets; + } + + /** + * @throws OverrideEnrollmentStatusException + * @throws Throwable + */ + public function handle(OverrideEnrollmentStatusDTO $dto): EnrollmentTransition + { + return DB::transaction(function () use ($dto): EnrollmentTransition { + $enrollment = Enrollment::query() + ->whereKey($dto->enrollment->id) + ->lockForUpdate() + ->firstOrFail(); + + $fromStatus = $enrollment->status; + + throw_unless( + $fromStatus === $dto->fromStatus, + OverrideEnrollmentStatusException::statusChanged($dto->fromStatus, $fromStatus), + ); + + throw_unless( + $this->isAllowed($fromStatus, $dto->toStatus), + OverrideEnrollmentStatusException::overrideNotAllowed($fromStatus, $dto->toStatus), + ); + + $attributes = ['status' => $dto->toStatus]; + + if ($timestampColumn = $dto->toStatus->timestampColumn()) { + $attributes[$timestampColumn] = now(); + } + + $enrollment->update($attributes); + + return EnrollmentTransition::query()->create([ + 'enrollment_id' => $enrollment->id, + 'from_status' => $fromStatus, + 'to_status' => $dto->toStatus, + 'actor_id' => $dto->actorId, + 'triggered_by' => TriggeredBy::Admin, + 'reason' => $dto->reason, + ]); + }); + } + + private function isAllowed(EnrollmentStatus $from, EnrollmentStatus $to): bool + { + return array_any(self::ALLOWED_OVERRIDES, fn (array $pair) => $pair['from'] === $from && $pair['to'] === $to); + } +} diff --git a/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php b/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php new file mode 100644 index 000000000..62ab5948c --- /dev/null +++ b/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php @@ -0,0 +1,27 @@ +reason)) { + throw OverrideEnrollmentStatusException::reasonRequired(); + } + } +} diff --git a/app-modules/events/src/Closure/Events/ParticipantAttended.php b/app-modules/events/src/Closure/Events/ParticipantAttended.php new file mode 100644 index 000000000..a19ab6eb7 --- /dev/null +++ b/app-modules/events/src/Closure/Events/ParticipantAttended.php @@ -0,0 +1,21 @@ + $from->value, 'to' => $to->value]), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function statusChanged(EnrollmentStatus $expected, EnrollmentStatus $actual): self + { + return new self( + __('events::exceptions.override_status_changed', [ + 'expected' => $expected->value, + 'actual' => $actual->value, + ]), + Response::HTTP_CONFLICT, + ); + } +} diff --git a/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php new file mode 100644 index 000000000..d19039049 --- /dev/null +++ b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php @@ -0,0 +1,56 @@ +eventId; + } + + public function handle(CloseEventAction $action): void + { + $event = Event::query()->find($this->eventId); + + if ($event === null) { + return; + } + + $action->handle($event); + } + + public function failed(Throwable $e): void + { + Log::error('Event closure failed', [ + 'event_id' => $this->eventId, + 'error' => $e->getMessage(), + ]); + } +} diff --git a/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php new file mode 100644 index 000000000..7f2ccea93 --- /dev/null +++ b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php @@ -0,0 +1,38 @@ +where('ends_at', '<', now()) + ->whereHas('enrollments', static function (Builder $query): void { + $query->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]); + }) + ->select('id') + ->each(static function (Event $event) use (&$count): void { + dispatch(new ProcessEventClosureJob($event->id)); + $count++; + }); + + $this->info(sprintf('Dispatched %d closure job(s).', $count)); + + return self::SUCCESS; + } +} diff --git a/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php b/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php new file mode 100644 index 000000000..c83ff2e57 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php @@ -0,0 +1,106 @@ +whereKey($dto->enrollmentId) + ->lockForUpdate() + ->firstOrFail(); + + throw_unless( + $enrollment->status === EnrollmentStatus::Pending, + EnrollmentException::enrollmentNotPending(), + ); + + $policy = EnrollmentPolicy::query() + ->where('event_id', $enrollment->event_id) + ->lockForUpdate() + ->firstOrFail(); + + $toStatus = $this->resolveApprovalStatus($enrollment->event_id, $policy); + + $waitlistPosition = null; + if ($toStatus === EnrollmentStatus::Waitlisted) { + $waitlistPosition = (int) Enrollment::query() + ->where('event_id', $enrollment->event_id) + ->waitlisted() + ->max('waitlist_position') + 1; + + $enrollment->update(['waitlist_position' => $waitlistPosition]); + } + + $this->transitionEnrollment->handle(new TransitionEnrollmentDTO( + enrollment: $enrollment, + toStatus: $toStatus, + triggeredBy: TriggeredBy::Admin, + actorId: $dto->actorId, + )); + + if ($toStatus->isConfirmed()) { + event(new EnrollmentConfirmed( + enrollmentId: $enrollment->id, + eventId: $enrollment->event_id, + userId: $enrollment->user_id, + xpRewardOnConfirmed: $policy->xp_on_confirmed ?? 0, + )); + } + + if ($toStatus->isWaitlisted()) { + event(new EnrollmentWaitlisted( + enrollmentId: $enrollment->id, + eventId: $enrollment->event_id, + userId: $enrollment->user_id, + waitlistPosition: $waitlistPosition, + )); + } + + return $enrollment->fresh(['event.enrollmentPolicy']); + }); + } + + private function resolveApprovalStatus(string $eventId, EnrollmentPolicy $policy): EnrollmentStatus + { + $capacity = $policy->capacity; + + if ($capacity === null) { + return EnrollmentStatus::Confirmed; + } + + $occupiedCount = Enrollment::query() + ->where('event_id', $eventId) + ->active() + ->count(); + + if ($occupiedCount < $capacity) { + return EnrollmentStatus::Confirmed; + } + + if ($policy->has_waitlist) { + return EnrollmentStatus::Waitlisted; + } + + throw EnrollmentException::eventFull(); + } +} diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php new file mode 100644 index 000000000..5bb286037 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php @@ -0,0 +1,245 @@ +loadLockedEnrollmentContext($dto->eventId); + + $this->validate($event, $policy, $dto->userId, $dto->applicationData); + + $initial = $this->resolveInitialEnrollment($dto->eventId, $policy); + + try { + $enrollment = Enrollment::query()->create([ + 'event_id' => $dto->eventId, + 'user_id' => $dto->userId, + 'status' => $initial['status'], + 'enrolled_at' => now(), + 'confirmed_at' => $initial['confirmedAt'], + 'waitlist_position' => $initial['waitlistPosition'], + 'application_data' => $dto->applicationData, + ]); + + EnrollmentTransition::query()->create([ + 'enrollment_id' => $enrollment->id, + 'from_status' => null, + 'to_status' => $initial['status'], + 'actor_id' => $dto->userId, + 'triggered_by' => TriggeredBy::User, + ]); + + if ($initial['status']->isConfirmed()) { + event(new EnrollmentConfirmed( + enrollmentId: $enrollment->id, + eventId: $dto->eventId, + userId: $dto->userId, + xpRewardOnConfirmed: $policy->xp_on_confirmed ?? 0, + )); + } + + if ($initial['status']->isWaitlisted()) { + event(new EnrollmentWaitlisted( + enrollmentId: $enrollment->id, + eventId: $dto->eventId, + userId: $dto->userId, + waitlistPosition: $initial['waitlistPosition'], + )); + } + + return $enrollment->fresh(['event.enrollmentPolicy']); + } catch (UniqueConstraintViolationException) { + throw EnrollmentException::alreadyEnrolled(); + } + }); + } + + /** + * Load event and policy for validation and capacity checks. + * + * lockForUpdate() runs SELECT ... FOR UPDATE. Row locks are held until this + * DB::transaction commits or rolls back — there is no explicit unlock in code. + * Returned models are reused below so rules run on fresh DB state, not caller snapshots. + * + * @return array{0: Event, 1: ?EnrollmentPolicy} + */ + private function loadLockedEnrollmentContext(string $eventId): array + { + $event = Event::query() + ->whereKey($eventId) + ->lockForUpdate() + ->firstOrFail(); + + $policy = EnrollmentPolicy::query() + ->where('event_id', $eventId) + ->lockForUpdate() + ->first(); + + return [$event, $policy]; + } + + /** + * @return array{status: EnrollmentStatus, waitlistPosition: ?int, confirmedAt: ?CarbonInterface} + */ + private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $policy): array + { + if ($policy?->enrollment_method === EnrollmentMethod::Application) { + return [ + 'status' => EnrollmentStatus::Pending, + 'waitlistPosition' => null, + 'confirmedAt' => null, + ]; + } + + $capacity = $policy?->capacity; + + if ($capacity === null) { + return [ + 'status' => EnrollmentStatus::Confirmed, + 'waitlistPosition' => null, + 'confirmedAt' => now(), + ]; + } + + $occupiedCount = Enrollment::query() + ->where('event_id', $eventId) + ->active() + ->count(); + + if ($occupiedCount < $capacity) { + return [ + 'status' => EnrollmentStatus::Confirmed, + 'waitlistPosition' => null, + 'confirmedAt' => now(), + ]; + } + + if ($policy->has_waitlist) { + $nextPosition = (int) Enrollment::query() + ->where('event_id', $eventId) + ->waitlisted() + ->max('waitlist_position') + 1; + + return [ + 'status' => EnrollmentStatus::Waitlisted, + 'waitlistPosition' => $nextPosition, + 'confirmedAt' => null, + ]; + } + + throw EnrollmentException::eventFull(); + } + + /** + * @param array|null $applicationData + */ + private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId, ?array $applicationData): void + { + throw_unless( + $event->status === EventStatus::Published, + EnrollmentException::eventNotActive(), + ); + + throw_if( + $event->starts_at->lte(now()), + EnrollmentException::eventPast(), + ); + + $method = $policy?->enrollment_method; + + throw_unless( + in_array( + $method, + [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin, EnrollmentMethod::Application], + strict: true, + ), + EnrollmentException::invalidEnrollmentMethod(), + ); + + throw_if( + Enrollment::query() + ->where('event_id', $event->id) + ->where('user_id', $userId) + ->exists(), + EnrollmentException::alreadyEnrolled(), + ); + + if ($method === EnrollmentMethod::Application) { + throw_if( + $applicationData === null, + EnrollmentException::applicationDataInvalid(), + ); + + $this->validateApplicationData($applicationData, $policy->application_schema ?? []); + } + } + + /** + * @param array $data + * @param array> $schema + */ + private function validateApplicationData(array $data, array $schema): void + { + foreach ($schema as $field) { + $key = $field['key'] ?? null; + + if ($key === null) { + continue; + } + + $value = $data[$key] ?? null; + $type = $field['type'] ?? ''; + + if ($type === 'checkbox') { + $selected = is_array($value) ? $value : []; + + throw_if( + ($field['required'] ?? false) && $selected === [], + EnrollmentException::applicationDataInvalid(), + ); + + $options = $field['options'] ?? []; + foreach ($selected as $option) { + throw_unless( + in_array($option, $options, strict: true), + EnrollmentException::applicationDataInvalid(), + ); + } + + continue; + } + + if (($field['required'] ?? false) && (in_array($value, [null, '', false], strict: true))) { + throw EnrollmentException::applicationDataInvalid(); + } + + if ($type === 'select' && $value !== null) { + $options = $field['options'] ?? []; + if (!in_array($value, $options, strict: true)) { + throw EnrollmentException::applicationDataInvalid(); + } + } + } + } +} diff --git a/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php b/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php new file mode 100644 index 000000000..0511347c7 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php @@ -0,0 +1,47 @@ +whereKey($dto->enrollmentId) + ->lockForUpdate() + ->firstOrFail(); + + throw_unless( + $enrollment->status === EnrollmentStatus::Pending, + EnrollmentException::enrollmentNotPending(), + ); + + $enrollment->update(['rejection_reason' => $dto->reason]); + + $this->transitionEnrollment->handle(new TransitionEnrollmentDTO( + enrollment: $enrollment, + toStatus: EnrollmentStatus::Rejected, + triggeredBy: TriggeredBy::Admin, + actorId: $dto->actorId, + reason: $dto->reason, + )); + + return $enrollment->fresh(); + }); + } +} diff --git a/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php b/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php new file mode 100644 index 000000000..a68420708 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php @@ -0,0 +1,43 @@ +enrollment->status; + $toStatus = $dto->toStatus; + + throw_unless( + $fromStatus->canTransitionTo($toStatus), + EnrollmentException::invalidTransition($fromStatus, $toStatus), + ); + + $attributes = [ + 'status' => $toStatus, + ]; + + if ($timestampColumn = $toStatus->timestampColumn()) { + $attributes[$timestampColumn] = $dto->timestamp ?? now(); + } + + $dto->enrollment->update($attributes); + + return EnrollmentTransition::query()->create([ + 'enrollment_id' => $dto->enrollment->id, + 'from_status' => $fromStatus, + 'to_status' => $toStatus, + 'actor_id' => $dto->actorId, + 'triggered_by' => $dto->triggeredBy, + 'reason' => $dto->reason, + 'metadata' => $dto->metadata ?: null, + ]); + } +} diff --git a/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php b/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php new file mode 100644 index 000000000..ca7f83e82 --- /dev/null +++ b/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php @@ -0,0 +1,13 @@ +|null $applicationData + */ + public function __construct( + public string $eventId, + public string $userId, + public ?array $applicationData = null, + ) {} + + public static function fromModels(Event $event, User $user): self + { + return new self( + eventId: $event->id, + userId: $user->id, + ); + } +} diff --git a/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php b/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php new file mode 100644 index 000000000..9364973a7 --- /dev/null +++ b/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php @@ -0,0 +1,14 @@ + $metadata + */ + public function __construct( + public Enrollment $enrollment, + public EnrollmentStatus $toStatus, + public TriggeredBy $triggeredBy, + public ?string $actorId = null, + public ?string $reason = null, + public array $metadata = [], + public ?CarbonInterface $timestamp = null, + ) {} +} diff --git a/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php new file mode 100644 index 000000000..64fb8ea49 --- /dev/null +++ b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php @@ -0,0 +1,44 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::AllDays => Color::Blue, + self::AnyDay => Color::Green, + self::MinimumDays => Color::Amber, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::AllDays => Heroicon::Fire, + self::AnyDay => Heroicon::CheckCircle, + self::MinimumDays => Heroicon::OutlinedChartBar, + }; + } +} diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php new file mode 100644 index 000000000..878aa2d07 --- /dev/null +++ b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php @@ -0,0 +1,44 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::Rsvp => Color::Green, + self::RsvpCheckin => Color::Blue, + self::Application => Color::Amber, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Rsvp => Heroicon::CheckCircle, + self::RsvpCheckin => Heroicon::Flag, + self::Application => Heroicon::ClipboardDocumentList, + }; + } +} diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php new file mode 100644 index 000000000..75636baa4 --- /dev/null +++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php @@ -0,0 +1,94 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::Pending => Color::Gray, + self::Confirmed => Color::Blue, + self::Waitlisted => Color::Amber, + self::CheckedIn => Color::Teal, + self::Attended => Color::Green, + self::Cancelled => Color::Gray, + self::Rejected => Color::Red, + self::NoShow => Color::Orange, + }; + } + + public function canTransitionTo(self $target): bool + { + return match ($this) { + self::Pending => in_array($target, [self::Confirmed, self::Waitlisted, self::Rejected, self::Cancelled], strict: true), + self::Waitlisted => in_array($target, [self::Confirmed, self::Cancelled], strict: true), + self::Confirmed => in_array($target, [self::CheckedIn, self::Cancelled, self::NoShow], strict: true), + self::CheckedIn => in_array($target, [self::Attended, self::NoShow], strict: true), + self::Attended, self::Cancelled, self::Rejected, self::NoShow => false, + }; + } + + public function timestampColumn(): ?string + { + return match ($this) { + self::Confirmed, self::CheckedIn, self::Attended, self::Cancelled => $this->value.'_at', + default => null, + }; + } + + public function isTerminal(): bool + { + return in_array($this, [self::Attended, self::Cancelled, self::Rejected, self::NoShow], strict: true); + } + + public function is(self ...$statuses): bool + { + return in_array($this, $statuses, strict: true); + } + + public function isConfirmed(): bool + { + return $this->is(self::Confirmed); + } + + public function isWaitlisted(): bool + { + return $this->is(self::Waitlisted); + } + + public function getResponseMessage(?int $waitlistPosition = null): string + { + return match ($this) { + self::Confirmed => __('events::pages.confirm_presence_success'), + self::Waitlisted => __('events::pages.waitlist_success', [ + 'position' => $waitlistPosition, + ]), + default => throw EnrollmentException::responseMessageNotImplemented($this), + }; + } +} diff --git a/app-modules/events/src/Enrollment/Enums/TriggeredBy.php b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php new file mode 100644 index 000000000..d0daa24d5 --- /dev/null +++ b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php @@ -0,0 +1,44 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::User => Color::Blue, + self::Admin => Color::Amber, + self::System => Color::Gray, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::User => Heroicon::User, + self::Admin => Heroicon::ShieldCheck, + self::System => Heroicon::Cog6Tooth, + }; + } +} diff --git a/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php b/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php new file mode 100644 index 000000000..a500e3b20 --- /dev/null +++ b/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php @@ -0,0 +1,20 @@ + $from->value, 'to' => $to->value]), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function alreadyEnrolled(): self + { + return new self( + __('events::exceptions.already_enrolled'), + Response::HTTP_CONFLICT, + ); + } + + public static function eventPast(): self + { + return new self( + __('events::exceptions.event_past'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function eventNotActive(): self + { + return new self( + __('events::exceptions.event_not_active'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function invalidEnrollmentMethod(): self + { + return new self( + __('events::exceptions.invalid_enrollment_method'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function eventFull(): self + { + return new self( + __('events::exceptions.event_full'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function responseMessageNotImplemented(EnrollmentStatus $status): self + { + return new self( + __('events::exceptions.response_message_not_implemented', ['status' => $status->value]), + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + public static function enrollmentNotPending(): self + { + return new self( + __('events::exceptions.enrollment_not_pending'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + public static function applicationDataInvalid(): self + { + return new self( + __('events::exceptions.application_data_invalid'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } +} diff --git a/app-modules/events/src/Enrollment/Models/Enrollment.php b/app-modules/events/src/Enrollment/Models/Enrollment.php new file mode 100644 index 000000000..304b022a1 --- /dev/null +++ b/app-modules/events/src/Enrollment/Models/Enrollment.php @@ -0,0 +1,136 @@ +|null $application_data + * @property string|null $rejection_reason + * @property Carbon|null $enrolled_at + * @property Carbon|null $confirmed_at + * @property Carbon|null $checked_in_at + * @property Carbon|null $attended_at + * @property Carbon|null $cancelled_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ +#[Table(name: 'events_enrollments')] +final class Enrollment extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'event_id', + 'user_id', + 'is_public', + 'application_data', + 'rejection_reason', + 'enrolled_at', + 'confirmed_at', + 'checked_in_at', + 'attended_at', + 'cancelled_at', + ]; + + protected $attributes = [ + 'status' => 'pending', + 'is_public' => true, + ]; + + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return HasMany */ + public function transitions(): HasMany + { + return $this->hasMany(EnrollmentTransition::class); + } + + /** @return HasMany */ + public function checkIns(): HasMany + { + return $this->hasMany(CheckIn::class); + } + + /** @return HasOne */ + public function qrToken(): HasOne + { + return $this->hasOne(QrToken::class); + } + + protected static function newFactory(): EnrollmentFactory + { + return EnrollmentFactory::new(); + } + + /** @param Builder $query */ + protected function scopeConfirmed(Builder $query): void + { + $query->where('status', EnrollmentStatus::Confirmed); + } + + /** @param Builder $query */ + protected function scopeWaitlisted(Builder $query): void + { + $query->where('status', EnrollmentStatus::Waitlisted); + } + + /** @param Builder $query */ + protected function scopeActive(Builder $query): void + { + $query->whereIn('status', [ + EnrollmentStatus::Confirmed, + EnrollmentStatus::CheckedIn, + EnrollmentStatus::Attended, + ]); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'status' => EnrollmentStatus::class, + 'is_public' => 'boolean', + 'application_data' => 'array', + 'enrolled_at' => 'datetime', + 'confirmed_at' => 'datetime', + 'checked_in_at' => 'datetime', + 'attended_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } +} diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php new file mode 100644 index 000000000..b118136cc --- /dev/null +++ b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php @@ -0,0 +1,102 @@ +>|null $application_schema + * @property Carbon $created_at + * @property Carbon $updated_at + */ +#[Table(name: 'events_enrollment_policies')] +final class EnrollmentPolicy extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'event_id', + 'enrollment_method', + 'check_in_method', + 'capacity', + 'has_waitlist', + 'attendance_requirement', + 'minimum_days', + 'cancellation_deadline_hours', + 'xp_on_confirmed', + 'xp_on_checked_in', + 'xp_on_attended', + 'application_schema', + ]; + + protected $attributes = [ + 'has_waitlist' => false, + 'attendance_requirement' => 'all_days', + 'xp_on_confirmed' => 0, + 'xp_on_checked_in' => 0, + 'xp_on_attended' => 0, + ]; + + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function resolveAttendance(Enrollment $enrollment): EnrollmentStatus + { + $checkInDays = $enrollment->checkIns()->count(); + + $meets = match ($this->attendance_requirement) { + AttendanceRequirement::AllDays => $checkInDays === $enrollment->event->totalDays(), + AttendanceRequirement::AnyDay => $checkInDays >= 1, + AttendanceRequirement::MinimumDays => $checkInDays >= ($this->minimum_days ?? 0), + }; + + return $meets ? EnrollmentStatus::Attended : EnrollmentStatus::NoShow; + } + + protected static function newFactory(): EnrollmentPolicyFactory + { + return EnrollmentPolicyFactory::new(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'enrollment_method' => EnrollmentMethod::class, + 'check_in_method' => CheckInMethod::class, + 'attendance_requirement' => AttendanceRequirement::class, + 'has_waitlist' => 'boolean', + 'application_schema' => 'array', + ]; + } +} diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php b/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php new file mode 100644 index 000000000..6e377f740 --- /dev/null +++ b/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php @@ -0,0 +1,76 @@ +|null $metadata + * @property Carbon $created_at + */ +#[Table(name: 'events_enrollment_transitions')] +final class EnrollmentTransition extends Model +{ + /** @use HasFactory */ + use HasFactory; + use HasUuids; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'enrollment_id', + 'from_status', + 'to_status', + 'actor_id', + 'triggered_by', + 'reason', + 'metadata', + ]; + + /** @return BelongsTo */ + public function enrollment(): BelongsTo + { + return $this->belongsTo(Enrollment::class); + } + + /** @return BelongsTo */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + protected static function newFactory(): EnrollmentTransitionFactory + { + return EnrollmentTransitionFactory::new(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'from_status' => EnrollmentStatus::class, + 'to_status' => EnrollmentStatus::class, + 'triggered_by' => TriggeredBy::class, + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app-modules/events/src/Event/Enums/EventStatus.php b/app-modules/events/src/Event/Enums/EventStatus.php new file mode 100644 index 000000000..4671543d0 --- /dev/null +++ b/app-modules/events/src/Event/Enums/EventStatus.php @@ -0,0 +1,79 @@ + */ + public static function viewableByParticipant(): array + { + return [ + self::Published, + self::Completed, + self::Cancelled, + ]; + } + + public function getLabel(): string + { + return __('events::enums.event_status.'.$this->value); + } + + public function getColor(): array + { + return match ($this) { + self::Draft => Color::Gray, + self::Published => Color::Green, + self::Completed => Color::Blue, + self::Cancelled => Color::Red, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Draft => Heroicon::PencilSquare, + self::Published => Heroicon::GlobeAlt, + self::Completed => Heroicon::CheckBadge, + self::Cancelled => Heroicon::XCircle, + }; + } + + public function canTransitionTo(self $target): bool + { + return match ($this) { + self::Draft => in_array($target, [self::Published, self::Cancelled], strict: true), + self::Published => in_array($target, [self::Completed, self::Cancelled], strict: true), + self::Completed, self::Cancelled => false, + }; + } + + public function isTerminal(): bool + { + return in_array($this, [self::Completed, self::Cancelled], strict: true); + } + + public function isViewableByParticipant(): bool + { + return match ($this) { + self::Published, self::Completed, self::Cancelled => true, + self::Draft => false, + }; + } +} diff --git a/app-modules/events/src/Event/Enums/EventType.php b/app-modules/events/src/Event/Enums/EventType.php new file mode 100644 index 000000000..02614170a --- /dev/null +++ b/app-modules/events/src/Event/Enums/EventType.php @@ -0,0 +1,44 @@ +value); + } + + public function getColor(): array + { + return match ($this) { + self::Meetup => Color::Blue, + self::Workshop => Color::Amber, + self::Conference => Color::Purple, + }; + } + + public function getIcon(): Heroicon + { + return match ($this) { + self::Meetup => Heroicon::UserGroup, + self::Workshop => Heroicon::CodeBracket, + self::Conference => Heroicon::Megaphone, + }; + } +} diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php new file mode 100644 index 000000000..c586ab26e --- /dev/null +++ b/app-modules/events/src/Event/Models/Event.php @@ -0,0 +1,135 @@ + */ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'tenant_id', + 'slug', + 'title', + 'description', + 'event_type', + 'location', + 'starts_at', + 'ends_at', + 'status', + ]; + + protected $attributes = [ + 'status' => 'draft', + ]; + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** @return HasOne */ + public function enrollmentPolicy(): HasOne + { + return $this->hasOne(EnrollmentPolicy::class); + } + + /** @return HasMany */ + public function enrollments(): HasMany + { + return $this->hasMany(Enrollment::class); + } + + /** @return HasMany */ + public function checkInCodes(): HasMany + { + return $this->hasMany(CheckInCode::class); + } + + public function isPast(): bool + { + return $this->ends_at->isPast(); + } + + public function totalDays(): int + { + return (int) $this->starts_at->startOfDay()->diffInDays($this->ends_at->startOfDay()) + 1; + } + + protected static function newFactory(): EventFactory + { + return EventFactory::new(); + } + + /** @param Builder $query */ + protected function scopePublished(Builder $query): void + { + $query->where('status', EventStatus::Published); + } + + /** @param Builder $query */ + protected function scopeViewableByParticipant(Builder $query): void + { + $query->whereIn('status', EventStatus::viewableByParticipant()); + } + + /** @param Builder $query */ + protected function scopeUpcoming(Builder $query): void + { + $query->where('starts_at', '>', now()); + } + + /** @param Builder $query */ + protected function scopeActive(Builder $query): void + { + $query->where('status', EventStatus::Published) + ->where('ends_at', '>', now()); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'event_type' => EventType::class, + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'status' => EventStatus::class, + ]; + } +} diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php index 42bbe5d1d..55745965b 100644 --- a/app-modules/events/src/EventsServiceProvider.php +++ b/app-modules/events/src/EventsServiceProvider.php @@ -4,12 +4,35 @@ namespace He4rt\Events; +use He4rt\Events\CheckIn\Events\CheckInRequested; +use He4rt\Events\CheckIn\Listeners\GenerateQrTokenOnConfirmed; +use He4rt\Events\CheckIn\Listeners\HandleBotCheckIn; +use He4rt\Events\Console\Commands\ClosePendingEventsCommand; +use He4rt\Events\Enrollment\Events\EnrollmentConfirmed; +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class EventsServiceProvider extends ServiceProvider { + /** + * @throws BindingResolutionException + */ public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'events'); + $this->loadTranslationsFrom(__DIR__.'/../lang', 'events'); + + Event::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class); + Event::listen(CheckInRequested::class, HandleBotCheckIn::class); + + if ($this->app->runningInConsole()) { + $this->app->make(Schedule::class) + ->command(ClosePendingEventsCommand::class) + ->everyFifteenMinutes() + ->withoutOverlapping(); + } } } diff --git a/app-modules/events/tests/Feature/CheckInActionTest.php b/app-modules/events/tests/Feature/CheckInActionTest.php new file mode 100644 index 000000000..01a5dc38b --- /dev/null +++ b/app-modules/events/tests/Feature/CheckInActionTest.php @@ -0,0 +1,174 @@ +create(); + $participant = User::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); + + return Enrollment::factory()->create(array_merge([ + 'event_id' => $event->id, + 'user_id' => $participant->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ], $enrollmentAttributes)); +} + +test('when organizer checks in confirmed enrollment, then check-in is recorded and enrollment transitions', function (): void { + EventFacade::fake([ParticipantCheckedIn::class]); + + $organizer = User::factory()->create(); + $enrollment = createConfirmedEnrollmentForCheckIn(); + + $checkIn = resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: $organizer->id, + eventDate: now(), + ), + ); + + expect($checkIn)->toBeInstanceOf(CheckIn::class) + ->and($checkIn->enrollment_id)->toBe($enrollment->id) + ->and($checkIn->method)->toBe(CheckInMethod::Manual) + ->and($checkIn->event_date->isSameDay(now()))->toBeTrue() + ->and($checkIn->checked_in_at)->not->toBeNull() + ->and($checkIn->payload)->toBe(['actor_user_id' => $organizer->id]) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($enrollment->fresh()->checked_in_at)->not->toBeNull(); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->where('to_status', EnrollmentStatus::CheckedIn) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->from_status)->toBe(EnrollmentStatus::Confirmed) + ->and($transition->actor_id)->toBe($organizer->id) + ->and($transition->triggered_by)->toBe(TriggeredBy::Admin); + + EventFacade::assertDispatched(fn (ParticipantCheckedIn $event): bool => $event->enrollmentId === $enrollment->id + && $event->eventId === $enrollment->event_id + && $event->userId === $enrollment->user_id + && $event->checkInId === $checkIn->id); +}); + +test('when checked-in enrollment checks in on another event date, then only check-in record is created', function (): void { + $organizer = User::factory()->create(); + $startsAt = now()->setTime(9, 0); + $enrollment = createConfirmedEnrollmentForCheckIn([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0), + ], [ + 'status' => EnrollmentStatus::CheckedIn, + ]); + + CheckIn::factory()->create([ + 'enrollment_id' => $enrollment->id, + 'event_date' => $startsAt->toDateString(), + 'method' => CheckInMethod::Manual, + 'checked_in_at' => now(), + ]); + + $checkIn = resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: $organizer->id, + eventDate: $startsAt->clone()->addDay(), + ), + ); + + expect($checkIn->event_date->isSameDay($startsAt->clone()->addDay()))->toBeTrue() + ->and(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(2) + ->and(EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->where('to_status', EnrollmentStatus::CheckedIn) + ->count())->toBe(0); +}); + +test('when enrollment already has check-in for event date, then duplicate is rejected', function (): void { + $enrollment = createConfirmedEnrollmentForCheckIn(); + + CheckIn::factory()->create([ + 'enrollment_id' => $enrollment->id, + 'event_date' => now()->toDateString(), + 'method' => CheckInMethod::Manual, + ]); + + expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: User::factory()->create()->id, + eventDate: now(), + ), + ))->toThrow(CheckInException::class); +}); + +test('when check-in date is outside event date range, then check-in is rejected', function (): void { + $startsAt = now()->setTime(9, 0); + $enrollment = createConfirmedEnrollmentForCheckIn([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + ]); + + expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: User::factory()->create()->id, + eventDate: $startsAt->clone()->addDay(), + ), + ))->toThrow(CheckInException::class); +}); + +test('when enrollment status is not confirmed or checked in, then check-in is rejected', function (): void { + $enrollment = createConfirmedEnrollmentForCheckIn(enrollmentAttributes: [ + 'status' => EnrollmentStatus::Waitlisted, + 'confirmed_at' => null, + ]); + + expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: User::factory()->create()->id, + eventDate: now(), + ), + ))->toThrow(CheckInException::class); +}); + +test('when manual check-in has no actor user id, then check-in is rejected', function (): void { + $enrollment = createConfirmedEnrollmentForCheckIn(); + + expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: '', + eventDate: now(), + ), + ))->toThrow(CheckInException::class); +}); diff --git a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php new file mode 100644 index 000000000..4c6f60859 --- /dev/null +++ b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php @@ -0,0 +1,247 @@ +create(); + $startsAt = now()->subDays(3)->setTime(9, 0); + + return Event::factory() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->state(array_merge([ + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + 'xp_on_attended' => 0, + ], $policyAttributes)), 'enrollmentPolicy') + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); +} + +test('when checked_in enrollment meets all_days, then action transitions to Attended and dispatches ParticipantAttended', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $startsAt = now()->subDays(3)->setTime(9, 0); + $event = createEventForClosure([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'xp_on_attended' => 75, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]); + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDays(2)->toDateString(), 'method' => CheckInMethod::Manual]); + + $attended = resolve(CloseEventAction::class)->handle($event); + + expect($attended)->toBe(1) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Attended) + ->and($enrollment->fresh()->attended_at)->not->toBeNull(); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->where('to_status', EnrollmentStatus::Attended) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->from_status)->toBe(EnrollmentStatus::CheckedIn) + ->and($transition->triggered_by)->toBe(TriggeredBy::System); + + EventFacade::assertDispatched(fn (ParticipantAttended $e): bool => $e->enrollmentId === $enrollment->id + && $e->eventId === $event->id + && $e->xpRewardOnAttended === 75); +}); + +test('when checked_in enrollment is missing check-ins for all_days, then action transitions to NoShow without dispatching event', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $startsAt = now()->subDays(3)->setTime(9, 0); + $event = createEventForClosure([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AllDays, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + + $attended = resolve(CloseEventAction::class)->handle($event); + + expect($attended)->toBe(0) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow); + + EventFacade::assertNotDispatched(ParticipantAttended::class); +}); + +test('when confirmed enrollment never checked in, then action transitions to NoShow', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $event = createEventForClosure(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + $attended = resolve(CloseEventAction::class)->handle($event); + + expect($attended)->toBe(0) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow); + + EventFacade::assertNotDispatched(ParticipantAttended::class); +}); + +test('when terminal enrollment is present, then action skips it', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $event = createEventForClosure(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Cancelled, + 'cancelled_at' => now()->subDay(), + ]); + + $attended = resolve(CloseEventAction::class)->handle($event); + + expect($attended)->toBe(0) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Cancelled); + + $transitionCount = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->count(); + + expect($transitionCount)->toBe(0); +}); + +test('when action runs twice, then second run is no-op (idempotent)', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $event = createEventForClosure(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + $first = resolve(CloseEventAction::class)->handle($event); + $second = resolve(CloseEventAction::class)->handle($event); + + expect($first)->toBe(0) + ->and($second)->toBe(0); + + $transitions = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->where('to_status', EnrollmentStatus::NoShow) + ->count(); + + expect($transitions)->toBe(1); +}); + +test('when action processes multiple enrollments, then each is closed independently with system audit trail', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $startsAt = now()->subDays(3)->setTime(9, 0); + $event = createEventForClosure([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AnyDay, + ]); + $confirmed = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + $checkedIn = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now()->subDay(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $checkedIn->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + + resolve(CloseEventAction::class)->handle($event); + + expect($confirmed->fresh()->status)->toBe(EnrollmentStatus::NoShow) + ->and($checkedIn->fresh()->status)->toBe(EnrollmentStatus::Attended) + ->and($checkedIn->fresh()->attended_at)->not->toBeNull(); + + foreach ([$confirmed->id, $checkedIn->id] as $enrollmentId) { + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollmentId) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->triggered_by)->toBe(TriggeredBy::System); + } +}); + +test('when action processes more than one chunk of eligible enrollments, then all are closed in one run', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $event = createEventForClosure(); + + Enrollment::factory() + ->count(1_001) + ->create([ + 'event_id' => $event->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + resolve(CloseEventAction::class)->handle($event); + + $remainingEligible = Enrollment::query() + ->where('event_id', $event->id) + ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]) + ->count(); + + $noShowTransitions = EnrollmentTransition::query() + ->whereHas('enrollment', fn (Builder $query) => $query->where('event_id', $event->id)) + ->where('to_status', EnrollmentStatus::NoShow) + ->count(); + + expect($remainingEligible)->toBe(0) + ->and($noShowTransitions)->toBe(1_001); +}); diff --git a/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php new file mode 100644 index 000000000..650c2a0f4 --- /dev/null +++ b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php @@ -0,0 +1,108 @@ +create(); + $startsAt = now()->subDays(3)->setTime(9, 0); + + return Event::factory() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->state(array_merge([ + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + ], $policyAttributes)), 'enrollmentPolicy') + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); +} + +test('when command runs and past event has non-terminal enrollments, then it dispatches a closure job', function (): void { + Bus::fake([ProcessEventClosureJob::class]); + + $event = makeEventForCommandTest(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + $this->artisan('events:close-pending') + ->expectsOutputToContain('Dispatched 1 closure job(s).') + ->assertSuccessful(); + + Bus::assertDispatched(fn (ProcessEventClosureJob $job): bool => $job->eventId === $event->id); +}); + +test('when command runs and event is in the future, then no job is dispatched', function (): void { + Bus::fake([ProcessEventClosureJob::class]); + + $futureStartsAt = now()->addDays(5)->setTime(9, 0); + $event = makeEventForCommandTest([ + 'starts_at' => $futureStartsAt, + 'ends_at' => $futureStartsAt->clone()->setTime(18, 0), + ]); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $this->artisan('events:close-pending'); + + Bus::assertNotDispatched(ProcessEventClosureJob::class); +}); + +test('when command runs and past event has only terminal enrollments, then no job is dispatched', function (): void { + Bus::fake([ProcessEventClosureJob::class]); + + $event = makeEventForCommandTest(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Attended, + 'attended_at' => now()->subDay(), + ]); + + $this->artisan('events:close-pending'); + + Bus::assertNotDispatched(ProcessEventClosureJob::class); +}); + +test('when command runs and multiple past events need closure, then it dispatches one job per event', function (): void { + Bus::fake([ProcessEventClosureJob::class]); + + $eventA = makeEventForCommandTest(); + $eventB = makeEventForCommandTest(); + Enrollment::factory()->create([ + 'event_id' => $eventA->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + ]); + Enrollment::factory()->create([ + 'event_id' => $eventB->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now()->subDay(), + ]); + + $this->artisan('events:close-pending'); + + Bus::assertDispatchedTimes(ProcessEventClosureJob::class, 2); +}); diff --git a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php new file mode 100644 index 000000000..9f86df03d --- /dev/null +++ b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php @@ -0,0 +1,203 @@ +create(); + $startsAt = now()->subDays(2)->setTime(9, 0); + + return Event::factory() + ->for($tenant) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); +} + +test('when admin overrides no_show enrollment to attended with reason, then transition is recorded with admin trigger', function (): void { + $event = createPastEvent(); + $organizer = User::factory()->create(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::NoShow, + 'confirmed_at' => now()->subDay(), + ]); + + $transition = resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::NoShow, + toStatus: EnrollmentStatus::Attended, + actorId: $organizer->id, + reason: 'Participant was actually present', + ), + ); + + expect($transition->from_status)->toBe(EnrollmentStatus::NoShow) + ->and($transition->to_status)->toBe(EnrollmentStatus::Attended) + ->and($transition->triggered_by)->toBe(TriggeredBy::Admin) + ->and($transition->actor_id)->toBe($organizer->id) + ->and($transition->reason)->toBe('Participant was actually present') + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Attended) + ->and($enrollment->fresh()->attended_at)->not->toBeNull(); +}); + +test('when admin overrides confirmed enrollment to checked_in with reason, then status transitions and timestamp is set', function (): void { + $event = createPastEvent(); + $organizer = User::factory()->create(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::Confirmed, + toStatus: EnrollmentStatus::CheckedIn, + actorId: $organizer->id, + reason: 'Late arrival', + ), + ); + + $enrollment->refresh(); + + expect($enrollment->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($enrollment->checked_in_at)->not->toBeNull(); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->where('to_status', EnrollmentStatus::CheckedIn) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->triggered_by)->toBe(TriggeredBy::Admin) + ->and($transition->reason)->toBe('Late arrival'); +}); + +test('when admin tries to override without reason, then reason required exception is thrown', function (): void { + $event = createPastEvent(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::NoShow, + ]); + + expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::NoShow, + toStatus: EnrollmentStatus::Attended, + actorId: User::factory()->create()->id, + reason: ' ', + ), + ))->toThrow(OverrideEnrollmentStatusException::class); +}); + +test('when admin tries to override from cancelled to attended, then override not allowed exception is thrown', function (): void { + $event = createPastEvent(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Cancelled, + ]); + + expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::Cancelled, + toStatus: EnrollmentStatus::Attended, + actorId: User::factory()->create()->id, + reason: 'Should not work', + ), + ))->toThrow(OverrideEnrollmentStatusException::class); +}); + +test('when allowedTargetsFor is queried for NoShow, then it returns only Attended', function (): void { + $targets = OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::NoShow); + + expect($targets)->toBe([EnrollmentStatus::Attended]); +}); + +test('when allowedTargetsFor is queried for Confirmed, then it returns only CheckedIn', function (): void { + $targets = OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Confirmed); + + expect($targets)->toBe([EnrollmentStatus::CheckedIn]); +}); + +test('when allowedTargetsFor is queried for a terminal or unrelated status, then it returns an empty array', function (): void { + expect(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Attended))->toBeEmpty() + ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Cancelled))->toBeEmpty() + ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Rejected))->toBeEmpty() + ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::NoShow))->not->toBeEmpty(); +}); + +test('when admin tries to override from no_show to confirmed, then override not allowed exception is thrown', function (): void { + $event = createPastEvent(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::NoShow, + ]); + + expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::NoShow, + toStatus: EnrollmentStatus::Confirmed, + actorId: User::factory()->create()->id, + reason: 'Should not work', + ), + ))->toThrow(OverrideEnrollmentStatusException::class); +}); + +test('when admin overrides a stale confirmed enrollment that is already no_show, then current status is enforced', function (): void { + $event = createPastEvent(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + resolve(TransitionEnrollmentAction::class)->handle( + new TransitionEnrollmentDTO( + enrollment: $enrollment->fresh(), + toStatus: EnrollmentStatus::NoShow, + triggeredBy: TriggeredBy::System, + ), + ); + + expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $enrollment, + fromStatus: EnrollmentStatus::Confirmed, + toStatus: EnrollmentStatus::CheckedIn, + actorId: User::factory()->create()->id, + reason: 'Late arrival', + ), + ))->toThrow(static function (OverrideEnrollmentStatusException $exception): void { + expect($exception->getCode())->toBe(409); + }); + + expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow); +}); diff --git a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php new file mode 100644 index 000000000..dc361862e --- /dev/null +++ b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php @@ -0,0 +1,102 @@ +create(); + $startsAt = now()->subDays(3)->setTime(9, 0); + + return Event::factory() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->state(array_merge([ + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + ], $policyAttributes)), 'enrollmentPolicy') + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); +} + +test('when job runs for an event with non-terminal enrollments, then it closes them', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + $event = makeEventForJobTest(); + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now()->subDay(), + ]); + + new ProcessEventClosureJob($event->id)->handle(resolve(CloseEventAction::class)); + + expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->triggered_by)->toBe(TriggeredBy::System); +}); + +test('when job unique id is queried, then it returns the event id', function (): void { + $job = new ProcessEventClosureJob('event-123'); + + expect($job->uniqueId())->toBe('event-123'); +}); + +test('when job runs for a non-existent event id, then it silently returns without throwing', function (): void { + EventFacade::fake([ParticipantAttended::class]); + + new ProcessEventClosureJob(Str::uuid()->toString())->handle(resolve(CloseEventAction::class)); + + EventFacade::assertNotDispatched(ParticipantAttended::class); +}); + +test('when job fails, then it logs the event id and error', function (): void { + Log::spy(); + + $exception = new RuntimeException('db down'); + + new ProcessEventClosureJob('event-xyz')->failed($exception); + + Log::shouldHaveReceived('error')->once()->withArgs(fn (string $message, array $context): bool => $message === 'Event closure failed' + && $context['event_id'] === 'event-xyz' + && $context['error'] === 'db down'); +}); + +test('when job is configured, then it has correct backoff, tries, and unique ttl', function (): void { + $ref = new ReflectionClass(ProcessEventClosureJob::class); + + $tries = $ref->getAttributes(Tries::class)[0]->newInstance()->tries; + $backoff = $ref->getAttributes(Backoff::class)[0]->newInstance()->backoff; + $uniqueFor = $ref->getAttributes(UniqueFor::class)[0]->newInstance()->uniqueFor; + + expect($tries)->toBe(4) + ->and($backoff)->toBe([1, 5, 10]) + ->and($uniqueFor)->toBe(1_800); +}); diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php new file mode 100644 index 000000000..19ad986ff --- /dev/null +++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php @@ -0,0 +1,449 @@ +published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->rsvp()->state($policyAttributes), 'enrollmentPolicy') + ->create($eventAttributes); +} + +test('when a user enrolls in an rsvp event, then enrollment is confirmed with audit trail', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], ['xp_on_confirmed' => 50]); + + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed) + ->and($enrollment->enrolled_at)->not->toBeNull() + ->and($enrollment->confirmed_at)->not->toBeNull(); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->from_status)->toBeNull() + ->and($transition->to_status)->toBe(EnrollmentStatus::Confirmed) + ->and($transition->actor_id)->toBe($user->id) + ->and($transition->triggered_by)->toBe(TriggeredBy::User); + + EventFacade::assertDispatched(fn (EnrollmentConfirmed $event): bool => $event->enrollmentId === $enrollment->id + && $event->eventId === $enrollment->event_id + && $event->userId === $user->id + && $event->xpRewardOnConfirmed === 50); +}); + +test('when concurrent enrollment hits unique index, then already enrolled exception is thrown', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant); + $dto = EnrollUserDTO::fromModels($event, $user); + + $raced = false; + + Enrollment::creating(static function (Enrollment $enrollment) use (&$raced): void { + if ($raced) { + return; + } + + $raced = true; + + Enrollment::withoutEvents(static function () use ($enrollment): void { + Enrollment::factory()->create([ + 'event_id' => $enrollment->event_id, + 'user_id' => $enrollment->user_id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + }); + }); + + try { + expect(fn (): Enrollment => resolve(EnrollUserAction::class)->handle($dto)) + ->toThrow(EnrollmentException::class); + } finally { + Enrollment::flushEventListeners(); + } +}); + +test('when a user enrolls twice in the same event, then duplicate enrollment is rejected', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); +})->throws(EnrollmentException::class); + +test('when a user enrolls in a past event, then enrollment is rejected', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->past() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') + ->create(); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); +})->throws(EnrollmentException::class); + +test('when a user enrolls in a draft event, then enrollment is rejected', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') + ->create(['status' => EventStatus::Draft]); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); +})->throws(EnrollmentException::class); + +test('when an event uses application enrollment method, then rsvp enrollment is rejected', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->state([ + 'enrollment_method' => EnrollmentMethod::Application, + ]), 'enrollmentPolicy') + ->create(); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); +})->throws(EnrollmentException::class); + +test('when event is at capacity with waitlist enabled, then enrollment is waitlisted', function (): void { + EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 1, + 'has_waitlist' => true, + ]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect($enrollment->status)->toBe(EnrollmentStatus::Waitlisted) + ->and($enrollment->waitlist_position)->toBe(1) + ->and($enrollment->confirmed_at)->toBeNull(); + + EventFacade::assertNotDispatched(EnrollmentConfirmed::class); + + EventFacade::assertDispatched(fn (EnrollmentWaitlisted $event): bool => $event->enrollmentId === $enrollment->id + && $event->eventId === $enrollment->event_id + && $event->userId === $user->id + && $event->waitlistPosition === 1); +}); + +test('when event is at capacity without waitlist, then enrollment is rejected with 422', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 1, + 'has_waitlist' => false, + ]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + try { + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + expect(value: false)->toBeTrue('Expected EnrollmentException was not thrown'); + } catch (EnrollmentException $enrollmentException) { + expect($enrollmentException->getCode())->toBe(Response::HTTP_UNPROCESSABLE_ENTITY); + } +}); + +test('when event has unlimited capacity, then all enrollments are confirmed', function (): void { + EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); + + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => null, + 'has_waitlist' => true, + ]); + + $users = User::factory()->count(3)->create(); + + foreach ($users as $user) { + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed); + } + + expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(3); + + EventFacade::assertNotDispatched(EnrollmentWaitlisted::class); +}); + +test('when multiple users enroll beyond capacity with waitlist, then fifo waitlist positions are assigned', function (): void { + EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); + + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 2, + 'has_waitlist' => true, + ]); + + $users = User::factory()->count(4)->create(); + $results = []; + + foreach ($users as $user) { + $results[] = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + } + + expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(2) + ->and($results[0]->status)->toBe(EnrollmentStatus::Confirmed) + ->and($results[1]->status)->toBe(EnrollmentStatus::Confirmed) + ->and($results[2]->status)->toBe(EnrollmentStatus::Waitlisted) + ->and($results[2]->waitlist_position)->toBe(1) + ->and($results[3]->status)->toBe(EnrollmentStatus::Waitlisted) + ->and($results[3]->waitlist_position)->toBe(2); +}); + +test('when enrollments are processed in rapid succession, then active count never exceeds capacity', function (): void { + EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); + + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 2, + 'has_waitlist' => true, + ]); + + $users = User::factory()->count(5)->create(); + + foreach ($users as $user) { + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBeLessThanOrEqual(2); + } + + expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(2) + ->and(Enrollment::query()->where('event_id', $event->id)->waitlisted()->count())->toBe(3); +}); + +test('when checked-in enrollment occupies the last seat, then new enrollment is waitlisted', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 1, + 'has_waitlist' => true, + ]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::CheckedIn, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + 'checked_in_at' => now(), + ]); + + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect($enrollment->status)->toBe(EnrollmentStatus::Waitlisted) + ->and($enrollment->waitlist_position)->toBe(1); + + EventFacade::assertNotDispatched(EnrollmentConfirmed::class); +}); + +test('when event has available capacity, then enrollment is confirmed', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant, [], [ + 'capacity' => 2, + 'has_waitlist' => true, + ]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed) + ->and($enrollment->confirmed_at)->not->toBeNull(); + + EventFacade::assertDispatched(EnrollmentConfirmed::class); +}); + +test('when duplicate enrollment exists in database, then only one enrollment record is kept', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createRsvpEvent($tenant); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect(Enrollment::query()->where('event_id', $event->id)->where('user_id', $user->id)->count())->toBe(1); +}); + +// ── Application enrollment ──────────────────────────────────────────────────── + +test('when a user submits an application, then enrollment is pending with application_data and audit trail', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application([ + ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true], + ]), 'enrollmentPolicy') + ->create(); + + $dto = new EnrollUserDTO( + eventId: $event->id, + userId: $user->id, + applicationData: ['why_join' => 'I love PHP!'], + ); + + $enrollment = resolve(EnrollUserAction::class)->handle($dto); + + expect($enrollment->status)->toBe(EnrollmentStatus::Pending) + ->and($enrollment->confirmed_at)->toBeNull() + ->and($enrollment->application_data)->toBe(['why_join' => 'I love PHP!']); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->first(); + + expect($transition)->not->toBeNull() + ->and($transition->from_status)->toBeNull() + ->and($transition->to_status)->toBe(EnrollmentStatus::Pending) + ->and($transition->triggered_by)->toBe(TriggeredBy::User); + + EventFacade::assertNotDispatched(EnrollmentConfirmed::class); +}); + +test('when application is submitted without applicationData, then exception is thrown', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); +})->throws(EnrollmentException::class); + +test('when application is missing a required field, then exception is thrown', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application([ + ['key' => 'why', 'type' => 'text', 'label' => 'Why?', 'required' => true], + ]), 'enrollmentPolicy') + ->create(); + + $dto = new EnrollUserDTO( + eventId: $event->id, + userId: $user->id, + applicationData: ['why' => ''], + ); + + resolve(EnrollUserAction::class)->handle($dto); +})->throws(EnrollmentException::class); + +test('when application is submitted with no schema defined, then enrollment is pending', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + $dto = new EnrollUserDTO( + eventId: $event->id, + userId: $user->id, + applicationData: [], + ); + + $enrollment = resolve(EnrollUserAction::class)->handle($dto); + + expect($enrollment->status)->toBe(EnrollmentStatus::Pending); +}); + +test('when application event is past, then application enrollment is rejected', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->past() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + $dto = new EnrollUserDTO( + eventId: $event->id, + userId: $user->id, + applicationData: [], + ); + + resolve(EnrollUserAction::class)->handle($dto); +})->throws(EnrollmentException::class); diff --git a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php new file mode 100644 index 000000000..c1d0bc791 --- /dev/null +++ b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php @@ -0,0 +1,160 @@ +create(); + + return Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'status' => EnrollmentStatus::Pending, + 'enrolled_at' => now(), + 'application_data' => ['q1' => 'My answer'], + ]); +} + +function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): Event +{ + return Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application()->state($policyAttributes), 'enrollmentPolicy') + ->create(); +} + +test('when an application is approved, then enrollment transitions to confirmed with audit trail', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createApplicationEvent($tenant, ['xp_on_confirmed' => 100]); + $enrollment = createPendingApplicationEnrollment($event); + + $result = resolve(ApproveApplicationAction::class)->handle( + new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id), + ); + + expect($result->status)->toBe(EnrollmentStatus::Confirmed) + ->and($result->confirmed_at)->not->toBeNull(); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->latest() + ->first(); + + expect($transition->from_status)->toBe(EnrollmentStatus::Pending) + ->and($transition->to_status)->toBe(EnrollmentStatus::Confirmed) + ->and($transition->triggered_by)->toBe(TriggeredBy::Admin) + ->and($transition->actor_id)->toBe($organizer->id); + + EventFacade::assertDispatched(fn (EnrollmentConfirmed $e): bool => $e->enrollmentId === $enrollment->id + && $e->xpRewardOnConfirmed === 100); +}); + +test('when event is at capacity and has waitlist, then approval results in waitlisted', function (): void { + EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]); + + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => true]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + $enrollment = createPendingApplicationEnrollment($event); + + $result = resolve(ApproveApplicationAction::class)->handle( + new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id), + ); + + expect($result->status)->toBe(EnrollmentStatus::Waitlisted) + ->and($result->waitlist_position)->toBe(1); + + EventFacade::assertNotDispatched(EnrollmentConfirmed::class); + EventFacade::assertDispatched(fn (EnrollmentWaitlisted $e): bool => $e->enrollmentId === $enrollment->id + && $e->waitlistPosition === 1); +}); + +test('when event is at capacity without waitlist, then approval throws event full exception', function (): void { + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => false]); + + $existingUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $existingUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + $enrollment = createPendingApplicationEnrollment($event); + + resolve(ApproveApplicationAction::class)->handle( + new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id), + ); +})->throws(EnrollmentException::class); + +test('when approving a non-pending enrollment, then exception is thrown', function (): void { + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createApplicationEvent($tenant); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + resolve(ApproveApplicationAction::class)->handle( + new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id), + ); +})->throws(EnrollmentException::class); + +test('when event has no capacity limit, then approval always confirms', function (): void { + EventFacade::fake([EnrollmentConfirmed::class]); + + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = createApplicationEvent($tenant, ['capacity' => null]); + + $enrollments = collect(range(1, 3))->map( + fn (): Enrollment => createPendingApplicationEnrollment($event), + ); + + foreach ($enrollments as $enrollment) { + $result = resolve(ApproveApplicationAction::class)->handle( + new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id), + ); + expect($result->status)->toBe(EnrollmentStatus::Confirmed); + } + + EventFacade::assertDispatchedTimes(EnrollmentConfirmed::class, 3); +}); diff --git a/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php new file mode 100644 index 000000000..a7ff4be33 --- /dev/null +++ b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php @@ -0,0 +1,142 @@ +create(); + $startsAt = now()->setTime(9, 0); + + return Event::factory() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->state(array_merge([ + 'attendance_requirement' => AttendanceRequirement::AllDays, + 'minimum_days' => null, + ], $policyAttributes)), 'enrollmentPolicy') + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); +} + +test('when checked_in enrollment meets all_days requirement, then resolveAttendance returns Attended', function (): void { + $startsAt = now()->setTime(9, 0); + $event = createEventWithPolicy([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AllDays, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]); + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDays(2)->toDateString(), 'method' => CheckInMethod::Manual]); + + expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended); +}); + +test('when checked_in enrollment is missing days for all_days requirement, then resolveAttendance returns NoShow', function (): void { + $startsAt = now()->setTime(9, 0); + $event = createEventWithPolicy([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AllDays, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + + expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::NoShow); +}); + +test('when checked_in enrollment has at least one check-in and requirement is any_day, then resolveAttendance returns Attended', function (): void { + $startsAt = now()->setTime(9, 0); + $event = createEventWithPolicy([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::AnyDay, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + + expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended); +}); + +test('when checked_in enrollment meets minimum_days requirement, then resolveAttendance returns Attended', function (): void { + $startsAt = now()->setTime(9, 0); + $event = createEventWithPolicy([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + 'minimum_days' => 2, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]); + + expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended); +}); + +test('when checked_in enrollment is below minimum_days requirement, then resolveAttendance returns NoShow', function (): void { + $startsAt = now()->setTime(9, 0); + $event = createEventWithPolicy([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + ], [ + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + 'minimum_days' => 2, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::CheckedIn, + 'checked_in_at' => now(), + ]); + + CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]); + + expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::NoShow); +}); diff --git a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php new file mode 100644 index 000000000..bbf5768d8 --- /dev/null +++ b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php @@ -0,0 +1,112 @@ +create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Pending, + 'enrolled_at' => now(), + 'application_data' => ['q1' => 'My answer'], + ]); + + $result = resolve(RejectApplicationAction::class)->handle( + new RejectApplicationDTO( + enrollmentId: $enrollment->id, + actorId: $organizer->id, + reason: 'Not enough experience.', + ), + ); + + expect($result->status)->toBe(EnrollmentStatus::Rejected) + ->and($result->rejection_reason)->toBe('Not enough experience.'); + + $transition = EnrollmentTransition::query() + ->where('enrollment_id', $enrollment->id) + ->latest() + ->first(); + + expect($transition->from_status)->toBe(EnrollmentStatus::Pending) + ->and($transition->to_status)->toBe(EnrollmentStatus::Rejected) + ->and($transition->triggered_by)->toBe(TriggeredBy::Admin) + ->and($transition->actor_id)->toBe($organizer->id) + ->and($transition->reason)->toBe('Not enough experience.'); +}); + +test('when rejecting a non-pending enrollment, then exception is thrown', function (): void { + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + resolve(RejectApplicationAction::class)->handle( + new RejectApplicationDTO( + enrollmentId: $enrollment->id, + actorId: $organizer->id, + reason: 'Test reason.', + ), + ); +})->throws(EnrollmentException::class); + +test('when application is rejected, then application_data is preserved', function (): void { + $organizer = User::factory()->create(); + $tenant = Tenant::factory()->create(); + $event = Event::factory() + ->published() + ->upcoming() + ->for($tenant) + ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy') + ->create(); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => User::factory()->create()->id, + 'status' => EnrollmentStatus::Pending, + 'enrolled_at' => now(), + 'application_data' => ['q1' => 'Original answer'], + ]); + + $result = resolve(RejectApplicationAction::class)->handle( + new RejectApplicationDTO( + enrollmentId: $enrollment->id, + actorId: $organizer->id, + reason: 'Rejected.', + ), + ); + + expect($result->application_data)->toBe(['q1' => 'Original answer']); +}); diff --git a/app-modules/events/tests/Feature/EventFactoriesTest.php b/app-modules/events/tests/Feature/EventFactoriesTest.php new file mode 100644 index 000000000..bfa4ad261 --- /dev/null +++ b/app-modules/events/tests/Feature/EventFactoriesTest.php @@ -0,0 +1,159 @@ +create(); + + expect($event->id)->not->toBeNull() + ->and($event->title)->not->toBeEmpty() + ->and($event->starts_at)->not->toBeNull() + ->and($event->ends_at->isAfter($event->starts_at))->toBeTrue(); +}); + +test('when creating an enrollment policy via factory, then it is linked to an event', function (): void { + $policy = EnrollmentPolicy::factory()->create(); + + expect($policy->id)->not->toBeNull() + ->and($policy->event_id)->not->toBeNull() + ->and($policy->event)->toBeInstanceOf(Event::class); +}); + +test('when creating an enrollment via factory, then it is linked to an event and user', function (): void { + $enrollment = Enrollment::factory()->create(); + + expect($enrollment->id)->not->toBeNull() + ->and($enrollment->event_id)->not->toBeNull() + ->and($enrollment->user_id)->not->toBeNull(); +}); + +test('when creating an enrollment transition via factory, then it is linked to an enrollment', function (): void { + $transition = EnrollmentTransition::factory()->create(); + + expect($transition->id)->not->toBeNull() + ->and($transition->enrollment_id)->not->toBeNull() + ->and($transition->to_status)->not->toBeNull(); +}); + +test('when creating a check-in via factory, then it is linked to an enrollment', function (): void { + $checkIn = CheckIn::factory()->create(); + + expect($checkIn->id)->not->toBeNull() + ->and($checkIn->enrollment_id)->not->toBeNull() + ->and($checkIn->event_date)->not->toBeNull(); +}); + +test('when creating a check-in code via factory, then it is linked to an event with valid dates', function (): void { + $code = CheckInCode::factory()->create(); + + expect($code->id)->not->toBeNull() + ->and($code->event_id)->not->toBeNull() + ->and($code->code)->not->toBeEmpty() + ->and($code->expires_at->isAfter($code->starts_at))->toBeTrue(); +}); + +test('when creating a qr token via factory, then it is linked to an enrollment with a 64-char token', function (): void { + $token = QrToken::factory()->create(); + + expect($token->id)->not->toBeNull() + ->and($token->enrollment_id)->not->toBeNull() + ->and(mb_strlen($token->token))->toBe(64); +}); + +test('when an event has an enrollment policy, then it is accessible via the relationship', function (): void { + $event = Event::factory() + ->has(EnrollmentPolicy::factory(), 'enrollmentPolicy') + ->create(); + + expect($event->enrollmentPolicy)->toBeInstanceOf(EnrollmentPolicy::class) + ->and($event->enrollmentPolicy->event_id)->toBe($event->id); +}); + +test('when an event has enrollments, then they are accessible via the relationship', function (): void { + $event = Event::factory() + ->has(Enrollment::factory()->count(3), 'enrollments') + ->create(); + + expect($event->enrollments)->toBeInstanceOf(Collection::class) + ->and($event->enrollments)->toHaveCount(3) + ->and($event->enrollments->first()->event_id)->toBe($event->id); +}); + +test('when an event has check-in codes, then they are accessible via the relationship', function (): void { + $event = Event::factory() + ->has(CheckInCode::factory()->count(2), 'checkInCodes') + ->create(); + + expect($event->checkInCodes)->toHaveCount(2) + ->and($event->checkInCodes->first()->event_id)->toBe($event->id); +}); + +test('when creating a workshop via factory preset, then event and enrollment policy match community defaults', function (): void { + $event = Event::factory()->asWorkshop()->upcoming()->create(); + + $event->load('enrollmentPolicy'); + + expect($event->event_type)->toBe(EventType::Workshop) + ->and($event->status)->toBe(EventStatus::Published) + ->and($event->enrollmentPolicy)->not->toBeNull() + ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::RsvpCheckin) + ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::NumericCode) + ->and($event->enrollmentPolicy->capacity)->toBe(50) + ->and($event->enrollmentPolicy->has_waitlist)->toBeTrue() + ->and($event->enrollmentPolicy->attendance_requirement)->toBe(AttendanceRequirement::AllDays) + ->and($event->enrollmentPolicy->xp_on_confirmed)->toBe(100); +}); + +test('when creating a meetup via factory preset, then event has open rsvp enrollment policy', function (): void { + $event = Event::factory()->asMeetup()->create(); + + $event->load('enrollmentPolicy'); + + expect($event->event_type)->toBe(EventType::Meetup) + ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp) + ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::Manual) + ->and($event->enrollmentPolicy->capacity)->toBeNull() + ->and($event->enrollmentPolicy->has_waitlist)->toBeFalse(); +}); + +test('when creating a conference via factory preset, then event has application enrollment with waitlist', function (): void { + $event = Event::factory()->asConference()->past()->create(); + + $event->load('enrollmentPolicy'); + + expect($event->event_type)->toBe(EventType::Conference) + ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Application) + ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::QrCode) + ->and($event->enrollmentPolicy->capacity)->toBe(200) + ->and($event->enrollmentPolicy->has_waitlist)->toBeTrue() + ->and($event->enrollmentPolicy->attendance_requirement)->toBe(AttendanceRequirement::MinimumDays) + ->and($event->enrollmentPolicy->minimum_days)->toBe(2) + ->and($event->enrollmentPolicy->cancellation_deadline_hours)->toBe(48); +}); + +test('when creating multiple meetup presets, then each event gets its own enrollment policy', function (): void { + $events = Event::factory()->count(3)->asMeetup()->create(); + + expect($events)->toHaveCount(3); + + foreach ($events as $event) { + $event->load('enrollmentPolicy'); + + expect($event->enrollmentPolicy)->not->toBeNull() + ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp); + } +}); diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php new file mode 100644 index 000000000..f787d8e62 --- /dev/null +++ b/app-modules/events/tests/Feature/EventResourceTest.php @@ -0,0 +1,292 @@ +create(['username' => 'events-test-admin']); + $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']); + $tenant->members()->attach($admin); + + config(['he4rt.admins' => 'events-test-admin']); + $this->actingAs($admin); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + Filament::setTenant($tenant); + + $this->tenant = $tenant; +}); + +test('when visiting the events list page, then it renders successfully', function (): void { + livewire(ListEvents::class) + ->assertSuccessful(); +}); + +test('when an event exists, then it appears in the events list', function (): void { + $event = Event::factory()->recycle($this->tenant)->create(['title' => 'He4rt Meetup #42']); + + livewire(ListEvents::class) + ->loadTable() + ->assertCanSeeTableRecords([$event]); +}); + +test('when visiting the create event page, then it renders successfully', function (): void { + livewire(CreateEvent::class) + ->assertSuccessful(); +}); + +test('when visiting the edit event page, then it renders successfully', function (): void { + $event = Event::factory()->create(); + + livewire(EditEvent::class, ['record' => $event->getRouteKey()]) + ->assertSuccessful(); +}); + +test('when checking the event resource model, then it points to Event', function (): void { + expect(EventResource::getModel())->toBe(Event::class); +}); + +test('when submitting the create form with valid data, then event and enrollment policy are persisted', function (): void { + $startsAt = now()->addDay(); + + livewire(CreateEvent::class) + ->fillForm([ + 'title' => 'He4rt Meetup #42', + 'slug' => 'he4rt-meetup-42', + 'event_type' => EventType::Meetup, + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addHours(3), + 'enrollmentPolicy' => [ + 'enrollment_method' => EnrollmentMethod::Rsvp, + 'check_in_method' => CheckInMethod::Manual, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'xp_on_confirmed' => 0, + 'xp_on_checked_in' => 0, + 'xp_on_attended' => 0, + ], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $event = Event::query()->where('slug', 'he4rt-meetup-42')->first(); + expect($event)->not->toBeNull() + ->and($event->title)->toBe('He4rt Meetup #42') + ->and($event->enrollmentPolicy)->not->toBeNull() + ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp); +}); + +test('when creating a same-day event, then attendance requirement options only allow any day', function (): void { + $startsAt = now()->addDay()->setTime(9, 0); + + livewire(CreateEvent::class) + ->fillForm([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + ]) + ->assertFormFieldExists( + 'enrollmentPolicy.attendance_requirement', + fn (Select $field): bool => array_keys($field->getOptions()) === [AttendanceRequirement::AnyDay->value], + ); +}); + +test('when creating a multi-day event, then minimum days cannot exceed event days', function (): void { + $startsAt = now()->addDay()->setTime(9, 0); + + livewire(CreateEvent::class) + ->fillForm([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0), + 'enrollmentPolicy' => [ + 'attendance_requirement' => AttendanceRequirement::MinimumDays, + ], + ]) + ->assertFormFieldExists( + 'enrollmentPolicy.minimum_days', + fn (TextInput $field): bool => $field->getMaxValue() === 3, + ); +}); + +test('when submitting the create form with a duplicate slug for the same tenant, then validation fails', function (): void { + $tenant = Filament::getTenant(); + $startsAt = now()->addDay(); + + Event::factory()->for($tenant)->create(['slug' => 'duplicate-slug']); + + livewire(CreateEvent::class) + ->fillForm([ + 'title' => 'Another Event', + 'slug' => 'duplicate-slug', + 'tenant_id' => $tenant->getKey(), + 'event_type' => EventType::Meetup, + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addHours(3), + 'enrollmentPolicy' => [ + 'enrollment_method' => EnrollmentMethod::Rsvp, + 'check_in_method' => CheckInMethod::Manual, + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'xp_on_confirmed' => 0, + 'xp_on_checked_in' => 0, + 'xp_on_attended' => 0, + ], + ]) + ->call('create') + ->assertHasFormErrors(['slug' => 'unique']); +}); + +test('when submitting the edit form with a new title, then it is updated in the database', function (): void { + $event = Event::factory() + ->has(EnrollmentPolicy::factory()->state([ + 'attendance_requirement' => AttendanceRequirement::AnyDay, + 'minimum_days' => null, + ]), 'enrollmentPolicy') + ->create(['title' => 'Old Title']); + + livewire(EditEvent::class, ['record' => $event->getRouteKey()]) + ->fillForm(['title' => 'New Title']) + ->call('save') + ->assertHasNoFormErrors(); + + expect($event->fresh()->title)->toBe('New Title'); +}); + +test('when visiting the enrollments relation manager, then it renders successfully', function (): void { + $event = Event::factory()->create(); + + livewire(EnrollmentsRelationManager::class, [ + 'ownerRecord' => $event, + 'pageClass' => EditEvent::class, + ]) + ->assertSuccessful(); +}); + +test('when admin generates a check-in code, then persisted code matches preview value', function (): void { + $startsAt = now()->setTime(9, 0); + $event = Event::factory()->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + ]); + + livewire(CheckInCodesRelationManager::class, [ + 'ownerRecord' => $event, + 'pageClass' => EditEvent::class, + ]) + ->callTableAction('generateCode', data: [ + 'digits' => '4', + 'code_preview' => '1234', + 'event_date' => now()->toDateString(), + 'starts_at' => now(), + 'expires_at' => now()->addHours(2), + 'max_uses' => '', + ]) + ->assertHasNoTableActionErrors(); + + expect(CheckInCode::query()->where('event_id', $event->id)->sole()->code)->toBe('1234'); +}); + +test('when admin checks in an enrollment from relation manager, then participant is checked in', function (): void { + $event = Event::factory()->create([ + 'starts_at' => now()->setTime(9, 0), + 'ends_at' => now()->setTime(18, 0), + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + livewire(EnrollmentsRelationManager::class, [ + 'ownerRecord' => $event, + 'pageClass' => EditEvent::class, + ]) + ->callTableAction('checkIn', $enrollment, data: [ + 'event_date' => now()->toDateString(), + ]) + ->assertHasNoTableActionErrors(); + + expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(1); +}); + +test('when admin bulk checks in selected enrollments, then all selected participants are checked in', function (): void { + $event = Event::factory()->create([ + 'starts_at' => now()->setTime(9, 0), + 'ends_at' => now()->setTime(18, 0), + ]); + + $enrollments = Enrollment::factory() + ->count(2) + ->create([ + 'event_id' => $event->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + livewire(EnrollmentsRelationManager::class, [ + 'ownerRecord' => $event, + 'pageClass' => EditEvent::class, + ]) + ->callTableBulkAction('checkInSelected', $enrollments, data: [ + 'event_date' => now()->toDateString(), + ]) + ->assertHasNoTableBulkActionErrors(); + + expect(Enrollment::query()->whereKey($enrollments->pluck('id'))->where('status', EnrollmentStatus::CheckedIn)->count())->toBe(2) + ->and(CheckIn::query()->whereIn('enrollment_id', $enrollments->pluck('id'))->count())->toBe(2); +}); + +test('when enrollment has check-ins, then relation manager shows check-in history', function (): void { + $startsAt = now()->setTime(9, 0); + $event = Event::factory()->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0), + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'status' => EnrollmentStatus::CheckedIn, + ]); + + CheckIn::factory()->create([ + 'enrollment_id' => $enrollment->id, + 'event_date' => $startsAt->toDateString(), + 'method' => CheckInMethod::Manual, + ]); + + CheckIn::factory()->create([ + 'enrollment_id' => $enrollment->id, + 'event_date' => $startsAt->clone()->addDay()->toDateString(), + 'method' => CheckInMethod::Manual, + ]); + + livewire(EnrollmentsRelationManager::class, [ + 'ownerRecord' => $event, + 'pageClass' => EditEvent::class, + ]) + ->loadTable() + ->assertSee($startsAt->toDateString()) + ->assertSee($startsAt->clone()->addDay()->toDateString()); +}); diff --git a/app-modules/events/tests/Feature/HandleBotCheckInTest.php b/app-modules/events/tests/Feature/HandleBotCheckInTest.php new file mode 100644 index 000000000..6300801d1 --- /dev/null +++ b/app-modules/events/tests/Feature/HandleBotCheckInTest.php @@ -0,0 +1,375 @@ +setTime(12, 0)); +}); + +afterEach(function (): void { + Date::setTestNow(); +}); + +/** + * @return array|User|Collection|Event|Collection|Enrollment|Collection|Collection|CheckInCode|string> + */ +function createBotCheckInScenario(array $codeOverrides = []): array +{ + $tenant = Tenant::factory()->create(); + $participant = User::factory()->create(); + $externalId = '900900900'; + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $participant->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + ExternalIdentity::factory()->create([ + 'tenant_id' => $tenant->id, + 'provider' => IdentityProvider::Discord, + 'external_account_id' => $externalId, + 'model_type' => (new User)->getMorphClass(), + 'model_id' => $participant->id, + ]); + + $code = CheckInCode::factory()->create(array_merge([ + 'event_id' => $event->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + 'max_uses' => null, + 'uses_count' => 0, + ], $codeOverrides)); + + return ['tenant' => $tenant, 'participant' => $participant, 'externalId' => $externalId, 'event' => $event, 'enrollment' => $enrollment, 'code' => $code]; +} + +test('dispatching CheckInRequested is handled by the registered listener', function (): void { + $scenario = createBotCheckInScenario(); + + event(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn); + $this->assertDatabaseHas('events_check_ins', [ + 'enrollment_id' => $scenario['enrollment']->id, + 'method' => CheckInMethod::NumericCode->value, + ]); +}); + +test('successful bot check-in records the check-in and dispatches a success response', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + channelContext: ['channel_id' => 'C1'], + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($scenario['code']->fresh()->uses_count)->toBe(1); + + $this->assertDatabaseHas('events_check_ins', [ + 'enrollment_id' => $scenario['enrollment']->id, + 'method' => CheckInMethod::NumericCode->value, + ]); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success + && $event->enrollmentId === $scenario['enrollment']->id + && $event->externalUserId === $scenario['externalId'] + && $event->channelContext === ['channel_id' => 'C1'] + && $event->message === __('events::check_in.bot_check_in_success')); +}); + +test('bot check-in for an unlinked external user dispatches an error and records nothing', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: 'unlinked-user', + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); + $this->assertDatabaseCount('events_check_ins', 0); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->enrollmentId === null + && $event->message === __('events::check_in.bot_user_not_linked')); +}); + +test('bot check-in with an invalid code dispatches an error', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '000000', + tenantId: $scenario['tenant']->id, + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.invalid_check_in_code')); +}); + +test('bot check-in for a user with no enrollment dispatches no active enrollment error', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + $scenario['enrollment']->delete(); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.bot_no_active_enrollment')); +}); + +test('bot check-in is rejected when the enrollment event is not happening today', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + $futureStart = now()->addDays(5)->setTime(9, 0); + $scenario['event']->update([ + 'starts_at' => $futureStart, + 'ends_at' => $futureStart->clone()->setTime(18, 0), + ]); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.bot_no_active_enrollment')); +}); + +test('bot check-in with multiple events today checks into the one matching the typed code', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + $afternoonStart = now()->setTime(14, 0); + $afternoonEvent = Event::factory() + ->for($scenario['tenant']) + ->create([ + 'starts_at' => $afternoonStart, + 'ends_at' => $afternoonStart->clone()->setTime(20, 0), + 'status' => EventStatus::Published, + ]); + + $afternoonEnrollment = Enrollment::factory()->create([ + 'event_id' => $afternoonEvent->id, + 'user_id' => $scenario['participant']->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + CheckInCode::factory()->create([ + 'event_id' => $afternoonEvent->id, + 'event_date' => now()->toDateString(), + 'code' => '654321', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(8), + ]); + + // User types the MORNING code — only that event owns it. + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($afternoonEnrollment->fresh()->status)->toBe(EnrollmentStatus::Confirmed); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success + && $event->enrollmentId === $scenario['enrollment']->id); +}); + +test('bot check-in with multiple events today and a non-matching code errors as invalid', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + $startsAt = now()->setTime(9, 0); + $secondEvent = Event::factory() + ->for($scenario['tenant']) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + Enrollment::factory()->create([ + 'event_id' => $secondEvent->id, + 'user_id' => $scenario['participant']->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + CheckInCode::factory()->create([ + 'event_id' => $secondEvent->id, + 'event_date' => now()->toDateString(), + 'code' => '654321', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + ]); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '999999', + tenantId: $scenario['tenant']->id, + )); + + expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.invalid_check_in_code')); +}); + +test('bot check-in errors when the same code exists on multiple events today', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + $startsAt = now()->setTime(9, 0); + $secondEvent = Event::factory() + ->for($scenario['tenant']) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + Enrollment::factory()->create([ + 'event_id' => $secondEvent->id, + 'user_id' => $scenario['participant']->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + CheckInCode::factory()->create([ + 'event_id' => $secondEvent->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + ]); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.bot_multiple_active_events')); +}); + +test('bot check-in when already checked in today dispatches an error', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + + $request = new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + ); + + resolve(HandleBotCheckIn::class)->handle($request); + resolve(HandleBotCheckIn::class)->handle($request); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error + && $event->message === __('events::check_in.already_checked_in_for_date')); +}); + +test('bot check-in ignores a waitlisted enrollment and checks in the confirmed one', function (): void { + EventFacade::fake([CheckInProcessed::class]); + $scenario = createBotCheckInScenario(); + $scenario['enrollment']->update(['status' => EnrollmentStatus::Waitlisted]); + + $startsAt = now()->setTime(9, 0); + $confirmedEvent = Event::factory() + ->for($scenario['tenant']) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $confirmedEnrollment = Enrollment::factory()->create([ + 'event_id' => $confirmedEvent->id, + 'user_id' => $scenario['participant']->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + CheckInCode::factory()->create([ + 'event_id' => $confirmedEvent->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + ]); + + resolve(HandleBotCheckIn::class)->handle(new CheckInRequested( + provider: IdentityProvider::Discord, + externalUserId: $scenario['externalId'], + code: '123456', + tenantId: $scenario['tenant']->id, + )); + + expect($confirmedEnrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Waitlisted); + + EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success + && $event->enrollmentId === $confirmedEnrollment->id); +}); diff --git a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php new file mode 100644 index 000000000..9e66b3289 --- /dev/null +++ b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php @@ -0,0 +1,302 @@ +setTime(12, 0)); +}); + +afterEach(function (): void { + Date::setTestNow(); +}); + +/** + * @return array|Collection|CheckInCode|Event|Collection|User|Collection> + */ +function createScenarioWithCode(array $codeOverrides = []): array +{ + $tenant = Tenant::factory()->create(); + $participant = User::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $participant->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $code = CheckInCode::factory()->create(array_merge([ + 'event_id' => $event->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + 'max_uses' => null, + 'uses_count' => 0, + ], $codeOverrides)); + + return ['enrollment' => $enrollment, 'code' => $code, 'event' => $event, 'participant' => $participant]; +} + +test('when participant checks in with valid numeric code, then check-in is recorded and enrollment transitions', function (): void { + EventFacade::fake([ParticipantCheckedIn::class]); + + $scenario = createScenarioWithCode(); + $enrollment = $scenario['enrollment']; + $code = $scenario['code']; + + $checkIn = resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $enrollment, + code: '123456', + eventDate: now(), + ), + ); + + expect($checkIn)->toBeInstanceOf(CheckIn::class) + ->and($checkIn->enrollment_id)->toBe($enrollment->id) + ->and($checkIn->method)->toBe(CheckInMethod::NumericCode) + ->and($checkIn->event_date->isSameDay(now()))->toBeTrue() + ->and($checkIn->checked_in_at)->not->toBeNull() + ->and($checkIn->payload)->toBe(['code' => '123456']) + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn) + ->and($enrollment->fresh()->checked_in_at)->not->toBeNull(); + + $code->refresh(); + expect($code->uses_count)->toBe(1); + + EventFacade::assertDispatched(fn (ParticipantCheckedIn $event): bool => $event->enrollmentId === $enrollment->id); +}); + +test('when code does not exist, then invalid check-in code error is thrown', function (): void { + $scenario = createScenarioWithCode(); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '999999', + eventDate: now(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.invalid_check_in_code')); +}); + +test('when code is for a different date, then wrong date error is thrown', function (): void { + $scenario = createScenarioWithCode(); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: now()->addDay(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_wrong_date')); +}); + +test('when code is expired, then expired error is thrown', function (): void { + $scenario = createScenarioWithCode([ + 'expires_at' => now()->subDay(), + ]); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: now(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired')); +}); + +test('when code validity window has not started, then expired error is thrown', function (): void { + $scenario = createScenarioWithCode([ + 'starts_at' => now()->addHour(), + 'expires_at' => now()->addHours(2), + ]); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: now(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired')); +}); + +test('when code is revoked, then expired error is thrown', function (): void { + $scenario = createScenarioWithCode([ + 'revoked_at' => now(), + ]); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: now(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired')); +}); + +test('when code has reached max uses, then exhausted error is thrown', function (): void { + $scenario = createScenarioWithCode([ + 'max_uses' => 3, + 'uses_count' => 3, + ]); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: now(), + ), + ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_exhausted')); +}); + +test('uses_count is atomically incremented on each successful check-in', function (): void { + $tenant = Tenant::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $participant1 = User::factory()->create(); + $enrollment1 = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $participant1->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $participant2 = User::factory()->create(); + $enrollment2 = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $participant2->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $code = CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subDay(), + 'expires_at' => now()->addDay(), + 'max_uses' => 5, + ]); + + resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $enrollment1, + code: '123456', + eventDate: now(), + ), + ); + + expect($code->fresh()->uses_count)->toBe(1); + + resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $enrollment2, + code: '123456', + eventDate: now(), + ), + ); + + expect($code->fresh()->uses_count)->toBe(2); +}); + +test('when same numeric code exists for multiple event dates, then current date code is used', function (): void { + $tenant = Tenant::factory()->create(); + $participant = User::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'user_id' => $participant->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $tomorrowCode = CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => now()->addDay()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + ]); + + $todayCode = CheckInCode::factory()->create([ + 'event_id' => $event->id, + 'event_date' => now()->toDateString(), + 'code' => '123456', + 'starts_at' => now()->subHour(), + 'expires_at' => now()->addHours(2), + ]); + + resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $enrollment, + code: '123456', + eventDate: now(), + ), + ); + + expect($todayCode->fresh()->uses_count)->toBe(1) + ->and($tomorrowCode->fresh()->uses_count)->toBe(0); +}); + +test('when check-in date is outside event date range, then check-in is rejected by core action', function (): void { + $startsAt = now()->setTime(9, 0); + $scenario = createScenarioWithCode([ + 'event_date' => $startsAt->clone()->addDays(2)->toDateString(), + ]); + $scenario['event']->update([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + ]); + + expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $scenario['enrollment'], + code: '123456', + eventDate: $startsAt->clone()->addDays(2), + ), + ))->toThrow(CheckInException::class); +}); diff --git a/app-modules/events/tests/Feature/QrCheckInActionTest.php b/app-modules/events/tests/Feature/QrCheckInActionTest.php new file mode 100644 index 000000000..670c5d94b --- /dev/null +++ b/app-modules/events/tests/Feature/QrCheckInActionTest.php @@ -0,0 +1,258 @@ +|Enrollment|Collection|QrToken> + */ +function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollmentAttributes = []): array +{ + $tenant = Tenant::factory()->create(); + $participant = User::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory() + ->for($tenant) + ->create(array_merge([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ], $eventAttributes)); + + $enrollment = Enrollment::factory()->create(array_merge([ + 'event_id' => $event->id, + 'user_id' => $participant->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ], $enrollmentAttributes)); + + $qrToken = resolve(GenerateQrTokenAction::class)->handle($enrollment); + + return [$event, $enrollment, $qrToken]; +} + +test('when enrollment is confirmed, generate qr token action creates a unique token', function (): void { + $enrollment = Enrollment::factory()->create([ + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $qrToken = resolve(GenerateQrTokenAction::class)->handle($enrollment); + + expect($qrToken)->toBeInstanceOf(QrToken::class) + ->and($qrToken->enrollment_id)->toBe($enrollment->id) + ->and(Str::isUuid($qrToken->token))->toBeTrue() + ->and($qrToken->expires_at)->toBeNull(); +}); + +test('when generate qr token is called twice, the same token is returned', function (): void { + $enrollment = Enrollment::factory()->create([ + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $first = resolve(GenerateQrTokenAction::class)->handle($enrollment); + $second = resolve(GenerateQrTokenAction::class)->handle($enrollment); + + expect($second->id)->toBe($first->id) + ->and($second->token)->toBe($first->token); +}); + +test('when organizer scans a valid qr token, check-in is recorded and participant checked in event is dispatched', function (): void { + EventFacade::fake([ParticipantCheckedIn::class]); + + [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken(); + $organizer = User::factory()->create(); + + $checkIn = resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: now(), + actorUserId: $organizer->id, + ), + ); + + expect($checkIn)->toBeInstanceOf(CheckIn::class) + ->and($checkIn->enrollment_id)->toBe($enrollment->id) + ->and($checkIn->method)->toBe(CheckInMethod::QrCode) + ->and($checkIn->payload)->toBe(['token' => $qrToken->token]) + ->and($checkIn->event_date->isSameDay(now()))->toBeTrue() + ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn); + + EventFacade::assertDispatched(fn (ParticipantCheckedIn $e): bool => $e->enrollmentId === $enrollment->id); +}); + +test('when token does not exist, qr check-in is rejected', function (): void { + [$event] = createEnrollmentWithQrToken(); + + expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: 'nonexistent-token-that-does-not-exist-in-the-database-at-all', + event: $event, + eventDate: now(), + actorUserId: User::factory()->create()->id, + ), + ))->toThrow(CheckInException::class); +}); + +test('when token belongs to a different event, qr check-in is rejected', function (): void { + [, , $qrToken] = createEnrollmentWithQrToken(); + + $otherTenant = Tenant::factory()->create(); + $startsAt = now()->setTime(9, 0); + $otherEvent = Event::factory()->for($otherTenant)->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $otherEvent, + eventDate: now(), + actorUserId: User::factory()->create()->id, + ), + ))->toThrow(CheckInException::class); +}); + +test('when token is expired, qr check-in is rejected', function (): void { + $tenant = Tenant::factory()->create(); + $startsAt = now()->setTime(9, 0); + + $event = Event::factory()->for($tenant)->create([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->setTime(18, 0), + 'status' => EventStatus::Published, + ]); + + $enrollment = Enrollment::factory()->create([ + 'event_id' => $event->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + $expiredToken = QrToken::factory()->create([ + 'enrollment_id' => $enrollment->id, + 'expires_at' => now()->subHour(), + ]); + + expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $expiredToken->token, + event: $event, + eventDate: now(), + actorUserId: User::factory()->create()->id, + ), + ))->toThrow(CheckInException::class); +}); + +test('when duplicate scan on same day, qr check-in is rejected', function (): void { + [$event, , $qrToken] = createEnrollmentWithQrToken(); + $organizer = User::factory()->create(); + + resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: now(), + actorUserId: $organizer->id, + ), + ); + + expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: now(), + actorUserId: $organizer->id, + ), + ))->toThrow(CheckInException::class); +}); + +test('when cancelled enrollment token is scanned, qr check-in is rejected', function (): void { + [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken( + enrollmentAttributes: ['status' => EnrollmentStatus::Cancelled, 'cancelled_at' => now()], + ); + + expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: now(), + actorUserId: User::factory()->create()->id, + ), + ))->toThrow(CheckInException::class); +}); + +test('when enrollment is confirmed via enroll user action, qr token is generated by listener', function (): void { + $user = User::factory()->create(); + $tenant = Tenant::factory()->create(); + + $event = Event::factory() + ->for($tenant) + ->published() + ->upcoming() + ->has( + EnrollmentPolicy::factory()->rsvp()->state([ + 'check_in_method' => CheckInMethod::QrCode, + ]), + 'enrollmentPolicy', + ) + ->create(); + + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); + + expect(QrToken::query()->where('enrollment_id', $enrollment->id)->exists())->toBeTrue(); +}); + +test('when same token is scanned on different event days, each scan creates a new check-in record', function (): void { + $startsAt = now()->setTime(9, 0); + [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken([ + 'starts_at' => $startsAt, + 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0), + ]); + $organizer = User::factory()->create(); + + resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: $startsAt, + actorUserId: $organizer->id, + ), + ); + + resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: $qrToken->token, + event: $event, + eventDate: $startsAt->clone()->addDay(), + actorUserId: $organizer->id, + ), + ); + + expect(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(2); +}); diff --git a/app-modules/events/tests/Unit/EnrollmentScopeTest.php b/app-modules/events/tests/Unit/EnrollmentScopeTest.php new file mode 100644 index 000000000..54e8bed31 --- /dev/null +++ b/app-modules/events/tests/Unit/EnrollmentScopeTest.php @@ -0,0 +1,38 @@ +create(); + $event = Event::factory()->published()->upcoming()->for($tenant)->create(); + + $occupying = [ + EnrollmentStatus::Confirmed, + EnrollmentStatus::CheckedIn, + EnrollmentStatus::Attended, + ]; + + foreach ($occupying as $status) { + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'status' => $status, + ]); + } + + foreach ([EnrollmentStatus::Pending, EnrollmentStatus::Waitlisted, EnrollmentStatus::Cancelled] as $status) { + Enrollment::factory()->create([ + 'event_id' => $event->id, + 'status' => $status, + ]); + } + + expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(3); +}); diff --git a/app-modules/events/tests/Unit/EnrollmentStatusTest.php b/app-modules/events/tests/Unit/EnrollmentStatusTest.php new file mode 100644 index 000000000..646e01988 --- /dev/null +++ b/app-modules/events/tests/Unit/EnrollmentStatusTest.php @@ -0,0 +1,84 @@ +canTransitionTo(EnrollmentStatus::Confirmed))->toBeTrue() + ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Rejected))->toBeTrue() + ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue(); +}); + +test('when a pending enrollment is evaluated, then it can transition to waitlisted when capacity is full', function (): void { + expect(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Waitlisted))->toBeTrue(); +}); + +test('when a pending enrollment is evaluated, then it cannot transition to mid-flow or terminal states', function (): void { + expect(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::CheckedIn))->toBeFalse() + ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Attended))->toBeFalse() + ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::NoShow))->toBeFalse(); +}); + +test('when a waitlisted enrollment is evaluated, then it can transition to confirmed or cancelled', function (): void { + expect(EnrollmentStatus::Waitlisted->canTransitionTo(EnrollmentStatus::Confirmed))->toBeTrue() + ->and(EnrollmentStatus::Waitlisted->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue(); +}); + +test('when a confirmed enrollment is evaluated, then it can transition to checked_in, cancelled, or no_show', function (): void { + expect(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::CheckedIn))->toBeTrue() + ->and(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue() + ->and(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::NoShow))->toBeTrue(); +}); + +test('when a checked_in enrollment is evaluated, then it can transition to attended or no_show', function (): void { + expect(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Attended))->toBeTrue() + ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::NoShow))->toBeTrue() + ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Confirmed))->toBeFalse() + ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Cancelled))->toBeFalse(); +}); + +test('when a terminal enrollment status is evaluated, then it cannot transition to any state', function (EnrollmentStatus $status): void { + foreach (EnrollmentStatus::cases() as $target) { + expect($status->canTransitionTo($target))->toBeFalse(); + } +})->with([ + EnrollmentStatus::Attended, + EnrollmentStatus::Cancelled, + EnrollmentStatus::Rejected, + EnrollmentStatus::NoShow, +]); + +test('when enrollment statuses are evaluated, then terminal states are correctly identified', function (): void { + expect(EnrollmentStatus::Attended->isTerminal())->toBeTrue() + ->and(EnrollmentStatus::Cancelled->isTerminal())->toBeTrue() + ->and(EnrollmentStatus::Rejected->isTerminal())->toBeTrue() + ->and(EnrollmentStatus::NoShow->isTerminal())->toBeTrue() + ->and(EnrollmentStatus::Pending->isTerminal())->toBeFalse() + ->and(EnrollmentStatus::Confirmed->isTerminal())->toBeFalse() + ->and(EnrollmentStatus::Waitlisted->isTerminal())->toBeFalse() + ->and(EnrollmentStatus::CheckedIn->isTerminal())->toBeFalse(); +}); + +test('when enrollment status helpers are evaluated, then is and named checks work', function (): void { + expect(EnrollmentStatus::Confirmed->isConfirmed())->toBeTrue() + ->and(EnrollmentStatus::Confirmed->is(EnrollmentStatus::Confirmed))->toBeTrue() + ->and(EnrollmentStatus::Confirmed->is(EnrollmentStatus::Waitlisted))->toBeFalse() + ->and(EnrollmentStatus::Waitlisted->isWaitlisted())->toBeTrue() + ->and(EnrollmentStatus::Pending->is(EnrollmentStatus::Confirmed, EnrollmentStatus::Waitlisted))->toBeFalse(); +}); + +test('when rsvp enrollment status is evaluated, then response message matches status', function (EnrollmentStatus $status, string $expectedKey): void { + $position = 5; + + expect($status->getResponseMessage($position))->toBe(__($expectedKey, ['position' => $position])); +})->with([ + [EnrollmentStatus::Confirmed, 'events::pages.confirm_presence_success'], + [EnrollmentStatus::Waitlisted, 'events::pages.waitlist_success'], +]); + +test('when response message is requested for unsupported enrollment status, then it fails explicitly', function (): void { + expect(fn (): string => EnrollmentStatus::Pending->getResponseMessage()) + ->toThrow(EnrollmentException::class); +}); diff --git a/app-modules/events/tests/Unit/EventStatusTest.php b/app-modules/events/tests/Unit/EventStatusTest.php new file mode 100644 index 000000000..2704b146d --- /dev/null +++ b/app-modules/events/tests/Unit/EventStatusTest.php @@ -0,0 +1,39 @@ +canTransitionTo(EventStatus::Published))->toBeTrue() + ->and(EventStatus::Draft->canTransitionTo(EventStatus::Cancelled))->toBeTrue(); +}); + +test('when a draft event is evaluated, then it cannot transition to completed', function (): void { + expect(EventStatus::Draft->canTransitionTo(EventStatus::Completed))->toBeFalse(); +}); + +test('when a published event is evaluated, then it can transition to completed or cancelled', function (): void { + expect(EventStatus::Published->canTransitionTo(EventStatus::Completed))->toBeTrue() + ->and(EventStatus::Published->canTransitionTo(EventStatus::Cancelled))->toBeTrue(); +}); + +test('when a published event is evaluated, then it cannot transition to draft', function (): void { + expect(EventStatus::Published->canTransitionTo(EventStatus::Draft))->toBeFalse(); +}); + +test('when a terminal event status is evaluated, then it cannot transition to any state', function (EventStatus $status): void { + foreach (EventStatus::cases() as $target) { + expect($status->canTransitionTo($target))->toBeFalse(); + } +})->with([ + EventStatus::Completed, + EventStatus::Cancelled, +]); + +test('when event statuses are evaluated, then terminal states are correctly identified', function (): void { + expect(EventStatus::Completed->isTerminal())->toBeTrue() + ->and(EventStatus::Cancelled->isTerminal())->toBeTrue() + ->and(EventStatus::Draft->isTerminal())->toBeFalse() + ->and(EventStatus::Published->isTerminal())->toBeFalse(); +}); diff --git a/app-modules/gamification/database/factories/.gitkeep b/app-modules/gamification/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/gamification/database/migrations/.gitkeep b/app-modules/gamification/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/gamification/database/seeders/.gitkeep b/app-modules/gamification/database/seeders/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/identity/database/factories/.gitkeep b/app-modules/identity/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/identity/database/migrations/.gitkeep b/app-modules/identity/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php index bf04b364c..c23d43e74 100644 --- a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php +++ b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php @@ -4,6 +4,7 @@ namespace He4rt\Identity\Auth\Actions; +use He4rt\Identity\Auth\Events\AccountsMerged; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; use Illuminate\Database\UniqueConstraintViolationException; @@ -28,6 +29,8 @@ public function execute(User $currentUser, User $oldUser): void $currentUser->tenants()->pluck('tenants.id') ); + event(new AccountsMerged($oldUser->id, $currentUser->id)); + $currentUser->delete(); $this->enrichOldUser($currentUser, $oldUser); diff --git a/app-modules/identity/src/Auth/Events/AccountsMerged.php b/app-modules/identity/src/Auth/Events/AccountsMerged.php new file mode 100644 index 000000000..99714df30 --- /dev/null +++ b/app-modules/identity/src/Auth/Events/AccountsMerged.php @@ -0,0 +1,22 @@ +toBe(collect([$tenantA->id, $tenantB->id])->sort()->values()->all()); }); +test('reassigns timeline posts from current to old user so they are not orphaned', function (): void { + $tenant = Tenant::factory()->create(); + $oldUser = User::factory()->create(['first_login_at' => now()]); + $currentUser = User::factory()->create(); + + $post = Timeline::factory()->for($currentUser)->create([ + 'tenant_id' => $tenant->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => PostEntry::factory()->create()->id, + ]); + + $action = new MergeAccountsAction(); + $action->execute($currentUser, $oldUser); + + expect($post->refresh()->user_id)->toBe($oldUser->id); +}); + test('deletes current user after merge', function (): void { $oldUser = User::factory()->create(['first_login_at' => now()]); $currentUser = User::factory()->create(); diff --git a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php index 2c1c53a6d..05a0ea022 100644 --- a/app-modules/integration-devto/src/Polling/SyncDevToArticles.php +++ b/app-modules/integration-devto/src/Polling/SyncDevToArticles.php @@ -17,8 +17,8 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; -#[Description('Sync articles from DevTo organization and track as interactions')] -#[Signature('devto:sync-articles')] +#[Description(description: 'Sync articles from DevTo organization and track as interactions')] +#[Signature(signature: 'devto:sync-articles')] class SyncDevToArticles extends Command { public function __construct( diff --git a/app-modules/integration-discord/database/factories/.gitkeep b/app-modules/integration-discord/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-discord/database/migrations/.gitkeep b/app-modules/integration-discord/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php b/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php index 17e817320..26986dacc 100644 --- a/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/BackfillVoiceLogsCommand.php @@ -26,8 +26,8 @@ use function Laravel\Prompts\task; use function Laravel\Prompts\warning; -#[Description('Backfill voice logs by paginating a Discord channel where a bot logs voice join/left events')] -#[Signature('discord:backfill-voice +#[Description(description: 'Backfill voice logs by paginating a Discord channel where a bot logs voice join/left events')] +#[Signature(signature: 'discord:backfill-voice {channel_id : Discord channel ID where the bot posts voice join/left logs} {--since= : Start date (Y-m-d). Defaults to 2026-03-01} {--until= : End date (Y-m-d). Defaults to now} diff --git a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php index ac8f4a456..f289935ce 100644 --- a/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/ImportDiscordMessagesCommand.php @@ -31,8 +31,8 @@ use function Laravel\Prompts\info; use function Laravel\Prompts\table; -#[Description('Importa mensagens Discord de um dump completo (messages, reactions, voice, moderation)')] -#[Signature('discord:import-messages +#[Description(description: 'Importa mensagens Discord de um dump completo (messages, reactions, voice, moderation)')] +#[Signature(signature: 'discord:import-messages {path : Caminho da pasta discord-dump} {--limit= : Para apos importar N mensagens (total)} {--channels= : Lista de nomes (ou substrings) de canais separados por virgula} diff --git a/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php index ea167606b..968ff572e 100644 --- a/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/ImportDiscordProfilesCommand.php @@ -23,8 +23,8 @@ use function Laravel\Prompts\info; use function Laravel\Prompts\table; -#[Description('Importa perfis Discord de todos os chunks JSON para Users e ExternalIdentities')] -#[Signature('discord:import-profiles +#[Description(description: 'Importa perfis Discord de todos os chunks JSON para Users e ExternalIdentities')] +#[Signature(signature: 'discord:import-profiles {path : Caminho do diretorio com os chunks JSON} {--from=0 : Numero do chunk inicial (ex: 5 para comecar do chunk_5)}')] class ImportDiscordProfilesCommand extends Command diff --git a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php index 3e65f4272..4f0a63c9c 100644 --- a/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php +++ b/app-modules/integration-discord/src/ETL/Console/MergeDuplicateDiscordProfilesCommand.php @@ -22,8 +22,8 @@ use function Laravel\Prompts\table; use function Laravel\Prompts\warning; -#[Description('Reverte users duplicados criados pelo bug do discord:import-profiles (re-aponta FKs e identities, deleta dups)')] -#[Signature('discord:merge-duplicate-profiles +#[Description(description: 'Reverte users duplicados criados pelo bug do discord:import-profiles (re-aponta FKs e identities, deleta dups)')] +#[Signature(signature: 'discord:merge-duplicate-profiles {--tenant=he4rt : Slug do tenant alvo (ignorado se --pairs-file for usado)} {--from-date=2026-05-01 : Cutoff: users criados a partir desta data sao candidatos (ignorado se --pairs-file for usado)} {--pairs-file= : Caminho de JSONL pre-gerado por discord:export-merge-pairs (fonte de verdade)} diff --git a/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php index 88b25f93b..96f845e00 100644 --- a/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php +++ b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php @@ -14,8 +14,8 @@ use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Exceptions\Request\RequestException; -#[Description('Purge unused infinite Discord guild invites (max_age=0, uses=0)')] -#[Signature('discord:purge-invites {guild_id?} {--dry-run : List invites without deleting} {--include-expiring : Also purge unused invites that have an expiration time}')] +#[Description(description: 'Purge unused infinite Discord guild invites (max_age=0, uses=0)')] +#[Signature(signature: 'discord:purge-invites {guild_id?} {--dry-run : List invites without deleting} {--include-expiring : Also purge unused invites that have an expiration time}')] final class PurgeUnusedInvitesCommand extends Command { /** diff --git a/app-modules/integration-discord/src/Sync/Console/SyncDiscordGuildCommand.php b/app-modules/integration-discord/src/Sync/Console/SyncDiscordGuildCommand.php index 7eb045647..d85bb3c67 100644 --- a/app-modules/integration-discord/src/Sync/Console/SyncDiscordGuildCommand.php +++ b/app-modules/integration-discord/src/Sync/Console/SyncDiscordGuildCommand.php @@ -9,8 +9,8 @@ use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -#[Description('Sync Discord guild data (channels, roles, members) from the Discord API')] -#[Signature('discord:sync {guild_id?} {--fresh : Truncate guild data before re-importing}')] +#[Description(description: 'Sync Discord guild data (channels, roles, members) from the Discord API')] +#[Signature(signature: 'discord:sync {guild_id?} {--fresh : Truncate guild data before re-importing}')] final class SyncDiscordGuildCommand extends Command { public function handle(SyncDiscordGuildAction $action): int diff --git a/app-modules/integration-github/database/factories/.gitkeep b/app-modules/integration-github/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-github/database/migrations/.gitkeep b/app-modules/integration-github/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-github/src/Backfill/Jobs/BackfillGithubRepository.php b/app-modules/integration-github/src/Backfill/Jobs/BackfillGithubRepository.php index 66c471fb3..0a0b96d22 100644 --- a/app-modules/integration-github/src/Backfill/Jobs/BackfillGithubRepository.php +++ b/app-modules/integration-github/src/Backfill/Jobs/BackfillGithubRepository.php @@ -32,8 +32,8 @@ * backoff até o limite de exceções; o resto é encerrado em failed(). */ #[Backoff([10, 30, 60])] -#[MaxExceptions(3)] -#[Timeout(600)] +#[MaxExceptions(maxExceptions: 3)] +#[Timeout(timeout: 600)] final class BackfillGithubRepository implements ShouldBeUniqueUntilProcessing, ShouldQueue { use Dispatchable; diff --git a/app-modules/integration-github/src/Console/BackfillGithubCommand.php b/app-modules/integration-github/src/Console/BackfillGithubCommand.php index 3c952dba6..c6af69539 100644 --- a/app-modules/integration-github/src/Console/BackfillGithubCommand.php +++ b/app-modules/integration-github/src/Console/BackfillGithubCommand.php @@ -25,8 +25,8 @@ use function Laravel\Prompts\table; use function Laravel\Prompts\warning; -#[Description('Faz backfill do histórico de contribuições dos repositórios da allowlist')] -#[Signature('github:backfill +#[Description(description: 'Faz backfill do histórico de contribuições dos repositórios da allowlist')] +#[Signature(signature: 'github:backfill {repo? : owner/repo específico. Default: todos os repositórios habilitados} {--full : Ignora o last_backfilled_at e varre o histórico inteiro}')] final class BackfillGithubCommand extends Command diff --git a/app-modules/integration-github/tests/Feature/.gitkeep b/app-modules/integration-github/tests/Feature/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-twitch/database/migrations/.gitkeep b/app-modules/integration-twitch/database/migrations/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php b/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php index be2890328..8698f5967 100644 --- a/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php +++ b/app-modules/integration-twitch/src/Console/LinkTwitchChannelCommand.php @@ -15,8 +15,8 @@ use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -#[Description('Link a Twitch channel to a tenant via ExternalIdentity')] -#[Signature('twitch:link-channel {login : Twitch channel login name} {--tenant= : Tenant slug or ID}')] +#[Description(description: 'Link a Twitch channel to a tenant via ExternalIdentity')] +#[Signature(signature: 'twitch:link-channel {login : Twitch channel login name} {--tenant= : Tenant slug or ID}')] final class LinkTwitchChannelCommand extends Command { public function handle(TwitchHelixConnector $helix): int diff --git a/app-modules/integration-twitch/src/Console/SubscribeTwitchEventsCommand.php b/app-modules/integration-twitch/src/Console/SubscribeTwitchEventsCommand.php index dda5b04a5..ead14a612 100644 --- a/app-modules/integration-twitch/src/Console/SubscribeTwitchEventsCommand.php +++ b/app-modules/integration-twitch/src/Console/SubscribeTwitchEventsCommand.php @@ -14,8 +14,8 @@ use Illuminate\Console\Command; use Saloon\Exceptions\Request\RequestException; -#[Description('Manage Twitch EventSub webhook subscriptions for a broadcaster')] -#[Signature('twitch:subscribe +#[Description(description: 'Manage Twitch EventSub webhook subscriptions for a broadcaster')] +#[Signature(signature: 'twitch:subscribe {broadcaster_user_id : The Twitch broadcaster user ID} {--type= : Subscribe to a specific event type} {--all : Subscribe to all available event types} diff --git a/app-modules/integration-whatsapp/src/Models/WhatsAppEventLog.php b/app-modules/integration-whatsapp/src/Models/WhatsAppEventLog.php index c4cbb60a9..e9f8047b5 100644 --- a/app-modules/integration-whatsapp/src/Models/WhatsAppEventLog.php +++ b/app-modules/integration-whatsapp/src/Models/WhatsAppEventLog.php @@ -23,7 +23,7 @@ * @property CarbonInterface|null $updated_at */ #[Table(name: 'whatsapp_event_logs')] -#[UseFactory(WhatsAppEventLogFactory::class)] +#[UseFactory(factoryClass: WhatsAppEventLogFactory::class)] final class WhatsAppEventLog extends Model { /** @use HasFactory */ diff --git a/app-modules/moderation/src/Appeals/ModerationAppeal.php b/app-modules/moderation/src/Appeals/ModerationAppeal.php index d929f4c40..229906a8b 100644 --- a/app-modules/moderation/src/Appeals/ModerationAppeal.php +++ b/app-modules/moderation/src/Appeals/ModerationAppeal.php @@ -31,8 +31,8 @@ * @property string|null $tenant_id * @property CarbonInterface $created_at */ -#[Table('moderation_appeals', timestamps: false)] -#[UseFactory(ModerationAppealFactory::class)] +#[Table(name: 'moderation_appeals', timestamps: false)] +#[UseFactory(factoryClass: ModerationAppealFactory::class)] final class ModerationAppeal extends Model { /** @use HasFactory */ diff --git a/app-modules/moderation/src/Audit/ModerationAuditLog.php b/app-modules/moderation/src/Audit/ModerationAuditLog.php index 6d5e93d3f..17e76e99e 100644 --- a/app-modules/moderation/src/Audit/ModerationAuditLog.php +++ b/app-modules/moderation/src/Audit/ModerationAuditLog.php @@ -19,7 +19,7 @@ * @property string|null $tenant_id * @property CarbonInterface $created_at */ -#[Table('moderation_audit_log', timestamps: false)] +#[Table(name: 'moderation_audit_log', timestamps: false)] final class ModerationAuditLog extends Model { /** @return array */ diff --git a/app-modules/moderation/src/Cases/Models/ModerationCase.php b/app-modules/moderation/src/Cases/Models/ModerationCase.php index 5df71db77..f2ab6c864 100644 --- a/app-modules/moderation/src/Cases/Models/ModerationCase.php +++ b/app-modules/moderation/src/Cases/Models/ModerationCase.php @@ -45,8 +45,8 @@ * @property CarbonInterface $created_at * @property CarbonInterface $updated_at */ -#[Table('moderation_cases')] -#[UseFactory(ModerationCaseFactory::class)] +#[Table(name: 'moderation_cases')] +#[UseFactory(factoryClass: ModerationCaseFactory::class)] final class ModerationCase extends Model { /** @use HasFactory */ diff --git a/app-modules/moderation/src/Cases/Models/ModerationReport.php b/app-modules/moderation/src/Cases/Models/ModerationReport.php index bfa88e5c3..9748bdb92 100644 --- a/app-modules/moderation/src/Cases/Models/ModerationReport.php +++ b/app-modules/moderation/src/Cases/Models/ModerationReport.php @@ -25,8 +25,8 @@ * @property Platform $platform * @property CarbonInterface $created_at */ -#[Table('moderation_reports', timestamps: false)] -#[UseFactory(ModerationReportFactory::class)] +#[Table(name: 'moderation_reports', timestamps: false)] +#[UseFactory(factoryClass: ModerationReportFactory::class)] final class ModerationReport extends Model { /** @use HasFactory */ diff --git a/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php index 24679a630..8a9199111 100644 --- a/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php +++ b/app-modules/moderation/src/Classification/Jobs/ClassifyAndRoute.php @@ -31,7 +31,7 @@ * created by rules (classifier_version='rules'), auto-execution IS allowed here. */ #[Backoff([5, 15, 30])] -#[Tries(3)] +#[Tries(tries: 3)] final class ClassifyAndRoute implements ShouldQueue { use InteractsWithQueue; diff --git a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php index 5a60392d6..b825980d5 100644 --- a/app-modules/moderation/src/Classification/Jobs/ScreenContent.php +++ b/app-modules/moderation/src/Classification/Jobs/ScreenContent.php @@ -38,7 +38,7 @@ * CaseReadyForEnforcement is never emitted — AI-only results always go to human review. */ #[Backoff([5, 15, 30])] -#[Tries(3)] +#[Tries(tries: 3)] final class ScreenContent implements ShouldQueue { use InteractsWithQueue; diff --git a/app-modules/moderation/src/Enforcement/ModerationAction.php b/app-modules/moderation/src/Enforcement/ModerationAction.php index d9a64cd06..7ba8baba9 100644 --- a/app-modules/moderation/src/Enforcement/ModerationAction.php +++ b/app-modules/moderation/src/Enforcement/ModerationAction.php @@ -33,8 +33,8 @@ * @property string|null $tenant_id * @property CarbonInterface $created_at */ -#[Table('moderation_actions', timestamps: false)] -#[UseFactory(ModerationActionFactory::class)] +#[Table(name: 'moderation_actions', timestamps: false)] +#[UseFactory(factoryClass: ModerationActionFactory::class)] final class ModerationAction extends Model { /** @use HasFactory */ diff --git a/app-modules/moderation/src/Rules/ModerationRule.php b/app-modules/moderation/src/Rules/ModerationRule.php index 4c559142f..e6d3f116e 100644 --- a/app-modules/moderation/src/Rules/ModerationRule.php +++ b/app-modules/moderation/src/Rules/ModerationRule.php @@ -29,7 +29,7 @@ * @property CarbonInterface $created_at * @property CarbonInterface $updated_at */ -#[Table('moderation_rules')] +#[Table(name: 'moderation_rules')] final class ModerationRule extends Model { use HasUuids; diff --git a/app-modules/moderation/tests/Feature/.gitkeep b/app-modules/moderation/tests/Feature/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/moderation/tests/Unit/.gitkeep b/app-modules/moderation/tests/Unit/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/onboarding/composer.json b/app-modules/onboarding/composer.json index dc3cc0266..ba42e74de 100644 --- a/app-modules/onboarding/composer.json +++ b/app-modules/onboarding/composer.json @@ -20,7 +20,7 @@ "extra": { "laravel": { "providers": [ - "He4rt\\Onboarding\\Providers\\OnboardingServiceProvider" + "He4rt\\Onboarding\\OnboardingServiceProvider" ] } } diff --git a/app-modules/onboarding/src/Providers/OnboardingServiceProvider.php b/app-modules/onboarding/src/OnboardingServiceProvider.php similarity index 84% rename from app-modules/onboarding/src/Providers/OnboardingServiceProvider.php rename to app-modules/onboarding/src/OnboardingServiceProvider.php index 1558f42d5..9cb2d8e26 100644 --- a/app-modules/onboarding/src/Providers/OnboardingServiceProvider.php +++ b/app-modules/onboarding/src/OnboardingServiceProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Onboarding\Providers; +namespace He4rt\Onboarding; use Illuminate\Support\ServiceProvider; diff --git a/app-modules/panel-admin/lang/en/events.php b/app-modules/panel-admin/lang/en/events.php new file mode 100644 index 000000000..865227d09 --- /dev/null +++ b/app-modules/panel-admin/lang/en/events.php @@ -0,0 +1,113 @@ + 'Event', + 'plural' => 'Events', + + 'columns' => [ + 'title' => 'Title', + 'slug' => 'Slug', + 'type' => 'Type', + 'tenant' => 'Tenant', + 'location' => 'Location', + 'description' => 'Description', + 'starts_at' => 'Starts At', + 'ends_at' => 'Ends At', + 'status' => 'Status', + 'created_at' => 'Created At', + 'date' => 'Date', + 'code' => 'Code', + 'event_date' => 'Event Date', + 'valid_from' => 'Valid From', + 'expires_at' => 'Expires At', + 'uses' => 'Uses', + 'revoked_at' => 'Revoked At', + ], + + 'sections' => [ + 'enrollment_policy' => 'Enrollment Policy', + ], + + 'form' => [ + 'enrollment_method' => 'Enrollment Method', + 'check_in_method' => 'Check-in Method', + 'capacity' => 'Capacity', + 'waitlist_enabled' => 'Waitlist Enabled', + 'attendance_requirement' => 'Attendance Requirement', + 'minimum_days' => 'Minimum Days', + 'cancellation_deadline_hours' => 'Cancellation Deadline (hours before event)', + 'xp_on_confirmed' => 'XP on Confirmed', + 'xp_on_checked_in' => 'XP on Checked-in', + 'xp_on_attended' => 'XP on Attended', + 'application_form_schema' => 'Application Form Schema', + 'application_schema_key' => 'Field name', + 'application_schema_value' => 'Field type / label', + 'helpers' => [ + 'minimum_days' => 'Required when attendance requirement is "Minimum Days". Default 1, max = event days.', + ], + ], + + 'relations' => [ + 'enrollments' => 'Enrollments', + 'check_in_codes' => 'Check-in Codes', + ], + + 'enrollments' => [ + 'columns' => [ + 'participant' => 'Participant', + 'waitlist' => 'Waitlist', + 'enrolled_at' => 'Enrolled At', + 'confirmed_at' => 'Confirmed At', + 'check_in_history' => 'Check-in History', + 'cancelled_at' => 'Cancelled At', + ], + 'actions' => [ + 'check_in' => 'Check In', + 'check_in_selected' => 'Check In Selected', + 'override_status' => 'Override Status', + 'new_status' => 'New Status', + 'reason' => 'Reason', + ], + 'notifications' => [ + 'participant_checked_in' => 'Participant checked in.', + 'selected_participants_checked_in' => 'Selected participants checked in.', + 'status_overridden' => 'Enrollment status overridden.', + ], + ], + + 'check_in_codes' => [ + 'actions' => [ + 'generate_code' => 'Generate Code', + 'revoke' => 'Revoke', + ], + 'fields' => [ + 'code_length' => 'Code Length', + 'generated_code' => 'Generated Code', + 'max_uses' => 'Max Uses (optional)', + ], + 'digits' => [ + 'four' => '4 digits', + 'six' => '6 digits', + ], + 'unlimited' => 'Unlimited', + 'notifications' => [ + 'code_revoked' => 'Code revoked.', + ], + ], + + 'edit' => [ + 'scan_qr' => 'Scan QR', + 'qr_token' => 'QR Token', + 'qr_token_placeholder' => 'Scan or paste the participant token', + 'check_in_submit' => 'Check In', + 'participant_fallback' => 'Participant', + 'notifications' => [ + 'check_in_success_title' => 'Check-in successful', + 'check_in_success_body' => ':name has been checked in.', + 'check_in_failed_title' => 'Check-in failed', + 'check_in_unexpected_error' => 'An unexpected error occurred. Please try again.', + ], + ], +]; diff --git a/app-modules/panel-admin/lang/pt_BR/events.php b/app-modules/panel-admin/lang/pt_BR/events.php new file mode 100644 index 000000000..e4ee75ed0 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/events.php @@ -0,0 +1,113 @@ + 'Evento', + 'plural' => 'Eventos', + + 'columns' => [ + 'title' => 'Título', + 'slug' => 'Slug', + 'type' => 'Tipo', + 'tenant' => 'Tenant', + 'location' => 'Local', + 'description' => 'Descrição', + 'starts_at' => 'Início', + 'ends_at' => 'Término', + 'status' => 'Status', + 'created_at' => 'Criado em', + 'date' => 'Data', + 'code' => 'Código', + 'event_date' => 'Data do evento', + 'valid_from' => 'Válido de', + 'expires_at' => 'Expira em', + 'uses' => 'Usos', + 'revoked_at' => 'Revogado em', + ], + + 'sections' => [ + 'enrollment_policy' => 'Política de inscrição', + ], + + 'form' => [ + 'enrollment_method' => 'Método de inscrição', + 'check_in_method' => 'Método de check-in', + 'capacity' => 'Capacidade', + 'waitlist_enabled' => 'Lista de espera habilitada', + 'attendance_requirement' => 'Requisito de presença', + 'minimum_days' => 'Dias mínimos', + 'cancellation_deadline_hours' => 'Prazo de cancelamento (horas antes do evento)', + 'xp_on_confirmed' => 'XP ao confirmar', + 'xp_on_checked_in' => 'XP no check-in', + 'xp_on_attended' => 'XP ao comparecer', + 'application_form_schema' => 'Schema do formulário de inscrição', + 'application_schema_key' => 'Nome do campo', + 'application_schema_value' => 'Tipo / rótulo do campo', + 'helpers' => [ + 'minimum_days' => 'Obrigatório quando o requisito de presença é "Dias mínimos". Padrão 1, máximo = dias do evento.', + ], + ], + + 'relations' => [ + 'enrollments' => 'Inscrições', + 'check_in_codes' => 'Códigos de check-in', + ], + + 'enrollments' => [ + 'columns' => [ + 'participant' => 'Participante', + 'waitlist' => 'Lista de espera', + 'enrolled_at' => 'Inscrito em', + 'confirmed_at' => 'Confirmado em', + 'check_in_history' => 'Histórico de check-in', + 'cancelled_at' => 'Cancelado em', + ], + 'actions' => [ + 'check_in' => 'Fazer check-in', + 'check_in_selected' => 'Check-in selecionados', + 'override_status' => 'Alterar status', + 'new_status' => 'Novo status', + 'reason' => 'Motivo', + ], + 'notifications' => [ + 'participant_checked_in' => 'Participante com check-in realizado.', + 'selected_participants_checked_in' => 'Participantes selecionados com check-in realizado.', + 'status_overridden' => 'Status da inscrição alterado.', + ], + ], + + 'check_in_codes' => [ + 'actions' => [ + 'generate_code' => 'Gerar código', + 'revoke' => 'Revogar', + ], + 'fields' => [ + 'code_length' => 'Tamanho do código', + 'generated_code' => 'Código gerado', + 'max_uses' => 'Máximo de usos (opcional)', + ], + 'digits' => [ + 'four' => '4 dígitos', + 'six' => '6 dígitos', + ], + 'unlimited' => 'Ilimitado', + 'notifications' => [ + 'code_revoked' => 'Código revogado.', + ], + ], + + 'edit' => [ + 'scan_qr' => 'Escanear QR', + 'qr_token' => 'Token QR', + 'qr_token_placeholder' => 'Escaneie ou cole o token do participante', + 'check_in_submit' => 'Fazer check-in', + 'participant_fallback' => 'Participante', + 'notifications' => [ + 'check_in_success_title' => 'Check-in realizado', + 'check_in_success_body' => ':name fez check-in com sucesso.', + 'check_in_failed_title' => 'Falha no check-in', + 'check_in_unexpected_error' => 'Ocorreu um erro inesperado. Tente novamente.', + ], + ], +]; diff --git a/app-modules/panel-admin/resources/views/.gitkeep b/app-modules/panel-admin/resources/views/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php b/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php new file mode 100644 index 000000000..2ee884dfb --- /dev/null +++ b/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php @@ -0,0 +1,51 @@ +
+ @if ($record->rejection_reason) +
+

{{ + __( + 'events::pages.admin_reject_application_reason_label', + ) + }}

+

{{ $record->rejection_reason }}

+
+ @endif + + @if (count($answers) > 0) +
+ @foreach ($answers as $answer) +
+
+ {{ $answer['label'] }} +
+
+ @if (is_bool($answer['value'])) + {{ + $answer['value'] + ? __('events::pages.admin_application_answer_yes') + : __('events::pages.admin_application_answer_no') + }} + @elseif (is_array($answer['value'])) + @if ($answer['value'] === []) + {{ __('events::pages.admin_application_no_answer') }} + @else + {{ implode(', ', $answer['value']) }} + @endif + @elseif ($answer['value'] === '—' || $answer['value'] === null || $answer['value'] === '') + {{ __('events::pages.admin_application_no_answer') }} + @else + {{ $answer['value'] }} + @endif +
+
+ @endforeach +
+ @else +

{{ __('events::pages.admin_application_no_data') }}

+ @endif +
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php new file mode 100644 index 000000000..169a573e1 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php @@ -0,0 +1,82 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListEvents::route('/'), + 'create' => CreateEvent::route('/create'), + 'edit' => EditEvent::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php new file mode 100644 index 000000000..44e70e1c0 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php @@ -0,0 +1,13 @@ +mountAction('scanQr'); + } + + /** + * @return ViewComponent[] + */ + protected function getHeaderActions(): array + { + return [ + Action::make('scanQr') + ->label(__('panel-admin::events.edit.scan_qr')) + ->icon(Heroicon::QrCode) + ->color('success') + ->schema([ + TextInput::make('token') + ->label(__('panel-admin::events.edit.qr_token')) + ->required() + ->autofocus() + ->placeholder(__('panel-admin::events.edit.qr_token_placeholder')), + ]) + ->modalSubmitActionLabel(__('panel-admin::events.edit.check_in_submit')) + ->action(function (array $data): void { + /** @var Event $event */ + $event = $this->getRecord(); + + try { + $checkIn = resolve(QrCheckInAction::class)->handle( + new QrCheckInDTO( + token: Arr::string($data, 'token'), + event: $event, + eventDate: now(), + actorUserId: (string) auth()->id(), + ), + ); + + $checkIn->enrollment->loadMissing('user'); + $participantName = $checkIn->enrollment->user->name ?? __('panel-admin::events.edit.participant_fallback'); + + Notification::make() + ->success() + ->title(__('panel-admin::events.edit.notifications.check_in_success_title')) + ->body(__('panel-admin::events.edit.notifications.check_in_success_body', [ + 'name' => $participantName, + ])) + ->send(); + } catch (CheckInException $e) { + Notification::make() + ->danger() + ->title(__('panel-admin::events.edit.notifications.check_in_failed_title')) + ->body($e->getMessage()) + ->send(); + } catch (Throwable $e) { + Notification::make() + ->danger() + ->title(__('panel-admin::events.edit.notifications.check_in_failed_title')) + ->body(__('panel-admin::events.edit.notifications.check_in_unexpected_error')) + ->send(); + + report($e); + } finally { + $this->dispatch('reopen-scan-qr'); + } + }), + DeleteAction::make(), + ]; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php new file mode 100644 index 000000000..e9a66dff5 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php @@ -0,0 +1,24 @@ +label(__('events::pages.admin_approve_application')) + ->icon(Heroicon::OutlinedCheckCircle) + ->color('success') + ->visible(fn (Enrollment $record): bool => $record->status === EnrollmentStatus::Pending) + ->requiresConfirmation() + ->modalHeading(__('events::pages.admin_approve_application_modal_heading')) + ->modalDescription(fn (Enrollment $record): string => __('events::pages.admin_approve_application_modal_description', ['name' => $record->user?->name])) + ->action(static function (Enrollment $record): void { + try { + resolve(ApproveApplicationDomainAction::class)->handle( + new ApproveApplicationDTO( + enrollmentId: $record->id, + actorId: (string) auth()->id(), + ), + ); + + Notification::make() + ->success() + ->title(__('events::pages.admin_approve_application_success')) + ->send(); + } catch (EnrollmentException $enrollmentException) { + Notification::make() + ->danger() + ->title($enrollmentException->getMessage()) + ->send(); + } + }); + } + + public static function getDefaultName(): string + { + return 'approveApplication'; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php new file mode 100644 index 000000000..4539db4b9 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php @@ -0,0 +1,127 @@ +label(__('panel-admin::events.check_in_codes.actions.generate_code')) + ->icon(Heroicon::OutlinedPlusCircle) + ->color('success') + ->schema($this->generateFormSchema(...)) + ->action($this->persistCheckInCode(...)); + } + + public static function getDefaultName(): string + { + return 'generateCode'; + } + + /** + * @param array{digits?: string, code_preview: string, event_date: string, starts_at: string, expires_at: string, max_uses?: string|int|null} $data + */ + public function persistCheckInCode(array $data, RelationManager $livewire): CheckInCode + { + /** @var Event $event */ + $event = $livewire->getOwnerRecord(); + + $code = (string) $data['code_preview']; + + if (!preg_match('/^\d{4}$|^\d{6}$/', $code)) { + $code = $this->generateNumericCode((int) ($data['digits'] ?? 6)); + } + + return CheckInCode::query()->create([ + 'event_id' => $event->id, + 'event_date' => $data['event_date'], + 'code' => $code, + 'starts_at' => $data['starts_at'], + 'expires_at' => $data['expires_at'], + 'max_uses' => filled($data['max_uses'] ?? null) ? (int) $data['max_uses'] : null, + ]); + } + + /** + * @return array + */ + private function generateFormSchema(RelationManager $livewire): array + { + /** @var Event $event */ + $event = $livewire->getOwnerRecord(); + + return [ + Section::make() + ->columns(2) + ->schema([ + Select::make('digits') + ->label(__('panel-admin::events.check_in_codes.fields.code_length')) + ->options([ + '4' => __('panel-admin::events.check_in_codes.digits.four'), + '6' => __('panel-admin::events.check_in_codes.digits.six'), + ]) + ->default('6') + ->live() + ->afterStateUpdated(function (Set $set, ?string $state): void { + $set('code_preview', $this->generateNumericCode((int) ($state ?: 6))); + }) + ->selectablePlaceholder(condition: false) + ->required(), + + TextInput::make('code_preview') + ->label(__('panel-admin::events.check_in_codes.fields.generated_code')) + ->readOnly() + ->default(fn (): string => $this->generateNumericCode(6)) + ->dehydrated() + ->required(), + + DatePicker::make('event_date') + ->label(__('panel-admin::events.columns.event_date')) + ->default($event->starts_at->toDateString()) + ->minDate($event->starts_at->toDateString()) + ->maxDate($event->ends_at->toDateString()) + ->required(), + + DateTimePicker::make('starts_at') + ->label(__('panel-admin::events.columns.valid_from')) + ->default(now()) + ->required(), + + DateTimePicker::make('expires_at') + ->label(__('panel-admin::events.columns.expires_at')) + ->afterOrEqual('starts_at') + ->default(now()->addHours(2)) + ->required(), + + TextInput::make('max_uses') + ->label(__('panel-admin::events.check_in_codes.fields.max_uses')) + ->numeric() + ->minValue(1) + ->placeholder(__('panel-admin::events.check_in_codes.unlimited')), + ]), + ]; + } + + private function generateNumericCode(int $digits): string + { + $min = 10 ** ($digits - 1); + $max = 10 ** $digits - 1; + + return (string) random_int($min, $max); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php new file mode 100644 index 000000000..938d5790e --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php @@ -0,0 +1,63 @@ +label(__('panel-admin::events.enrollments.actions.override_status')) + ->icon(Heroicon::OutlinedPencilSquare) + ->color('warning') + ->visible(fn (Enrollment $record): bool => OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status) !== []) + ->schema([ + Select::make('to_status') + ->label(__('panel-admin::events.enrollments.actions.new_status')) + ->options(fn (Enrollment $record): array => collect(OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status)) + ->mapWithKeys(fn (EnrollmentStatus $s): array => [$s->value => $s->getLabel()]) + ->all()) + ->required(), + Textarea::make('reason') + ->label(__('panel-admin::events.enrollments.actions.reason')) + ->required() + ->minLength(3) + ->rows(3), + ]) + ->action(static function (Enrollment $record, array $data): void { + resolve(OverrideEnrollmentStatusDomainAction::class)->handle( + new OverrideEnrollmentStatusDTO( + enrollment: $record, + fromStatus: $record->status, + toStatus: EnrollmentStatus::from(Arr::string($data, 'to_status')), + actorId: (string) auth()->id(), + reason: Arr::string($data, 'reason'), + ), + ); + + Notification::make() + ->success() + ->title(__('panel-admin::events.enrollments.notifications.status_overridden')) + ->send(); + }); + } + + public static function getDefaultName(): string + { + return 'overrideStatus'; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php new file mode 100644 index 000000000..591e73e9e --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php @@ -0,0 +1,63 @@ +label(__('events::pages.admin_reject_application')) + ->icon(Heroicon::OutlinedXCircle) + ->color('danger') + ->visible(fn (Enrollment $record): bool => $record->status === EnrollmentStatus::Pending) + ->schema([ + Textarea::make('reason') + ->label(__('events::pages.admin_reject_application_reason_label')) + ->required() + ->minLength(3) + ->maxLength(500) + ->rows(3), + ]) + ->action(static function (Enrollment $record, array $data): void { + try { + resolve(RejectApplicationDomainAction::class)->handle( + new RejectApplicationDTO( + enrollmentId: $record->id, + actorId: (string) auth()->id(), + reason: Arr::string($data, 'reason'), + ), + ); + + Notification::make() + ->success() + ->title(__('events::pages.admin_reject_application_success')) + ->send(); + } catch (EnrollmentException $enrollmentException) { + Notification::make() + ->danger() + ->title($enrollmentException->getMessage()) + ->send(); + } + }); + } + + public static function getDefaultName(): string + { + return 'rejectApplication'; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php new file mode 100644 index 000000000..e0e6501bf --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php @@ -0,0 +1,37 @@ +label(__('panel-admin::events.check_in_codes.actions.revoke')) + ->icon(Heroicon::OutlinedNoSymbol) + ->color('danger') + ->visible(fn (CheckInCode $record): bool => $record->revoked_at === null) + ->requiresConfirmation() + ->action(static function (CheckInCode $record): void { + $record->update(['revoked_at' => now()]); + + Notification::make() + ->success() + ->title(__('panel-admin::events.check_in_codes.notifications.code_revoked')) + ->send(); + }); + } + + public static function getDefaultName(): string + { + return 'revoke-check-in-code-action'; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php new file mode 100644 index 000000000..d63daffc2 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php @@ -0,0 +1,75 @@ +checkInCodes()->count(); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('code') + ->modifyQueryUsing(fn (Builder $query): Builder => $query->latest()) + ->columns([ + TextColumn::make('code') + ->label(__('panel-admin::events.columns.code')) + ->badge() + ->color(fn (CheckInCode $record): string => $record->revoked_at !== null ? 'gray' : ($record->expires_at->isPast() ? 'warning' : 'success')) + ->searchable(), + + TextColumn::make('event_date') + ->label(__('panel-admin::events.columns.event_date')) + ->date() + ->sortable(), + + TextColumn::make('starts_at') + ->label(__('panel-admin::events.columns.valid_from')) + ->dateTime() + ->sortable(), + + TextColumn::make('expires_at') + ->label(__('panel-admin::events.columns.expires_at')) + ->dateTime() + ->sortable(), + + TextColumn::make('uses_count') + ->label(__('panel-admin::events.columns.uses')) + ->state(fn (CheckInCode $record): string => $record->uses_count.($record->max_uses !== null ? '/'.$record->max_uses : '')), + + TextColumn::make('revoked_at') + ->label(__('panel-admin::events.columns.revoked_at')) + ->dateTime() + ->placeholder('-'), + ]) + ->headerActions([ + GenerateCheckInCodeAction::make(), + ]) + ->recordActions([ + RevokeCheckInCodeAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php new file mode 100644 index 000000000..4786fdc03 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php @@ -0,0 +1,210 @@ +recordTitle(fn (Enrollment $record): string => $record->user->name ?? $record->id) + ->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user'])) + ->columns([ + TextColumn::make('user.name') + ->label(__('panel-admin::events.enrollments.columns.participant')) + ->searchable() + ->sortable(), + + TextColumn::make('status') + ->label(__('panel-admin::events.columns.status')) + ->badge() + ->sortable(), + + TextColumn::make('waitlist_position') + ->label(__('panel-admin::events.enrollments.columns.waitlist')) + ->sortable() + ->placeholder('-') + ->toggleable(), + + TextColumn::make('enrolled_at') + ->label(__('panel-admin::events.enrollments.columns.enrolled_at')) + ->dateTime() + ->sortable(), + + TextColumn::make('confirmed_at') + ->label(__('panel-admin::events.enrollments.columns.confirmed_at')) + ->dateTime() + ->sortable(), + + TextColumn::make('check_in_history') + ->label(__('panel-admin::events.enrollments.columns.check_in_history')) + ->state(fn (Enrollment $record): string => $record->checkIns + ->sortBy('event_date') + ->map(fn (CheckIn $checkIn): string => $checkIn->event_date->toDateString()) + ->implode(', ')) + ->placeholder('-') + ->toggleable(), + + TextColumn::make('cancelled_at') + ->label(__('panel-admin::events.enrollments.columns.cancelled_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('panel-admin::events.columns.status')) + ->options(EnrollmentStatus::class), + ]) + ->recordActions([ + $this->viewApplicationAction(), + ApproveApplicationAction::make(), + RejectApplicationAction::make(), + $this->checkInAction(), + OverrideEnrollmentStatusAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + $this->bulkCheckInAction(), + DeleteBulkAction::make(), + ]), + ]); + } + + private function viewApplicationAction(): Action + { + return Action::make('viewApplication') + ->label('View Application') + ->icon(Heroicon::OutlinedDocumentText) + ->color('gray') + ->visible(fn (Enrollment $record): bool => $record->application_data !== null) + ->modalContent(static function (Enrollment $record): View { + $schema = $record->event?->enrollmentPolicy->application_schema ?? []; + $data = $record->application_data ?? []; + + $answers = collect($schema) + ->map(fn (array $field, int $index): array => [ + 'label' => $field['label'] ?? 'Question '.$index, + 'value' => $data[$field['key'] ?? null] ?? '—', + ]) + ->values() + ->all(); + + return view('panel-admin::enrollments.application-data', [ + 'answers' => $answers, + 'record' => $record, + ]); + }) + ->modalSubmitAction(action: false); + } + + private function checkInAction(): Action + { + return Action::make('checkIn') + ->label(__('panel-admin::events.enrollments.actions.check_in')) + ->icon(Heroicon::OutlinedCheckCircle) + ->color('success') + ->visible(fn (Enrollment $record): bool => $record->status->is(EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn)) + ->schema($this->checkInSchema()) + ->action(function (Enrollment $record, array $data): void { + $this->checkIn($record, Date::parse(Arr::string($data, 'event_date'))); + + Notification::make() + ->success() + ->title(__('panel-admin::events.enrollments.notifications.participant_checked_in')) + ->send(); + }); + } + + private function bulkCheckInAction(): BulkAction + { + return BulkAction::make('checkInSelected') + ->label(__('panel-admin::events.enrollments.actions.check_in_selected')) + ->icon(Heroicon::OutlinedCheckCircle) + ->color('success') + ->schema($this->checkInSchema()) + ->action(function (Collection $records, array $data): void { + foreach ($records as $record) { + if (!$record instanceof Enrollment) { + continue; + } + + $this->checkIn($record, Date::parse(Arr::string($data, 'event_date'))); + } + + Notification::make() + ->success() + ->title(__('panel-admin::events.enrollments.notifications.selected_participants_checked_in')) + ->send(); + }) + ->deselectRecordsAfterCompletion(); + } + + /** + * @return array + */ + private function checkInSchema(): array + { + /** @var Event $event */ + $event = $this->getOwnerRecord(); + + return [ + DatePicker::make('event_date') + ->label(__('panel-admin::events.columns.date')) + ->default(now()) + ->minDate($event->starts_at->toDateString()) + ->maxDate($event->ends_at->toDateString()) + ->required(), + ]; + } + + private function checkIn(Enrollment $enrollment, CarbonInterface $eventDate): CheckIn + { + return resolve(ManualCheckInAction::class)->handle( + new ManualCheckInDTO( + enrollment: $enrollment, + actorUserId: (string) auth()->id(), + eventDate: $eventDate, + ), + ); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php new file mode 100644 index 000000000..f8e01731b --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php @@ -0,0 +1,240 @@ +columns(2) + ->components([ + TextInput::make('title') + ->label(__('panel-admin::events.columns.title')) + ->required() + ->maxLength(200) + ->columnSpanFull(), + + TextInput::make('slug') + ->label(__('panel-admin::events.columns.slug')) + ->required() + ->maxLength(120) + ->unique( + table: Event::class, + column: 'slug', + ignoreRecord: true, + modifyRuleUsing: static function (Unique $rule, Get $get): Unique { + $tenantId = $get('tenant_id'); + + return filled($tenantId) + ? $rule->where('tenant_id', $tenantId) + : $rule->whereNull('tenant_id'); + }, + ), + + Select::make('event_type') + ->label(__('panel-admin::events.columns.type')) + ->options(EventType::class) + ->required(), + + Select::make('tenant_id') + ->label(__('panel-admin::events.columns.tenant')) + ->relationship('tenant', 'name') + ->searchable() + ->nullable(), + + TextInput::make('location') + ->label(__('panel-admin::events.columns.location')) + ->nullable(), + + Textarea::make('description') + ->label(__('panel-admin::events.columns.description')) + ->nullable() + ->columnSpanFull(), + + DateTimePicker::make('starts_at') + ->label(__('panel-admin::events.columns.starts_at')) + ->required(), + + DateTimePicker::make('ends_at') + ->label(__('panel-admin::events.columns.ends_at')) + ->required() + ->after('starts_at'), + + Select::make('status') + ->label(__('panel-admin::events.columns.status')) + ->options(EventStatus::class) + ->default(EventStatus::Draft) + ->required() + ->columnSpanFull(), + + Section::make(__('panel-admin::events.sections.enrollment_policy')) + ->relationship('enrollmentPolicy') + ->columns(2) + ->schema([ + Select::make('enrollment_method') + ->label(__('panel-admin::events.form.enrollment_method')) + ->options(EnrollmentMethod::class) + ->live() + ->required(), + + Select::make('check_in_method') + ->label(__('panel-admin::events.form.check_in_method')) + ->options(CheckInMethod::class) + ->required(), + + TextInput::make('capacity') + ->label(__('panel-admin::events.form.capacity')) + ->integer() + ->minValue(1) + ->nullable(), + + Toggle::make('has_waitlist') + ->label(__('panel-admin::events.form.waitlist_enabled')) + ->default(state: false), + + Select::make('attendance_requirement') + ->label(__('panel-admin::events.form.attendance_requirement')) + ->options(fn (Get $get): array => self::attendanceRequirementOptions($get)) + ->live() + ->required(), + + TextInput::make('minimum_days') + ->label(__('panel-admin::events.form.minimum_days')) + ->helperText(__('panel-admin::events.form.helpers.minimum_days')) + ->integer() + ->minValue(1) + ->maxValue(fn (Get $get): ?int => self::minimumDaysMaxValue($get)) + ->default(1) + ->required(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value) + ->visible(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value), + + TextInput::make('cancellation_deadline_hours') + ->label(__('panel-admin::events.form.cancellation_deadline_hours')) + ->integer() + ->minValue(0) + ->nullable(), + + TextInput::make('xp_on_confirmed') + ->label(__('panel-admin::events.form.xp_on_confirmed')) + ->integer() + ->minValue(0) + ->default(0), + + TextInput::make('xp_on_checked_in') + ->label(__('panel-admin::events.form.xp_on_checked_in')) + ->integer() + ->minValue(0) + ->default(0), + + TextInput::make('xp_on_attended') + ->label(__('panel-admin::events.form.xp_on_attended')) + ->integer() + ->minValue(0) + ->default(0), + + Repeater::make('application_schema') + ->label('Application Form Questions') + ->nullable() + ->columnSpanFull() + ->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application) + ->addActionLabel('Add question') + ->reorderable() + ->collapsible() + ->schema([ + Hidden::make('key') + ->default(fn (): string => (string) Str::uuid()), + Select::make('type') + ->label('Type') + ->options([ + 'text' => 'Short text', + 'textarea' => 'Long text', + 'select' => 'Single choice', + 'checkbox' => 'Multiple choice', + ]) + ->required() + ->live(), + TextInput::make('label') + ->label('Question') + ->required() + ->maxLength(255), + Toggle::make('required') + ->label('Required') + ->default(state: false), + TagsInput::make('options') + ->label('Options') + ->placeholder('Type an option and press Enter') + ->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'checkbox'], strict: true)) + ->required(fn (Get $get): bool => in_array($get('type'), ['select', 'checkbox'], strict: true)), + ]), + ]), + ]); + } + + /** + * @return array + */ + private static function attendanceRequirementOptions(Get $get): array + { + $all = [ + AttendanceRequirement::AllDays->value => __('events::enums.attendance_requirement.all_days'), + AttendanceRequirement::AnyDay->value => __('events::enums.attendance_requirement.any_day'), + AttendanceRequirement::MinimumDays->value => __('events::enums.attendance_requirement.minimum_days'), + ]; + + $eventDays = self::eventDays($get); + + if ($eventDays === null) { + return $all; + } + + if ($eventDays === 1) { + return [AttendanceRequirement::AnyDay->value => $all[AttendanceRequirement::AnyDay->value]]; + } + + return $all; + } + + private static function minimumDaysMaxValue(Get $get): ?int + { + return self::eventDays($get); + } + + private static function eventDays(Get $get): ?int + { + $startsAt = $get('../starts_at'); + $endsAt = $get('../ends_at'); + + if ($startsAt === null || $endsAt === null) { + return null; + } + + $startsDay = Date::parse($startsAt)->startOfDay(); + $endsDay = Date::parse($endsAt)->startOfDay(); + + return (int) $startsDay->diffInDays($endsDay) + 1; + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php new file mode 100644 index 000000000..23e7e7d21 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php @@ -0,0 +1,96 @@ +columns(2) + ->components([ + TextEntry::make('title') + ->label(__('panel-admin::events.columns.title')) + ->columnSpanFull(), + + TextEntry::make('slug') + ->label(__('panel-admin::events.columns.slug')), + + TextEntry::make('event_type') + ->label(__('panel-admin::events.columns.type')) + ->badge(), + + TextEntry::make('tenant.name') + ->label(__('panel-admin::events.columns.tenant')), + + TextEntry::make('location') + ->label(__('panel-admin::events.columns.location')), + + TextEntry::make('description') + ->label(__('panel-admin::events.columns.description')) + ->columnSpanFull(), + + TextEntry::make('starts_at') + ->label(__('panel-admin::events.columns.starts_at')) + ->dateTime(), + + TextEntry::make('ends_at') + ->label(__('panel-admin::events.columns.ends_at')) + ->dateTime(), + + TextEntry::make('status') + ->label(__('panel-admin::events.columns.status')) + ->badge(), + + TextEntry::make('created_at') + ->label(__('panel-admin::events.columns.created_at')) + ->dateTime(), + + Section::make(__('panel-admin::events.sections.enrollment_policy')) + ->relationship('enrollmentPolicy') + ->columns(2) + ->schema([ + TextEntry::make('enrollment_method') + ->label(__('panel-admin::events.form.enrollment_method')) + ->badge(), + + TextEntry::make('check_in_method') + ->label(__('panel-admin::events.form.check_in_method')) + ->badge(), + + TextEntry::make('capacity') + ->label(__('panel-admin::events.form.capacity')), + + IconEntry::make('has_waitlist') + ->label(__('panel-admin::events.form.waitlist_enabled')) + ->boolean(), + + TextEntry::make('attendance_requirement') + ->label(__('panel-admin::events.form.attendance_requirement')) + ->badge(), + + TextEntry::make('minimum_days') + ->label(__('panel-admin::events.form.minimum_days')), + + TextEntry::make('cancellation_deadline_hours') + ->label(__('panel-admin::events.form.cancellation_deadline_hours')), + + TextEntry::make('xp_on_confirmed') + ->label(__('panel-admin::events.form.xp_on_confirmed')), + + TextEntry::make('xp_on_checked_in') + ->label(__('panel-admin::events.form.xp_on_checked_in')), + + TextEntry::make('xp_on_attended') + ->label(__('panel-admin::events.form.xp_on_attended')), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php new file mode 100644 index 000000000..827ba8d06 --- /dev/null +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php @@ -0,0 +1,78 @@ +columns([ + TextColumn::make('title') + ->label(__('panel-admin::events.columns.title')) + ->searchable() + ->sortable(), + + TextColumn::make('event_type') + ->label(__('panel-admin::events.columns.type')) + ->badge() + ->sortable(), + + TextColumn::make('tenant.name') + ->label(__('panel-admin::events.columns.tenant')) + ->searchable() + ->sortable(), + + TextColumn::make('starts_at') + ->label(__('panel-admin::events.columns.starts_at')) + ->dateTime() + ->sortable(), + + TextColumn::make('ends_at') + ->label(__('panel-admin::events.columns.ends_at')) + ->dateTime() + ->sortable(), + + TextColumn::make('status') + ->label(__('panel-admin::events.columns.status')) + ->badge() + ->sortable(), + + TextColumn::make('created_at') + ->label(__('panel-admin::events.columns.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('event_type') + ->label(__('panel-admin::events.columns.type')) + ->options(EventType::class), + + SelectFilter::make('status') + ->label(__('panel-admin::events.columns.status')) + ->options(EventStatus::class), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index 99eb308ea..73ae45dfb 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -7,6 +7,7 @@ use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationItem; use Filament\Panel; +use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; use He4rt\PanelAdmin\Github\GithubCluster; use He4rt\PanelAdmin\Marketing\MarketingCluster; @@ -35,6 +36,7 @@ public function register(): void ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, + EventResource::class, ]) ->discoverResources( in: __DIR__.'/Moderation/Resources', @@ -110,6 +112,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...TwitchCluster::getNavigationItems(), ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), + ...EventResource::getNavigationItems(), ]); } diff --git a/app-modules/panel-admin/tests/Feature/.gitkeep b/app-modules/panel-admin/tests/Feature/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index a45f5f399..1eaac516c 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -28,8 +28,8 @@ 'skill_years_experience' => 'Years', 'platform' => 'Platform', 'handle' => 'Handle / URL', - 'country' => 'Country (ISO)', - 'state' => 'State (UF)', + 'country' => 'Country', + 'state' => 'State', 'city' => 'City', 'avatar' => 'Photo', 'cover' => 'Cover', @@ -54,11 +54,13 @@ 'headline' => 'Your job title or role', 'about' => 'Tell us about yourself...', 'handle' => '@username or https://...', + 'city_search' => 'Search city...', ], 'hints' => [ 'headline' => 'e.g. Frontend Developer, Product Designer', 'available_for_proposals' => 'When active, recruiters will see a green badge on your profile', + 'city' => 'If your city is not listed, search for it.', 'has_disability' => 'Sensitive information — used only for affirmative-action roles.', 'expected_salary' => 'Monthly amount in BRL. Private, used only in proposals.', 'skills' => 'Pick your skills and set your level and years of experience for each.', diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index 5745ca183..990dacc86 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -28,8 +28,8 @@ 'skill_years_experience' => 'Anos', 'platform' => 'Plataforma', 'handle' => 'Handle / URL', - 'country' => 'País (ISO)', - 'state' => 'Estado (UF)', + 'country' => 'País', + 'state' => 'Estado', 'city' => 'Cidade', 'avatar' => 'Foto', 'cover' => 'Capa', @@ -54,6 +54,7 @@ 'headline' => 'Seu cargo ou título profissional', 'about' => 'Conte um pouco sobre você...', 'handle' => '@usuario ou https://...', + 'city_search' => 'Buscar cidade...', ], 'hints' => [ @@ -62,6 +63,7 @@ 'has_disability' => 'Informação sensível — usada apenas para vagas afirmativas/PcD.', 'expected_salary' => 'Valor mensal em R$. Informação privada, usada apenas em propostas.', 'skills' => 'Selecione suas skills e informe o nível e os anos de experiência em cada uma.', + 'city' => 'Se sua cidade não estiver na listagem, pesquise.', ], 'actions' => [ diff --git a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php index 962087ae3..66d6d2353 100644 --- a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php @@ -59,10 +59,10 @@ class="absolute inset-0 z-30 flex items-center justify-center bg-black/50" {{-- Bottom section: avatar + fields --}} -
+
{{-- Avatar (overlapping cover) --}}
@else
{{ $initials }}
diff --git a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php index d2872fe8d..5386dac5f 100644 --- a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php @@ -37,9 +37,11 @@ ); $address = $data['address'] ?? []; - $location = collect([$address['city'] ?? null, $address['state'] ?? null, $address['country'] ?? null]) - ->filter() - ->implode(', '); + $location = \App\Geo\Support\GeoLocation::formatLocation( + $address['city'] ?? null, + $address['state'] ?? null, + $address['country'] ?? null, + ); $level = $character?->level ?? 1; $experience = $character?->experience ?? 0; @@ -98,11 +100,11 @@ class="absolute top-3 right-3 rounded-md bg-white/20 px-2 py-0.5 text-xs font-bo {{ $name }} @else
{{ $initials }}
diff --git a/app-modules/panel-app/resources/views/components/timeline/header.blade.php b/app-modules/panel-app/resources/views/components/timeline/header.blade.php index ab386a812..a01d3bf15 100644 --- a/app-modules/panel-app/resources/views/components/timeline/header.blade.php +++ b/app-modules/panel-app/resources/views/components/timeline/header.blade.php @@ -1,15 +1,17 @@ -@props (['user', 'pinned' => false, 'createdAt']) +@props (['user' => null, 'pinned' => false, 'createdAt']) + +@php($displayName = $user?->name ?? 'Usuário removido')
- {{ str($user->name)->substr(0, 2)->upper() }} + {{ str($displayName)->substr(0, 2)->upper() }}
- {{ $user->name }} - @if ($user->username) + {{ $displayName }} + @if ($user?->username) +
+
+
+

{{ $this->event->title }}

+ +
+ {{ $this->event->starts_at->format('d/m/Y H:i') }} + + {{ $this->event->ends_at->format('d/m/Y H:i') }} + + @if ($this->event->location) + {{ $this->event->location }} + @endif +
+
+ + + {{ $this->event->event_type->getLabel() }} + +
+ + @if ($this->event->description) +

{{ $this->event->description }}

+ @endif +
+ + @if ($this->enrollment) +
+
+

+ {{ __('events::pages.enrollment_status_label') }} +

+ + + {{ $this->enrollment->status->getLabel() }} + +
+ + @if ($this->enrollment->status->isWaitlisted() && $this->enrollment->waitlist_position) +

+ {{ + __('events::pages.waitlist_status', [ + 'position' => $this->enrollment->waitlist_position, + ]) + }} +

+ @endif + + @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Pending) +

+ {{ __('events::pages.application_pending_hint') }} +

+ @if ($this->enrollment->application_data && $this->event->enrollmentPolicy?->application_schema) +
+

+ {{ __('events::pages.application_your_answers') }} +

+ @foreach ($this->event->enrollmentPolicy->application_schema as $field) +
+

{{ $field['label'] ?? '' }}

+

+ @php $answer = $this->enrollment->application_data[$field['key'] ?? null] ?? null; @endphp + @if (is_array($answer)) + {{ $answer !== [] ? implode(', ', $answer) : '—' }} + @else + {{ $answer ?? '—' }} + @endif +

+
+ @endforeach +
+ @endif + @endif + + @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Rejected) +

+ {{ __('events::pages.application_rejected_hint') }} +

+ @if ($this->enrollment->rejection_reason) +

+ {{ + __('events::pages.application_rejection_reason', [ + 'reason' => $this->enrollment->rejection_reason, + ]) + }} +

+ @endif + @endif +
+ @if ($this->qrToken) +
+
+

+ {{ __('events::pages.my_qr_code') }} +

+ + @if ($this->hasCheckedInToday) + + {{ __('events::pages.checked_in_today') }} + + @endif +
+ +
{!! $this->qrCodeSvg !!}
+ +

+ {{ __('events::pages.qr_code_hint') }} +

+ +
+ + {{ __('events::pages.copy_token') }} + {{ __('events::pages.token_copied') }} + + + + {{ __('events::pages.download_qr') }} + +
+
+ @endif + @if ($this->checkIns->isNotEmpty()) +
+

+ {{ __('events::pages.check_in_history') }} +

+ +
    + @foreach ($this->checkIns as $checkIn) +
  • + + {{ $checkIn->event_date->format('d/m/Y') }} +
  • + @endforeach +
+
+ @endif + @elseif ($this->canApply) +
+

{{ __('events::pages.apply_hint') }}

+ +
+ @foreach ($this->event->enrollmentPolicy->application_schema ?? [] as $field) + @continue(!isset($field['key'])) +
+ + + @if (($field['type'] ?? '') === 'textarea') + + @elseif (($field['type'] ?? '') === 'select') + + @elseif (($field['type'] ?? '') === 'checkbox') +
+ @foreach ($field['options'] ?? [] as $option) + + @endforeach +
+ @else + + @endif +
+ @endforeach + + + {{ __('events::pages.apply_submit') }} + +
+
+ @elseif ($this->canConfirmPresence) +
+

{{ __('events::pages.confirm_presence_hint') }}

+ +
+ + {{ __('events::pages.confirm_presence') }} + +
+ +
+

+ {{ __('events::pages.confirm_presence_prompt') }} +

+ +
+ + {{ __('events::pages.confirm_presence_yes') }} + + + + {{ __('events::pages.confirm_presence_cancel') }} + +
+
+
+ @elseif ($this->isEventFull) +
+

{{ __('events::pages.event_full') }}

+
+ @endif + + +
diff --git a/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php new file mode 100644 index 000000000..70858da7c --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php @@ -0,0 +1,43 @@ + diff --git a/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php new file mode 100644 index 000000000..f86fa00b7 --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php @@ -0,0 +1,26 @@ +
+
+ @forelse ($this->enrollments as $enrollment) + @if ($this->canOpenEvent($enrollment)) + + @include ('panel-app::livewire.events.partials.my-events-list-item', ['enrollment' => $enrollment]) + + @else +
+ @include ('panel-app::livewire.events.partials.my-events-list-item', ['enrollment' => $enrollment]) +
+ @endif + @empty +
+

{{ __('events::pages.no_enrollments') }}

+
+ @endforelse +
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php new file mode 100644 index 000000000..bab7e26cf --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php @@ -0,0 +1,35 @@ +
+ @if ($this->canCheckIn) +
+

+ {{ __('events::pages.enter_check_in_code_hint') }} +

+ +
+
+ + + + + @error ('code') +

{{ $message }}

+ @enderror + + @if ($error) +

{{ $error }}

+ @endif +
+ + + {{ __('events::pages.check_in') }} + +
+
+ @endif +
diff --git a/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php new file mode 100644 index 000000000..216b46b70 --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php @@ -0,0 +1,27 @@ +
+
+

{{ $enrollment->event->title }}

+ +
+ {{ $enrollment->event->starts_at->format('d/m/Y H:i') }} + + @if ($enrollment->enrolled_at) + {{ + __('events::pages.enrolled_at', [ + 'date' => $enrollment->enrolled_at->format('d/m/Y H:i'), + ]) + }} + @endif +
+
+ +
+ + {{ $enrollment->status->getLabel() }} + + + + {{ $enrollment->event->status->getLabel() }} + +
+
diff --git a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php index 23f57f0ac..eee4e5d3c 100644 --- a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php +++ b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php @@ -14,7 +14,7 @@ @if ($showReplies && $timeline->children_count > 0)
@foreach ($timeline->children->take(3) as $reply) - @continue (!$reply->postable) + @continue (!$reply->postable || !$reply->user)
+ + diff --git a/app-modules/panel-app/resources/views/pages/events.blade.php b/app-modules/panel-app/resources/views/pages/events.blade.php new file mode 100644 index 000000000..b277f96c0 --- /dev/null +++ b/app-modules/panel-app/resources/views/pages/events.blade.php @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/app-modules/panel-app/resources/views/pages/my-events.blade.php b/app-modules/panel-app/resources/views/pages/my-events.blade.php new file mode 100644 index 000000000..d60ad02ca --- /dev/null +++ b/app-modules/panel-app/resources/views/pages/my-events.blade.php @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php new file mode 100644 index 000000000..1b71eb6f2 --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php @@ -0,0 +1,264 @@ + $checkIns + * @property-read bool $hasCheckedInToday + * @property-read bool $canConfirmPresence + * @property-read bool $canApply + * @property-read bool $isEventFull + */ +final class EventDetail extends Component +{ + public string $eventId; + + /** @var array */ + public array $applicationFormData = []; + + public function mount(string $eventId): void + { + $this->eventId = $eventId; + + foreach ($this->event->enrollmentPolicy->application_schema ?? [] as $field) { + if (($field['type'] ?? null) === 'checkbox' && isset($field['key'])) { + $this->applicationFormData[(string) $field['key']] = []; + } + } + } + + #[Computed] + public function event(): Event + { + return Event::query() + ->with('enrollmentPolicy') + ->where('id', $this->eventId) + ->where('tenant_id', filament()->getTenant()->getKey()) + ->viewableByParticipant() + ->firstOrFail(); + } + + #[Computed] + public function enrollment(): ?Enrollment + { + return Enrollment::query() + ->with('qrToken') + ->where('event_id', $this->eventId) + ->where('user_id', auth()->id()) + ->first(); + } + + #[Computed] + public function qrToken(): ?QrToken + { + if ($this->enrollment === null) { + return null; + } + + if (!in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true)) { + return null; + } + + if ($this->event->enrollmentPolicy?->check_in_method !== CheckInMethod::QrCode) { + return null; + } + + return $this->enrollment->qrToken; + } + + #[Computed] + public function qrCodeSvg(): ?string + { + if ($this->qrToken === null) { + return null; + } + + $writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd())); + + return $writer->writeString($this->qrToken->token); + } + + /** @return Collection */ + #[Computed] + public function checkIns(): Collection + { + if ($this->enrollment === null) { + return new Collection(); + } + + return CheckIn::query() + ->where('enrollment_id', $this->enrollment->id) + ->oldest('event_date') + ->get(); + } + + #[Computed] + public function hasCheckedInToday(): bool + { + return $this->checkIns->contains( + fn (CheckIn $c): bool => $c->event_date->isToday(), + ); + } + + #[Computed] + public function canConfirmPresence(): bool + { + if ($this->event->status !== EventStatus::Published) { + return false; + } + + if ($this->enrollment !== null) { + return false; + } + + $policy = $this->event->enrollmentPolicy; + + if (!in_array($policy?->enrollment_method, [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], strict: true)) { + return false; + } + + if (!$this->event->starts_at->isFuture()) { + return false; + } + + if ($policy->capacity === null) { + return true; + } + + $occupiedCount = Enrollment::query() + ->where('event_id', $this->eventId) + ->active() + ->count(); + + if ($occupiedCount < $policy->capacity) { + return true; + } + + return $policy->has_waitlist; + } + + #[Computed] + public function canApply(): bool + { + if ($this->event->status !== EventStatus::Published) { + return false; + } + + if ($this->enrollment !== null) { + return false; + } + + if ($this->event->enrollmentPolicy?->enrollment_method !== EnrollmentMethod::Application) { + return false; + } + + return $this->event->starts_at->isFuture(); + } + + #[Computed] + public function isEventFull(): bool + { + if ($this->enrollment !== null) { + return false; + } + + $policy = $this->event->enrollmentPolicy; + + if ($policy?->capacity === null || $policy->has_waitlist) { + return false; + } + + $occupiedCount = Enrollment::query() + ->where('event_id', $this->eventId) + ->active() + ->count(); + + return $occupiedCount >= $policy->capacity; + } + + public function confirmPresence(): void + { + /** @var User $user */ + $user = auth()->user(); + + try { + $enrollment = resolve(EnrollUserAction::class)->handle( + EnrollUserDTO::fromModels($this->event, $user), + ); + + unset($this->enrollment, $this->canConfirmPresence, $this->canApply, $this->isEventFull); + + Notification::make() + ->success() + ->title($enrollment->status->getResponseMessage($enrollment->waitlist_position)) + ->send(); + } catch (EnrollmentException $enrollmentException) { + Notification::make() + ->danger() + ->title($enrollmentException->getMessage()) + ->send(); + } + } + + public function apply(): void + { + /** @var User $user */ + $user = auth()->user(); + + try { + resolve(EnrollUserAction::class)->handle( + new EnrollUserDTO( + eventId: $this->event->id, + userId: $user->id, + applicationData: $this->applicationFormData, + ), + ); + + unset($this->enrollment, $this->canApply); + $this->applicationFormData = []; + + Notification::make() + ->success() + ->title(__('events::pages.application_submitted')) + ->send(); + } catch (EnrollmentException $enrollmentException) { + Notification::make() + ->danger() + ->title($enrollmentException->getMessage()) + ->send(); + } + } + + public function render(): View + { + return view('panel-app::livewire.events.event-detail'); + } +} diff --git a/app-modules/panel-app/src/Livewire/Events/EventsList.php b/app-modules/panel-app/src/Livewire/Events/EventsList.php new file mode 100644 index 000000000..4d3ba3f05 --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/EventsList.php @@ -0,0 +1,35 @@ + */ + public function getEventsProperty(): Collection + { + return Event::query() + ->with('enrollmentPolicy') + ->where('tenant_id', filament()->getTenant()->getKey()) + ->active() + ->orderBy('starts_at') + ->get(); + } + + public function eventUrl(Event $event): string + { + return EventPage::getUrl(['record' => $event->getKey()]); + } + + public function render(): View + { + return view('panel-app::livewire.events.events-list'); + } +} diff --git a/app-modules/panel-app/src/Livewire/Events/MyEventsList.php b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php new file mode 100644 index 000000000..45af6f442 --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php @@ -0,0 +1,45 @@ + */ + public function getEnrollmentsProperty(): Collection + { + return Enrollment::query() + ->with(['event']) + ->where('user_id', auth()->id()) + ->whereHas('event', fn (Builder $query) => $query->where('tenant_id', filament()->getTenant()->getKey())) + ->latest('enrolled_at') + ->get(); + } + + public function canOpenEvent(Enrollment $enrollment): bool + { + /** @var Event $event */ + $event = $enrollment->event; + + return $event->status->isViewableByParticipant(); + } + + public function eventUrl(Enrollment $enrollment): string + { + return EventPage::getUrl(['record' => $enrollment->event_id]); + } + + public function render(): View + { + return view('panel-app::livewire.events.my-events-list'); + } +} diff --git a/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php new file mode 100644 index 000000000..f63d212d3 --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php @@ -0,0 +1,133 @@ +eventId = $eventId; + } + + #[Computed] + public function enrollment(): ?Enrollment + { + return Enrollment::query() + ->with('event.enrollmentPolicy') + ->where('event_id', $this->eventId) + ->where('user_id', auth()->id()) + ->first(); + } + + #[Computed] + public function canCheckIn(): bool + { + if ($this->enrollment === null) { + return false; + } + + if ($this->enrollment->event->enrollmentPolicy?->check_in_method !== CheckInMethod::NumericCode) { + return false; + } + + return in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true); + } + + public function checkIn(): void + { + $this->error = null; + $this->code = mb_trim($this->code); + + if ($this->enrollment === null || !$this->canCheckIn) { + return; + } + + $this->validate([ + 'code' => ['required', 'string', new CheckInCodeRule()], + ], [ + 'code.required' => CheckInException::invalidCheckInCodeFormat()->getMessage(), + ]); + + if (!$this->ensureNotRateLimited()) { + return; + } + + try { + resolve(NumericCodeCheckInAction::class)->handle( + new NumericCodeCheckInDTO( + enrollment: $this->enrollment, + code: $this->code, + eventDate: Date::today(), + ), + ); + + RateLimiter::clear($this->getRateLimitKey()); + $this->code = ''; + + Notification::make() + ->success() + ->title('Check-in confirmed!') + ->send(); + } catch (CheckInException $checkInException) { + $this->error = $checkInException->getMessage(); + } + } + + public function render(): View + { + return view('panel-app::livewire.events.numeric-code-check-in'); + } + + private function ensureNotRateLimited(): bool + { + $rateLimitKey = $this->getRateLimitKey(); + + if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) { + $this->error = CheckInException::checkInCodeRateLimited( + RateLimiter::availableIn($rateLimitKey) + )->getMessage(); + + return false; + } + + RateLimiter::hit($rateLimitKey, 60); + + return true; + } + + private function getRateLimitKey(): string + { + return sprintf( + 'numeric-code-check-in:%s:%s:%s', + auth()->id() ?? 'guest', + $this->eventId, + md5((string) (request()->ip() ?? 'unknown')), + ); + } +} diff --git a/app-modules/panel-app/src/Livewire/Timeline/Feed.php b/app-modules/panel-app/src/Livewire/Timeline/Feed.php index e4f13c0b7..cd64d8a36 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/Feed.php +++ b/app-modules/panel-app/src/Livewire/Timeline/Feed.php @@ -18,9 +18,9 @@ final class Feed extends Component #[Locked] public string $tenantId; - #[On('timeline.post-created')] - #[On('timeline.reply-created')] - #[On('timeline.reply-deleted')] + #[On(event: 'timeline.post-created')] + #[On(event: 'timeline.reply-created')] + #[On(event: 'timeline.reply-deleted')] public function refresh(): void {} public function render(): View diff --git a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php index 1157445b2..bd1abfa33 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php +++ b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php @@ -21,7 +21,7 @@ final class PostShow extends Component public bool $showReplies = true; - #[On('timeline.post-updated')] + #[On(event: 'timeline.post-updated')] public function refresh(): void {} public function togglePin(#[CurrentUser] ?User $user): void diff --git a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php index e5ad05983..353c8719e 100644 --- a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php +++ b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php @@ -21,8 +21,8 @@ final class ThreadReplies extends Component #[Locked] public string $timelineId; - #[On('timeline.reply-created')] - #[On('timeline.reply-deleted')] + #[On(event: 'timeline.reply-created')] + #[On(event: 'timeline.reply-deleted')] public function refresh(): void {} public function deleteReply(string $replyId, #[CurrentUser] ?User $user): void diff --git a/app-modules/panel-app/src/Pages/EventPage.php b/app-modules/panel-app/src/Pages/EventPage.php new file mode 100644 index 000000000..c5b2f0ad9 --- /dev/null +++ b/app-modules/panel-app/src/Pages/EventPage.php @@ -0,0 +1,37 @@ +where('id', $record) + ->where('tenant_id', filament()->getTenant()->getKey()) + ->viewableByParticipant() + ->exists(); + + abort_unless($exists, 404); + + $this->record = $record; + } +} diff --git a/app-modules/panel-app/src/Pages/EventsPage.php b/app-modules/panel-app/src/Pages/EventsPage.php new file mode 100644 index 000000000..b309db0e6 --- /dev/null +++ b/app-modules/panel-app/src/Pages/EventsPage.php @@ -0,0 +1,21 @@ +schema([ Select::make('country') ->label(__('panel-app::profile.fields.country')) - ->options([ - 'BRA' => '🇧🇷 Brasil', - 'USA' => '🇺🇸 United States', - 'PRT' => '🇵🇹 Portugal', - 'ARG' => '🇦🇷 Argentina', - 'DEU' => '🇩🇪 Deutschland', - 'CAN' => '🇨🇦 Canada', - 'GBR' => '🇬🇧 United Kingdom', - 'FRA' => '🇫🇷 France', - 'ESP' => '🇪🇸 España', - 'ITA' => '🇮🇹 Italia', - 'JPN' => '🇯🇵 Japan', - 'AUS' => '🇦🇺 Australia', - 'MEX' => '🇲🇽 México', - 'COL' => '🇨🇴 Colombia', - 'CHL' => '🇨🇱 Chile', - 'URY' => '🇺🇾 Uruguay', - 'IRL' => '🇮🇪 Ireland', - 'NLD' => '🇳🇱 Nederland', - ]) + ->options(static fn (): array => GeoLocation::countries()) ->default('BRA') ->searchable() + ->preload() ->live() + ->afterStateUpdated(static function (Set $set): void { + $set('state', null); + $set('city', null); + }) ->columnSpan(1), Select::make('state') ->label(__('panel-app::profile.fields.state')) - ->options(fn (Get $get): array => ($get('country') ?? 'BRA') === 'BRA' ? [ - 'AC' => 'Acre', 'AL' => 'Alagoas', 'AP' => 'Amapá', 'AM' => 'Amazonas', - 'BA' => 'Bahia', 'CE' => 'Ceará', 'DF' => 'Distrito Federal', - 'ES' => 'Espírito Santo', 'GO' => 'Goiás', 'MA' => 'Maranhão', - 'MT' => 'Mato Grosso', 'MS' => 'Mato Grosso do Sul', 'MG' => 'Minas Gerais', - 'PA' => 'Pará', 'PB' => 'Paraíba', 'PR' => 'Paraná', 'PE' => 'Pernambuco', - 'PI' => 'Piauí', 'RJ' => 'Rio de Janeiro', 'RN' => 'Rio Grande do Norte', - 'RS' => 'Rio Grande do Sul', 'RO' => 'Rondônia', 'RR' => 'Roraima', - 'SC' => 'Santa Catarina', 'SP' => 'São Paulo', 'SE' => 'Sergipe', - 'TO' => 'Tocantins', - ] : []) + ->options(static fn (Get $get): array => GeoLocation::statesFor($get('country'))) ->searchable() - ->allowHtml(condition: false) ->live() + ->visible(static fn (Get $get): bool => filled($get('country'))) + ->afterStateUpdated(static function (Set $set): void { + $set('city', null); + }) ->columnSpan(1), - TextInput::make('city') + Select::make('city') ->label(__('panel-app::profile.fields.city')) - ->placeholder('São Paulo') - ->maxLength(100) - ->live(onBlur: true) + ->options(static fn (Get $get): array => GeoLocation::citiesFor($get('country'), $get('state'))) + ->getSearchResultsUsing(static fn (string $search, Get $get): array => GeoLocation::citiesFor($get('country'), $get('state'), $search)) + ->getOptionLabelUsing(static fn (?string $value): ?string => $value) + ->searchable() + ->preload() + ->searchPrompt(__('panel-app::profile.placeholders.city_search')) + ->hintIcon('heroicon-o-information-circle', __('panel-app::profile.hints.city')) + ->visible(static fn (Get $get): bool => filled($get('state'))) ->columnSpan(1), ]), ]), diff --git a/app-modules/panel-app/src/PanelAppServiceProvider.php b/app-modules/panel-app/src/PanelAppServiceProvider.php index c64ef513a..8a0da2def 100644 --- a/app-modules/panel-app/src/PanelAppServiceProvider.php +++ b/app-modules/panel-app/src/PanelAppServiceProvider.php @@ -4,6 +4,10 @@ namespace He4rt\PanelApp; +use He4rt\PanelApp\Livewire\Events\EventDetail; +use He4rt\PanelApp\Livewire\Events\EventsList; +use He4rt\PanelApp\Livewire\Events\MyEventsList; +use He4rt\PanelApp\Livewire\Events\NumericCodeCheckIn; use He4rt\PanelApp\Livewire\Timeline\Composer; use He4rt\PanelApp\Livewire\Timeline\Feed; use He4rt\PanelApp\Livewire\Timeline\PostShow; @@ -21,6 +25,11 @@ public function boot(): void $this->loadViewsFrom(__DIR__.'/../resources/views', 'panel-app'); $this->loadTranslationsFrom(__DIR__.'/../lang', 'panel-app'); + Livewire::component('events-list', EventsList::class); + Livewire::component('my-events-list', MyEventsList::class); + Livewire::component('event-detail', EventDetail::class); + Livewire::component('numeric-code-check-in', NumericCodeCheckIn::class); + Livewire::component('timeline-composer', Composer::class); Livewire::component('timeline-feed', Feed::class); Livewire::component('timeline-post-show', PostShow::class); diff --git a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php new file mode 100644 index 000000000..75e5bd133 --- /dev/null +++ b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php @@ -0,0 +1,139 @@ +user = User::factory()->create(); + $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); + $this->tenant->members()->attach($this->user); + + $this->actingAs($this->user); + + Filament::setCurrentPanel(Filament::getPanel('app')); + Filament::setTenant($this->tenant); + + $this->schema = [ + ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true], + ['key' => 'experience_level', 'type' => 'select', 'label' => 'Experience level', 'required' => false, 'options' => ['Beginner', 'Intermediate', 'Advanced']], + ]; + + $this->event = Event::factory() + ->published() + ->upcoming() + ->for($this->tenant) + ->has(EnrollmentPolicy::factory()->application($this->schema), 'enrollmentPolicy') + ->create(['title' => 'He4rt Conf Application']); +}); + +test('event page renders apply button for application event', function (): void { + $this->get(EventPage::getUrl(['record' => $this->event->id])) + ->assertSuccessful() + ->assertSee('He4rt Conf Application') + ->assertSee(__('events::pages.apply_submit')); +}); + +test('event detail shows canApply true and canConfirmPresence false for application event', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canApply', value: true) + ->assertSet('canConfirmPresence', value: false); +}); + +test('when user submits application, then enrollment is created as pending', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate']) + ->call('apply') + ->assertHasNoErrors() + ->assertNotified(); + + $enrollment = Enrollment::query() + ->where('event_id', $this->event->id) + ->where('user_id', $this->user->id) + ->first(); + + expect($enrollment)->not->toBeNull() + ->and($enrollment->status)->toBe(EnrollmentStatus::Pending) + ->and($enrollment->confirmed_at)->toBeNull() + ->and($enrollment->application_data['why_join'])->toBe('I love Laravel!'); +}); + +test('after submitting application, event detail shows pending status with answers', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate']) + ->call('apply'); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canApply', value: false) + ->assertSee(EnrollmentStatus::Pending->getLabel()) + ->assertSee(__('events::pages.application_pending_hint')) + ->assertSee('I love Laravel!'); +}); + +test('when application is rejected, then rejection reason is shown', function (): void { + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'status' => EnrollmentStatus::Rejected, + 'enrolled_at' => now(), + 'rejection_reason' => 'Not enough experience.', + 'application_data' => ['why_join' => 'I am new'], + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canApply', value: false) + ->assertSee(EnrollmentStatus::Rejected->getLabel()) + ->assertSee(__('events::pages.application_rejected_hint')) + ->assertSee('Not enough experience.'); +}); + +test('when user has already applied, then apply button is not shown', function (): void { + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'status' => EnrollmentStatus::Pending, + 'enrolled_at' => now(), + 'application_data' => ['why_join' => 'My answer'], + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canApply', value: false) + ->assertDontSee(__('events::pages.apply_submit')); +}); + +test('when questions are reordered after submission, then answers still match the correct question', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate']) + ->call('apply'); + + $this->event->enrollmentPolicy->update([ + 'application_schema' => array_reverse($this->schema), + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSeeInOrder([ + 'Experience level', + 'Intermediate', + 'Why do you want to join?', + 'I love Laravel!', + ]); +}); + +test('when application data is missing required field, then error notification is shown', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->set('applicationFormData', ['why_join' => '', 'experience_level' => 'Beginner']) + ->call('apply') + ->assertNotified(); + + expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(0); +}); diff --git a/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php new file mode 100644 index 000000000..fcd5529f6 --- /dev/null +++ b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php @@ -0,0 +1,67 @@ +user = User::factory()->create(); + $this->tenant = Tenant::factory()->create(['slug' => 'numeric-code-test-tenant']); + $this->tenant->members()->attach($this->user); + + $this->actingAs($this->user); + + Filament::setCurrentPanel(Filament::getPanel('app')); + Filament::setTenant($this->tenant); + + $this->event = Event::factory() + ->published() + ->upcoming() + ->for($this->tenant) + ->has(EnrollmentPolicy::factory()->rsvp()->state([ + 'check_in_method' => CheckInMethod::NumericCode, + ]), 'enrollmentPolicy') + ->create(); + + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'status' => EnrollmentStatus::Confirmed, + 'confirmed_at' => now(), + ]); + + RateLimiter::clear(sprintf('numeric-code-check-in:%s:%s:%s', $this->user->id, $this->event->id, md5('127.0.0.1'))); +}); + +test('when check-in code format is invalid, then component rejects it before action', function (): void { + livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id]) + ->set('code', 'abc123') + ->call('checkIn') + ->assertHasErrors(['code']); +}); + +test('when participant repeats invalid numeric codes, then component rate limits attempts', function (): void { + $component = livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id]) + ->set('code', '999999'); + + foreach (range(1, 5) as $_) { + $component + ->call('checkIn') + ->assertSet('error', __('events::check_in.invalid_check_in_code')); + } + + $component + ->call('checkIn') + ->assertSet('error', fn (string $error): bool => str_contains($error, 'Too many attempts.')); +}); diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php new file mode 100644 index 000000000..1bcc5f39f --- /dev/null +++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php @@ -0,0 +1,205 @@ +user = User::factory()->create(); + $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); + $this->tenant->members()->attach($this->user); + + $this->actingAs($this->user); + + Filament::setCurrentPanel(Filament::getPanel('app')); + Filament::setTenant($this->tenant); + + $this->event = Event::factory() + ->published() + ->upcoming() + ->for($this->tenant) + ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') + ->create(['title' => 'He4rt Meetup RSVP']); +}); + +test('events page renders successfully', function (): void { + $this->get(EventsPage::getUrl()) + ->assertSuccessful() + ->assertSee('He4rt Meetup RSVP'); +}); + +test('event page renders confirm presence button for rsvp event', function (): void { + $this->get(EventPage::getUrl(['record' => $this->event->id])) + ->assertSuccessful() + ->assertSee('He4rt Meetup RSVP') + ->assertSee(__('events::pages.confirm_presence')); +}); + +test('when user confirms presence, then enrollment is created and shown in my events', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->call('confirmPresence') + ->assertHasNoErrors(); + + $enrollment = Enrollment::query() + ->where('event_id', $this->event->id) + ->where('user_id', $this->user->id) + ->first(); + + expect($enrollment)->not->toBeNull() + ->and($enrollment->status)->toBe(EnrollmentStatus::Confirmed); + + $this->get(MyEventsPage::getUrl()) + ->assertSuccessful() + ->assertSee('He4rt Meetup RSVP') + ->assertSee(EnrollmentStatus::Confirmed->getLabel()); +}); + +test('when user tries to confirm presence twice, then duplicate enrollment is rejected', function (): void { + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->call('confirmPresence'); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->call('confirmPresence') + ->assertNotified(); + + expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(1); +}); + +test('event page returns 404 for event from another tenant', function (): void { + $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']); + $otherEvent = Event::factory() + ->published() + ->upcoming() + ->for($otherTenant) + ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') + ->create(); + + $this->get(EventPage::getUrl(['record' => $otherEvent->id])) + ->assertNotFound(); +}); + +test('when event is at capacity without waitlist, then event full message is shown', function (): void { + $this->event->enrollmentPolicy->update([ + 'capacity' => 1, + 'has_waitlist' => false, + ]); + + $otherUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $otherUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canConfirmPresence', value: false) + ->assertSet('isEventFull', value: true) + ->assertSee(__('events::pages.event_full')) + ->assertDontSee(__('events::pages.confirm_presence')); +}); + +test('when user is waitlisted, then waitlist position is shown on event page', function (): void { + $this->event->enrollmentPolicy->update([ + 'capacity' => 1, + 'has_waitlist' => true, + ]); + + $otherUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $otherUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->call('confirmPresence') + ->assertHasNoErrors() + ->assertSee(__('events::pages.waitlist_status', ['position' => 1])); +}); + +test('when event is at capacity with waitlist, then confirm presence button is still shown', function (): void { + $this->event->enrollmentPolicy->update([ + 'capacity' => 1, + 'has_waitlist' => true, + ]); + + $otherUser = User::factory()->create(); + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $otherUser->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canConfirmPresence', value: true) + ->assertSee(__('events::pages.confirm_presence')); +}); + +test('when enrolled event is completed, then event detail page is accessible', function (): void { + $this->event->update(['status' => EventStatus::Completed]); + + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + $this->get(EventPage::getUrl(['record' => $this->event->id])) + ->assertSuccessful() + ->assertSee('He4rt Meetup RSVP'); + + livewire(EventDetail::class, ['eventId' => $this->event->id]) + ->assertSet('canConfirmPresence', value: false); +}); + +test('when enrolled event is draft, then my events list does not link to detail', function (): void { + $this->event->update(['status' => EventStatus::Draft]); + + Enrollment::factory()->create([ + 'event_id' => $this->event->id, + 'user_id' => $this->user->id, + 'status' => EnrollmentStatus::Confirmed, + 'enrolled_at' => now(), + 'confirmed_at' => now(), + ]); + + livewire(MyEventsList::class) + ->assertSet('enrollments', fn ($enrollments): bool => $enrollments->count() === 1) + ->assertSee('He4rt Meetup RSVP') + ->assertDontSee(EventPage::getUrl(['record' => $this->event->id]), escape: false); +}); + +test('past event does not show confirm presence button', function (): void { + $pastEvent = Event::factory() + ->published() + ->past() + ->for($this->tenant) + ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') + ->create(['title' => 'Past Meetup']); + + livewire(EventDetail::class, ['eventId' => $pastEvent->id]) + ->assertSet('canConfirmPresence', value: false) + ->assertDontSee(__('events::pages.confirm_presence')); +}); diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php index 428e7a260..60ff35996 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -12,10 +12,33 @@ use He4rt\Profile\Enums\StartAvailability; use He4rt\Profile\Models\Profile; use He4rt\Profile\Models\Skill; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use function Pest\Livewire\livewire; beforeEach(function (): void { + Cache::flush(); + + Http::fake([ + 'world.bmbc.cloud/api/countries*' => Http::response([ + 'success' => true, + 'data' => [ + ['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil'], + ['id' => 231, 'iso2' => 'US', 'iso3' => 'USA', 'name' => 'United States'], + ], + ]), + 'world.bmbc.cloud/api/states*' => Http::response([ + 'success' => true, + 'data' => [ + ['id' => 486, 'name' => 'São Paulo', 'cities' => [ + ['id' => 2, 'name' => 'São Paulo'], + ['id' => 3, 'name' => 'Campinas'], + ]], + ], + ]), + ]); + $this->user = User::factory()->create(); $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); $this->tenant->members()->attach($this->user); diff --git a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php index 8311289d1..d4bc4f4c2 100644 --- a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php +++ b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php @@ -7,6 +7,7 @@ use He4rt\Activity\Timeline\Timeline; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; +use He4rt\PanelApp\Livewire\Timeline\PostShow; use He4rt\PanelApp\Livewire\Timeline\ReplyComposer; use He4rt\PanelApp\Livewire\Timeline\ThreadReplies; use He4rt\PanelApp\Pages\ThreadPage; @@ -84,6 +85,23 @@ ->assertSeeInOrder(['First reply', 'Second reply']); }); +test('post renders without crashing when its author was deleted', function (): void { + $ghost = User::factory()->create(); + $entry = PostEntry::factory()->create(['content' => 'Orphaned post content']); + $orphan = Timeline::factory()->for($ghost)->create([ + 'tenant_id' => $this->tenant->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => $entry->id, + ]); + + $ghost->delete(); + + livewire(PostShow::class, ['timelineId' => $orphan->id]) + ->assertOk() + ->assertSee('Usuário removido') + ->assertSee('Orphaned post content'); +}); + // --- Critical: Tenant isolation --- test('thread page returns 404 for post from another tenant', function (): void { diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 63824d6d5..770b5e59e 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -17,8 +17,8 @@ use Livewire\Attributes\Url; use Livewire\Component; -#[Layout('portal::components.layouts.deck')] -#[Title('Quem fez a He4rt bater')] +#[Layout(name: 'portal::components.layouts.deck')] +#[Title(content: 'Quem fez a He4rt bater')] final class CommunityRetrospectivePage extends Component { public string $tenantId; diff --git a/app-modules/portal/src/Livewire/Homepage.php b/app-modules/portal/src/Livewire/Homepage.php index fbd79fa31..d1de23f08 100644 --- a/app-modules/portal/src/Livewire/Homepage.php +++ b/app-modules/portal/src/Livewire/Homepage.php @@ -9,8 +9,8 @@ use Livewire\Attributes\Title; use Livewire\Component; -#[Layout('portal::components.layouts.app')] -#[Title('Home')] +#[Layout(name: 'portal::components.layouts.app')] +#[Title(content: 'Home')] final class Homepage extends Component { public function render(): View diff --git a/app-modules/portal/src/Livewire/SocialLinksPage.php b/app-modules/portal/src/Livewire/SocialLinksPage.php index 6885e9656..ce3f1064c 100644 --- a/app-modules/portal/src/Livewire/SocialLinksPage.php +++ b/app-modules/portal/src/Livewire/SocialLinksPage.php @@ -10,8 +10,8 @@ use Livewire\Attributes\Title; use Livewire\Component; -#[Layout('portal::components.layouts.app')] -#[Title('Nossas redes')] +#[Layout(name: 'portal::components.layouts.app')] +#[Title(content: 'Nossas redes')] final class SocialLinksPage extends Component { /** diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php index 60da2533a..af26f16c9 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php @@ -211,7 +211,7 @@ private function filteredOutByOutcome(GithubContribution $contribution): bool return match ($this->filters->outcome) { 'merged' => !$merged, 'open' => $state !== 'open', - 'closed' => !($state === 'closed' && !$merged), + 'closed' => $state !== 'closed' || $merged, }; } diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php index 2c44b529a..3e536350d 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php @@ -219,6 +219,28 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution ->and($data['people'][0]['prs'])->toBe(1); }); +it('keeps only truly-closed PRs under outcome=closed: !(A ∧ ¬B) ≡ ¬A ∨ B', function (string $state, bool $merged, bool $shouldKeep): void { + contribution($this->tenant, [ + 'actor_login' => 'a', + 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', + 'occurred_at' => '2026-06-02', + 'metadata' => ['state' => $state, 'merged' => $merged], + ]); + + $filters = RetrospectiveFilters::make($this->since, $this->until, outcome: 'closed'); + $data = ($this->build)($filters); + + // meta.prs counts PRs surviving reject(filteredOutByOutcome). + expect($data['meta']['prs'])->toBe($shouldKeep ? 1 : 0); +})->with([ + // state, merged, keep? + 'closed + unmerged → kept (the only true "closed")' => ['closed', false, true], + 'closed + merged → dropped (it is a merge, not a pure close)' => ['closed', true, false], + 'open + unmerged → dropped (state !== closed)' => ['open', false, false], + 'open + merged → dropped (state !== closed)' => ['open', true, false], +]); + it('ordena o ranking por linhas quando sort=lines', function (): void { contribution($this->tenant, ['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]); contribution($this->tenant, ['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]); diff --git a/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php index 2f2f3c247..433fc64e5 100644 --- a/app-modules/profile/src/Models/Profile.php +++ b/app-modules/profile/src/Models/Profile.php @@ -43,7 +43,7 @@ * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ -#[UseFactory(ProfileFactory::class)] +#[UseFactory(factoryClass: ProfileFactory::class)] #[Table(name: 'user_profiles')] final class Profile extends Model { diff --git a/app-modules/profile/src/Models/WorkExperience.php b/app-modules/profile/src/Models/WorkExperience.php index a6db349e8..a1e41b722 100644 --- a/app-modules/profile/src/Models/WorkExperience.php +++ b/app-modules/profile/src/Models/WorkExperience.php @@ -26,7 +26,7 @@ * @property CarbonInterface|null $updated_at * @property-read Profile $profile */ -#[UseFactory(WorkExperienceFactory::class)] +#[UseFactory(factoryClass: WorkExperienceFactory::class)] #[Table(name: 'work_experiences')] final class WorkExperience extends Model { diff --git a/app-modules/squads/composer.json b/app-modules/squads/composer.json index c7b79a4c9..784beeb38 100644 --- a/app-modules/squads/composer.json +++ b/app-modules/squads/composer.json @@ -20,7 +20,7 @@ "extra": { "laravel": { "providers": [ - "He4rt\\Squads\\Providers\\SquadsServiceProvider" + "He4rt\\Squads\\SquadsServiceProvider" ] } } diff --git a/app-modules/squads/src/Providers/SquadsServiceProvider.php b/app-modules/squads/src/SquadsServiceProvider.php similarity index 85% rename from app-modules/squads/src/Providers/SquadsServiceProvider.php rename to app-modules/squads/src/SquadsServiceProvider.php index c9437e8e2..35c7e62f6 100644 --- a/app-modules/squads/src/Providers/SquadsServiceProvider.php +++ b/app-modules/squads/src/SquadsServiceProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace He4rt\Squads\Providers; +namespace He4rt\Squads; use Illuminate\Support\ServiceProvider; diff --git a/app/Console/Commands/AnalyzeDiscordProfiles.php b/app/Console/Commands/AnalyzeDiscordProfiles.php index a8c4a885a..56288fa92 100644 --- a/app/Console/Commands/AnalyzeDiscordProfiles.php +++ b/app/Console/Commands/AnalyzeDiscordProfiles.php @@ -15,8 +15,8 @@ use function Laravel\Prompts\table; use function Laravel\Prompts\warning; -#[Description('Analyze scraped Discord profiles and count connected social accounts')] -#[Signature('discord:analyze-profiles')] +#[Description(description: 'Analyze scraped Discord profiles and count connected social accounts')] +#[Signature(signature: 'discord:analyze-profiles')] class AnalyzeDiscordProfiles extends Command { public function handle(): void diff --git a/app/Console/Commands/CommunityReport.php b/app/Console/Commands/CommunityReport.php index b71e5b72e..164a2bc8d 100644 --- a/app/Console/Commands/CommunityReport.php +++ b/app/Console/Commands/CommunityReport.php @@ -21,8 +21,8 @@ use function Laravel\Prompts\table; use function Laravel\Prompts\warning; -#[Description('Generate a comprehensive community analytics report')] -#[Signature('discord:community-report')] +#[Description(description: 'Generate a comprehensive community analytics report')] +#[Signature(signature: 'discord:community-report')] class CommunityReport extends Command { /** @var array */ diff --git a/app/Console/Commands/FetchDiscordMembers.php b/app/Console/Commands/FetchDiscordMembers.php index ce765efcd..81aab108d 100644 --- a/app/Console/Commands/FetchDiscordMembers.php +++ b/app/Console/Commands/FetchDiscordMembers.php @@ -11,8 +11,8 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Sleep; -#[Description('Fetch all guild members from Discord API and save to JSON')] -#[Signature('discord:fetch-members {--limit=0 : Max members to fetch (0 = all)}')] +#[Description(description: 'Fetch all guild members from Discord API and save to JSON')] +#[Signature(signature: 'discord:fetch-members {--limit=0 : Max members to fetch (0 = all)}')] class FetchDiscordMembers extends Command { public function handle(): void diff --git a/app/Console/Commands/FetchDiscordProfile.php b/app/Console/Commands/FetchDiscordProfile.php index 5c28f73a1..6d7be0475 100644 --- a/app/Console/Commands/FetchDiscordProfile.php +++ b/app/Console/Commands/FetchDiscordProfile.php @@ -9,8 +9,8 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; -#[Description('Fetch a Discord user profile including connected accounts using a user token')] -#[Signature('discord:fetch-profile +#[Description(description: 'Fetch a Discord user profile including connected accounts using a user token')] +#[Signature(signature: 'discord:fetch-profile {user_id : Discord user ID to fetch} {--token= : Discord user token (or set DISCORD_USER_TOKEN in .env)}')] class FetchDiscordProfile extends Command diff --git a/app/Console/Commands/FetchDiscordProfiles.php b/app/Console/Commands/FetchDiscordProfiles.php index 3a64c4276..5fce87e00 100644 --- a/app/Console/Commands/FetchDiscordProfiles.php +++ b/app/Console/Commands/FetchDiscordProfiles.php @@ -20,8 +20,8 @@ use function Laravel\Prompts\task; use function Laravel\Prompts\warning; -#[Description('Fetch full Discord profiles for all members, tracking progress across runs')] -#[Signature('discord:fetch-profiles +#[Description(description: 'Fetch full Discord profiles for all members, tracking progress across runs')] +#[Signature(signature: 'discord:fetch-profiles {--token= : Discord user token (or set DISCORD_USER_TOKEN in .env)} {--members-file=discord/members.json : Path to members JSON} {--limit=0 : Max profiles to fetch this run (0 = all remaining)} diff --git a/app/Console/Commands/FixPostSwitchTimestampsCommand.php b/app/Console/Commands/FixPostSwitchTimestampsCommand.php index e130e9155..8d54f5258 100644 --- a/app/Console/Commands/FixPostSwitchTimestampsCommand.php +++ b/app/Console/Commands/FixPostSwitchTimestampsCommand.php @@ -16,8 +16,8 @@ use function Laravel\Prompts\task; use function Laravel\Prompts\warning; -#[Description('Fix post-switch timestamp data after ALTER migrations (Option D Step 2)')] -#[Signature('maintenance:fix-post-switch-timestamps +#[Description(description: 'Fix post-switch timestamp data after ALTER migrations (Option D Step 2)')] +#[Signature(signature: 'maintenance:fix-post-switch-timestamps {--dry-run : Show what would be done without altering data} {--module= : Process only a specific module} {--table= : Process only a specific table}')] diff --git a/app/Console/Commands/GenerateDiscordTenant.php b/app/Console/Commands/GenerateDiscordTenant.php index a671680bc..3e1e821a0 100644 --- a/app/Console/Commands/GenerateDiscordTenant.php +++ b/app/Console/Commands/GenerateDiscordTenant.php @@ -13,8 +13,8 @@ use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -#[Description('Command description')] -#[Signature('misc:generate-discord-tenant {guildId}')] +#[Description(description: 'Command description')] +#[Signature(signature: 'misc:generate-discord-tenant {guildId}')] class GenerateDiscordTenant extends Command { /** diff --git a/app/Console/Commands/ImportDiscordMembers.php b/app/Console/Commands/ImportDiscordMembers.php index 374acbbbb..171eeb614 100644 --- a/app/Console/Commands/ImportDiscordMembers.php +++ b/app/Console/Commands/ImportDiscordMembers.php @@ -14,8 +14,8 @@ use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Support\Facades\Storage; -#[Description('Import Discord members and GitHub connections from JSON files into the database')] -#[Signature('discord:import-members +#[Description(description: 'Import Discord members and GitHub connections from JSON files into the database')] +#[Signature(signature: 'discord:import-members {--members-file=discord/members.json : Path to members JSON (relative to storage/app/private)} {--github-file=discord/github_connections.json : Path to GitHub connections JSON} {--dry-run : Show what would be imported without writing} diff --git a/app/Geo/Models/GeoCountry.php b/app/Geo/Models/GeoCountry.php new file mode 100644 index 000000000..97600ca5b --- /dev/null +++ b/app/Geo/Models/GeoCountry.php @@ -0,0 +1,47 @@ + + */ + protected $schema = [ + 'id' => 'integer', + 'iso2' => 'string', + 'iso3' => 'string', + 'name' => 'string', + ]; + + /** + * @return list> + */ + public function getRows(): array + { + return Cache::remember( + 'geo.countries', + 60 * 60 * 24 * 30, + static fn (): array => resolve(WorldApiClient::class)->countries(), + ); + } +} diff --git a/app/Geo/Support/GeoLocation.php b/app/Geo/Support/GeoLocation.php new file mode 100644 index 000000000..949044665 --- /dev/null +++ b/app/Geo/Support/GeoLocation.php @@ -0,0 +1,135 @@ + ISO3 => country name + */ + public static function countries(): array + { + return GeoCountry::query() + ->orderBy('name') + ->get() + ->mapWithKeys(static fn (GeoCountry $country): array => [ + $country->iso3 => $country->name, + ]) + ->all(); + } + + public static function countryLabel(?string $iso3): ?string + { + if (blank($iso3)) { + return null; + } + + /** @var string|null $name */ + $name = GeoCountry::query() + ->where('iso3', mb_strtoupper($iso3)) + ->value('name'); + + return $name; + } + + /** + * @return array state name => state name + */ + public static function statesFor(?string $iso3): array + { + return collect(self::statesPayload($iso3)) + ->sortBy('name') + ->mapWithKeys(static fn (array $state): array => [ + (string) $state['name'] => (string) $state['name'], + ]) + ->all(); + } + + /** + * @return array city name => city name + */ + public static function citiesFor(?string $iso3, ?string $stateName, ?string $search = null): array + { + if (blank($stateName)) { + return []; + } + + /** @var array|null $state */ + $state = collect(self::statesPayload($iso3)) + ->firstWhere('name', $stateName); + + if ($state === null) { + return []; + } + + /** @var list> $cities */ + $cities = $state['cities'] ?? []; + $term = mb_trim((string) $search); + + return collect($cities) + ->when($term !== '', static fn (Collection $collection): Collection => $collection->filter( + static fn (array $city): bool => self::matches((string) $city['name'], $term), + )) + ->sortBy('name') + ->take(50) + ->mapWithKeys(static fn (array $city): array => [ + (string) $city['name'] => (string) $city['name'], + ]) + ->all(); + } + + public static function formatLocation(?string $city, ?string $state, ?string $iso3): ?string + { + $country = filled($iso3) ? (self::countryLabel($iso3) ?? $iso3) : null; + + $location = collect([$city, $state, $country]) + ->filter() + ->implode(', '); + + return $location !== '' ? $location : null; + } + + /** + * @return list> + */ + private static function statesPayload(?string $iso3): array + { + if (blank($iso3)) { + return []; + } + + $iso3 = mb_strtoupper($iso3); + + /** @var list> $payload */ + $payload = Cache::remember('geo.states.'.$iso3, self::CACHE_TTL, static function () use ($iso3): array { + /** @var string|null $iso2 */ + $iso2 = GeoCountry::query()->where('iso3', $iso3)->value('iso2'); + + if (blank($iso2)) { + return []; + } + + return resolve(WorldApiClient::class)->states((string) $iso2); + }); + + return $payload; + } + + private static function matches(string $value, string $term): bool + { + return str_contains( + Str::lower(Str::ascii($value)), + Str::lower(Str::ascii($term)), + ); + } +} diff --git a/app/Geo/WorldApiClient.php b/app/Geo/WorldApiClient.php new file mode 100644 index 000000000..c64eaf5a8 --- /dev/null +++ b/app/Geo/WorldApiClient.php @@ -0,0 +1,53 @@ +> + */ + public function countries(): array + { + return $this->get('/countries', [ + 'fields' => 'iso2,iso3', + ]); + } + + /** + * @return list> + */ + public function states(string $iso2): array + { + return $this->get('/states', [ + 'filters' => ['country_code' => $iso2], + 'fields' => 'cities', + ]); + } + + /** + * @param array $query + * @return list> + */ + private function get(string $path, array $query = []): array + { + $response = Http::baseUrl(mb_rtrim($this->baseUrl, '/')) + ->acceptJson() + ->get($path, $query); + + $response->throw(); + + /** @var list> $data */ + $data = $response->json('data', []); + + return $data; + } +} diff --git a/app/Http/Controllers/SwitchLocaleController.php b/app/Http/Controllers/SwitchLocaleController.php new file mode 100644 index 000000000..283ae17d4 --- /dev/null +++ b/app/Http/Controllers/SwitchLocaleController.php @@ -0,0 +1,20 @@ + $locale]); + + return back(); + } +} diff --git a/app/Http/Middleware/SetApplicationLocale.php b/app/Http/Middleware/SetApplicationLocale.php new file mode 100644 index 000000000..6f202800b --- /dev/null +++ b/app/Http/Middleware/SetApplicationLocale.php @@ -0,0 +1,23 @@ +app->bind(PaginatorInterface::class, Paginator::class); + $this->app->singleton(WorldApiClient::class, fn (): WorldApiClient => new WorldApiClient( + config()->string('geo.world_api_url'), + )); + $this->registerDebugbar(); $this->registerTelescope(); } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 12abd930f..4685346fb 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -6,6 +6,7 @@ use App\Enums\FilamentPanel; use App\Filament\Pages\Login; +use App\Http\Middleware\SetApplicationLocale; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -53,6 +54,7 @@ public function panel(Panel $panel): Panel EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, + SetApplicationLocale::class, AuthenticateSession::class, ShareErrorsFromSession::class, PreventRequestForgery::class, diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 3d225ca57..52cb04081 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -5,6 +5,7 @@ namespace App\Providers\Filament; use App\Enums\FilamentPanel; +use App\Http\Middleware\SetApplicationLocale; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -13,7 +14,10 @@ use Filament\PanelProvider; use Filament\Support\Colors\Color; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\PanelApp\Pages\EventPage; +use He4rt\PanelApp\Pages\EventsPage; use He4rt\PanelApp\Pages\LoginPage; +use He4rt\PanelApp\Pages\MyEventsPage; use He4rt\PanelApp\Pages\ProfilePage; use He4rt\PanelApp\Pages\ThreadPage; use He4rt\PanelApp\Pages\TimelinePage; @@ -46,6 +50,9 @@ public function panel(Panel $panel): Panel ->discoverWidgets(in: app_path('Filament/App/Widgets'), for: 'App\Filament\App\Widgets') ->pages([ TimelinePage::class, + EventsPage::class, + MyEventsPage::class, + EventPage::class, ThreadPage::class, ProfilePage::class, ]) @@ -53,6 +60,7 @@ public function panel(Panel $panel): Panel EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, + SetApplicationLocale::class, AuthenticateSession::class, ShareErrorsFromSession::class, PreventRequestForgery::class, diff --git a/app/Providers/FilamentServiceProvider.php b/app/Providers/FilamentServiceProvider.php index 4077959aa..134113479 100644 --- a/app/Providers/FilamentServiceProvider.php +++ b/app/Providers/FilamentServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Enums\FilamentPanel; +use App\Support\ApplicationLocale; use Filament\Actions\Action; use Filament\Forms\Components\Builder; use Filament\Forms\Components\DateTimePicker; @@ -12,6 +13,7 @@ use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Panel; +use Filament\Schemas\Schema; use Filament\Support\Enums\Alignment; use Filament\Support\Enums\VerticalAlignment; use Filament\Support\Enums\Width; @@ -44,6 +46,7 @@ public function boot(): void $this->configureBuilder(); $this->configureSelectFilter(); $this->configureTable(); + $this->configureSchema(); } public function register(): void @@ -123,7 +126,18 @@ private function configureTable(): void ->defaultPaginationPageOption(10) ->filtersFormWidth(Width::Medium) ->paginated([10, 25, 50]) - ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle)); + ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle) + ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat()) + ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat()) + ->defaultTimeDisplayFormat('H:i:s')); + } + + private function configureSchema(): void + { + Schema::configureUsing(fn (Schema $schema): Schema => $schema + ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat()) + ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat()) + ->defaultTimeDisplayFormat('H:i:s')); } private function configureSelect(): void @@ -143,6 +157,8 @@ private function configureDateTimePicker(): void ->seconds(condition: false) ->minDate(now()->subYears(25)) ->maxDate(now()->addYears(25)) + ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat()) + ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat()) ->translateLabel()); } diff --git a/app/Support/ApplicationLocale.php b/app/Support/ApplicationLocale.php new file mode 100644 index 000000000..e39be1a7e --- /dev/null +++ b/app/Support/ApplicationLocale.php @@ -0,0 +1,70 @@ + + */ + public const array SUPPORTED = [ + self::EN, + self::PT_BR, + ]; + + public static function isSupported(string $locale): bool + { + return in_array($locale, self::SUPPORTED, strict: true); + } + + public static function resolve(): string + { + $locale = session(self::SESSION_KEY, config('app.locale', self::EN)); + + if (!is_string($locale) || !self::isSupported($locale)) { + return self::EN; + } + + return $locale; + } + + public static function apply(string $locale): void + { + app()->setLocale($locale); + Date::setLocale(self::carbonLocale($locale)); + } + + public static function carbonLocale(string $locale): string + { + return match ($locale) { + self::PT_BR => 'pt_BR', + default => 'en', + }; + } + + public static function dateFormat(): string + { + return match (app()->getLocale()) { + self::PT_BR => 'd/m/Y', + default => 'M j, Y', + }; + } + + public static function dateTimeFormat(): string + { + return match (app()->getLocale()) { + self::PT_BR => 'd/m/Y H:i:s', + default => 'M j, Y H:i:s', + }; + } +} diff --git a/app/Support/PestShardPlugin.php b/app/Support/PestShardPlugin.php new file mode 100644 index 000000000..6992dc70d --- /dev/null +++ b/app/Support/PestShardPlugin.php @@ -0,0 +1,558 @@ +\tests\...` (lowercase + * `tests`), so they match nothing, get filtered OUT of every shard, and run in + * NO shard — 87/945 tests were silently skipped in sharded CI while it stayed + * green. This plugin generalizes that one regex to `([^:]+)` so every test + * class is discovered regardless of namespace. + * + * This class is a non-official plugin, so Pest's Loader sorts it BEFORE the + * official `Pest\Plugins\Shard`; it consumes `--shard` first (via popArgument), + * leaving the built-in one a no-op. Everything else mirrors the upstream Shard + * plugin verbatim so `--update-shards` / time-balancing keep working. + * + * Remove once Pest fixes the upstream regex. + * + * @see https://mozex.dev/blog/2-fixing-pest-sharding-for-modular-laravel-applications + * @see https://github.com/pestphp/pest/issues/1445 + * @see https://github.com/pestphp/pest/issues/1711 + * @see https://github.com/pestphp/pest/issues/1688 + */ +final class PestShardPlugin implements AddsOutput, HandlesArguments, Terminable +{ + use HandleArguments; + + private const string SHARD_OPTION = 'shard'; + + /** + * The shard index and total number of shards. + * + * @var array{ + * index: int, + * total: int, + * testsRan: int, + * testsCount: int + * }|null + */ + private static ?array $shard = null; + + /** + * Whether to update the shards.json file. + */ + private static bool $updateShards = false; + + /** + * Whether time-balanced sharding was used. + */ + private static bool $timeBalanced = false; + + /** + * Whether the shards.json file is outdated. + */ + private static bool $shardsOutdated = false; + + /** + * Whether the test suite passed. + */ + private static bool $passed = false; + + /** + * Collected timings from workers or subscribers. + * + * @var array|null + */ + private static ?array $collectedTimings = null; + + /** + * The canonical list of test classes from --list-tests. + * + * @var list|null + */ + private static ?array $knownTests = null; + + /** + * Creates a new Plugin instance. + */ + public function __construct( + private readonly OutputInterface $output, + ) {} + + /** + * Returns the shard information. + * + * @return array{index: int, total: int} + */ + public static function getShard(InputInterface $input): array + { + if ($input->hasParameterOption('--'.self::SHARD_OPTION)) { + $shard = $input->getParameterOption('--'.self::SHARD_OPTION); + } else { + $shard = null; + } + + if (!is_string($shard) || !preg_match('/^\d+\/\d+$/', $shard)) { + throw new InvalidOption('The [--shard] option must be in the format "index/total".'); + } + + [$index, $total] = explode('/', $shard); + + if (!is_numeric($index) || !is_numeric($total)) { + throw new InvalidOption('The [--shard] option must be in the format "index/total".'); + } + + if ($index <= 0 || $total <= 0 || $index > $total) { + throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.'); + } + + $index = (int) $index; + $total = (int) $total; + + return [ + 'index' => $index, + 'total' => $total, + ]; + } + + /** + * {@inheritDoc} + */ + public function handleArguments(array $arguments): array + { + if ($this->hasArgument('--update-shards', $arguments)) { + return $this->handleUpdateShards($arguments); + } + + if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) { + self::$updateShards = true; + + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); + + return $arguments; + } + + if (!$this->hasArgument('--shard', $arguments)) { + return $arguments; + } + + // @phpstan-ignore-next-line + $input = new ArgvInput($arguments); + + ['index' => $index, 'total' => $total] = self::getShard($input); + + $arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument( + "$index/$total", + $arguments, + ))); + + /** @phpstan-ignore-next-line */ + $tests = $this->allTests($arguments); + + $timings = $this->loadShardsFile(); + if ($timings !== null) { + $knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test]))); + $newTests = array_values(array_diff($tests, $knownTests)); + + $partitions = $this->partitionByTime($knownTests, $timings, $total); + + foreach ($newTests as $i => $test) { + $partitions[$i % $total][] = $test; + } + + $testsToRun = $partitions[$index - 1] ?? []; + self::$timeBalanced = true; + self::$shardsOutdated = $newTests !== []; + } else { + $testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? []; + } + + self::$shard = [ + 'index' => $index, + 'total' => $total, + 'testsRan' => count($testsToRun), + 'testsCount' => count($tests), + ]; + + if ($testsToRun === []) { + return $arguments; + } + + return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)]; + } + + /** + * Adds output after the Test Suite execution. + */ + public function addOutput(int $exitCode): int + { + self::$passed = $exitCode === 0; + + if (self::$updateShards && self::$passed && !Parallel::isWorker()) { + self::$collectedTimings = $this->collectTimings(); + + $count = self::$knownTests !== null + ? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests))) + : count(self::$collectedTimings); + + $this->output->writeln(sprintf( + ' Shards: shards.json updated with timings for %d test class%s.', + $count, + $count === 1 ? '' : 'es', + )); + } + + if (self::$shard === null) { + return $exitCode; + } + + [ + 'index' => $index, + 'total' => $total, + 'testsRan' => $testsRan, + 'testsCount' => $testsCount, + ] = self::$shard; + + $this->output->writeln(sprintf( + ' Shard: %d of %d — %d file%s ran, out of %d%s.', + $index, + $total, + $testsRan, + $testsRan === 1 ? '' : 's', + $testsCount, + self::$timeBalanced ? ' (time-balanced)' : '', + )); + + if (self::$shardsOutdated) { + $this->output->writeln(' WARN The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.'); + } + + return $exitCode; + } + + /** + * Terminates the plugin. + */ + public function terminate(): void + { + if (!self::$updateShards) { + return; + } + + if (Parallel::isWorker()) { + $this->writeWorkerTimings(); + + return; + } + + if (!self::$passed) { + return; + } + + $timings = self::$collectedTimings ?? $this->collectTimings(); + + if ($timings === []) { + return; + } + + $this->writeTimings($timings); + } + + /** + * Handles the --update-shards argument. + * + * @param array $arguments + * @return array + */ + private function handleUpdateShards(array $arguments): array + { + if ($this->hasArgument('--shard', $arguments)) { + throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].'); + } + + $arguments = $this->popArgument('--update-shards', $arguments); + + self::$updateShards = true; + + /** @phpstan-ignore-next-line */ + self::$knownTests = $this->allTests($arguments); + + if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) { + Parallel::setGlobal('UPDATE_SHARDS', true); + Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true)); + } else { + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); + } + + return $arguments; + } + + /** + * Returns all tests that the test suite would run. + * + * @param list $arguments + * @return list + */ + private function allTests(array $arguments): array + { + $output = (new Process([ + 'php', + ...$this->removeParallelArguments($arguments), + '--list-tests', + ]))->setTimeout(120)->mustRun()->getOutput(); + + // Upstream hard-codes `Tests\\` here, which excludes modular tests + // namespaced `Appmodules\\tests\...`. Match ANY class instead. + preg_match_all('/ - (?:P\\\\)?([^:]+)::/', $output, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * @param array $arguments + * @return array + */ + private function removeParallelArguments(array $arguments): array + { + return array_filter($arguments, fn (string $argument): bool => !in_array($argument, ['--parallel', '-p'], strict: true)); + } + + /** + * Builds the filter argument for the given tests to run. + */ + private function buildFilterArgument(mixed $testsToRun): string + { + return addslashes(implode('|', $testsToRun)); + } + + /** + * Collects timings from subscribers or worker temp files. + * + * @return array + */ + private function collectTimings(): array + { + $runId = Parallel::getGlobal('SHARD_RUN_ID'); + + if (is_string($runId)) { + return $this->readWorkerTimings($runId); + } + + return EnsureShardTimingsAreCollected::timings(); + } + + /** + * Writes the current worker's timing data to a temp file. + */ + private function writeWorkerTimings(): void + { + $timings = EnsureShardTimingsAreCollected::timings(); + + if ($timings === []) { + return; + } + + $runId = Parallel::getGlobal('SHARD_RUN_ID'); + + if (!is_string($runId)) { + return; + } + + $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json'; + + file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR)); + } + + /** + * Reads and merges timing data from all worker temp files. + * + * @return array + */ + private function readWorkerTimings(string $runId): array + { + $pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json'; + $files = glob($pattern); + + if ($files === false || $files === []) { + return []; + } + + $merged = []; + + foreach ($files as $file) { + $contents = file_get_contents($file); + + if ($contents === false) { + continue; + } + + $timings = json_decode($contents, true); + + if (is_array($timings)) { + $merged = array_merge($merged, $timings); + } + + unlink($file); + } + + return $merged; + } + + /** + * Returns the path to shards.json. + */ + private function shardsPath(): string + { + $testSuite = TestSuite::getInstance(); + + return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']); + } + + /** + * Loads the timings from shards.json. + * + * @return array|null + */ + private function loadShardsFile(): ?array + { + $path = $this->shardsPath(); + + if (!file_exists($path)) { + return null; + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.'); + } + + $data = json_decode($contents, true); + + if (!is_array($data) || !isset($data['timings']) || !is_array($data['timings'])) { + throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.'); + } + + return $data['timings']; + } + + /** + * Partitions tests across shards using the LPT (Longest Processing Time) algorithm. + * + * @param list $tests + * @param array $timings + * @return list> + */ + private function partitionByTime(array $tests, array $timings, int $total): array + { + $knownTimings = array_filter( + array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests), + fn (?float $t): bool => $t !== null, + ); + + $median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0; + + $testsWithTimings = array_map( + fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median], + $tests, + ); + + usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']); + + /** @var list> */ + $bins = array_fill(0, $total, []); + /** @var non-empty-list */ + $binTimes = array_fill(0, $total, 0.0); + + foreach ($testsWithTimings as $item) { + $minIndex = array_search(min($binTimes), $binTimes, strict: true); + assert(is_int($minIndex)); + + $bins[$minIndex][] = $item['test']; + $binTimes[$minIndex] += $item['time']; + } + + return $bins; + } + + /** + * Calculates the median of an array of floats. + * + * @param list $values + */ + private function median(array $values): float + { + sort($values); + + $count = count($values); + $middle = (int) floor($count / 2); + + if ($count % 2 === 0) { + return ($values[$middle - 1] + $values[$middle]) / 2; + } + + return $values[$middle]; + } + + /** + * Writes the timings to shards.json. + * + * @param array $timings + */ + private function writeTimings(array $timings): void + { + $path = $this->shardsPath(); + + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + if (self::$knownTests !== null) { + $knownSet = array_flip(self::$knownTests); + $timings = array_intersect_key($timings, $knownSet); + } + + ksort($timings); + + $canonical = self::$knownTests ?? array_keys($timings); + sort($canonical); + + file_put_contents($path, json_encode([ + 'timings' => $timings, + 'checksum' => md5(implode("\n", $canonical)), + 'updated_at' => date('c'), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index bb3bcbe0e..86c27f279 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Middleware\SetApplicationLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -19,6 +20,10 @@ TrustProxies::class, Monicahq\Cloudflare\Http\Middleware\TrustProxies::class ); + + $middleware->web(append: [ + SetApplicationLocale::class, + ]); }) ->withExceptions(static function (Exceptions $exceptions): void {}) ->create(); diff --git a/composer.json b/composer.json index da144666b..e84e22e26 100644 --- a/composer.json +++ b/composer.json @@ -9,36 +9,37 @@ "license": "MIT", "require": { "php": "^8.4", + "bacon/bacon-qr-code": "^2.0.8", "calebporzio/sushi": "^2.5.4", - "dedoc/scramble": "^0.13.30", - "filament/filament": "^5.6.7", - "filament/spatie-laravel-media-library-plugin": "^5.6.7", - "guzzlehttp/guzzle": "^7.13.1", - "he4rt/activity": ">=1", - "he4rt/bot-discord": ">=1.0", - "he4rt/community": ">=1", - "he4rt/docs": ">=1", - "he4rt/economy": ">=1", - "he4rt/events": ">=1", - "he4rt/gamification": ">=1", - "he4rt/he4rt-core": ">=1", - "he4rt/identity": ">=1", - "he4rt/integration-devto": ">=1", - "he4rt/integration-discord": ">=1", - "he4rt/integration-github": ">=1", - "he4rt/integration-twitch": ">=1", - "he4rt/integration-whatsapp": ">=1", - "he4rt/moderation": "^1.0", - "he4rt/onboarding": ">=1", - "he4rt/panel-admin": ">=1", - "he4rt/panel-app": ">=1", - "he4rt/portal": ">=1", - "he4rt/profile": ">=1", - "he4rt/squads": ">=1", + "dedoc/scramble": "^0.13.32", + "filament/filament": "^5.6.8", + "filament/spatie-laravel-media-library-plugin": "^5.6.8", + "guzzlehttp/guzzle": "^7.14.0", + "he4rt/activity": "^1.0.0", + "he4rt/bot-discord": "^1.0.0", + "he4rt/community": "^1.0.0", + "he4rt/docs": "^1.0.0", + "he4rt/economy": "^1.0.0", + "he4rt/events": "^1.0.0", + "he4rt/gamification": "^1.0.0", + "he4rt/he4rt-core": "^1.0.0", + "he4rt/identity": "^1.0.0", + "he4rt/integration-devto": "^1.0.0", + "he4rt/integration-discord": "^1.0.0", + "he4rt/integration-github": "^1.0.0", + "he4rt/integration-twitch": "^1.0.0", + "he4rt/integration-whatsapp": "^1.0.0", + "he4rt/moderation": "^1.0.0", + "he4rt/onboarding": "^1.0.0", + "he4rt/panel-admin": "^1.0.0", + "he4rt/panel-app": "^1.0.0", + "he4rt/portal": "^1.0.0", + "he4rt/profile": "^1.0.0", + "he4rt/squads": "^1.0.0", "internachi/modular": "^3.0.2", "laracord/framework": "dev-next#e7b64d6", - "laravel/framework": "^13.17.0", - "laravel/nightwatch": "^1.28.3", + "laravel/framework": "^13.19.0", + "laravel/nightwatch": "^1.28.4", "laravel/sanctum": "^4.3.2", "laravel/telescope": "^5.20.0", "laravel/tinker": "^3.0.2", @@ -58,22 +59,22 @@ "require-dev": { "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", - "fruitcake/laravel-debugbar": "^4.3.0", + "fruitcake/laravel-debugbar": "^4.4.0", "larastan/larastan": "^3.10.0", - "laravel/boost": "^2.4.10", + "laravel/boost": "^2.4.12", "laravel/pail": "^1.2.7", "laravel/pao": "^1.1.2", "laravel/pint": "^1.29.3", "mockery/mockery": "^1.6.12", - "mrpunyapal/rector-pest": "^0.2.15", + "mrpunyapal/rector-pest": "^0.2.17", "nunomaduro/collision": "^8.9.4", - "pestphp/pest": "^4.7.4", + "pestphp/pest": "^4.7.5", "pestphp/pest-plugin-drift": "^4.1.0", "pestphp/pest-plugin-faker": "^4.0.0", "pestphp/pest-plugin-laravel": "^4.1.0", "pestphp/pest-plugin-livewire": "^4.1.0", "phpstan/extension-installer": "^1.4.3", - "rector/rector": "^2.5.2" + "rector/rector": "^2.5.5" }, "autoload": { "psr-4": { @@ -93,16 +94,25 @@ "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi" + "@php artisan package:discover --ansi", + "@php artisan filament:upgrade", + "@php artisan modules:sync --no-phpunit --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], + "pre-package-uninstall": [ + "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall" + ], "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", + "@php -r \"file_exists('.env.testing') || copy('.env.testing.example', '.env.testing');\"" ], - "post-create-project-cmd": [ + "key:generate": [ "@php artisan key:generate --ansi", + "@php artisan key:generate --ansi --env=testing" + ], + "post-create-project-cmd": [ "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", "@php artisan migrate --graceful --ansi" ], @@ -110,8 +120,8 @@ "composer install", "npm install", "composer run-script post-root-package-install", + "composer run-script key:generate", "composer run-script post-create-project-cmd", - "@php artisan key:generate --ansi", "@php artisan storage:link --ansi" ], "bot": "@php artisan bot:boot", @@ -144,6 +154,11 @@ "barryvdh/laravel-debugbar", "laravel/telescope" ] + }, + "pest": { + "plugins": [ + "App\\Support\\PestShardPlugin" + ] } }, "config": { diff --git a/composer.lock b/composer.lock index 0d5ccabcf..938bb3d83 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,127 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d99073592af48a849b8d049d16bddaee", + "content-hash": "8486c7b6b0ca0dda54288a27381865a1", "packages": [ + { + "name": "anourvalar/eloquent-serialize", + "version": "1.3.10", + "source": { + "type": "git", + "url": "https://github.com/AnourValar/eloquent-serialize.git", + "reference": "2be26f176b764a2d6f20118bfa4b125f71fa88f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/2be26f176b764a2d6f20118bfa4b125f71fa88f8", + "reference": "2be26f176b764a2d6f20118bfa4b125f71fa88f8", + "shasum": "" + }, + "require": { + "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.26", + "laravel/legacy-factories": "^1.1", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^9.5|^10.5|^11.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "EloquentSerialize": "AnourValar\\EloquentSerialize\\Facades\\EloquentSerializeFacade" + } + } + }, + "autoload": { + "psr-4": { + "AnourValar\\EloquentSerialize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Query Builder (Eloquent) serialization", + "homepage": "https://github.com/AnourValar/eloquent-serialize", + "keywords": [ + "anourvalar", + "builder", + "copy", + "eloquent", + "job", + "laravel", + "query", + "querybuilder", + "queue", + "serializable", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/AnourValar/eloquent-serialize/issues", + "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.10" + }, + "time": "2026-06-20T14:30:25+00:00" + }, + { + "name": "bacon/bacon-qr-code", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + }, + "time": "2022-12-07T17:46:57+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.7.0", @@ -77,16 +196,16 @@ }, { "name": "blade-ui-kit/blade-icons", - "version": "1.10.0", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/driesvints/blade-icons.git", - "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a" + "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a", - "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/6e072d021ea6249986c330b93293c33d0c4f0e34", + "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34", "shasum": "" }, "require": { @@ -154,7 +273,7 @@ "type": "paypal" } ], - "time": "2026-04-23T19:03:45+00:00" + "time": "2026-06-30T09:44:12+00:00" }, { "name": "brick/math", @@ -643,16 +762,16 @@ }, { "name": "composer/composer", - "version": "2.10.1", + "version": "2.10.2", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "4120703b9bda8795075047b40361d7ec4d2abe49" + "reference": "8d4439f572a97670a9edc039eb3b093cc976b4bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/4120703b9bda8795075047b40361d7ec4d2abe49", - "reference": "4120703b9bda8795075047b40361d7ec4d2abe49", + "url": "https://api.github.com/repos/composer/composer/zipball/8d4439f572a97670a9edc039eb3b093cc976b4bc", + "reference": "8d4439f572a97670a9edc039eb3b093cc976b4bc", "shasum": "" }, "require": { @@ -740,7 +859,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.10.1" + "source": "https://github.com/composer/composer/tree/2.10.2" }, "funding": [ { @@ -752,20 +871,20 @@ "type": "github" } ], - "time": "2026-06-04T08:25:59+00:00" + "time": "2026-07-01T09:24:45+00:00" }, { "name": "composer/metadata-minifier", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/composer/metadata-minifier.git", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/8e86142e3ade750b837d55e55f0cdc786d8da006", + "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006", "shasum": "" }, "require": { @@ -773,8 +892,8 @@ }, "require-dev": { "composer/composer": "^2", - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1", + "symfony/phpunit-bridge": "^4.2 || ^5 || ^6 || ^7" }, "type": "library", "extra": { @@ -805,7 +924,7 @@ ], "support": { "issues": "https://github.com/composer/metadata-minifier/issues", - "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + "source": "https://github.com/composer/metadata-minifier/tree/1.0.1" }, "funding": [ { @@ -815,13 +934,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2021-04-07T13:37:33+00:00" + "time": "2026-06-30T11:34:59+00:00" }, { "name": "composer/pcre", @@ -1223,18 +1338,68 @@ ], "time": "2026-03-16T11:29:23+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dedoc/scramble", - "version": "v0.13.30", + "version": "v0.13.32", "source": { "type": "git", "url": "https://github.com/dedoc/scramble.git", - "reference": "e727d2ac2a7561528f90296226788727937f9a28" + "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dedoc/scramble/zipball/e727d2ac2a7561528f90296226788727937f9a28", - "reference": "e727d2ac2a7561528f90296226788727937f9a28", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/546a727720d4c38f2248e7c63b43178fbea7a96e", + "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e", "shasum": "" }, "require": { @@ -1294,7 +1459,7 @@ ], "support": { "issues": "https://github.com/dedoc/scramble/issues", - "source": "https://github.com/dedoc/scramble/tree/v0.13.30" + "source": "https://github.com/dedoc/scramble/tree/v0.13.32" }, "funding": [ { @@ -1302,7 +1467,7 @@ "type": "github" } ], - "time": "2026-06-26T16:57:26+00:00" + "time": "2026-07-09T09:04:02+00:00" }, { "name": "dflydev/dot-access-data", @@ -2006,19 +2171,20 @@ }, { "name": "filament/actions", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/actions.git", - "reference": "4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9" + "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/actions/zipball/4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9", - "reference": "4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9", + "url": "https://api.github.com/repos/filamentphp/actions/zipball/1c479a47ee1612f7c05a7921b2491230aeed392c", + "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c", "shasum": "" }, "require": { + "anourvalar/eloquent-serialize": "^1.3.6", "filament/forms": "self.version", "filament/infolists": "self.version", "filament/notifications": "self.version", @@ -2050,20 +2216,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:27:06+00:00" + "time": "2026-07-02T08:19:13+00:00" }, { "name": "filament/filament", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/panels.git", - "reference": "2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a" + "reference": "c54ec7692245787057c24f82d92d9fee7bc24941" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/panels/zipball/2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a", - "reference": "2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a", + "url": "https://api.github.com/repos/filamentphp/panels/zipball/c54ec7692245787057c24f82d92d9fee7bc24941", + "reference": "c54ec7692245787057c24f82d92d9fee7bc24941", "shasum": "" }, "require": { @@ -2107,20 +2273,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:24:15+00:00" + "time": "2026-07-02T08:17:14+00:00" }, { "name": "filament/forms", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/forms.git", - "reference": "f7b5e8701982408f84e1b12fcca75eadad47905b" + "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/forms/zipball/f7b5e8701982408f84e1b12fcca75eadad47905b", - "reference": "f7b5e8701982408f84e1b12fcca75eadad47905b", + "url": "https://api.github.com/repos/filamentphp/forms/zipball/7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb", + "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb", "shasum": "" }, "require": { @@ -2157,20 +2323,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:27:44+00:00" + "time": "2026-07-02T08:16:27+00:00" }, { "name": "filament/infolists", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/infolists.git", - "reference": "0b87686a37160bf7f8bccae1eedc733bbf928dc9" + "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/infolists/zipball/0b87686a37160bf7f8bccae1eedc733bbf928dc9", - "reference": "0b87686a37160bf7f8bccae1eedc733bbf928dc9", + "url": "https://api.github.com/repos/filamentphp/infolists/zipball/01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7", + "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7", "shasum": "" }, "require": { @@ -2202,20 +2368,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:28:23+00:00" + "time": "2026-07-02T08:19:40+00:00" }, { "name": "filament/notifications", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/notifications.git", - "reference": "28ce63bf4e378a4e38efac7b1f0519a5fed4b352" + "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/notifications/zipball/28ce63bf4e378a4e38efac7b1f0519a5fed4b352", - "reference": "28ce63bf4e378a4e38efac7b1f0519a5fed4b352", + "url": "https://api.github.com/repos/filamentphp/notifications/zipball/a2b4564d9c7fcb5b8e904da40b4f6c2782934071", + "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071", "shasum": "" }, "require": { @@ -2249,20 +2415,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:27:42+00:00" + "time": "2026-07-02T08:08:59+00:00" }, { "name": "filament/query-builder", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/query-builder.git", - "reference": "fb4b6cdebe6ab2c233cc78d3688a64f022263c56" + "reference": "401d0d61f5ead5d440f184857e1921675138fd5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/fb4b6cdebe6ab2c233cc78d3688a64f022263c56", - "reference": "fb4b6cdebe6ab2c233cc78d3688a64f022263c56", + "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/401d0d61f5ead5d440f184857e1921675138fd5a", + "reference": "401d0d61f5ead5d440f184857e1921675138fd5a", "shasum": "" }, "require": { @@ -2295,20 +2461,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-05-27T16:17:11+00:00" + "time": "2026-07-02T08:20:10+00:00" }, { "name": "filament/schemas", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/schemas.git", - "reference": "c3ecdfe73a215927caaf7b28bc5ae2b3891e805b" + "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/schemas/zipball/c3ecdfe73a215927caaf7b28bc5ae2b3891e805b", - "reference": "c3ecdfe73a215927caaf7b28bc5ae2b3891e805b", + "url": "https://api.github.com/repos/filamentphp/schemas/zipball/036549f1b6cbf3d908e1b9fa42f5e221d1db3240", + "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240", "shasum": "" }, "require": { @@ -2340,11 +2506,11 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:28:03+00:00" + "time": "2026-07-02T08:20:18+00:00" }, { "name": "filament/spatie-laravel-media-library-plugin", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", @@ -2381,16 +2547,16 @@ }, { "name": "filament/support", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/support.git", - "reference": "caa2bf186de5b32a789d3c7167bc63db153386a1" + "reference": "024caa553010c2617bb90114eb0e78883e33138b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/support/zipball/caa2bf186de5b32a789d3c7167bc63db153386a1", - "reference": "caa2bf186de5b32a789d3c7167bc63db153386a1", + "url": "https://api.github.com/repos/filamentphp/support/zipball/024caa553010c2617bb90114eb0e78883e33138b", + "reference": "024caa553010c2617bb90114eb0e78883e33138b", "shasum": "" }, "require": { @@ -2435,20 +2601,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:28:36+00:00" + "time": "2026-07-02T08:20:15+00:00" }, { "name": "filament/tables", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/tables.git", - "reference": "bb98022d73347eeb090976ae0730147289135ffb" + "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/tables/zipball/bb98022d73347eeb090976ae0730147289135ffb", - "reference": "bb98022d73347eeb090976ae0730147289135ffb", + "url": "https://api.github.com/repos/filamentphp/tables/zipball/bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a", + "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a", "shasum": "" }, "require": { @@ -2481,20 +2647,20 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:28:26+00:00" + "time": "2026-07-02T08:20:10+00:00" }, { "name": "filament/widgets", - "version": "v5.6.7", + "version": "v5.6.8", "source": { "type": "git", "url": "https://github.com/filamentphp/widgets.git", - "reference": "8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc" + "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filamentphp/widgets/zipball/8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc", - "reference": "8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc", + "url": "https://api.github.com/repos/filamentphp/widgets/zipball/329c92e8560536fd6dd738b45eaf6ba063cd53ea", + "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea", "shasum": "" }, "require": { @@ -2525,7 +2691,7 @@ "issues": "https://github.com/filamentphp/filament/issues", "source": "https://github.com/filamentphp/filament" }, - "time": "2026-06-08T09:28:17+00:00" + "time": "2026-07-02T08:19:55+00:00" }, { "name": "fruitcake/php-cors", @@ -2662,22 +2828,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.13.1", + "version": "7.14.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" + "reference": "aef242412e13128b5049864867bb49fc37dd39de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", - "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", + "reference": "aef242412e13128b5049864867bb49fc37dd39de", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.4", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -2689,7 +2855,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", @@ -2770,7 +2936,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.1" + "source": "https://github.com/guzzle/guzzle/tree/7.14.0" }, "funding": [ { @@ -2786,20 +2952,20 @@ "type": "tidelift" } ], - "time": "2026-06-29T20:14:18+00:00" + "time": "2026-07-08T22:54:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { @@ -2854,7 +3020,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -2870,20 +3036,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.12.4", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "shasum": "" }, "require": { @@ -2973,7 +3139,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.12.4" }, "funding": [ { @@ -2989,20 +3155,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-07-08T15:56:20+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.8", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176", + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176", "shasum": "" }, "require": { @@ -3059,7 +3225,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.9" }, "funding": [ { @@ -3075,7 +3241,7 @@ "type": "tidelift" } ], - "time": "2026-06-23T13:02:23+00:00" + "time": "2026-07-08T16:19:22+00:00" }, { "name": "he4rt/activity", @@ -3613,13 +3779,13 @@ "dist": { "type": "path", "url": "app-modules/onboarding", - "reference": "3a220d43dbb84baa4f55ad195653e0cf99c92c84" + "reference": "323beee88a6e398ee6655e9b2051ad55525c9920" }, "type": "library", "extra": { "laravel": { "providers": [ - "He4rt\\Onboarding\\Providers\\OnboardingServiceProvider" + "He4rt\\Onboarding\\OnboardingServiceProvider" ] } }, @@ -3799,13 +3965,13 @@ "dist": { "type": "path", "url": "app-modules/squads", - "reference": "6d710964545ed1c8635e42b11780e181211075e7" + "reference": "9f403fc7ce21863d32cead1e6e8718adea29bdf8" }, "type": "library", "extra": { "laravel": { "providers": [ - "He4rt\\Squads\\Providers\\SquadsServiceProvider" + "He4rt\\Squads\\SquadsServiceProvider" ] } }, @@ -4240,16 +4406,16 @@ }, { "name": "laravel/framework", - "version": "v13.17.0", + "version": "v13.19.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "0802b7a81f3252d78200b8037ac183a686a529f0" + "reference": "514502b38e11bd676ecf83b271c9452cc7500f16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/0802b7a81f3252d78200b8037ac183a686a529f0", - "reference": "0802b7a81f3252d78200b8037ac183a686a529f0", + "url": "https://api.github.com/repos/laravel/framework/zipball/514502b38e11bd676ecf83b271c9452cc7500f16", + "reference": "514502b38e11bd676ecf83b271c9452cc7500f16", "shasum": "" }, "require": { @@ -4460,20 +4626,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-06-23T19:42:45+00:00" + "time": "2026-07-07T14:13:33+00:00" }, { "name": "laravel/nightwatch", - "version": "v1.28.3", + "version": "v1.28.4", "source": { "type": "git", "url": "https://github.com/laravel/nightwatch.git", - "reference": "375f05b589e53d90560dd4aaac9f53d26d1d0433" + "reference": "c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/nightwatch/zipball/375f05b589e53d90560dd4aaac9f53d26d1d0433", - "reference": "375f05b589e53d90560dd4aaac9f53d26d1d0433", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb", + "reference": "c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb", "shasum": "" }, "require": { @@ -4554,7 +4720,7 @@ "issues": "https://github.com/laravel/nightwatch/issues", "source": "https://github.com/laravel/nightwatch" }, - "time": "2026-06-24T05:04:12+00:00" + "time": "2026-06-30T06:54:16+00:00" }, { "name": "laravel/prompts", @@ -5215,16 +5381,16 @@ }, { "name": "league/flysystem", - "version": "3.35.1", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -5292,9 +5458,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-06-25T06:52:23+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", @@ -6668,20 +6834,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -6720,9 +6885,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/termwind", @@ -7344,16 +7509,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "version": "2.3.3", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "shasum": "" }, "require": { @@ -7385,9 +7550,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2026-07-08T07:01:06+00:00" }, { "name": "pragmarx/google2fa", @@ -13606,16 +13771,16 @@ }, { "name": "team-reflex/discord-php", - "version": "v10.50.0", + "version": "v10.51.0", "source": { "type": "git", "url": "https://github.com/discord-php/DiscordPHP.git", - "reference": "11fa8eddc78cde79b7f9eb185a02c91683178942" + "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/11fa8eddc78cde79b7f9eb185a02c91683178942", - "reference": "11fa8eddc78cde79b7f9eb185a02c91683178942", + "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/02f7c48e5e1af09564f77b70efed429acc52d96f", + "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f", "shasum": "" }, "require": { @@ -13687,7 +13852,7 @@ "chat": "https://discord.gg/dphp", "docs": "https://discord-php.github.io/DiscordPHP/", "issues": "https://github.com/discord-php/DiscordPHP/issues", - "source": "https://github.com/discord-php/DiscordPHP/tree/v10.50.0", + "source": "https://github.com/discord-php/DiscordPHP/tree/v10.51.0", "wiki": "https://github.com/discord-php/DiscordPHP/wiki" }, "funding": [ @@ -13708,7 +13873,7 @@ "type": "patreon" } ], - "time": "2026-06-26T17:59:46+00:00" + "time": "2026-07-01T05:08:27+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -13884,16 +14049,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.6.4", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", "shasum": "" }, "require": { @@ -13952,7 +14117,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" }, "funding": [ { @@ -13964,7 +14129,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-07-06T19:11:50+00:00" }, { "name": "voku/portable-ascii", @@ -14416,16 +14581,16 @@ }, { "name": "fruitcake/laravel-debugbar", - "version": "v4.3.0", + "version": "v4.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/laravel-debugbar.git", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c" + "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/3d76ea8d78b82225b92789de65fc630c1cd8e80c", - "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c", + "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/80ef956bda9e1a5824037d6f2cd06e73092e5634", + "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634", "shasum": "" }, "require": { @@ -14433,7 +14598,7 @@ "illuminate/session": "^11|^12|^13.0", "illuminate/support": "^11|^12|^13.0", "php": "^8.2", - "php-debugbar/php-debugbar": "^3.7.2", + "php-debugbar/php-debugbar": "^3.8.0", "php-debugbar/symfony-bridge": "^1.1" }, "replace": { @@ -14441,6 +14606,7 @@ }, "require-dev": { "larastan/larastan": "^3", + "laravel/ai": "^0.8", "laravel/octane": "^2", "laravel/pennant": "^1", "laravel/pint": "^1", @@ -14502,7 +14668,7 @@ ], "support": { "issues": "https://github.com/fruitcake/laravel-debugbar/issues", - "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.3.0" + "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.0" }, "funding": [ { @@ -14514,7 +14680,7 @@ "type": "github" } ], - "time": "2026-06-04T07:54:01+00:00" + "time": "2026-07-04T08:30:57+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -14822,16 +14988,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.10", + "version": "v2.4.12", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb" + "reference": "d43bdf901fee8d216145cb062c9847c892844908" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/080189f51c8d27c0792a03483a70adc7770f6eeb", - "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb", + "url": "https://api.github.com/repos/laravel/boost/zipball/d43bdf901fee8d216145cb062c9847c892844908", + "reference": "d43bdf901fee8d216145cb062c9847c892844908", "shasum": "" }, "require": { @@ -14884,7 +15050,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-06-09T10:21:08+00:00" + "time": "2026-07-08T09:53:34+00:00" }, { "name": "laravel/mcp", @@ -15339,16 +15505,16 @@ }, { "name": "mrpunyapal/rector-pest", - "version": "0.2.15", + "version": "0.2.17", "source": { "type": "git", "url": "https://github.com/MrPunyapal/rector-pest.git", - "reference": "624dba596ad1605e34a2ec7ac5eb5fffce960596" + "reference": "967b5fe54c524f24a616e6e65327cb4f30bf9486" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MrPunyapal/rector-pest/zipball/624dba596ad1605e34a2ec7ac5eb5fffce960596", - "reference": "624dba596ad1605e34a2ec7ac5eb5fffce960596", + "url": "https://api.github.com/repos/MrPunyapal/rector-pest/zipball/967b5fe54c524f24a616e6e65327cb4f30bf9486", + "reference": "967b5fe54c524f24a616e6e65327cb4f30bf9486", "shasum": "" }, "require": { @@ -15395,7 +15561,7 @@ ], "support": { "issues": "https://github.com/MrPunyapal/rector-pest/issues", - "source": "https://github.com/MrPunyapal/rector-pest/tree/0.2.15" + "source": "https://github.com/MrPunyapal/rector-pest/tree/0.2.17" }, "funding": [ { @@ -15403,7 +15569,7 @@ "type": "github" } ], - "time": "2026-05-15T18:51:02+00:00" + "time": "2026-07-03T10:14:25+00:00" }, { "name": "nunomaduro/collision", @@ -15503,16 +15669,16 @@ }, { "name": "pestphp/pest", - "version": "v4.7.4", + "version": "v4.7.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb" + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb", - "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", "shasum": "" }, "require": { @@ -15535,11 +15701,11 @@ "webmozart/assert": "<1.11.0" }, "require-dev": { - "mrpunyapal/peststan": "^0.2.10", + "mrpunyapal/peststan": "^0.2.11", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", - "psy/psysh": "^0.12.23" + "psy/psysh": "^0.12.24" }, "bin": [ "bin/pest" @@ -15606,7 +15772,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.7.4" + "source": "https://github.com/pestphp/pest/tree/v4.7.5" }, "funding": [ { @@ -15618,7 +15784,7 @@ "type": "github" } ], - "time": "2026-06-25T19:09:05+00:00" + "time": "2026-07-06T17:06:29+00:00" }, { "name": "pestphp/pest-plugin", @@ -16286,16 +16452,16 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v3.7.6", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56" + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/1690ee1728827f9deb4b60457fa387cf44672c56", - "reference": "1690ee1728827f9deb4b60457fa387cf44672c56", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/18ced90d4b882ed449b2278fea8692f8f7d1c13c", + "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c", "shasum": "" }, "require": { @@ -16337,7 +16503,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.8-dev" } }, "autoload": { @@ -16372,7 +16538,7 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.7.6" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.8.0" }, "funding": [ { @@ -16384,7 +16550,7 @@ "type": "github" } ], - "time": "2026-04-30T07:31:44+00:00" + "time": "2026-07-02T12:38:20+00:00" }, { "name": "php-debugbar/symfony-bridge", @@ -16678,11 +16844,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.2", + "version": "2.2.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", - "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "shasum": "" }, "require": { @@ -16738,7 +16904,7 @@ "type": "github" } ], - "time": "2026-06-05T09:00:01+00:00" + "time": "2026-07-05T06:31:06+00:00" }, { "name": "phpunit/php-code-coverage", @@ -17177,16 +17343,16 @@ }, { "name": "rector/rector", - "version": "2.5.2", + "version": "2.5.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24" + "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", - "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e", + "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e", "shasum": "" }, "require": { @@ -17225,7 +17391,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.5.2" + "source": "https://github.com/rectorphp/rector/tree/2.5.5" }, "funding": [ { @@ -17233,7 +17399,7 @@ "type": "github" } ], - "time": "2026-06-22T11:39:33+00:00" + "time": "2026-07-09T09:48:44+00:00" }, { "name": "sebastian/cli-parser", diff --git a/config/app-modules.php b/config/app-modules.php index 159bd93c6..1420eb140 100644 --- a/config/app-modules.php +++ b/config/app-modules.php @@ -85,7 +85,7 @@ 'composer.json' => base_path('stubs/app-modules/composer-stub.json'), 'phpstan.neon' => base_path('stubs/app-modules/phpstan.neon'), 'phpstan.ignore.neon' => base_path('stubs/app-modules/phpstan.ignore.neon'), - 'src/Providers/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'), + 'src/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'), 'tests/Unit/.gitkeep' => base_path('stubs/app-modules/.gitkeep'), 'tests/Feature/.gitkeep' => base_path('stubs/app-modules/.gitkeep'), 'database/factories/.gitkeep' => base_path('stubs/app-modules/.gitkeep'), diff --git a/config/geo.php b/config/geo.php new file mode 100644 index 000000000..d42109a50 --- /dev/null +++ b/config/geo.php @@ -0,0 +1,18 @@ + env('GEO_WORLD_API_URL', 'https://world.bmbc.cloud/api'), + +]; diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/database/migrations/2026_07_08_010345_widen_addresses_state_length.php b/database/migrations/2026_07_08_010345_widen_addresses_state_length.php new file mode 100644 index 000000000..9c060b1a7 --- /dev/null +++ b/database/migrations/2026_07_08_010345_widen_addresses_state_length.php @@ -0,0 +1,27 @@ +string('state', 120)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('addresses', static function (Blueprint $table): void { + $table->string('state', 4)->nullable()->change(); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0f56a1814..361e9e762 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use He4rt\Events\Database\Seeders\EventsSeeder; use Illuminate\Database\Seeder; final class DatabaseSeeder extends Seeder @@ -16,6 +17,7 @@ public function run(): void { $this->call([ BaseSeeder::class, + EventsSeeder::class, ]); } } diff --git a/docs/agents/domain.md b/docs/agents/domain.md deleted file mode 100644 index 206319759..000000000 --- a/docs/agents/domain.md +++ /dev/null @@ -1,43 +0,0 @@ -# Domain Docs - -How the engineering skills should consume this repo's domain documentation when exploring the codebase. - -## Before exploring, read these - -- **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read each one relevant to the topic. -- **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check `app-modules//docs/adr/` for module-scoped decisions. - -If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. - -## File structure - -This is a multi-context repo (modular monorepo via `internachi/modular`): - -``` -/ -├── CONTEXT-MAP.md <- system-wide context map -├── docs/adr/ <- system-wide decisions -└── app-modules/ - ├── moderation/ - │ ├── CONTEXT.md - │ └── docs/adr/ <- module-specific decisions - ├── bot-discord/ - │ ├── CONTEXT.md - │ └── docs/adr/ - ├── identity/ - │ ├── CONTEXT.md - │ └── docs/adr/ - └── ... -``` - -## Use the glossary's vocabulary - -When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. - -If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). - -## Flag ADR conflicts - -If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: - -> _Contradicts ADR-0007 — but worth reopening because..._ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md deleted file mode 100644 index e8569160e..000000000 --- a/docs/agents/issue-tracker.md +++ /dev/null @@ -1,22 +0,0 @@ -# Issue tracker: GitHub - -Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI for all operations. - -## Conventions - -- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. -- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. -- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. -- **Comment on an issue**: `gh issue comment --body "..."` -- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` -- **Close**: `gh issue close --comment "..."` - -Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. - -## When a skill says "publish to the issue tracker" - -Create a GitHub issue. - -## When a skill says "fetch the relevant ticket" - -Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md deleted file mode 100644 index a1c862949..000000000 --- a/docs/agents/triage-labels.md +++ /dev/null @@ -1,15 +0,0 @@ -# Triage Labels - -The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. - -| Label in skills | Label in our tracker | Meaning | -| ----------------- | -------------------- | ---------------------------------------- | -| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | -| `needs-info` | `needs-info` | Waiting on reporter for more information | -| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | -| `ready-for-human` | `ready-for-human` | Requires human implementation | -| `wontfix` | `wontfix` | Will not be actioned | - -When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. - -Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/lang/en/app.php b/lang/en/app.php new file mode 100644 index 000000000..8f8917c0e --- /dev/null +++ b/lang/en/app.php @@ -0,0 +1,12 @@ + [ + 'english' => 'English', + 'english_short' => 'EN', + 'portuguese' => 'Português (Brasil)', + 'portuguese_short' => 'PT-BR', + ], +]; diff --git a/lang/pt_BR/app.php b/lang/pt_BR/app.php new file mode 100644 index 000000000..8f8917c0e --- /dev/null +++ b/lang/pt_BR/app.php @@ -0,0 +1,12 @@ + [ + 'english' => 'English', + 'english_short' => 'EN', + 'portuguese' => 'Português (Brasil)', + 'portuguese_short' => 'PT-BR', + ], +]; diff --git a/package-lock.json b/package-lock.json index b0efcf12b..72e833fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "3.0.0", "license": "MIT", "devDependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.2", "concurrently": "10.0.3", @@ -18,19 +18,19 @@ "laravel-vite-plugin": "3.1.0", "lint-staged": "17.0.8", "npm-check-updates": "22.2.9", - "prettier": "3.9.3", + "prettier": "3.9.4", "tailwindcss": "4.3.2", "tw-animate-css": "1.4.0", - "vite": "8.1.0" + "vite": "8.1.4" }, "engines": { "node": "^24" } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "peer": true, @@ -40,9 +40,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "peer": true, @@ -130,9 +130,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -140,9 +140,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -157,9 +157,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -174,9 +174,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -191,9 +191,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -208,9 +208,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -225,9 +225,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -242,9 +242,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -259,9 +259,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -276,9 +276,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -293,9 +293,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -310,9 +310,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -327,9 +327,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -344,9 +344,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -362,10 +362,33 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -380,9 +403,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1526,9 +1549,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1582,9 +1605,9 @@ } }, "node_modules/prettier": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz", - "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { @@ -1622,13 +1645,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1638,21 +1661,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rxjs": { @@ -1729,9 +1752,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1858,17 +1881,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 443a26ee4..4b678305b 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "outdated": "npm-check-updates" }, "devDependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.2", "concurrently": "10.0.3", @@ -25,10 +25,10 @@ "laravel-vite-plugin": "3.1.0", "lint-staged": "17.0.8", "npm-check-updates": "22.2.9", - "prettier": "3.9.3", + "prettier": "3.9.4", "tailwindcss": "4.3.2", "tw-animate-css": "1.4.0", - "vite": "8.1.0" + "vite": "8.1.4" }, "lint-staged": { "*.{js,ts,json,yml,md,css}": [ diff --git a/phpstan.neon b/phpstan.neon index ac57cebb1..6f76e4c21 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,10 @@ parameters: level: 7 checkImplicitMixed: true - paths: - app/ + + # Verbatim copy of Pest's internal Shard plugin (only the discovery regex is + # patched). Treated as third-party code — kept re-syncable with upstream. + excludePaths: + - app/Support/PestShardPlugin.php diff --git a/phpunit.xml b/phpunit.xml index 74f048c08..c6b90c572 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,7 +21,6 @@ - diff --git a/rector.php b/rector.php index dd7180d32..e95c5f606 100644 --- a/rector.php +++ b/rector.php @@ -3,10 +3,8 @@ declare(strict_types=1); use Rector\Caching\ValueObject\Storage\FileCacheStorage; -use Rector\CodingStyle\Rector\Closure\StaticClosureRector; use Rector\CodingStyle\Rector\PostInc\PostIncDecToPreIncDecRector; use Rector\Config\RectorConfig; -use Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector; use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector; use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector; use RectorLaravel\Rector\Class_\AddHasFactoryToModelsRector; @@ -78,8 +76,10 @@ AddHasFactoryToModelsRector::class, AddOverrideAttributeToOverriddenMethodsRector::class, PostIncDecToPreIncDecRector::class, - StaticCallOnNonStaticToInstanceCallRector::class, __DIR__.'/bootstrap/cache', + // Verbatim copy of Pest's internal Shard plugin (only the discovery + // regex is patched) — keep it re-syncable with upstream. + __DIR__.'/app/Support/PestShardPlugin.php', ]) ->withCache(cacheDirectory: __DIR__.'/.rector.result.cache', cacheClass: FileCacheStorage::class) ->withImportNames(removeUnusedImports: true) @@ -111,7 +111,6 @@ ReplaceFakerInstanceWithHelperRector::class, ConfigToTypedConfigMethodCallRector::class, EnsureTypeChecksFirstRector::class, - StaticClosureRector::class, ...$laravel13Attributes, ]) ->withSets([ diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css index 6b2c0eb56..40308c886 100644 --- a/resources/css/filament/admin/theme.css +++ b/resources/css/filament/admin/theme.css @@ -1,5 +1,6 @@ @import '../../../../vendor/filament/filament/resources/css/theme.css'; @import '../../../../vendor/livewire/flux/dist/flux.css'; +@import '../locale-switcher.css' layer(components); @source '../../../../app/Filament/**/*'; @source '../../../../resources/views/**/*'; diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css index 8bc6c0311..d6f968e49 100644 --- a/resources/css/filament/app/theme.css +++ b/resources/css/filament/app/theme.css @@ -1,4 +1,5 @@ @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../locale-switcher.css' layer(components); @source '../../../../app/Filament/**/*'; @source '../../../../app/Filament/**/*'; @@ -47,3 +48,15 @@ opacity: 0.35; } } + +/* Keep user avatar circle visible against dark sidebar + (default ui-avatars uses gray-950, which blends into dark mode) */ +.fi-sidebar .fi-user-avatar { + @apply ring-1 ring-gray-300 dark:ring-white/20; +} + +/* Soft edge between sidebar navigation and main content + (same weight as Filament form input borders) */ +.fi-sidebar { + @apply border-e border-gray-200 dark:border-white/10; +} diff --git a/resources/css/filament/locale-switcher.css b/resources/css/filament/locale-switcher.css new file mode 100644 index 000000000..9b89b86c8 --- /dev/null +++ b/resources/css/filament/locale-switcher.css @@ -0,0 +1,15 @@ +.fi-locale-switcher { + @apply grid grid-flow-col gap-x-1; +} + +.fi-locale-switcher-btn { + @apply flex justify-center rounded-md px-3 py-2 text-xs font-semibold tracking-wide outline-hidden transition duration-75 hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5; + + &.fi-active { + @apply text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-white/5; + } + + &:not(.fi-active) { + @apply text-gray-400 hover:text-gray-500 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 dark:focus-visible:text-gray-400; + } +} diff --git a/resources/views/components/filament/locale-switcher/button.blade.php b/resources/views/components/filament/locale-switcher/button.blade.php new file mode 100644 index 000000000..84c581405 --- /dev/null +++ b/resources/views/components/filament/locale-switcher/button.blade.php @@ -0,0 +1,26 @@ +@props (['label', 'locale', 'active' => false]) + +@php + $ariaLabel = match ($locale) { + \App\Support\ApplicationLocale::EN => __('app.locale.english'), + \App\Support\ApplicationLocale::PT_BR => __('app.locale.portuguese'), + default => $label, + }; +@endphp + + $active, + 'text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus-visible:bg-gray-50 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:bg-white/5 dark:hover:text-gray-400 dark:focus-visible:bg-white/5' => !$active + ]) + x-on:click="close()" +> + {{ $label }} + diff --git a/resources/views/components/filament/locale-switcher/dropdown.blade.php b/resources/views/components/filament/locale-switcher/dropdown.blade.php new file mode 100644 index 000000000..e029ec636 --- /dev/null +++ b/resources/views/components/filament/locale-switcher/dropdown.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/components/filament/locale-switcher/index.blade.php b/resources/views/components/filament/locale-switcher/index.blade.php new file mode 100644 index 000000000..35c89c614 --- /dev/null +++ b/resources/views/components/filament/locale-switcher/index.blade.php @@ -0,0 +1,19 @@ +@php + use App\Support\ApplicationLocale; + + $currentLocale = app()->getLocale(); +@endphp + +
+ + + +
diff --git a/resources/views/vendor/filament-panels/components/user-menu.blade.php b/resources/views/vendor/filament-panels/components/user-menu.blade.php new file mode 100644 index 000000000..3d958e341 --- /dev/null +++ b/resources/views/vendor/filament-panels/components/user-menu.blade.php @@ -0,0 +1,137 @@ +@props([ + 'position' => null, +]) + +@php + use Filament\Actions\Action; + use Filament\Enums\UserMenuPosition; + use Illuminate\Support\Arr; + + $user = filament()->auth()->user(); + + $items = $this->getUserMenuItems(); + + $itemsBeforeAndAfterThemeSwitcher = collect($items) + ->groupBy(fn (Action $item): bool => $item->getSort() < 0, preserveKeys: true) + ->all(); + $itemsBeforeThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[true] ?? collect(); + $itemsAfterThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[false] ?? collect(); + + $hasProfileHeader = $itemsBeforeThemeSwitcher->has('profile') && + blank(($item = Arr::first($itemsBeforeThemeSwitcher))->getUrl()) && + (! $item->hasAction()); + + if ($itemsBeforeThemeSwitcher->has('profile')) { + $itemsBeforeThemeSwitcher = $itemsBeforeThemeSwitcher->prepend($itemsBeforeThemeSwitcher->pull('profile'), 'profile'); + } + + $position ??= filament()->getUserMenuPosition(); + + $isSidebarCollapsibleOnDesktop = filament()->isSidebarCollapsibleOnDesktop(); +@endphp + +{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_BEFORE) }} + + + + @if ($position === UserMenuPosition::Topbar) + + @else + + @endif + + + @if ($hasProfileHeader) + @php + $item = $itemsBeforeThemeSwitcher['profile']; + $itemColor = $item->getColor(); + $itemIcon = $item->getIcon(); + + unset($itemsBeforeThemeSwitcher['profile']); + @endphp + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }} + + + {{ $item->getLabel() }} + + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }} + @endif + + @if ($itemsBeforeThemeSwitcher->isNotEmpty()) + + @foreach ($itemsBeforeThemeSwitcher as $key => $item) + @if ($key === 'profile') + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }} + + {{ $item }} + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }} + @else + {{ $item }} + @endif + @endforeach + + @endif + + @if (filament()->hasDarkMode() && (! filament()->hasDarkModeForced())) + + + + @endif + + + + @if ($itemsAfterThemeSwitcher->isNotEmpty()) + + @foreach ($itemsAfterThemeSwitcher as $key => $item) + @if ($key === 'profile') + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }} + + {{ $item }} + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }} + @else + {{ $item }} + @endif + @endforeach + + @endif + + +{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_AFTER) }} diff --git a/routes/web.php b/routes/web.php index 51d13a585..68bccfd9e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,15 +2,10 @@ declare(strict_types=1); -/* -|-------------------------------------------------------------------------- -| Web Routes -|-------------------------------------------------------------------------- -| -| Here is where you can register web routes for your application. These -| routes are loaded by the RouteServiceProvider within a group which -| contains the "web" middleware group. Now create something great! -| -*/ +use App\Http\Controllers\SwitchLocaleController; +use App\Support\ApplicationLocale; +use Illuminate\Support\Facades\Route; -// Route::get('/', fn () => view('welcome')); +Route::get('/locale/{locale}', SwitchLocaleController::class) + ->whereIn('locale', ApplicationLocale::SUPPORTED) + ->name('locale.switch'); diff --git a/stubs/app-modules/ServiceProvider.php b/stubs/app-modules/ServiceProvider.php index 616bcb0d7..94c269a96 100644 --- a/stubs/app-modules/ServiceProvider.php +++ b/stubs/app-modules/ServiceProvider.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace StubModuleNamespace\StubClassNamePrefix\Providers; +namespace StubModuleNamespace\StubClassNamePrefix; use Illuminate\Support\ServiceProvider; diff --git a/stubs/app-modules/composer-stub.json b/stubs/app-modules/composer-stub.json index ae03850e1..fa7c1b45a 100644 --- a/stubs/app-modules/composer-stub.json +++ b/stubs/app-modules/composer-stub.json @@ -19,7 +19,7 @@ "minimum-stability": "stable", "extra": { "laravel": { - "providers": ["StubModuleNamespace\\StubClassNamePrefix\\Providers\\StubClassNamePrefixServiceProvider"] + "providers": ["StubModuleNamespace\\StubClassNamePrefix\\StubClassNamePrefixServiceProvider"] } } } diff --git a/tests/.pest/shards.json b/tests/.pest/shards.json new file mode 100644 index 000000000..fa7917d70 --- /dev/null +++ b/tests/.pest/shards.json @@ -0,0 +1,161 @@ +{ + "timings": { + "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 2.095, + "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 0.8115, + "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 1.1597, + "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 1.1538, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 0.5821, + "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 0.5406, + "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 0.5695, + "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 0.7663, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 0.8555, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 0.4472, + "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 0.6389, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 0.1645, + "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 0.5703, + "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.1508, + "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 0.1945, + "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 0.4563, + "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2335, + "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.2337, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.5523, + "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.5776, + "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 5.7378, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.5881, + "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.3027, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.0536, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.1009, + "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.301, + "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 0.6902, + "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.4961, + "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.8584, + "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 0.9489, + "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.4992, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.2006, + "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.4947, + "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 0.9454, + "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.3025, + "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.4243, + "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.3033, + "Appmodules\\economy\\tests\\Unit\\CreditTest": 0.1546, + "Appmodules\\economy\\tests\\Unit\\DebitTest": 0.3025, + "Appmodules\\economy\\tests\\Unit\\TransferTest": 0.4794, + "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\EventResourceTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.5448, + "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.5448, + "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 0.5448, + "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.5448, + "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.5448, + "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.704, + "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 2.0406, + "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 0.7491, + "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.98, + "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.5563, + "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 1.5236, + "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.3237, + "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 1.773, + "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 0.8692, + "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1746, + "Appmodules\\integrationdevto\\tests\\Feature\\SyncDevToArticlesTest": 0.404, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 6.8537, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 2.2039, + "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 2.92, + "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1222, + "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.177, + "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 2.2978, + "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 11.8086, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1482, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2467, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1543, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1472, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1466, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.1051, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0979, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1463, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1469, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.1053, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0985, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1462, + "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0975, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.549, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.2128, + "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.8143, + "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.2886, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.4922, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.6587, + "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 1.3299, + "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.413, + "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.5222, + "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.9796, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.4336, + "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.6951, + "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 1.077, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 1.3777, + "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 1.2935, + "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.8392, + "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.5974, + "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.5618, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 3.968, + "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.7962, + "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 1.2011, + "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 1.8584, + "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 1.0644, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.4463, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3201, + "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.3239, + "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 2.0921, + "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 0.4152, + "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.3993, + "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.3526, + "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 0.2867, + "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 2.1791, + "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.2098, + "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 0.8256, + "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.5649, + "Appmodules\\paneladmin\\tests\\Feature\\ApplyTenantScopesTest": 0.266, + "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 1.301, + "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.2831, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 3.4181, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 2.8877, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 2.5896, + "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 1.1423, + "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 0.5448, + "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.5448, + "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 0.5448, + "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 6.1316, + "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.6969, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 1.3169, + "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.5669, + "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.2566, + "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.122, + "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.636, + "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.6384, + "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.4501, + "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.3042, + "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 1.2219, + "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.7305, + "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 1.8365, + "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 5.2888, + "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.258, + "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0502, + "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.1017, + "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1599, + "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.3199, + "Tests\\Feature\\AddressTest": 0.5364, + "Tests\\Feature\\Geo\\GeoLocationTest": 0.4048, + "Tests\\Feature\\LocaleSwitcherTest": 0.5448 + }, + "checksum": "8a07148d596627eadecf191913c62e76", + "updated_at": "2026-07-09T17:13:52+00:00" +} diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/Feature/AddressTest.php b/tests/Feature/AddressTest.php index 98590352a..80d3a9fca 100644 --- a/tests/Feature/AddressTest.php +++ b/tests/Feature/AddressTest.php @@ -46,23 +46,23 @@ $user = User::factory()->create(); $user->address()->updateOrCreate([], [ - 'country' => 'BRA', - 'state' => 'SP', + 'country' => 'BR', + 'state' => 'São Paulo', 'city' => 'São Paulo', ]); expect($user->fresh()->address) - ->country->toBe('BRA') - ->state->toBe('SP') + ->country->toBe('BR') + ->state->toBe('São Paulo') ->city->toBe('São Paulo'); $user->address()->updateOrCreate([], [ - 'state' => 'RJ', + 'state' => 'Rio de Janeiro', 'city' => 'Rio de Janeiro', ]); expect($user->fresh()->address) - ->state->toBe('RJ') + ->state->toBe('Rio de Janeiro') ->city->toBe('Rio de Janeiro') ->and(Address::query()->where('addressable_id', $user->id)->count())->toBe(1); }); diff --git a/tests/Feature/Geo/GeoLocationTest.php b/tests/Feature/Geo/GeoLocationTest.php new file mode 100644 index 000000000..a962f9f5a --- /dev/null +++ b/tests/Feature/Geo/GeoLocationTest.php @@ -0,0 +1,89 @@ + Http::response([ + 'success' => true, + 'data' => [ + ['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil'], + ['id' => 231, 'iso2' => 'US', 'iso3' => 'USA', 'name' => 'United States'], + ], + ]), + 'world.bmbc.cloud/api/states*' => Http::response([ + 'success' => true, + 'data' => [ + ['id' => 485, 'name' => 'Acre', 'cities' => [ + ['id' => 1, 'name' => 'Rio Branco'], + ]], + ['id' => 486, 'name' => 'São Paulo', 'cities' => [ + ['id' => 2, 'name' => 'São Paulo'], + ['id' => 3, 'name' => 'Campinas'], + ]], + ], + ]), + ]); +}); + +it('returns an ISO3 => name map of countries', function (): void { + expect(GeoLocation::countries())->toBe([ + 'BRA' => 'Brazil', + 'USA' => 'United States', + ]); +}); + +it('resolves a country label from an ISO3 code', function (): void { + expect(GeoLocation::countryLabel('BRA'))->toBe('Brazil') + ->and(GeoLocation::countryLabel('USA'))->toBe('United States') + ->and(GeoLocation::countryLabel(iso3: null))->toBeNull(); +}); + +it('returns state names for a country', function (): void { + expect(GeoLocation::statesFor('BRA'))->toBe([ + 'Acre' => 'Acre', + 'São Paulo' => 'São Paulo', + ]); +}); + +it('returns cities embedded in the states payload without a separate request', function (): void { + expect(GeoLocation::citiesFor('BRA', 'São Paulo'))->toBe([ + 'Campinas' => 'Campinas', + 'São Paulo' => 'São Paulo', + ]); + + Http::assertNotSent(static fn ($request): bool => str_contains((string) $request->url(), '/cities')); +}); + +it('filters cities accent-insensitively', function (): void { + expect(GeoLocation::citiesFor('BRA', 'São Paulo', 'sao')) + ->toBe(['São Paulo' => 'São Paulo']); +}); + +it('caches the states payload for a country across calls', function (): void { + GeoLocation::statesFor('BRA'); + GeoLocation::statesFor('BRA'); + + Http::assertSentCount(2); + + $statesRequests = 0; + + Http::recorded(static function ($request) use (&$statesRequests): void { + if (str_contains((string) $request->url(), '/states')) { + $statesRequests++; + } + }); + + expect($statesRequests)->toBe(1); +}); + +it('formats a full location string', function (): void { + expect(GeoLocation::formatLocation('São Paulo', 'São Paulo', 'BRA')) + ->toBe('São Paulo, São Paulo, Brazil'); +}); diff --git a/tests/Feature/LocaleSwitcherTest.php b/tests/Feature/LocaleSwitcherTest.php new file mode 100644 index 000000000..b635ba8e7 --- /dev/null +++ b/tests/Feature/LocaleSwitcherTest.php @@ -0,0 +1,63 @@ +from('/admin') + ->get(route('locale.switch', ['locale' => ApplicationLocale::PT_BR])) + ->assertRedirect('/admin'); + + expect(session(ApplicationLocale::SESSION_KEY))->toBe(ApplicationLocale::PT_BR); +}); + +it('rejects unsupported locale', function (): void { + $this->get('/locale/fr')->assertNotFound(); +}); + +it('applies locale from session via middleware', function (): void { + session([ApplicationLocale::SESSION_KEY => ApplicationLocale::PT_BR]); + + $middleware = new SetApplicationLocale; + $request = Request::create('/'); + + $middleware->handle($request, static function (): ResponseFactory|Response { + expect(app()->getLocale())->toBe(ApplicationLocale::PT_BR) + ->and(Date::getLocale())->toBe('pt_BR'); + + return response('ok'); + }); +}); + +it('formats datetimes according to the active locale', function (): void { + ApplicationLocale::apply(ApplicationLocale::PT_BR); + + expect(ApplicationLocale::dateTimeFormat())->toBe('d/m/Y H:i:s'); + + ApplicationLocale::apply(ApplicationLocale::EN); + + expect(ApplicationLocale::dateTimeFormat())->toBe('M j, Y H:i:s'); +}); + +it('falls back to english for invalid session locale', function (): void { + session([ApplicationLocale::SESSION_KEY => 'invalid']); + + expect(ApplicationLocale::resolve())->toBe(ApplicationLocale::EN); +}); + +it('renders locale switcher with supported locales', function (): void { + $html = Blade::render(''); + + expect($html) + ->toContain('EN') + ->toContain('PT-BR') + ->toContain(route('locale.switch', ['locale' => ApplicationLocale::EN])) + ->toContain(route('locale.switch', ['locale' => ApplicationLocale::PT_BR])); +});