Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/agents/pr-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: pr-readiness
description: Run all CI-equivalent checks locally before creating a PR
model: haiku
---

Mirror what `.github/workflows/*.yaml` runs on a PR. Every step executes inside Docker (the project
ships only its `phpfpm` / `prettier` / `markdownlint` services — there is no `node` service and no
JS/CSS to lint). Stop early if a critical check fails.

## Checks

1. **Composer validate**: `docker compose exec -T phpfpm composer validate --strict`
2. **Composer normalize (dry-run)**: `docker compose exec -T phpfpm composer normalize --dry-run`
3. **PHP coding standards**: `task coding-standards:php:check`
4. **Twig coding standards**: `task coding-standards:twig:check`
5. **YAML coding standards**: `task coding-standards:yaml:check`
6. **Markdown coding standards**: `task coding-standards:markdown:check`
7. **PHPStan (level 6)**: `task code-analysis:phpstan`
8. **Test fixtures + API tests**: `task fixtures:load:test --yes && task api:test`. Tests hit a real Elasticsearch, so fixtures must be loaded first. If the load fails with "No alive nodes", run `docker compose up --detach --wait` and retry — see the `reload-fixtures` agent for the full recovery dance.
9. **API spec up to date** (mirrors `.github/workflows/api-spec.yml`):
- `task api:spec:export`
- `git diff --exit-code public/spec.yaml` — must be clean.
10. **CHANGELOG updated**: `git diff develop -- CHANGELOG.md` should show at least one entry under `## [Unreleased]`.

## Output

Report a summary table with columns: Check Name, Status (pass/fail), and error output for failures.
25 changes: 25 additions & 0 deletions .claude/agents/reload-fixtures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: reload-fixtures
description: Reload Elasticsearch fixtures and recover from a not-ready cluster
model: haiku
---

This project's "data layer" is Elasticsearch — there is no Doctrine database to migrate. After changing
API resources, filters, or anything that affects search behavior you generally want to reload fixtures
so subsequent manual / test runs see a consistent dataset.

Two flavours of fixtures exist (see `src/Model/IndexName.php` for the seven index names):

- **Dev fixtures** — pulled from the `event-database-imports` repo on GitHub: `task fixtures:load`
- **Test fixtures** — read from `tests/resources/*.json` and used by the PHPUnit suite: `task fixtures:load:test --yes`

## Steps

