Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .agents/setup
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ if ! grep -Fqx "$notes_marker" "$agents_file" 2>/dev/null; then
<!-- hi-events orb notes -->
# 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).
Expand Down
37 changes: 37 additions & 0 deletions .agents/skills/api-smoke-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <token>`):

```bash
curl -sk -X POST https://localhost:8443/api/auth/login -H "Content-Type: application/json" -H "Accept: application/json" -d '{"email":"<email>","password":"<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:<addr>`).
- Queued work (emails, webhooks) needs a queue worker: `docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work`.
22 changes: 22 additions & 0 deletions .agents/skills/e2e-dev-stack/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <spec>
```

## 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.
64 changes: 17 additions & 47 deletions .agents/skills/translations/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions .claude/rules/backend/abuse-and-spam.md
Original file line number Diff line number Diff line change
@@ -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`.
16 changes: 16 additions & 0 deletions .claude/rules/backend/account-deletion.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .claude/rules/backend/api-keys.md
Original file line number Diff line number Diff line change
@@ -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).
18 changes: 18 additions & 0 deletions .claude/rules/backend/lifecycle-emails.md
Original file line number Diff line number Diff line change
@@ -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`).
19 changes: 19 additions & 0 deletions .claude/rules/backend/mail.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions .claude/rules/backend/openapi.md
Original file line number Diff line number Diff line change
@@ -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`.
22 changes: 22 additions & 0 deletions .claude/rules/backend/sso.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading