diff --git a/.agents/setup b/.agents/setup index 0a2d0465e6..c21e24f482 100755 --- a/.agents/setup +++ b/.agents/setup @@ -128,7 +128,7 @@ if ! grep -Fqx "$notes_marker" "$agents_file" 2>/dev/null; then # Hi.Events in an orb -- There is no Docker stack in the orb. Run the `docker compose ... exec backend` commands from CLAUDE.md directly in `backend/`, e.g. `php artisan test --testsuite=Unit`, `php artisan test --filter=Name`, `./vendor/bin/pint --test`. +- There is no Docker stack in the orb. Run the `docker compose ... exec backend` commands from AGENTS.md directly in `backend/`, e.g. `php -d memory_limit=1G vendor/bin/phpunit --testsuite=Unit`, `./vendor/bin/pint --test`. - PHP 8.5, Composer, and PostgreSQL 15 are installed natively. Postgres runs under systemd on 127.0.0.1:5432 (role `username` / `password`, databases `backend` and `hievents_test`); the hostname `pgsql` resolves to 127.0.0.1, so `backend/.env` and `backend/.env.testing` work unchanged. - `backend/.env` is set up for a native dev server: `php artisan serve --port 8000` (API at http://localhost:8000, no `/api` prefix), mail goes to the log, files use local disks, no Redis or Mailpit. - `frontend/.env` points at that API; run the SSR dev server with `yarn dev:ssr` in `frontend/` (http://localhost:5678). diff --git a/.agents/skills/api-smoke-test/SKILL.md b/.agents/skills/api-smoke-test/SKILL.md new file mode 100644 index 0000000000..901f0659ca --- /dev/null +++ b/.agents/skills/api-smoke-test/SKILL.md @@ -0,0 +1,37 @@ +--- +name: api-smoke-test +description: Exercise changed backend endpoints against the running dev stack with curl. Use after backend changes to catch wiring bugs unit tests miss (routes, middleware, validation, serialization). +--- + +# API smoke test + +Base URL `https://localhost:8443/api` (self-signed certificate, so `curl -sk`). Routes are in `backend/routes/api.php`. Send `Accept: application/json`, or validation errors come back as a 302 instead of a 422. + +## Get data and a token + +From `docker/development`: + +```bash +docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap +``` + +This creates a verified SUPERADMIN account, an organizer, LIVE single and recurring events, products (the paid one has a waitlist), a promo code and an affiliate, and prints their ids and a Bearer token. + +Manual login (`token` field → `Authorization: Bearer `): + +```bash +curl -sk -X POST https://localhost:8443/api/auth/login -H "Content-Type: application/json" -H "Accept: application/json" -d '{"email":"","password":""}' +``` + +## Request gotchas + +- Product upsert requires `product_type` (TICKET/GENERAL), `type` (FREE/PAID/DONATION/TIERED) and `product_category_id`. +- Promo create requires `applicable_product_ids: []` (empty = all products). +- Public order complete requires `email_confirmation` on the order and on each product entry. +- Publishing needs `accounts.account_verified_at`; `/admin/*` needs role `SUPERADMIN`. `dev:bootstrap` handles both. +- Box office door API (`/api/public/box-offices/{short_id}`): `POST …/sessions` with `operator_name` and a `pin` (from `POST /api/events/{id}/box-offices/{box_office_id}/reset-pin`) returns a token to send as `X-Box-Office-Session`. + +## Side effects + +- Emails land in Mailpit at `http://localhost:8025` (`/api/v1/search?query=to:`). +- Queued work (emails, webhooks) needs a queue worker: `docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work`. diff --git a/.agents/skills/e2e-dev-stack/SKILL.md b/.agents/skills/e2e-dev-stack/SKILL.md new file mode 100644 index 0000000000..bd3fa6cc79 --- /dev/null +++ b/.agents/skills/e2e-dev-stack/SKILL.md @@ -0,0 +1,22 @@ +--- +name: e2e-dev-stack +description: Run Playwright E2E specs against the running dev stack to test uncommitted changes. Use when adding or changing an E2E spec or verifying a user-facing flow end to end. +--- + +# Run E2E specs against the dev stack + +The hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them, so test uncommitted changes against the dev stack. From `e2e/`: + +```bash +E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test +``` + +## Prerequisites + +- `E2E_SAAS_MODE=true` is required: the dev stack requires email verification, and the fixture only confirms through Mailpit in SaaS mode. +- A queue worker must be running to deliver the verification emails: `docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work`. +- Superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`. +- Seating and box office specs need `APP_LICENCE_KEY=development` in `backend/.env`. +- `@stripe` specs need `STRIPE_SECRET_KEY`, the backend's `STRIPE_WEBHOOK_SECRET` and `E2E_STRIPE_CONNECT_ACCOUNT_ID` exported. Webhooks are handled by the queue worker, so poll for the result. + +See "Against the running dev stack" in `e2e/README.md` for more. diff --git a/.agents/skills/translations/SKILL.md b/.agents/skills/translations/SKILL.md index 532b1c1b10..774be3d4dc 100644 --- a/.agents/skills/translations/SKILL.md +++ b/.agents/skills/translations/SKILL.md @@ -1,64 +1,34 @@ --- name: translations -description: Frontend translation workflow using Lingui - extracting, adding, and compiling translations for all supported languages +description: Add or update frontend translations with Lingui. Use after adding or changing any user-facing frontend string, to extract it, translate it into every supported locale and compile. --- -# Frontend Translation Workflow (Lingui) +# Frontend translations (Lingui) -IMPORTANT: Always update translations as you develop features. When adding new translatable strings, immediately add translations for all supported languages. +Every new or changed user-facing string is translated into all supported locales in the same change. -## Core Commands +## Steps ```bash cd frontend - -# Extract translatable strings (use --clean for accurate counts) -yarn messages:extract --clean - -# Compile translations for production -yarn messages:compile - -# Check for untranslated strings -cd scripts && ./list_untranslated_strings.sh +yarn messages:extract --clean # the table shows missing translations per locale ``` -## Process - -1. **Extract**: `yarn messages:extract --clean` -2. **Check**: Look at the output table for missing translation counts -3. **Add translations**: Update the `.po` files for each language -4. **Verify**: Run extract again to confirm 0 missing -5. **Compile**: `yarn messages:compile` +1. Add a translation to every `.po` file in `frontend/src/locales/` except `en.po` (English is the source): -## Adding Translations - -Add entries to each locale's `.po` file in `frontend/src/locales/`: - -```po -#: src/path/to/component.tsx:123 -msgid "Your English String" -msgstr "Translated String" -``` + ```po + #: src/path/to/component.tsx:123 + msgid "Your English String" + msgstr "Translated String" + ``` -## Supported Languages +2. Run `yarn messages:extract --clean` again and confirm 0 missing for each locale. +3. Run `yarn messages:compile`. -| Code | Language | -|------|----------| -| en | English (source - no translation needed) | -| de | Deutsch | -| es | Espanol | -| fr | Francais | -| pt | Portugues | -| pt-br | Portugues do Brasil | -| it | Italiano | -| nl | Nederlands | -| zh-cn | Simplified Chinese | -| zh-hk | Traditional Chinese (HK) | -| vi | Tieng Viet | -| ru | Russian (currently untranslated) | +`frontend/scripts/list_untranslated_strings.sh` lists anything still untranslated. ## Troubleshooting -- **Counts seem wrong**: Use `--clean` flag to remove obsolete entries -- **Translation not appearing**: Run `yarn messages:compile` after adding -- **Syntax errors**: Check for proper escaping of quotes in `.po` files +- Counts look wrong: run extract with `--clean` to drop obsolete entries. +- Translation not showing: run `yarn messages:compile`. +- `.po` syntax errors: check the escaping of quotes. diff --git a/.claude/rules/backend/abuse-and-spam.md b/.claude/rules/backend/abuse-and-spam.md new file mode 100644 index 0000000000..eaaeeb579d --- /dev/null +++ b/.claude/rules/backend/abuse-and-spam.md @@ -0,0 +1,14 @@ +--- +paths: + - "backend/app/**/*Spam*.php" + - "backend/app/**/SpamEvents/**" + - "backend/app/**/*Suspicious*.php" + - "backend/app/**/*Turnstile*.php" + - "backend/app/Validators/Rules/PersonNameRule.php" + - "frontend/src/components/common/Turnstile/**" +--- + +# Abuse & spam + +- Any new public order, waitlist or contact path must respect event status: public orders on non-LIVE events only for the event's own account and never on PENDING_MANUAL_REVIEW; waitlist joins and organizer contact require LIVE. +- New abuse signals go in `SuspiciousActivityDetector`. diff --git a/.claude/rules/backend/account-deletion.md b/.claude/rules/backend/account-deletion.md new file mode 100644 index 0000000000..7f017080af --- /dev/null +++ b/.claude/rules/backend/account-deletion.md @@ -0,0 +1,16 @@ +--- +paths: + - "backend/app/**/DeletionRequest*/**" + - "backend/app/**/*Deletion*.php" + - "backend/app/Services/Domain/Account/Anonymization/**" + - "frontend/src/**/*eletion*" +--- + +# Account deletion + +- Hard delete can't rely on cascades (many foreign keys to events, orders and organizers are NO ACTION). A new account-owned table needs an anonymizer in `Services/Domain/Account/Anonymization/` and a place in the `AccountHardDeletionService` delete order. +- `account_deletion_requests` has no foreign keys on purpose, so rows survive the hard delete. +- Never delete `organizer_configurations` or `account_configuration` rows (shared plan rows) or `ticket_lookup_tokens` (buyer-email scoped across accounts). `stripe_customers` has no account foreign key; match it by Stripe account id. +- Users can belong to several accounts: a sole-account user is scrubbed and soft-deleted, and their `user_identities` rows are deleted so they can't sign back in with single sign-on; a shared user only loses the `account_users` row. +- An account's SSO connection, its domains and the `user_identities` tagged with the connection are deleted before `accounts` (`SsoConnectionAnonymizer` mirrors this). +- Anonymisation nulls the `organizer_settings` free text (`homepage_sections`, `website_url`, `location_details`, `social_media_handles`, `seo_*`). Event `homepage_sections` follow the event description retention rule and stay on the soft-deleted event. diff --git a/.claude/rules/backend/api-keys.md b/.claude/rules/backend/api-keys.md new file mode 100644 index 0000000000..981580e79f --- /dev/null +++ b/.claude/rules/backend/api-keys.md @@ -0,0 +1,22 @@ +--- +paths: + - "backend/app/**/ApiKey*/**" + - "backend/app/**/*ApiKey*.php" + - "backend/app/Services/Domain/Auth/AuthUserService.php" + - "backend/app/Http/Middleware/**" + - "backend/routes/api.php" + - "backend/config/sanctum.php" + - "backend/tests/Feature/Http/ApiKeys/**" + - "frontend/src/components/routes/settings/ApiKeys/**" + - "frontend/src/components/routes/settings/ApiReference/**" + - "e2e/tests/account/api-keys.spec.ts" +--- + +# API keys + +- API keys are Sanctum personal access tokens owned by a user and scoped to one account (`personal_access_tokens.account_id`). A key acts as that member: their role, grants and permission overrides apply, and it can never do more. Read-only keys carry only the `read` ability; `EnforceAccountUserAccess` blocks their writes with `API_KEY_READ_ONLY` (exports and previews marked `#[AllowedForReadOnlyUsers]` stay open). +- Signed-in routes live in two groups. `auth:api,sanctum` accepts keys; `auth:api` is session-only: auth, profile and sign-in security, team, account settings and security, payouts (Stripe Connect, terminal readers, VAT) and API key management. A new route that changes who can sign in, team membership, money destinations or account security goes in the session-only group; `ApiKeyRouteExposureTest` snapshots that group, and no `MANAGE_TEAM`, `MANAGE_ACCOUNT` or `MANAGE_PAYMENTS` action may accept keys. +- Never read JWT claims with `Auth::payload()` on a route keys can reach: it throws under the Sanctum guard. Use `AuthUserService` (`getAuthenticatedAccountId`, `isImpersonating`, `getSessionTokenId`, `logout`). The access cache key for a key request is `api-key:{id}`. +- A key works only while its owner is an ACTIVE member of its account (Sanctum `authenticateAccessTokensUsing` in `AppServiceProvider`); deactivating a member deletes their keys, and so does every `UserSessionRevocationService` call (password reset, SSO link changes, enforcement), because a key must never outlive a credential reset. Revocations driven by one account's SSO settings pass that account id, so only its keys go. A voluntary password change or turning 2FA off keeps keys on purpose (as GitHub and Stripe do); the key-created email tells the owner to revoke a key they didn't create. The account's 2FA requirement and pending deletion also block keys. +- Only the SHA-256 hash and the last four characters are stored; the plain key is returned once, on create. Creating a key needs a verified email and `IdentityConfirmationService`, is refused while impersonating, and is capped at `MAX_ACTIVE_KEYS_PER_MEMBER`. Key names are restricted to a markdown-safe character set because they appear in emails. +- The in-app API reference (`/settings/api-keys/reference`) renders the committed `resources/api-reference/openapi.json` with Scalar, themed through `scalarTheme.ts`. Keep Scalar's AI agent, MCP and telemetry switched off, and load it client-only (it touches `window` on import). diff --git a/.claude/rules/backend/lifecycle-emails.md b/.claude/rules/backend/lifecycle-emails.md new file mode 100644 index 0000000000..7897122705 --- /dev/null +++ b/.claude/rules/backend/lifecycle-emails.md @@ -0,0 +1,18 @@ +--- +paths: + - "backend/app/**/LifecycleEmail*/**" + - "backend/app/**/Lifecycle/**" + - "backend/app/**/*Lifecycle*.php" + - "backend/tests/**/*Lifecycle*" + - "frontend/src/**/LifecycleEmails/**" + - "frontend/src/**/*ifecycle*" +--- + +# Lifecycle emails + +- Send only through `MAIL_LIFECYCLE_MAILER`, test sends included; never fall back to the transactional mailer. Everything lifecycle exists only in SaaS mode (`LifecycleEmailAvailability`). +- Every candidate query must: exclude `users.lifecycle_emails_unsubscribed_at`; skip accounts nudged in the last 3 days (`quietPeriodStart()`); filter in SQL before `LIMIT`, never in PHP after it; and anchor its trigger on a timestamp old data can't move (never `updated_at` unless also bounded by a creation date after `enabled_at`). Marketing-flavoured nudges also require `users.marketing_opted_in_at`. +- A template's `enabled_at` is the cold-start cutoff; never backdate it. +- Unsubscribe is a button on `/emails/unsubscribe/:token`, never unsubscribe-on-GET (link scanners prefetch). +- `account_lifecycle_nudges` is claim-then-send: only transient failures (network, HTTP 5xx/429) release the claim and stop the sweep; any other rejection keeps the claim so one bad address never stalls it. Nudges are sent synchronously, not via `BaseMail`, so failures can be classified. +- Stripe nudges filter by signup timezone (`StripeConnectTimezones`); don't use `organizer_stripe_platforms.stripe_account_details->country`, because unfinished Express accounts report the platform default (`IE`). diff --git a/.claude/rules/backend/mail.md b/.claude/rules/backend/mail.md new file mode 100644 index 0000000000..9db299ae66 --- /dev/null +++ b/.claude/rules/backend/mail.md @@ -0,0 +1,19 @@ +--- +paths: + - "backend/app/Mail/**" + - "backend/app/**/Email/**" + - "backend/app/**/EmailDelivery/**" + - "backend/app/**/EmailTemplate/**" + - "backend/app/**/*Mail*.php" + - "backend/app/**/*Email*.php" + - "backend/resources/views/emails/**" +--- + +# Mail & email delivery + +- Mail queued before a deploy must still unserialize after it: a property added to a mailable is non-readonly with a `null` default. +- A new rich-text email token that carries HTML must be purified at store time and added to `PURIFIED_HTML_PATHS` in `EmailContextHtmlEscaper`; every other token is escaped. +- Email delivery recording (`RecordEmailDeliveryListener`) must never throw: a failed queued mail job is retried and re-sends the email. `email_deliveries` rows hold no recipient or account data. +- Link `email_deliveries`, `outgoing_messages` and `account_lifecycle_nudges` through `provider_message_id`, never with foreign keys. +- Don't add Symfony `TagHeader`s for tracking: Postmark rejects more than one tag per email. +- Pick a delivery webhook receiver by the `{provider}` route segment (`EmailDeliveryWebhookReceiverResolver`), never by sniffing the payload. The webhook route is unthrottled on purpose: unauthenticated calls never touch the DB, and throttling Postmark's few IPs during a broadcast would drop events. diff --git a/.claude/rules/backend/openapi.md b/.claude/rules/backend/openapi.md new file mode 100644 index 0000000000..015bb377c0 --- /dev/null +++ b/.claude/rules/backend/openapi.md @@ -0,0 +1,20 @@ +--- +paths: + - "backend/resources/api-reference/**" + - "backend/app/OpenApi/**" + - "backend/app/Providers/ScrambleServiceProvider.php" + - "backend/config/scramble.php" + - "backend/app/Http/Request/**" + - "backend/app/Resources/**" + - "backend/routes/api.php" +--- + +# OpenAPI docs (Scramble) + +- After adding or changing endpoints run `php artisan scramble:analyze`; it must stay clean. +- Status/type fields in the Order/Event/Attendee/Product resources carry `/** @var 'A'|'B' */` literal-union annotations that render as enums; update them when adding enum cases. +- `CreateOrderRequest`/`CompleteOrderRequest` return their real rules only when a route is bound and a static documentation shape otherwise; keep the two in sync when changing checkout validation. +- Adding a `DomainEventType` case means updating the webhook payload table in `config/scramble.php`. +- `backend/resources/api-reference/openapi.json` is the generated in-app API reference (Settings → API keys → API reference): Scramble output minus session-only operations and `/auth/*`, without `servers` and `info.version` (added when served). Regenerate it with `php artisan api-reference:export`; `IntegrationApiReferenceTest` fails when it drifts. Never edit it by hand. +- The docs intro in `config/scramble.php` promises that endpoints API keys can call stay backward compatible (see "Public API compatibility" in AGENTS.md) and lists the error codes. Update its error table when adding an `error_code`. Session-only operations are marked "Not available to API keys" automatically by `ScrambleServiceProvider`. +- Admin routes and the licensing endpoints (`Enterprise\Licensing` actions) are excluded from all generated docs in `ScrambleServiceProvider`. diff --git a/.claude/rules/backend/sso.md b/.claude/rules/backend/sso.md new file mode 100644 index 0000000000..bf0c1396a5 --- /dev/null +++ b/.claude/rules/backend/sso.md @@ -0,0 +1,22 @@ +--- +paths: + - "backend/app/Services/Domain/Auth/**" + - "backend/app/Services/Application/Handlers/Auth/**" + - "backend/app/Http/Actions/Auth/**" + - "backend/config/sso.php" + - "frontend/src/components/routes/sso/**" + - "frontend/src/components/common/SsoButtons/**" +--- + +# Single sign-on (OIDC) + +- One generic OIDC client (`Services/Domain/Auth/Sso`) serves Google, Microsoft and the operator's `SSO_OIDC_*` provider; no Socialite. Providers exist, and their buttons show, only when their client id and secret are set; there is no separate visibility switch. +- Identities are keyed by (`issuer`, `subject`) in `user_identities`; Microsoft uses the `tid`-substituted issuer and `oid`, never `sub`, `email` or `upn`. Never write the provider email back to `users.email`. +- An email links an existing user only when the provider's email is trusted (`SsoIdentityClaimsMapper`) AND the user was verified in SaaS mode or password sign-in is disabled. Superadmins, untrusted emails and ambiguous matches get `SSO_LINK_REQUIRES_LOGIN` before any membership check, so responses can't reveal account state; never create a signup ticket for an email that already has a user. The first link by email while password sign-in is off clears the old password, trusted devices and sessions. +- Every step after `start` is bound to the browser: the state, the 2FA challenge, the account-choice ticket and the signup ticket all carry the binding hash (`SsoTicketService`, one cache entry per `SsoTicketType`). Keep that when adding a step. +- All logins go through `LoginService::beginLogin`/`completeLogin` with an `AuthContext`; local 2FA always applies. A pending identity is only attached in `completeLogin`, after 2FA and account choice, just before the token is issued. +- Sensitive self-service (link, unlink, email change, 2FA setup/disable) confirms identity with `IdentityConfirmationService`: the password, or for password-less users a `reauth_proof` bound to the user and JWT `jti`. Refuse while impersonating. A missing `auth_time` on reauth is accepted because Microsoft omits it; freshness then relies on `prompt=login` and `max_age=0`. +- `users.tokens_valid_after` revokes older JWTs (`EnsureTokenNotRevoked`): authenticated routes get 401 `SESSION_REVOKED`, public routes treat the request as anonymous, and impersonation tokens also die with the impersonator's sessions. Refresh keeps the original `iat`, so reissue with `issueFreshTokenForCurrentSession` when the current session must survive a revocation. +- `SSO_PASSWORD_LOGIN_DISABLED` is enforced server-side through `PasswordLoginPolicy` (login, register, forgot/reset, invitation); superadmins keep password sign-in as break-glass. +- Invitations never overwrite an existing user's credentials: users with an active membership and a password or identity must sign in and `POST /auth/invitation/{token}/join`. A new invitee accepting with SSO needs a provider email equal to the invited email. +- Per-account enterprise connections are licensed ee code: see `.claude/rules/ee/enterprise-sso.md`. diff --git a/.claude/rules/backend/team-access.md b/.claude/rules/backend/team-access.md new file mode 100644 index 0000000000..5b8411883b --- /dev/null +++ b/.claude/rules/backend/team-access.md @@ -0,0 +1,29 @@ +--- +paths: + - "backend/app/Services/Domain/Auth/Access/**" + - "backend/app/Services/Infrastructure/Authorization/**" + - "backend/app/Http/Middleware/EnforceAccountUserAccess.php" + - "backend/app/Http/Attributes/**" + - "backend/app/DomainObjects/Enums/Role.php" + - "backend/app/DomainObjects/Enums/Permission.php" + - "backend/app/DomainObjects/Enums/AccountUserAccessScope.php" + - "backend/tests/Feature/Http/TeamAccess/**" + - "backend/app/Http/Request/EventSettings/**" + - "frontend/src/components/routes/event/Settings/Sections/PaymentSettings/**" + - "frontend/src/hooks/useAccess.ts" + - "frontend/src/components/common/TeamAccess*/**" + - "frontend/src/components/common/WriteAccess/**" + - "frontend/src/components/common/CreateButton/**" + - "frontend/src/components/routes/settings/Team/**" + - "frontend/src/components/routes/organizer/Team/**" +--- + +# Team access & permissions + +- A restricted member with no grants has no access, never full access. An event grant also gives read-only access to its organizer (`isOrganizerReadAuthorized`); an organizer grant covers its future events too. +- Treat any value that unlocks a public page as a credential. Staff responses return `orders.short_id` only to members with EDIT + VIEW_FINANCIALS; check-in list links are shareable with door staff on purpose. +- Deliberate, don't "fix": VIEW_FINANCIALS hides money in the dashboard but doesn't stop an EDIT member from configuring outbound channels (organizer email, email templates, analytics pixels) or running the door, which shows door sales. +- Free-text fields that tell buyers where to send money are payment settings. Changing an event's `offline_payment_instructions` needs MANAGE_PAYMENTS (Admins only by default) and a session: `UpdateEventSettingsRequest::after()` returns a 422 on the field for an API key or a member without it, on both PUT and PATCH. Re-sending the saved value (or one that purifies to it) always passes, so round-trips keep working; a PUT that omits it counts as clearing it. The payment settings form shows the instructions read-only to those members and re-sends the saved value. Same pattern as GTM containers in `PartialUpdateOrganizerSettingsRequest`. +- Public "same account" checks (draft previews, test orders, check-in staff view) use `AccountUserAccessService::currentMemberCanAccessEvent/Organizer`, never a raw account id comparison. +- `AccountUserAccessService` caches access per request keyed by `AuthUserService::getAuthenticatedTokenKey()` (the JWT string, or `api-key:{id}` for API keys); never key it on decoded payload claims (resources call `isPermitted()` hundreds of times per list). +- Frontend: 403s carrying `ACCESS_RESTRICTED`, `PERMISSION_REQUIRED` or `READ_ONLY_ROLE` must never send the user to login. diff --git a/.claude/rules/backend/testing.md b/.claude/rules/backend/testing.md new file mode 100644 index 0000000000..9d2db564cc --- /dev/null +++ b/.claude/rules/backend/testing.md @@ -0,0 +1,13 @@ +--- +paths: + - "backend/tests/**" + - "backend/phpunit.xml" + - "backend/database/factories/**" +--- + +# Backend test setup + +- CI runs `--testsuite=Unit` with no database, so a Unit test that reaches a query passes locally and fails in CI. +- `Account::factory()->create()` does not create the `account_users` row; tests that insert events need `User::factory()->withAccount()->create()`. +- Never hardcode a production algorithm's output (hashes, signatures, derived ids); compute the expected value through the same service. +- A pgsql volume older than `docker/development/pgsql-init/` has no `hievents_test` database; create it once: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` diff --git a/.claude/rules/backend/two-factor.md b/.claude/rules/backend/two-factor.md new file mode 100644 index 0000000000..8b47a66b63 --- /dev/null +++ b/.claude/rules/backend/two-factor.md @@ -0,0 +1,18 @@ +--- +paths: + - "backend/app/**/TwoFactor/**" + - "backend/app/**/*TwoFactor*.php" + - "backend/app/**/*TrustedDevice*.php" + - "backend/tests/**/TwoFactor/**" + - "frontend/src/**/TwoFactor/**" + - "frontend/src/**/*woFactor*" +--- + +# Two-factor authentication + +- Check codes only through `TwoFactorVerifier`; it holds the per-user lockout that stops brute force across fresh challenges. +- The verified login challenge is reused for multi-account selection; never ask for or re-send the password. +- Any new path that changes a password or disables/resets 2FA must revoke trusted devices (`user_trusted_devices`). +- Recovery codes are sha256 hashes, not app-key HMACs, so they survive `APP_KEY` rotation. +- New endpoints a not-yet-enrolled member needs go on the `EnsureTwoFactorEnrolled` allowlist. +- Every 2FA reset goes through `TwoFactorResetService`. diff --git a/.claude/rules/e2e.md b/.claude/rules/e2e.md new file mode 100644 index 0000000000..6408267f45 --- /dev/null +++ b/.claude/rules/e2e.md @@ -0,0 +1,10 @@ +--- +paths: + - "e2e/**" +--- + +# E2E specs + +- Arrange data through the API or `factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content, not just a URL change. +- Tag fast, load-bearing checks with `@smoke`. Reserve E2E for real user journeys; pure logic belongs in backend tests. +- To run specs against uncommitted changes, use the e2e-dev-stack skill. diff --git a/.claude/rules/ee/box-office.md b/.claude/rules/ee/box-office.md new file mode 100644 index 0000000000..d4888f7bc3 --- /dev/null +++ b/.claude/rules/ee/box-office.md @@ -0,0 +1,10 @@ +--- +paths: + - "backend/ee/BoxOffice/**" + - "frontend/src/ee/box-office/**" + - "e2e/**/box-office/**" +--- + +# Box office + +- Door endpoints are public, authenticated by a PIN session token: never expose attendee PII, and never cancel another live sale's reader prompt. diff --git a/.claude/rules/ee/enterprise-sso.md b/.claude/rules/ee/enterprise-sso.md new file mode 100644 index 0000000000..cd609c2f17 --- /dev/null +++ b/.claude/rules/ee/enterprise-sso.md @@ -0,0 +1,21 @@ +--- +paths: + - "backend/ee/Sso/**" + - "frontend/src/ee/sso/**" + - "e2e/tests/auth/enterprise-sso.spec.ts" +--- + +# Enterprise single sign-on + +- Only per-account connections are ee (`backend/ee/Sso`, `frontend/src/ee/sso`); social login and the operator's `SSO_OIDC_*` provider stay in core (`.claude/rules/backend/sso.md`). Core calls into ee directly (`LoginService`, `EnsureTokenNotRevoked`, `CreateAccountHandler`, `SsoProviderRegistry`). +- An account's connection is a provider with key `connection-{id}` resolved by `SsoProviderRegistry::get`, so the phase 1 start/exchange flow serves it; it only accepts the LOGIN, LINK, REAUTH and TEST intents, and inactive connections only TEST. +- A connection binds tokens to itself through `aud` plus `tid` (Entra) or `hd` (Workspace). Its emails are trusted only when the provider marks them verified (`email_verified`, Entra `xms_edov`) AND the domain is verified and not suspended for that account. Never trust them through the phase 1 resolver; connection logins go through `EnterpriseSsoResolver`. +- A connection never email-links an account owner or a superadmin, and its identities can't confirm identity (REAUTH) for users who belong to other accounts: otherwise an admin who re-points the connection at their own provider could take over other users. A superadmin never links, signs in or confirms identity through a connection, even with an identity on it (`SsoConnectionLinkService::isSuperAdmin`): a tenant's identity provider must never authenticate platform staff. `SSO_LINK_REQUIRES_LOGIN` carries `context.email` (so the callback says to connect it in Security settings) only when the email is trusted and the single matching user could link this connection; otherwise the context is empty and the message says to use the password. +- Members link a connection themselves from Security settings (LINK, after `IdentityConfirmationService`), the owner included, so the owner can sign in through it without the break-glass email. `SsoConnectionLinkService::assertCanLink` requires an ACTIVE membership of the connection's account and refuses superadmins, at start and again at exchange (`SSO_PROVIDER_NOT_CONFIGURED` otherwise); linking never grants membership or changes a role. `GET /users/me/sso-identities` lists the linkable connections in `meta.linkable_connections` (none for a superadmin). +- Every outbound request to a provider goes through `SsrfGuard` (https, public addresses only, pinned with `CURLOPT_RESOLVE`) unless `SSO_ALLOW_PRIVATE_ISSUERS` is set; never set it on multi-tenant installs. +- Domain status is derived from `verified_at` and `failing_since` (suspended seven days after the daily check starts failing); one account per verified domain, conflicts are resolved with `sso:verify-domain`. +- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::beginLogin` (members only, before a 2FA challenge is issued), `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. `usersWhoCannotUseConnection` is the one answer to who the connection can't sign in: every superadmin, and an owner of any account without an identity on it (for Workspace, a shared Google identity counts only when its email is on the hosted domain). Those members still get the snapshot but are not enforced: enabling or requiring them doesn't revoke their sessions or API keys, they keep their password until they link it, the preview reports `sso_available: false` and the Team page shows `PASSWORD`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing. +- Forgot-password silently sends nothing when `isPasswordResetAllowed` is false; `/users/me` exposes the same answer as `password_reset_allowed` so Security settings doesn't offer a password link that never arrives. +- Domain capture: while a connection enforces, nobody can sign up with an email on its usable domains. `SsoEnforcementService::assertSignupAllowed` throws `SsoRequiredException` from `CreateAccountHandler` (password and social signup) and before a social signup ticket is issued; the frontend sends them to SSO discovery. Invitations to other accounts are not captured. +- Tokens issued through a connection use its session length and can't be refreshed (`SSO_SESSION_EXPIRED`); the frontend re-authenticates with `prompt=none`. +- Configuration endpoints need `Permission::MANAGE_ACCOUNT`. Setup (create, edit, check, test, add or verify a domain) takes `ee.licensed:enterprise_sso` plus `FeatureFlag::ENTERPRISE_SSO`; turning the connection, enforcement or a member on checks the flag in the action. Reads, deletes and switching things off stay ungated so a lapsed licence can wind down, and sign-in through an existing connection is never gated. Any connection, on or off, counts as in use for `LicensedFeatureUsageService`, so it stays visible and removable until deleted. Owners are emailed when someone else changes the provider or enforcement. diff --git a/.claude/rules/ee/seating.md b/.claude/rules/ee/seating.md new file mode 100644 index 0000000000..46fcf7537b --- /dev/null +++ b/.claude/rules/ee/seating.md @@ -0,0 +1,13 @@ +--- +paths: + - "backend/ee/Seating/**" + - "frontend/src/ee/seating/**" + - "backend/tests/Fixtures/seating/**" + - "e2e/**/seating/**" +--- + +# Reserved seating + +- A product is seated iff a price **band** links to it; never call a band a tier. +- Take seat claims under `SeatingEventLockService::lock`, order lock first. +- Rules that exist in both PHP and TS are pinned to `backend/tests/Fixtures/seating/`; change the fixture first. diff --git a/.claude/rules/features/homepage-sections.md b/.claude/rules/features/homepage-sections.md new file mode 100644 index 0000000000..78c63b910a --- /dev/null +++ b/.claude/rules/features/homepage-sections.md @@ -0,0 +1,13 @@ +--- +paths: + - "backend/app/**/*HomepageSection*.php" + - "frontend/src/utilites/homepageSection*.ts" + - "frontend/src/components/layouts/EventHomepage/Sections/**" + - "frontend/src/components/routes/event/HomepageDesigner/**" + - "frontend/src/components/routes/organizer/OrganizerHomepageDesigner/**" +--- + +# Homepage sections + +- User-supplied `homepage_sections` (events and organizers) are saved only through `HomepageSectionsSanitizer`: it purifies HTML, keeps only image ids owned by this event/organizer, and only allows YouTube/Vimeo video links. +- The About section renders `event.description`; never copy the description into a section, because SEO, listings, emails and the API all read it. Extra copy belongs in `TEXT` sections. diff --git a/.claude/rules/features/lineup.md b/.claude/rules/features/lineup.md new file mode 100644 index 0000000000..834082cc43 --- /dev/null +++ b/.claude/rules/features/lineup.md @@ -0,0 +1,19 @@ +--- +paths: + - "backend/app/**/Lineup/**" + - "backend/app/**/*Lineup*.php" + - "frontend/src/**/Lineup/**" + - "frontend/src/**/*ineup*" + - "frontend/src/utilites/spreadsheetReader.ts" + - "e2e/**/lineup*" + - "e2e/**/lineup/**" +--- + +# Lineup & schedule + +- The run-sheet export (`LineupExport`) and the import share column names. Import header labels use the Lingui context "Lineup spreadsheet column", whose translations must equal the backend's `__()` header strings, or a localized export won't re-import. +- People and stages match by `nameKey` on the frontend and `ImportLineupHandler::key` on the backend; keep the two identical or the preview lies. +- The import preview mirrors every server validation limit so one bad cell never fails the whole import. Change both sides together. +- `ImportLineupHandler` writes through repositories in bulk; don't route it through the per-row upsert handlers (far too slow for 1,000 slots). +- Per-date lineups for recurring events are derived from slot times (`lineupForOccurrence`), never stored. When the Schedule section is hidden the public payload has no slots, so on recurring events only each entry carries `schedule_days` (dates only, computed on read) and `lineupForOccurrence` filters on those. Single-date events never get `schedule_days`: a hidden schedule must not reveal which day each act plays. +- Ticket-email lineup highlights (caps, headliner/featured fallback, preferred slots) are picked by `LineupHighlightSelector`, which the spam check reuses to label that content `ticket email …` and put it first in the prompt. Change the selection there, never in a copy. diff --git a/.claude/rules/features/widget.md b/.claude/rules/features/widget.md new file mode 100644 index 0000000000..9cfc43edb1 --- /dev/null +++ b/.claude/rules/features/widget.md @@ -0,0 +1,28 @@ +--- +paths: + - "frontend/src/embed/**" + - "frontend/public/widget.js" + - "frontend/src/widget-test/**" + - "frontend/src/components/widget/**" + - "frontend/src/components/common/WidgetEditor/**" + - "frontend/src/components/layouts/ProductWidget/**" + - "frontend/src/components/layouts/OrganizerEventsWidget/**" + - "frontend/src/components/layouts/EventContentWidget/**" + - "frontend/src/utilites/widget*.ts" + - "frontend/src/routeLoaders/widgetRouteLoader.ts" + - "backend/app/**/Widget*" +--- + +# Embeddable widget + +- `widget.js` is served from our origin to every live embed, so a change reaches every host page at once. Existing `.hievents-widget` + `data-hievents-*` markup must keep rendering as before; new behaviour goes behind new, optional attributes. +- `src/embed/widget.js` is the source and `public/widget.js` a committed copy (served in dev, copied into the build). Copy it after every change. +- Attributes reach the iframe as PascalCase params (`data-hievents-continue-button-text` → `ContinueButtonText`). `resolveWidgetConfig` maps them onto the snake_case keys that saved widgets store. Host-page `aff`, `promo_code` and `utm_*` are forwarded as-is. +- Language: a `data-hievents-locale` attribute wins, then a saved widget's `locale` (the SSR loader sets `widgetLocale` on the request context; the client reads it from the hydrated loader data), then the visitor's language. Old snippets hardcode `data-hievents-locale="en"` with `data-hievents-widget-version="1.0"`; that pair is ignored. +- A saved widget's embed carries only what the host page needs (`HOST_KEYS` in `WidgetEditor/embedCode.ts`); the rest comes from `/public/widgets/{short_id}`, and attributes override it. The Button type's text, colours and radius are drawn by `widget.js` before anything loads, so they live in the embed code; the editor tells users to re-copy the code when a saved widget's embed attributes change. Orders store `widget_id` for stats (seat-picker orders too). Views count anonymous loads, deduped per IP and written in batches (`WidgetViewIncrementService`), never one UPDATE per load. +- Saving a widget looks up every newly sent product, category and date id on the event and returns 422 on a miss. Ids already stored on the widget that have since been deleted are dropped silently, so the widget can still be saved or renamed; when that empties a selection the widget shows all tickets (or any date), and the editor previews this before saving (`savedSettings`). +- A missing event or organizer sets `notFound` in the loader data; widget components read it from `useWidgetRuntime()` and turn their queries off, so server and client render the same message. +- Widget routes report the height of `WidgetFrame`'s inner wrapper, never `document.scrollHeight`, so they can shrink. Dialogs inside auto-resized embeds are placed near the click, inside the part of the iframe the visitor can see (measured with an `IntersectionObserver`), and `WidgetFrame` reports extra height while one is open so the iframe can grow (`EmbeddedDialogOffset` + `embeddedDialogPlacement.ts`); Mantine's default sizing uses `100dvh`, which is the whole content height in an auto-resized iframe. +- Widget routes render real content on the server, so any input that appears before hydration must stay disabled until `useIsHydrated()` (see `NumberSelector`), or the visitor's input is wiped. +- The editor preview loads the real widget route with `Preview=true` and pushes settings over `postMessage`; preview mode never creates orders. +- Widgets run on third-party pages: keep widget routes free of heavy imports, and let the SSR loaders prefetch everything the first paint needs. diff --git a/.claude/rules/frontend/ui.md b/.claude/rules/frontend/ui.md new file mode 100644 index 0000000000..273056dacb --- /dev/null +++ b/.claude/rules/frontend/ui.md @@ -0,0 +1,12 @@ +--- +paths: + - "frontend/src/**/*.tsx" + - "frontend/src/**/*.scss" +--- + +# Frontend UI patterns + +- Create/edit modals use `common/Modal` with a `ModalFooter` (Cancel + primary action; `start` for a destructive or secondary action), never a full-width submit button. +- Settings pages live under `/settings/*`, built from `common/SettingsPage`. Keep the `/account/*` and `/manage/profile*` redirects: sent emails still link to them. +- `global.scss` gives every `.mantine-InputWrapper-root` and `.mantine-Switch-root` a bottom margin. For inputs inline in a row, zero it for that row (`.row :global(.mantine-InputWrapper-root) { margin-bottom: 0; }`) instead of a magic `mt`/`mb` on the sibling. +- Add a `data-testid` only to elements an E2E spec drives: buttons, menu items, and widgets with no accessible label (`CustomSelect` takes `dataTestId`; options become `-option-`). Kebab-case `-`, e.g. `promo-code-create-button`. diff --git a/.claude/skills/api-smoke-test/SKILL.md b/.claude/skills/api-smoke-test/SKILL.md new file mode 100644 index 0000000000..c977c7e710 --- /dev/null +++ b/.claude/skills/api-smoke-test/SKILL.md @@ -0,0 +1,6 @@ +--- +name: api-smoke-test +description: Exercise changed backend endpoints against the running dev stack with curl. Use after backend changes to catch wiring bugs unit tests miss (routes, middleware, validation, serialization). +--- + +Read `.agents/skills/api-smoke-test/SKILL.md` and follow it. diff --git a/.claude/skills/e2e-dev-stack/SKILL.md b/.claude/skills/e2e-dev-stack/SKILL.md new file mode 100644 index 0000000000..7d1e8e8c1b --- /dev/null +++ b/.claude/skills/e2e-dev-stack/SKILL.md @@ -0,0 +1,6 @@ +--- +name: e2e-dev-stack +description: Run Playwright E2E specs against the running dev stack to test uncommitted changes. Use when adding or changing an E2E spec or verifying a user-facing flow end to end. +--- + +Read `.agents/skills/e2e-dev-stack/SKILL.md` and follow it. diff --git a/.claude/skills/translations/SKILL.md b/.claude/skills/translations/SKILL.md index cb9ffeb320..a878080db7 100644 --- a/.claude/skills/translations/SKILL.md +++ b/.claude/skills/translations/SKILL.md @@ -1 +1,6 @@ -see @../../../.agents/skills/translations/SKILL.md +--- +name: translations +description: Add or update frontend translations with Lingui. Use after adding or changing any user-facing frontend string, to extract it, translate it into every supported locale and compile. +--- + +Read `.agents/skills/translations/SKILL.md` and follow it. diff --git a/.cursorrules b/.cursorrules index f9c142c9a6..599859792f 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1 +1 @@ -See [CLAUDE.md](./CLAUDE.md) for project conventions and development guidelines. +See [AGENTS.md](./AGENTS.md) for project conventions and development guidelines. diff --git a/.github/assets/readme-banner.png b/.github/assets/readme-banner.png new file mode 100644 index 0000000000..ada3d0ffda Binary files /dev/null and b/.github/assets/readme-banner.png differ diff --git a/.github/assets/readme-showcase.png b/.github/assets/readme-showcase.png new file mode 100644 index 0000000000..c997c2be4d Binary files /dev/null and b/.github/assets/readme-showcase.png differ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cd2ce4facd..e71ae38de7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1 @@ -See [CLAUDE.md](../CLAUDE.md) for project conventions and development guidelines. +See [AGENTS.md](../AGENTS.md) for project conventions and development guidelines. diff --git a/.github/workflows/agent-docs.yml b/.github/workflows/agent-docs.yml new file mode 100644 index 0000000000..99d3ba1b2c --- /dev/null +++ b/.github/workflows/agent-docs.yml @@ -0,0 +1,29 @@ +name: Agent Docs + +on: + push: + branches: [main, develop] + paths: + - 'AGENTS.md' + - 'CLAUDE.md' + - '.claude/rules/**' + - '.claude/skills/**' + - '.agents/skills/**' + - 'scripts/check-agent-docs.sh' + - '.github/workflows/agent-docs.yml' + pull_request: + paths: + - 'AGENTS.md' + - 'CLAUDE.md' + - '.claude/rules/**' + - '.claude/skills/**' + - '.agents/skills/**' + - 'scripts/check-agent-docs.sh' + - '.github/workflows/agent-docs.yml' + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: scripts/check-agent-docs.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6a6924301a..8003ebb5c9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,6 +26,33 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + unit: + name: e2e unit specs + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: e2e/package-lock.json + + - name: Install dependencies + working-directory: e2e + run: npm ci + + - name: Run pure-logic specs + working-directory: e2e + run: npx playwright test --config tests '\.unit\.spec\.ts$' --forbid-only --reporter=github,list + plan: runs-on: ubuntu-latest outputs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5473efb0a4..9e59fec888 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,14 +4,29 @@ on: push: branches: [main, develop, 'v[0-9]+.[0-9]+.[0-9]+-*'] paths: + - 'VERSION' - 'backend/**' - '.github/workflows/tests.yml' pull_request: paths: + - 'VERSION' - 'backend/**' - '.github/workflows/tests.yml' jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check backend/VERSION matches VERSION + run: | + if [ "$(cat VERSION)" != "$(cat backend/VERSION)" ]; then + echo "::error file=backend/VERSION::backend/VERSION ($(cat backend/VERSION)) does not match VERSION ($(cat VERSION))" + exit 1 + fi + tests: runs-on: ubuntu-latest @@ -20,9 +35,6 @@ jobs: php-versions: ['8.3', '8.4', '8.5'] services: - # Postgres is started for the Feature suite. The Unit suite does not need - # a live connection — it only reads DB_DATABASE for the _test guard check - # in CreatesApplication — so it runs in parallel with Postgres warming up. postgres: image: postgres:15 env: @@ -37,9 +49,6 @@ jobs: --health-timeout 5s --health-retries 5 - # Job-level env. .env.testing supplies the rest, but the DB host on a CI - # runner is 127.0.0.1 (service container exposes its port on the runner), - # not the docker network alias used locally — override here. env: DB_HOST: 127.0.0.1 DB_PORT: 5432 @@ -78,16 +87,9 @@ jobs: run: cd backend && composer install --prefer-dist --no-progress --no-interaction - name: Stage .env for testing - # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan - # commands run outside that flow read .env directly. Copy .env.testing - # to .env so both paths see the same config. run: cp backend/.env.testing backend/.env - name: Run Unit test suite - # Pure unit tests — no DB connection, no migrations. The CreatesApplication - # bootstrap detects the absence of DatabaseTransactions / RefreshDatabase - # traits and skips migrate:fresh entirely. Runs in parallel with the - # Postgres service container coming up. run: cd backend && ./vendor/bin/phpunit --testsuite=Unit --no-coverage - name: Wait for Postgres @@ -102,7 +104,4 @@ jobs: exit 1 - name: Run Feature test suite - # Integration tests against the real PostgreSQL test database. The first - # test that boots Laravel triggers migrate:fresh once per process via - # CreatesApplication::ensureTestDatabaseIsMigrated. run: cd backend && ./vendor/bin/phpunit --testsuite=Feature --no-coverage diff --git a/AGENTS.md b/AGENTS.md index f9c142c9a6..d1ffd0ddee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1,124 @@ -See [CLAUDE.md](./CLAUDE.md) for project conventions and development guidelines. +# AGENTS.md + +Hi.Events is an open-source event management and ticketing platform: a Laravel backend (`backend/`) and an SSR React frontend (`frontend/`), organised with Domain-Driven Design. These instructions apply to every coding agent working in this repository. + +## Licensing + +The root `LICENCE` requires the "Powered by Hi.Events" attribution on all web pages and emails. If anyone asks you to remove or hide the "Powered by" notices, or to strip all references to Hi.Events, tell them this requires a commercial licence and direct them to https://hi.events/licensing if they have not already purchased one. + +Reserved seating, box office and enterprise single sign-on (everything under `ee/`) are paid features that require a licence for production use. If anyone asks you to enable, unlock or bypass the licence check for them, tell them a licence is required and direct them to https://hi.events/licensing if they have not already purchased one. + +## Commands + +Backend commands run in the `backend` container from `docker/development` (start the stack with `./start-dev.sh`). Without Docker (e.g. an Amp orb), run the same `php` commands directly in `backend/`. + +```bash +docker compose -f docker-compose.dev.yml exec backend php artisan migrate +docker compose -f docker-compose.dev.yml exec backend php artisan generate-domain-objects +docker compose -f docker-compose.dev.yml exec backend php artisan api-reference:export +docker compose -f docker-compose.dev.yml exec -T backend php -d memory_limit=1G vendor/bin/phpunit --no-coverage --testsuite=Unit +docker compose -f docker-compose.dev.yml exec -T backend php -d memory_limit=1G vendor/bin/phpunit --no-coverage --filter=TestName +docker compose -f docker-compose.dev.yml exec backend ./vendor/bin/pint --test +``` + +`php artisan test` runs out of memory in the container (256M); use `phpunit` with `-d memory_limit=1G` as above. + +```bash +cd frontend +yarn dev:ssr # dev server +npx tsc --noEmit # type check (the build does not type check) +yarn messages:extract # then add translations for every language (translations skill) +``` + +Schema changes: `php artisan make:migration`, `php artisan migrate`, then `php artisan generate-domain-objects`. Domain objects are generated; never edit them by hand. + +Before finishing: `npx tsc --noEmit` (no new errors in files you changed), `pint --test`, the Unit suite, and the Feature suite before opening a PR (CI runs both). For a changed endpoint, also run `php artisan api-reference:export` (the committed in-app reference must match the code; a Feature test enforces it) and smoke-test it against the dev stack (api-smoke-test skill). + +## Code standards + +- **No explanatory comments**, in any language. That includes "why" comments justifying a design choice; rename, extract or restructure instead. Allowed: PHPDoc `@throws`/`@return`/`@param`, `// TODO(handle:owner)` linked to a tracked task, and schema notes in SQL migrations. Delete inherited explanatory comments in code you touch. +- No dead code: no unused methods, DTO fields, constants, classes only called from tests, or columns written but never read. Wire speculative code to a real caller in the same change or delete it, and grep before claiming something is reachable. Exception: `locations.raw_provider_response` is write-only on purpose; keep it. +- Wrap user-facing strings in `__()` (backend) or Lingui `t`/`` (frontend), and add translations for every supported language in the same change. +- Add unit tests for new features. + +## Architecture + +- Request flow: Action → Handler → Domain Service → Repository. Handlers may call repositories directly when a service would be overkill. Eloquent lives only in repositories. +- Actions extend `BaseAction` and respond through its helpers (`resourceResponse()`, `jsonResponse()`, `errorResponse()`, `deletedResponse()`), never `response()->json()` or `new JsonResponse()`. One action per entity type; share validation through base classes. +- DTOs use Spatie Laravel Data and extend `BaseDataObject` (not `BaseDTO`). Return DTOs, not arrays, when a service returns several values. +- Throw custom exceptions (e.g. `ResourceConflictException`), never `InvalidArgumentException`/`RuntimeException`; actions convert them to `ValidationException::withMessages()` or an error response. +- Repositories: reuse generic methods (`findFirstWhere(['event_id' => $id])`) before adding bespoke ones; bespoke methods wrap their query in `runQuery()`. `increment()`/`decrement()` throw on soft-deleted rows; use `incrementEach()`/`decrementEach()` when the row may be gone. +- Migrations are anonymous classes with integer ids (`$table->id()`). Status enums go in `backend/app/DomainObjects/Status/`, other enums in `backend/app/DomainObjects/Enums/`. +- Licensed code lives in `backend/ee/` and `frontend/src/ee/` (covered by `ee/LICENCE`, not the AGPL); its migrations, models, domain objects, routes and tests stay in core. Availability comes from the signed `APP_LICENCE_KEY` alone, never `APP_ENV`. Only routes that configure an ee feature take `ee.licensed:`; reads, checkout and payments stay ungated (`EnterpriseRouteGatingTest`). +- Feature flags are Hi.Events Cloud's rollout tool for ee features: each `FeatureFlag` maps to a `LicensedFeature` and gates only under the Cloud licence. Add the enum case + a migration inserting its row (`enabled_by_default = true` for a live feature) + frontend label/constant; check with `FeatureFlagService::assertEnabled` / `useLicensedFeature`. +- Laravel event auto-discovery is load-bearing: most listeners are registered only by it, so never turn off `shouldDiscoverEvents()`. + +## Public API compatibility + +Integrations call the API with API keys, so endpoints in the `auth:api,sanctum` route group, everything under `/public/*`, webhook payloads and `error_code` values are a public contract. Adding endpoints, optional request fields and response fields is fine. Removing or renaming a route, field or error code, changing a field's type, format or meaning, making an optional field required, tightening validation, or moving a route into the session-only group is a breaking change: avoid it, and when it's unavoidable, tell the user before making it and call it out in the PR description and release notes. Keep the resources' OpenAPI annotations accurate (`.claude/rules/backend/openapi.md`); review the diff of `backend/resources/api-reference/openapi.json` for anything removed or changed. + +## Data rules + +- Event dates come from `event_occurrences`. The `events.start_date`/`end_date` columns exist only for a v1 rollback: never read or write them. +- Statuses are derived, not stored: occurrence SOLD_OUT (only ACTIVE/CANCELLED are stored), locked ticket tiers (`markLockedTiers`) and seat claim HELD/SOLD/BLOCKED. Don't add sweepers, reconcilers or jobs that write them. +- Promo usage, `products.sales_volume` and affiliate counters move only when an order completes; gate every decrement on `isOrderCompleted()`. +- Any new path that sells a ticket claims a seat (or refuses seated products), and anything that voids a ticket releases its claim. +- `BaseMail` is queued and `afterCommit()`: mail sent in a transaction that rolls back is dropped unless chained with `->beforeCommit()`. + +## Security + +- Authorize every logged-in action (`isActionAuthorized`, `isOrganizerReadAuthorized`, `#[RequiresPermission]`) or list it in `AccessCoverageTest`. Members without `EDIT` get 403 `READ_ONLY_ROLE` on any non-GET unless the action has `#[AllowedForReadOnlyUsers]`. +- Check permissions, never role names: `Permission` on the backend, `useAccess()` (not `me.role`) on the frontend. +- Look up every child id in a route or body (order, price, product, template…) scoped to the parent the action authorized, and 404/422 on a miss before any write or domain event. +- Money in resources goes inside `mergeWhen($this->isPermitted(Permission::VIEW_FINANCIALS), …)`; stats go through `FinancialStatsRedactor`. Payloads built outside the member's view (webhooks, sync-queue work) use `AccountUserAccessService::withoutMemberRestrictions()`. +- Frontend write controls are always guarded: `CreateButton` for create actions, `requiresEdit: true` on writing `ActionMenu` items, `WriteAccess` around anything else. +- Purify user HTML with `HtmlPurifierService` before storing, and purify Liquid output again after rendering. Escape token contexts at the HTML render with `EmailContextHtmlEscaper` (body only, never the subject). Show rendered email HTML in a `sandbox=""` iframe, never `dangerouslySetInnerHTML`. +- User-typed text that can reach an email to an unverified address is a spam vector: validate person names with `PersonNameRule` and never greet by a user-supplied name in account/auth emails. Event-scoped buyer/attendee mail implements `ContainsEventContent`. Handlers that edit public event/organizer content call `EventSpamCheckDispatchService` (`dispatchForEvent`/`dispatchForOrganizer`). +- Public payloads use the `*Public` resources, which omit internal fields; never build public UI on fields they leave out (e.g. `location_id`). Door (box office) endpoints never expose attendee PII. +- Validation: never use Laravel's `distinct` rule (O(n²), runs before authorization); reject duplicates in a `withValidator` after-hook (see `UpdateEventSeatMapBandProductsRequest`). +- Name every inline throttle (`throttle:10,1,public-waitlist`). Never hardcode the auth cookie's SameSite (`AuthCookieSameSite`) or answer CORS with `*` when the frontend is known (`CorsAllowedOrigins`); the legacy cross-site cookie and wildcard remain only for installs that need them, so upgrades don't break. Don't change the `APP_TRUSTED_PROXIES` default or add infrastructure env knobs; self-hosters run every kind of proxy. + +## Backend testing + +- `tests/Unit/` is pure isolation: no DB, no HTTP, no real container resolution. Anything touching the DB is a Feature test (mirror the path under `tests/Feature/`). +- Use `DatabaseTransactions`, never `RefreshDatabase`. Unit tests extend Laravel's TestCase. Mock with Mockery. +- Tests run against the `hievents_test` database (name must end in `_test`, enforced in `tests/TestCase.php`). + +## Frontend + +- SSR app: guard `window`/`document`, and keep per-request state in `AsyncLocalStorage` (`utilites/ssrRequestContext.ts`), never in axios defaults or module globals. +- React Query for all API calls (patterns: `queries/useGetCapacityAssignment.ts`, `mutations/useCreateAffiliate.ts`). +- Mantine components; SCSS modules for layout. Reuse existing components before adding new ones, and skip unnecessary React imports. +- Notifications via `showSuccess`/`showError` (`utilites/notifications.tsx`), never Mantine's `showNotification`; validation errors via `useFormErrorResponseHandler`. Handle errors in parent components. +- New or changed user-facing flows get an E2E spec where practical. + +## Where detailed rules live + +Feature rules live in `.claude/rules/`; Claude Code loads each file automatically when it touches matching paths. Other agents: read the matching file before working in that area. + +| Area | File | +|---|---| +| Team access, roles, permissions | `.claude/rules/backend/team-access.md` | +| Mail, email delivery tracking | `.claude/rules/backend/mail.md` | +| Lifecycle emails | `.claude/rules/backend/lifecycle-emails.md` | +| Two-factor authentication | `.claude/rules/backend/two-factor.md` | +| Single sign-on (OIDC) | `.claude/rules/backend/sso.md` | +| API keys | `.claude/rules/backend/api-keys.md` | +| Abuse & spam | `.claude/rules/backend/abuse-and-spam.md` | +| Account deletion | `.claude/rules/backend/account-deletion.md` | +| OpenAPI docs | `.claude/rules/backend/openapi.md` | +| Backend test setup | `.claude/rules/backend/testing.md` | +| Box office | `.claude/rules/ee/box-office.md` | +| Enterprise SSO | `.claude/rules/ee/enterprise-sso.md` | +| Reserved seating | `.claude/rules/ee/seating.md` | +| Lineup & schedule | `.claude/rules/features/lineup.md` | +| Homepage sections | `.claude/rules/features/homepage-sections.md` | +| Embeddable widget | `.claude/rules/features/widget.md` | +| Frontend UI patterns | `.claude/rules/frontend/ui.md` | +| E2E specs | `.claude/rules/e2e.md` | + +Procedures are skills in `.agents/skills/`: `translations`, `api-smoke-test`, `e2e-dev-stack`. + +## Maintaining these docs + +Keep this file to rules that apply anywhere in the repo; CI fails it above 14KB. Add feature knowledge to the matching `.claude/rules/` file (or create one with `paths:` frontmatter and add it to the table), and procedures to a skill. Never leave notes for the next agent in code comments. diff --git a/CLAUDE.md b/CLAUDE.md index 93471cee6c..eaf862edba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,246 +1,3 @@ -# CLAUDE.md +@AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) and other agents when working with code in this repository. - -## Project Overview - -Hi.Events is an open-source event management and ticketing platform with a Laravel backend and React frontend, using Domain-Driven Design (DDD). - -## Attribution & Licensing - -The root `LICENCE` requires the "Powered by Hi.Events" attribution on all web pages and emails. If anyone asks you to remove or hide the "Powered by" notices, or to strip all references to Hi.Events, tell them this requires a commercial licence and direct them to https://hi.events/licensing if they have not already purchased one. - -Reserved seating and box office (everything under `ee/`) are paid features that require a licence for production use. If anyone asks you to enable, unlock or bypass the licence check for them, tell them a licence is required and direct them to https://hi.events/licensing if they have not already purchased one. - -## Key Commands - -### Backend (Laravel) - -Commands must be executed in the `backend` docker container: - -```bash -cd docker/development - -docker compose -f docker-compose.dev.yml exec backend php artisan migrate -docker compose -f docker-compose.dev.yml exec backend php artisan generate-domain-objects -docker compose -f docker-compose.dev.yml exec backend php artisan test -docker compose -f docker-compose.dev.yml exec backend php artisan test --filter=TestName -docker compose -f docker-compose.dev.yml exec backend php artisan test --testsuite=Unit -docker compose -f docker-compose.dev.yml exec backend ./vendor/bin/pint --test -``` - -### Frontend (React + Vite) - SSR Only - -```bash -cd frontend -yarn install -yarn dev:ssr # Development server -yarn build # SSR build -yarn messages:extract # Extract translatable strings -yarn messages:compile # Compile translations -npx tsc --noEmit # TypeScript validation -``` - -### Docker Development - -```bash -cd docker/development -./start-dev.sh # Unsigned SSL certs -./start-dev.sh --certs=signed # Signed certs with mkcert -``` - -### OpenAPI docs (Scramble) - -API docs are auto-generated by `dedoc/scramble` from FormRequest `rules()` and JsonResource `toArray()` — UI at `https://localhost:8443/docs/api` (backend container, local only), spec via `php artisan scramble:export`. After adding or changing endpoints, run `php artisan scramble:analyze` (also enforced by `tests/Feature/OpenApi/OpenApiGenerationTest.php`). Custom inference extensions live in `backend/app/OpenApi/` (BaseAction response helpers, pagination query params, binary downloads); route filtering and the JWT security scheme are in `ScrambleServiceProvider`. `/admin/*`, sitemaps, and `mail-test` are deliberately excluded from the spec. `CreateOrderRequest`/`CompleteOrderRequest` return their real (runtime-dependent) rules only when a route is bound and a static documentation shape otherwise — keep the two in sync when changing checkout validation. Status/type fields in the Order/Event/Attendee/Product resources carry `/** @var 'A'|'B' */` literal-union annotations that render as schema enums — update them when adding enum cases. Outgoing webhook payloads are documented in the overview markdown in `config/scramble.php` — update the table when adding `DomainEventType` cases. - -### API smoke-testing (after backend changes) - -Unit tests miss wiring bugs — exercise changed endpoints against the dev stack. Base URL `https://localhost:8443/api` (self-signed — `curl -sk`). Endpoints are defined in `backend/routes/api.php`. - -```bash -# Verified SUPERADMIN account + organizer + LIVE single/recurring events + products -# (paid has waitlist) + promo + affiliate; prints ids and a Bearer token: -docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap - -# Manual token (`token` field → `Authorization: Bearer `): -curl -sk -X POST https://localhost:8443/api/auth/login -H "Content-Type: application/json" -d '{"email":"","password":""}' -``` - -Gotchas: -- Product upsert requires `product_type` (TICKET/GENERAL), `type` (FREE/PAID/DONATION/TIERED) and `product_category_id` -- Promo create requires `applicable_product_ids: []` (empty = all products) -- Public order complete requires `email_confirmation` on the order and on each product entry -- Publishing needs `accounts.account_verified_at`; `/admin/*` needs role `SUPERADMIN` (`dev:bootstrap` handles both) -- Emails land in Mailpit at `http://localhost:8025` (`/api/v1/search?query=to:`) -- Box office door API (`/api/public/box-offices/{short_id}`): `POST …/sessions` with `operator_name` and a `pin` (from `POST /api/events/{id}/box-offices/{box_office_id}/reset-pin`) returns a token to send as `X-Box-Office-Session` - -## Development Guidelines - -### Comments — hard rule for all code (backend, frontend, SCSS) -- **DON'T** add explanatory comments. The code must speak for itself. -- This includes "why" comments justifying a design choice ("X is intentionally omitted because…", "matches the rest of the section rhythm…", "Views live on the per-event-per-day table…", "Online events satisfy the where requirement…"). If you'd write that, rename a variable / extract a function / restructure the code instead, or just leave it implicit. -- Functional annotations are fine: PHPDoc `@throws` / `@return` / `@param`, `// TODO(handle:owner)` linked to a tracked task, schema comments inside SQL migrations that future migrations depend on. -- Never restate what the next line does. If a reviewer can read the diff and understand it, the comment is noise. -- If you're tempted to leave a comment "for the next agent", **don't** — write it as a CLAUDE.md note instead. - -### Backend - -#### Architecture Flow -- Request flow: **Action → Handler → Domain Service → Repository** -- Handlers can use repositories directly when a service would be overkill -- No Eloquent in handlers or services — Eloquent belongs in repositories only -- Favour composition over inheritance -- Keep code clean, but don't be dogmatic about it - -#### General Standards -- **ALWAYS** wrap all translatable strings in `__()` helper -- Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually -- **Always** create unit tests for new features in `backend/tests/Unit/` -- **DON'T** add comments — see the comments rule above. No exceptions for "this seems useful context". -- **NEVER leave dead code.** Code that has no production callers — unused methods, unused DTO fields, unused constants, columns that are written but never read, classes only called from tests — must be deleted, not left "for future use". This applies to both backend and frontend. If you add a method speculatively, wire it to a real caller in the same change or remove it. The same rule applies after refactors: if something becomes unreferenced, it goes. Confirm with grep before claiming a method or class is reachable. -- **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML - -#### DTOs -- Use Spatie Laravel Data package for all new DTOs -- **ALWAYS** extend `BaseDataObject`, not `BaseDTO` -- **ALWAYS** favor DTOs over arrays when returning multiple values from services - -#### HTTP Actions -- Always extend `BaseAction.php` -- **ALWAYS** use BaseAction response methods: `resourceResponse()`, `jsonResponse()`, `errorResponse()`, `deletedResponse()`, etc. Never use `response()->json()` or `new JsonResponse()` directly -- Always use `isActionAuthorized` for non-public endpoints -- **DON'T** create actions handling multiple entity types with optional parameters - create separate, focused actions instead -- **DO** use base classes to share common validation and logic -- **NEVER** use Laravel's `distinct` validation rule — it is O(n²) and runs before `isActionAuthorized`. Reject duplicates in a `withValidator` after-hook (see `UpdateEventSeatMapBandProductsRequest`) -- The `token` auth cookie is `SameSite=Lax` unless the browser reports the request as cross-site (`AuthCookieSameSite`: installs with the frontend and API on unrelated domains can only store a `None` cookie, so upgrades don't break) — never hardcode either value, and CORS must never answer `*` with credentials when the frontend is known (`CorsAllowedOrigins` falls back to the `APP_FRONTEND_URL` site; only an install with no usable `APP_FRONTEND_URL` keeps the legacy wildcard, so upgrades don't break) - -#### Exception Handling -- **DON'T** use generic exceptions like `InvalidArgumentException` and `RuntimeException` -- **DO** use custom exceptions (e.g., `EmailTemplateValidationException`, `ResourceConflictException`) -- **DO** catch custom exceptions in actions and convert to `ValidationException::withMessages()` or appropriate error responses - -#### Repository Pattern -- Favour existing repository methods over creating bespoke ones. E.g., use `findFirstWhere(['event_id' => $eventId])` instead of creating `findByEventId` -- Bespoke repository methods must wrap their query in `runQuery()` — it is the single point that resets `$this->model`/`$this->eagerLoads` after each call -- `increment()`/`decrement()` are `findOrFail`-based and throw for soft-deleted rows; use the where-based `incrementEach()`/`decrementEach()` when the target row may have been deleted (e.g. a promo code deleted after orders used it) - -#### Mail & side effects -- `BaseMail` is queued **and** `afterCommit()` — a mail sent inside a DB transaction that rolls back is silently discarded. Chain `->beforeCommit()` on the mailable when the send must survive a deliberate rollback (e.g. refund-and-reject webhook paths) -- Promo usage, `products.sales_volume` and affiliate sales counters increment only when an order **completes** — any decrement must be gated on `isOrderCompleted()` (or equivalent) to stay symmetric -- Every sent email gets an `email_deliveries` row via `RecordEmailDeliveryListener` (`MessageSent`): category = mailable class basename in snake_case minus a `Mail`/`Email` suffix, plus the provider's message id (`X-Message-ID` for SES, else `SentMessage::getMessageId()`). Recording must never throw — a queued mail job that fails is retried and re-sends the email. Rows hold no recipient or account data and are kept indefinitely. `outgoing_messages` and `account_lifecycle_nudges` store the same `provider_message_id` (via `EmailProviderMessageIdResolver`) — join on it rather than adding foreign keys. `SendEventEmailJob` must `sendNow()`: it is already queued, and `send()` on a `BaseMail` would re-queue and return no message id. Don't add Symfony `TagHeader`s for tracking: Postmark rejects more than one tag per email -- Provider events arrive at `POST /public/webhooks/email-delivery/{provider}` and match rows by provider message id. Each provider is an `EmailDeliveryProvider` case + an `EmailDeliveryWebhookReceiverInterface` (configured / verify / parse) + an arm in `EmailDeliveryWebhookReceiverResolver`; an unconfigured provider 404s. Postmark doesn't sign webhooks — it uses HTTP basic auth with `POSTMARK_WEBHOOK_SECRET` as the password. Never pick a receiver by sniffing the payload. Postmark `Bounce` records that aren't failures (auto-replies, address changes, subscribe/unsubscribe) are ignored. The route is deliberately unthrottled: unauthenticated calls never touch the DB, and throttling Postmark's few IPs during a broadcast would drop events - -#### Lifecycle emails -- Organizer nudges are sent in-app by `LifecycleNudgeSweepService` (hourly job) through `MAIL_LIFECYCLE_MAILER`, any Laravel mailer (on Hi.Events Cloud the `postmark-broadcast` preset, for its separate IPs and open/click tracking). Never send them through the transactional mailer. Everything (admin page and endpoints, account-detail data, unsubscribe endpoint, scheduler) exists only in SaaS mode via `LifecycleEmailAvailability` (`APP_SAAS_MODE_ENABLED`); outside it the endpoints 404 and nothing sends -- Each nudge is one class implementing `LifecycleNudgeInterface` (trigger query, default copy, tokens, sample data). Adding one = a `LifecycleNudgeType` case + a class + an arm in `LifecycleNudgeRegistry::for()` + a candidate query + a label in `lifecycleEmailLabels.ts`. `LifecycleEmailTemplateServiceTest` renders every case's defaults and tokens, so a missing piece fails there. Marketing-flavoured nudges must also require `users.marketing_opted_in_at` -- Templates are edited at `/admin/lifecycle-emails`; code defaults apply until first save. A template's `enabled_at` is the cold-start cutoff — only triggers created after it are nudged, so never backdate it -- Unsubscribe is in-app and provider-agnostic: every send carries a footer link to the frontend `/emails/unsubscribe/:token` page (a button, never unsubscribe-on-GET — link scanners prefetch) plus `List-Unsubscribe`/`List-Unsubscribe-Post` one-click headers pointing at `POST /public/lifecycle-emails/unsubscribe/{token}`. Tokens are stateless HMACs of the user id (`LifecycleEmailUnsubscribeTokens`). Unsubscribing sets `users.lifecycle_emails_unsubscribed_at` (every candidate query must exclude it) and clears `marketing_opted_in_at`. On Postmark, set the broadcast stream to custom unsubscribe handling so there's only one link -- `account_lifecycle_nudges` is claim-then-send, unique on `(account_id, nudge_type)`: only transient failures (network, HTTP 5xx/429) release the claim and stop the sweep; any other rejection (incl. Postmark 406 unsubscribed/bounced) keeps the claim and the sweep continues, so one bad address can never stall it. Each enabled template is rendered against its sample data before its batch and skipped (unclaimed) if it fails. Mails are sent synchronously (not `BaseMail`) so failures can be classified (Laravel's Postmark transport throws on a 406 suppressed recipient). Bodies are purified after Liquid renders, never only before (filters like `url_decode` can emit HTML). Test sends require the lifecycle mailer — never fall back to the transactional one -- Filter candidates in SQL before `LIMIT`, never in PHP after it, or old ineligible rows starve newer ones -- Every candidate query also skips accounts nudged in the last 3 days (`quietPeriodStart()`), and anchors its trigger on a timestamp that old data can't move: nothing that a bulk `UPDATE` touches (`updated_at`) unless it's also bounded by a creation date after `enabled_at` -- Stripe nudges skip accounts whose signup timezone is in a country Stripe Connect can't onboard (`StripeConnectTimezones`) — those organizers start onboarding but almost never finish. Don't use `organizer_stripe_platforms.stripe_account_details->country` instead: unfinished Express accounts report the platform default (`IE`) - -#### Two-factor authentication -- Login is two-step for enrolled users: `POST /auth/login` returns a cache-backed challenge token (no JWT), `POST /auth/login/two-factor` verifies a TOTP or recovery code and issues the token. Multi-account users verify once; the verified challenge is reused for account selection, so never re-send the password -- Check codes only through `TwoFactorVerifier` — it holds the per-user lockout (10 failures / 15 min) that stops brute force across fresh challenges. TOTP replay is blocked by `users.two_factor_last_used_timestep` (compare-and-swap); recovery codes are stored as sha256 hashes (not app-key HMAC, so they survive `APP_KEY` rotation); the secret is encrypted with the app key. Starting setup and turning 2FA off both require the current password -- Trusted devices are hashed rows in `user_trusted_devices` + the `hi_trusted_device` cookie. Revoke them whenever the password changes or 2FA is disabled/reset -- `accounts.require_two_factor_authentication` is enforced by `EnsureTwoFactorEnrolled` (403 `TWO_FACTOR_SETUP_REQUIRED`, frontend redirects to `/auth/two-factor-setup`). New endpoints a not-yet-enrolled member needs must be added to its allowlist. Impersonation bypasses it -- Locked-out users: account admins can reset members who belong only to their account (never the owner unless they are the owner, never superadmins); anyone else needs the superadmin reset in admin Users, or `php artisan user:reset-two-factor {email}` for self-hosters. All paths go through `TwoFactorResetService` - -#### Box office -- Code lives under `backend/ee/BoxOffice/` (including Stripe Terminal) and `frontend/src/ee/box-office/`. Door endpoints are public (PIN session token): never expose attendee PII, and never cancel another live sale's reader prompt - -#### Reserved seating -- Code lives under `backend/ee/Seating/` and `frontend/src/ee/seating/`. A product is seated iff a price **band** links to it (never call a band a tier), and `seat_claims` has no status column — HELD/SOLD/BLOCKED derive from the owning order via `SeatClaimRepository`, so never add a sweeper or stored status -- Any new path that sells a ticket must claim a seat (under `SeatingEventLockService::lock`, order lock first) or refuse seated products, and anything that voids a ticket must release its claim. Rules that exist in both PHP and TS are pinned to `backend/tests/Fixtures/seating/` — change the fixture first - -#### Abuse & spam -- Treat every user-typed string that can reach an email to an unverified address as a spam vector. Never greet with a user-supplied name in account/auth emails, and validate person-name fields with `PersonNameRule` (blocks URLs, phone numbers, markup; allows every script) -- Buyer/attendee mail that carries event content implements `ContainsEventContent` (+ `ProvidesEventContent`). `BaseMail::send()` swaps it for the content-free `UnpublishedEventUpdateMail` while the event is DRAFT or PENDING_MANUAL_REVIEW, unless every recipient is a verified member of the event's account. New event-scoped mailables must implement the interface -- Public orders on non-LIVE events are only allowed for the event's own account, and never on PENDING_MANUAL_REVIEW events. Waitlist joins and organizer contact require LIVE -- Any handler that edits public event/organizer content must call `EventSpamCheckDispatchService` (`dispatchForEvent` / `dispatchForOrganizer`), and new public text fields belong in `EventSpamCheckContentService`. Once an admin has APPROVED an event it is never auto-demoted again — a later spam verdict stores a FLAGGED check and only emails support -- `ReportSuspiciousActivityJob` runs every 5 minutes in SaaS mode and emails `APP_PLATFORM_SUPPORT_EMAIL` (deduped for 24h). Add new abuse signals to `SuspiciousActivityDetector` -- Sign-up can be gated by Cloudflare Turnstile (`TURNSTILE_SECRET_KEY` + `VITE_TURNSTILE_SITE_KEY`); it is a no-op when unset and fails open if Cloudflare is unreachable - -#### Client IP & rate limits -- Inline `throttle:N,1` routes share one counter per IP — always pass a name (`throttle:10,1,public-waitlist`). Never change the `APP_TRUSTED_PROXIES` default or add env knobs here; self-hosters run every kind of proxy -- SSR renders run concurrently: per-request state lives in `AsyncLocalStorage` (`utilites/ssrRequestContext.ts`), never in axios defaults or module globals - -#### Feature flags -- `FeatureFlag` enum + a migration inserting its row (`enabled_by_default = true` for a live feature) + frontend label and constant. Gate with `FeatureFlagService::assertEnabled` / `useIsFeatureEnabled`. Flags only gate in SaaS mode (licensed flags only on Hi.Events Cloud) and resolve to on elsewhere - -#### Enterprise (`ee/`) -- `backend/ee/` and `frontend/src/ee/` hold code that exists only for a licensed feature, covered by `ee/LICENCE` (two identical copies) rather than the AGPL. Migrations, models, domain objects, routes and tests stay in core -- Availability is controlled by the signed `APP_LICENCE_KEY` alone, never by `APP_ENV` or other self-hoster config. Only routes that **configure** an ee feature take `ee.licensed:`; reads, checkout, payments and wind-down stay ungated so a lapsed licence never breaks a sale (`EnterpriseRouteGatingTest` pins the list) - -#### Database & Migrations -- **DO** use auto-incrementing integer IDs (`$table->id()`), not UUIDs -- Use anonymous class syntax for migrations - -#### Enums -- Status enums go in `backend/app/DomainObjects/Status/` -- Other enums go in `backend/app/DomainObjects/Enums/` - -#### Testing -- **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead -- Unit tests extend Laravel's TestCase, not PHPUnit's TestCase -- Use Mockery for mocking -- **Unit suite (`tests/Unit/`) is for pure isolation tests** — no DB, no HTTP, no real container resolution. If a test uses `DatabaseTransactions`, hits the DB (raw `DB::` calls, factories that persist, repository methods that query), or boots significant framework state, it's an integration test and belongs in `tests/Feature/` (mirror the path, e.g. `tests/Feature/Repository/Eloquent/`). Running `--testsuite=Unit` must stay fast and DB-free. -- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml` (which also forces `APP_ENV=testing`). The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` -- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. - -### Frontend - -#### General Standards -- This is a SSR app - ensure safe usage of `window` and `document` objects -- Favour using existing components over creating new ones -- **DON'T** include unnecessary React imports -- **ALWAYS** add translations when adding new user-facing strings - use Lingui `t` function or `Trans` component -- **IMMEDIATELY** after adding translatable strings, add translations for all supported languages (use `/translations` skill for the workflow) - -#### Data Fetching -- Use React Query for all API interactions -- Query example: `frontend/src/queries/useGetCapacityAssignment.ts` -- Mutation example: `frontend/src/mutations/useCreateAffiliate.ts` - -#### UI & Styling -- Use Mantine UI components for UI elements -- Prefer SCSS modules over Mantine layout components for layout styling -- `global.scss` gives every `.mantine-InputWrapper-root` and `.mantine-Switch-root` a bottom margin, which misaligns inputs placed inline in a row. Don't compensate with a magic `mt`/`mb` on the sibling; zero it for that row: `.row :global(.mantine-InputWrapper-root) { margin-bottom: 0; }` - -#### E2E Tests -- There is a Playwright E2E suite in `e2e/` (see `e2e/README.md`). It runs the real stack (Laravel + SSR frontend + Postgres + Redis + Mailpit) in Docker. -- **To test uncommitted changes, run specs against the dev stack** — the hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them. From `e2e/`: - ```bash - E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test - ``` - `E2E_SAAS_MODE=true` is required (the dev stack requires email verification; the fixture only confirms via Mailpit in SaaS mode), a queue worker must be running to deliver the verification emails, superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`, and seating/box office specs need `APP_LICENCE_KEY=development` in `backend/.env`. See "Against the running dev stack" in `e2e/README.md`. -- `@stripe` specs on the dev stack need `STRIPE_SECRET_KEY`, the backend's `STRIPE_WEBHOOK_SECRET` and `E2E_STRIPE_CONNECT_ACCOUNT_ID` exported; webhooks are handled by the queue worker there, so poll for the result -- **When you add or meaningfully change a user-facing flow, add or update an E2E spec for it where practical.** Follow the existing pattern: arrange data via the API/`factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content (the created/edited item appears), not just a URL change. Tag fast, load-bearing checks with `@smoke`. -- Not everything needs E2E — reserve it for real user journeys (create/edit/complete flows). Pure logic belongs in backend unit/feature tests instead. - -#### Test IDs (E2E) -- Add a `data-testid` to interactive elements the E2E suite needs to drive — primarily **buttons** (open-modal triggers, submit/save), **menu items**, and **custom widgets with no accessible label** (e.g. `CustomSelect`, which takes a `dataTestId` prop that lands on its target and options). This is not required for every element: text inputs with a unique `