1. Confirm with the user (or take it from their prompt) whether to load **dev** or **test** fixtures.
2. Make sure the stack is up: `docker compose up --detach --wait`. The `--wait` is important — Elasticsearch is slow to become ready and the fixture loader fails fast with "No alive nodes" otherwise.
3. Run the load command (`task fixtures:load` or `task fixtures:load:test --yes`). The Taskfile prompts for confirmation unless `--yes` is passed.
4. If the command fails with "No alive nodes" or any Elasticsearch connection error:
- Poll the cluster health endpoint until it returns HTTP 200:
`docker compose exec elasticsearch curl 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' --verbose`
- Re-run the fixture load command.
5. Report which indices were loaded and any non-fatal warnings (the Taskfile sets `ignore_error: true` because some fixtures emit a benign `Warning: Undefined array key "entityId"`).
169 changes: 169 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"COMPOSE_USER": "deploy"
},
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(diff:*)",
"Bash(echo:*)",
"Bash(find:*)",
"Bash(gh:*)",
"Bash(git:*)",
"Bash(grep:*)",
"Bash(head:*)",
"Bash(ls:*)",
"Bash(pwd)",
"Bash(tail:*)",
"Bash(task:*)",
"Bash(tree:*)",
"Bash(wc:*)",
"Bash(which:*)",
"Bash(docker compose exec:*)",
"Bash(docker compose run:*)",
"Bash(docker compose up:*)",
"Bash(docker compose ps:*)",
"Bash(docker compose logs:*)",
"Bash(docker compose top:*)",
"Bash(docker compose config:*)",
"Bash(docker compose pull:*)",
"Bash(docker compose images:*)",
"Bash(docker network:*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(gh issue delete:*)",
"Bash(gh release delete:*)",
"Bash(gh repo delete:*)",
"Bash(gh label delete:*)",
"Read(./.env.local)",
"Read(./.env.local.*)",
"Read(./config/secrets/*)"
],
"ask": [
"Bash(docker compose down:*)",
"Bash(docker compose stop:*)",
"Bash(docker compose rm:*)",
"Bash(docker compose restart:*)",
"Bash(gh issue create:*)",
"Bash(gh issue close:*)",
"Bash(gh issue edit:*)",
"Bash(gh issue comment:*)",
"Bash(gh pr create:*)",
"Bash(gh pr close:*)",
"Bash(gh pr merge:*)",
"Bash(gh pr edit:*)",
"Bash(gh pr comment:*)",
"Bash(gh pr review:*)",
"Bash(gh release create:*)",
"Bash(gh release edit:*)",
"Bash(gh repo create:*)",
"Bash(gh label create:*)",
"Bash(gh label edit:*)",
"Bash(git push:*)",
"Bash(git branch -d:*)",
"Bash(git branch -D:*)",
"Bash(git tag -d:*)",
"Bash(git tag -a:*)",
"Bash(git tag :*)",
"Bash(git reset:*)",
"Bash(git rebase:*)",
"Bash(git merge:*)",
"Bash(git stash drop:*)",
"Bash(git clean:*)",
"Bash(git checkout -- :*)",
"Bash(git restore:*)",
"Bash(git commit:*)"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "docker compose up --detach --quiet-pull 2>/dev/null || true",
"timeout": 60,
"statusMessage": "Starting Docker services..."
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in */composer.lock|*/yarn.lock|*/.env.local|*/.env.local.*) echo 'BLOCKED: Do not edit lock files or .env.local directly' >&2; exit 1 ;; esac"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose exec -T phpfpm vendor/bin/php-cs-fixer fix --quiet \"$REL_PATH\" 2>/dev/null || true ;; esac",
"timeout": 30
},
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in *.php) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose exec -T phpfpm vendor/bin/phpstan analyse --no-progress --error-format=raw \"$REL_PATH\" 2>/dev/null || true ;; esac",
"timeout": 30
},
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in *.twig) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose exec -T phpfpm vendor/bin/twig-cs-fixer lint --fix \"$REL_PATH\" 2>/dev/null || true ;; esac",
"timeout": 15
},
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in */composer.json) docker compose exec -T phpfpm composer normalize --quiet 2>/dev/null || true ;; esac",
"timeout": 30
},
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in *.yaml|*.yml) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T prettier \"$REL_PATH\" --write 2>/dev/null || true ;; esac",
"timeout": 15
},
{
"type": "command",
"command": "case \"$CLAUDE_FILE_PATH\" in *.md) REL_PATH=\"${CLAUDE_FILE_PATH#$CLAUDE_PROJECT_DIR/}\"; docker compose run --rm -T markdownlint markdownlint \"$REL_PATH\" --fix 2>/dev/null || true ;; esac",
"timeout": 15
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "docker compose exec -T phpfpm bin/console lint:container 2>/dev/null || true",
"timeout": 30,
"statusMessage": "Validating Symfony DI container..."
}
]
}
]
},
"enabledPlugins": {
"php-lsp@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"code-review@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"playwright@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"itkdev-skills@itkdev-marketplace": true
},
"enabledMcpjsonServers": [
"context7"
],
"enableAllProjectMcpServers": true
}
18 changes: 18 additions & 0 deletions .claude/skills/update-api-spec/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: update-api-spec
description: Regenerate and stage the OpenAPI spec after API resource changes
user-invocable: true
---

The committed OpenAPI spec lives at `public/spec.yaml` (single YAML file — there is no separate JSON
export). `.github/workflows/api-spec.yml` fails the PR if it drifts from the resources defined under
`src/Api/`, and additionally diffs the spec against the base branch to flag breaking changes.

After touching anything under `src/Api/Dto/`, `src/Api/State/`, or `src/Api/Filter/`:

1. Regenerate the spec: `task api:spec:export`
(This is shorthand for `bin/console api:openapi:export --yaml --output=public/spec.yaml --no-interaction`.)
2. Inspect changes: `git diff public/spec.yaml`
3. If the diff is non-empty, stage the file: `git add public/spec.yaml`
4. Summarise what changed — new/removed operations, modified parameters, schema diffs — so the human
reviewer can sanity-check for unintended breaking changes before pushing.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

###> claude-code ###
/.claude/settings.local.json
###< claude-code ###
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ See [keep a changelog] for information about writing changes to this log.

## [Unreleased]

- [PR-27](https://github.com/itk-dev/event-database-api/pull/27)
Add Claude Code project setup (CLAUDE.md, agents, skills)

## [1.2.2] - 2026-05-22

- [PR-26](https://github.com/itk-dev/event-database-api/pull/26)
Expand Down
119 changes: 119 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this project is

API platform front-end for the Danish event database used by the municipality of Aarhus. This repo serves a
**read-only** REST/Hydra API (`/api/v2/...`) backed by **Elasticsearch** — data is *indexed* by a separate project,
[`itk-dev/event-database-imports`](https://github.com/itk-dev/event-database-imports). The MariaDB service in
`docker-compose.yml` is part of the standard ITK Dev Symfony image but is **not used for domain data** (the
`migrations/` and `src/Entity/` directories are empty).

Stack: PHP 8.3+, Symfony 7.4, API Platform 4.1, Elasticsearch 8.x. Runs entirely in Docker via
`itkdev/php8.3-fpm` + nginx.

## Common commands

All commands are wrapped in `Taskfile.yml` (run via [Task](https://taskfile.dev)). Most are just
`docker compose exec phpfpm …` underneath — useful to know when `task --dry <name>` to see the actual command.

### Setup / running

```shell
docker compose pull
docker compose up --detach --wait # --wait is important: Elasticsearch is slow to become ready
docker compose exec phpfpm composer install
task fixtures:load # loads demo data from event-database-imports into Elasticsearch
```

The site is reachable at `http://$(docker compose port nginx 8080)`. Every API call needs an `X-Api-Key` header
matching one of the entries in `APP_API_KEYS` (JSON array in `.env.local`).

### Tests

```shell
task fixtures:load:test --yes # loads tests/resources/*.json into Elasticsearch — REQUIRED before api:test
task api:test # phpunit
task api:test -- --filter EventsFilter # single test class / pattern
```

Tests hit a real Elasticsearch (no mocking) — see `tests/ApiPlatform/AbstractApiTestCase.php`. The hardcoded test
API key is `test_api_key`. If a test run dies with "No alive nodes", run `docker compose up --detach --wait` and
reload fixtures.

### Lint / static analysis

```shell
task coding-standards:check # markdown + php-cs-fixer + twig-cs-fixer + prettier (yaml)
task coding-standards:apply # auto-fix all of the above
task code-analysis # PHPStan, level 6
```

CI (GitHub Actions `pr.yaml`) runs all of these — run them locally before opening a PR.

### API spec

`public/spec.yaml` is the committed OpenAPI export and is checked in CI. Regenerate after changing any API resource:

```shell
task api:spec:export
```

## Architecture

### Request flow

API Platform resources are **DTOs**, not Doctrine entities. Each resource follows the same pattern — `Event` is the
canonical example:

1. `src/Api/Dto/<Resource>.php` — `#[ApiResource]` class declaring operations, pagination, and `#[ApiFilter]`
attributes. The `$id` property is identifier-only; real data is filled in by the provider.
2. `src/Api/State/<Resource>RepresentationProvider.php` — implements `ProviderInterface`. Receives the operation +
context, asks `AbstractProvider::getFilters()` to compile the declared `#[ApiFilter]` attributes into
Elasticsearch query fragments, then calls `IndexInterface::search()` and returns a `SearchResults` (collection) or
array (single item).
3. `src/Service/ElasticSearch/ElasticSearchIndex.php` — the only `IndexInterface` implementation. Talks to the
Elasticsearch cluster (`INDEX_URL` env var). Paginated results come back via `ElasticSearchPaginator`.

The seven indices are enumerated in `src/Model/IndexName.php` (`events`, `organizations`, `occurrences`,
`daily_occurrences`, `tags`, `vocabularies`, `locations`). Each index has a matching DTO and provider.

### Filters

Custom Elasticsearch filters live in `src/Api/Filter/ElasticSearch/` (`MatchFilter`, `IdFilter`, `BooleanFilter`,
`DateRangeFilter`, `DateFilter`, `TagFilter`). They implement API Platform's `FilterInterface` but `apply()` returns
ES query DSL rather than mutating a Doctrine queryBuilder. `AbstractProvider::getFilters()` separates filter clauses
from sort clauses via the `SortFilterInterface` marker.

When adding a filter to a resource, attach it with `#[ApiFilter(SomeFilter::class, properties: […])]` on the DTO —
the provider picks them up automatically through `api_platform.filter_locator` (injected via
`config/services.yaml`).

### Auth

Stateless. `src/Security/ApiKeyAuthenticator.php` reads `X-Api-Key`; `ApiUserProvider` validates against the
JSON-encoded `APP_API_KEYS` env var. `/api/v2/docs` is public; everything else under `/api` requires a valid key
(see `config/packages/security.yaml`). There is no role model — any valid key gets full read access.

### What lives where

- `src/Api/Dto/` + `src/Api/State/` + `src/Api/Filter/ElasticSearch/` — the API layer (see above).
- `src/Service/ElasticSearch/` — the only data access. If you find yourself adding a new persistence concern, this
is where it goes.
- `src/Command/FixturesLoadCommand.php` — loads JSON dumps into Elasticsearch (used by `task fixtures:load*`).
- `src/Model/` — value objects / enums (`IndexName`, `FilterType`, `DateLimit`, `SearchResults`, `DateFilterConfig`).
- `config/reference.php` — auto-generated, excluded from PHP-CS-Fixer (see `.php-cs-fixer.dist.php`).
- `tests/resources/*.json` — fixtures used by the test suite; loaded via
`--url=file:///app/tests/resources/<index>.json`.

## Conventions worth knowing

- Don't add Doctrine entities or migrations — domain data is owned by `event-database-imports`. New resources mean
new DTOs + providers, not entities.
- `paginationMaximumItemsPerPage` on a resource caps client-requested page size.
`AbstractProvider::MAX_PAGE_SIZE_FALLBACK = 20` is the safety net when the resource doesn't declare one.
- `failOnDeprecation`, `failOnNotice`, `failOnWarning` are all `true` in `phpunit.dist.xml` — a deprecation warning
will fail the suite.
- PHPStan errors about `App\Api\Dto\*::$id` being unused are intentionally ignored (API Platform writes the property
reflectively).
Loading