# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What is Antragsgrün Antragsgrün (motion.tools) is a PHP/Yii2 web application for NGOs and political parties to collaboratively manage motions, amendments, resolutions, votings, and speaking lists. Current version: 4.17.1. ## General Instructions for Claude Code Whenever there are non-trivial design decisions, ask rather than make assumptions. ## Commands ### Build / Assets ```bash pnpm install # Install JS dependencies pnpm run build # Compile SCSS/JS/Vue (runs Vite, see vite.config.js) pnpm run watch # Watch and recompile on change docs/create-static-resources.php dev # Create static resource manifests (run after pnpm build) ``` ### PHP dependencies ```bash ./composer.phar install # Install/update PHP dependencies ./yii migrate # Apply database migrations ``` ### Creating new DB entities / migrations When creating or changing database tables, follow this flow: 1. Create / update the ActiveRecord model in `models/db/` 2. Create a new migration under `migrations/` 3. Test that the migration can be applied and reverted: `./yii migrate` / `./yii migrate/down` 4. Add / update the table definition in `assets/db/create.sql` 5. Register the new migration as applied in `assets/db/data.sql` (the `INSERT INTO migration` rows) 6. Update `assets/db/delete.sql` if necessary 7. Add dummy data to the test fixture `tests/Support/Data/dbdata1.sql` if applicable (feel free to ask the user if necessary) ### After a git pull ```bash ./composer.phar install && ./yii migrate && pnpm run build ``` ### Linting / Static analysis ```bash pnpm run lint # ESLint on web/js/**/*.{js,ts} # PHPStan - generate baseline first, then verify changes don't introduce new errors: php -d memory_limit=1G vendor/bin/phpstan.phar analyse --configuration=phpstan.neon --generate-baseline php -d memory_limit=1G vendor/bin/phpstan.phar analyse --configuration=phpstan.use-baseline.neon ``` ### Coding Styles Use named arguments for function and method calls whenever boolean values are passed. ### Testing ```bash vendor/bin/codecept run Unit # All unit tests vendor/bin/codecept run Unit --skip-group=database # Unit tests without DB vendor/bin/codecept run Acceptance # All acceptance tests (requires Selenium + test.antragsgruen.test) vendor/bin/codecept run Acceptance motions/CreateCept # Single acceptance test ``` Acceptance tests require: - MariaDB test database (`antragsgruen_tests`) configured in `config/config_tests.json` - `test.antragsgruen.test` pointing to localhost in `/etc/hosts` - Selenium Grid running: `java -jar selenium-server-4.x.jar standalone --config selenium-antragsgruen.toml` ### Development environment (Docker) ```bash echo "RANDOM_SEED=$(openssl rand -base64 32)" > .env # one-time docker compose -f docker-compose.development.yml --profile pnpm-helper up docker exec -it antragsgruen-web-1 /var/www/antragsgruen/docker/initialize-development-environment.sh ``` Dev credentials: `testadmin@example.org` / `testadmin`, `testuser@example.org` / `testuser`. ## Architecture ### Framework Yii2 MVC framework. Entry point: `web/index.php`. Console entry: `./yii`. Configuration: `config/config.json` (or environment variables via `EnvironmentConfigLoader`). ### Core MVC layout - `controllers/` — HTTP controllers extending `controllers/Base.php`. Admin sub-controllers in `controllers/admin/`. - `models/db/` — ActiveRecord models (Yii2). Key entities: `Motion`, `Amendment`, `Consultation`, `ConsultationMotionType`, `IMotion` (abstract base for both Motion and Amendment), `Site`, `User`, `VotingBlock`. - `models/settings/` — JSON-serialized settings objects (stored as JSON columns or config files). `AntragsgruenApp` is the global app settings, `Consultation` are per-consultation settings. - `views/` — PHP view files. `views/layouts/` contains the main HTML layouts. `views/pdfLayouts/` are also scanned by PHPStan. - `components/` — Reusable services and utilities (email, HTML tools, diff, caching, PDF generation wrappers). - `commands/` — Yii console commands (admin, database, background job controllers). - `migrations/` — Yii DB migrations. ### Key domain concepts - **Site** — a top-level installation (can host multiple consultations in multisite mode). - **Consultation** — a meeting/event. Has motion types, agenda, users, and settings. - **ConsultationMotionType** — defines a category of motions with their own workflow, policies, and section layout. - **IMotion / Motion / Amendment** — `IMotion` is an abstract interface; `Motion` and `Amendment` are concrete ActiveRecord models that both implement it. - **IMotionSection / sectionTypes/** — sections of a motion (text, title, image, PDF, tabular data, etc.). The type system is in `models/sectionTypes/`. - **Policies** (`models/policies/`) — who can submit, support, or comment (everybody, logged-in users, specific groups, etc.). See "Permission system" below. - **Support types** (`models/supportTypes/`) — how supporters/signatories are collected. - **Votings** — `VotingBlock`, `Vote`, managed via `VotingController` and `models/votings/`. - **Proposed procedures** — `AmendmentProposal`, `models/proposedProcedure/`. ### Permission system Two separate mechanisms exist: **privileges** (admin-side rights, granted via user groups) and **policies** (who may submit/support/comment, configured per motion type). Do not confuse `models/settings/Permissions.php` (business rules, see below) with `models/settings/UserGroupPermissions.php` (per-group privilege storage). #### Privileges (admin rights) - Defined as integer constants in `models/settings/Privileges.php`. Plugins can add custom privileges via `ModuleBase::addCustomPrivileges()`. - Check with `User::havePrivilege($consultation, $privilege, $context)` (current user) or `$user->hasPrivilege(...)`. Privileges are always consultation-scoped. - Motion-related privileges are *restrictable*: a grant can be limited to a motion type, agenda item, or tag. When checking such a privilege, pass a `PrivilegeQueryContext` (e.g. `PrivilegeQueryContext::motion($motion)`) so the restriction can be evaluated; `null` context only matches unrestricted grants. Special values: - `PRIVILEGE_ANY` (0) — pseudo-privilege: matches if the user holds any privilege at all. Used e.g. to let admins act past deadlines. - `PRIVILEGE_SITE_ADMIN` (6) — all privileges on all consultations of the site; only grantable via a site-wide group with `admin-all`. - `PRIVILEGE_GLOBAL_USER_ADMIN` (10) — edit user account data itself (name, password), not just group assignments. Cannot be granted through groups; only superusers (`adminUserIds`) have it. General privileges (not motion-restrictable): - `PRIVILEGE_CONSULTATION_SETTINGS` (1) — manage consultation settings, motion types, user groups; also retains access when the consultation is in maintenance mode. - `PRIVILEGE_CONTENT_EDIT` (2) — edit content pages/texts and uploaded documents, see draft motions (not edit motions). - `PRIVILEGE_AGENDA` (15) — manage the agenda. - `PRIVILEGE_SPEECH_QUEUES` (8) — manage speech queues. - `PRIVILEGE_VOTINGS` (9) — manage votings. Motion-related privileges (restrictable to motion type / agenda item / tag): - `PRIVILEGE_SCREENING` (3) — review & publish (screen) submitted motions, amendments, and comments. - `PRIVILEGE_MOTION_SEE_UNPUBLISHED` (13) — see the admin motion list including unpublished motions/amendments (read-only, no editing rights). - `PRIVILEGE_MOTION_STATUS_EDIT` (4) — edit motion/amendment metadata: status, supporters/signatures, tags, title. Not text, initiators, or deletion. Dedicated screening actions require `PRIVILEGE_SCREENING`, but setting statuses through the admin form achieves the same effect. Also the privilege behind the `Admins` policy. - `PRIVILEGE_MOTION_TEXT_EDIT` (11) — edit the text; merge amendments into motions. In the admin UI it can only be granted together with `PRIVILEGE_MOTION_STATUS_EDIT` (see `Privilege::$dependentOnId`). - `PRIVILEGE_MOTION_INITIATORS` (5) — edit initiators; create motions in the name of someone else; move motions *to* this consultation. Also only grantable together with `PRIVILEGE_MOTION_STATUS_EDIT`. - `PRIVILEGE_MOTION_DELETE` (12) — delete motions/amendments; move motions away *from* this consultation. - `PRIVILEGE_CHANGE_PROPOSALS` (7) — edit the proposed procedure. - `PRIVILEGE_CHANGE_EDITORIAL` (14) — edit editorial texts / progress reports. #### User groups (how privileges are granted) - `models/db/ConsultationUserGroup` — scoped to a consultation, to a site, or global (externally synced groups identified by `externalId`, e.g. `gruenesnetz:`/OpenSlides). Membership via the `userGroup` join table (`User::getUserGroupsForConsultation()`). - Each group stores a `UserGroupPermissions` JSON blob in its `permissions` column: either `default_permissions` bundles (`admin-all`, `proposed-procedure`, `admin-speech-list`) or fine-grained `privileges` entries (`UserGroupPermissionEntry`, optionally restricted to motionType/agendaItem/tag). Fine-grained entries can only be set on consultation-level groups. A legacy plain comma-separated format is still parsed when reading old DB rows, but never written anymore. - Default template groups (`templateId`): Site admin, Consultation admin, Proposed procedure, Progress report, Participant. Template-based and system-wide groups are not user-editable. - **Superusers**: user IDs listed in `adminUserIds` (config.json) pass every privilege check (`User::currentUserIsSuperuser()`). The old `siteAdmin`/`consultationAdmin` DB tables were dropped long ago; user groups are the only grant mechanism besides `adminUserIds`. #### Policies (who may act) - `models/policies/` classes implementing `IPolicy`; serialized into the `policyMotions`, `policyAmendments`, `policyComments`, `policySupportMotions`, `policySupportAmendments` columns of `ConsultationMotionType`. `ConsultationText` and `VotingBlock` also implement `IHasPolicies`. - Registered policies: `Nobody`, `All`, `LoggedIn`, `Admins` (= holders of `PRIVILEGE_MOTION_STATUS_EDIT`), `UserGroups` (members of selected user groups), `GruenesNetz` (only when SAML is active), plus plugin-provided ones. `IPolicy::POLICY_ORGANIZATION` is reserved but currently has no implementation (the `organisations` consultation setting only feeds the initiator form dropdown). - Check via `checkCurrUserMotion()` / `checkCurrUserAmendment()` / `checkCurrUserComment()`, which also enforce the motion type's deadlines; users with any privilege may override deadlines unless `$allowAdmins = false`. #### Business rules `models/settings/Permissions.php` combines status, deadlines, policies, and privileges into concrete decisions (`motionCanEditText()`, `isCurrentlyAmendable()`, `motionCanMergeAmendments()`, ...). Obtained via `IMotion::getPermissionsObject()`; plugins can substitute their own subclass via `ModuleBase::getPermissionsClass()`. #### Consultation access gating `components/ConsultationAccess.php` restricts access to a whole consultation, checked before controllers run: maintenance mode (only users with `PRIVILEGE_CONSULTATION_SETTINGS` get in), `forceLogin`, `managedUserAccounts` (user must belong to a user group of the consultation; registration requests are queued in `UserConsultationScreening`), a consultation access password (`accessPwd`), and the site's allowed `loginMethods`. Plugins can grant limited access to specific pages. - Legacy JS: `web_src/js/antragsgruen.js` bundled into `web/js/antragsgruen.min.js` (concat + terser, see `assets/vite/legacy-bundles-plugin.js`). - Vue.js components: `web_src/js/vue/` (complex widgets: voting, speaking list, amendment merging). - TypeScript: `web_src/typescript/` only typings provided, not actually used. - SCSS: lives in `web_src/css/` and plugin asset directories; compiled to `web/css/`. - Third-party npm packages copied to `web/npm/` by the Vite build (`assets/vite/copy-npm-files-plugin.js`). ### Plugins Each plugin lives in `plugins//` and extends `plugins/ModuleBase.php`. Activated via the `plugins` array in `config/config.json`. Plugins can provide: - Custom themes (SCSS + `Assets.php` + `getProvidedLayout()` in `Module.php`) - Custom language variants (`getProvidedMessagesForLanguage()` + `messages//`) - Extra controllers, views, commands, policies, hooks - SSO/authentication providers Key plugins in this repo: `gruen_ci` (Green Party CI theme), `antragsgruen_sites` (multisite home page manager), `generic_sso` (OAuth2/SAML SSO). ### Internationalization (i18n) All user-facing strings are translated through Yii's i18n, backed by the custom `components/yii/MessageSource.php`. - **Message files**: `messages//.php`, each returning a `key => text` array. Base languages: `en` (canonical base), `de`, `fr`, `nl`, `ca`, `me`, `es`, `sv`. Categories are the file names (`base`, `admin`, `motion`, `amend`, `voting`, ...), except that the category `con` lives in `consultation.php` (`con` is a reserved filename on Windows). New categories must be registered in `MessageSource::getTranslatableCategories()`. - **Usage in PHP/views**: `Yii::t('category', 'key')`. Placeholders are plain-text tokens (mostly `%LINK%` / `%NAME%`, some JS strings use `{name}`) that the caller replaces via `str_replace` / `String.replace` — not ICU syntax. - **Always add every new string to all base languages** (`messages/en`, `de`, `fr`, `nl`, `ca`, `me`, `es`, `sv`), with a real translation — not an English copy. Keep the same key and position in each file. When renaming or removing a key, do it in all languages. Plugin categories live in `plugins//messages//.php`; add them for every language that plugin provides. - **Wording variants** (`messages/de-parteitag/`, `de-bewerbung`, `de-bdk`, `en-gb`, ...) only contain overrides of selected keys; they are selected via `Consultation::$wordingBase` and merged on top of the base language. Don't add new keys there unless a variant genuinely needs different wording. Plugins can provide additional variants via `getProvidedTranslations()` (stored under `plugins//messages//`). - **Merge order**: `en` → base language → wording variant → consultation-specific overrides (`ConsultationText` DB rows, editable by admins in the "Edit texts" UI). Missing keys therefore fall back to English. With `config/DEBUG` present, missing translations are logged to `missing-translations.log` in the tmp dir. - **Structured entries**: instead of a plain string, an entry can be an array `['text' => '...', 'description' => '...', 'placeholders' => [...], 'js' => true]`. `description` is a hint for translators / the admin text-editing UI (add one when a short string is ambiguous). **If the code supports placeholders that the default text doesn't use**, list all supported placeholders in a `placeholders` array, e.g. `'placeholders' => ['%TITLE%', '%LINK%', '%STATUS%']`, so admins customizing the text know what's available. The admin UI renders them behind a translated "Supported placeholders:" label (`admin.translating_placeholders`); don't write that label into the `description`. Don't add this when the text already contains exactly the supported placeholders. This structure only needs to exist in the **English** file — other languages just provide the plain string; `MessageSource::mergeWithStructure()` keeps the English metadata and swaps in the translated `text`. - **Strings used in JavaScript / Vue**: mark them with `'js' => true` in the English base file only, e.g. ```php 'save' => [ 'text' => 'Save', 'js' => true, ], ``` Only `js`-flagged strings are exported to the browser (`MessageSource::loadJsMessages()`). The view must register the category via `$layout->addJsTranslation('voting')`; `views/layouts/main.php` then calls `translate.registerTranslation()` for it. In Vue/JS, read strings with `translate.getTranslation('category', 'key')` (`import translate from "/js/vue/Translate.vue.js"`) or register the default export as a directive (e.g. `app.directive('t', translate)`, then `v-t="['category', 'key']"`; full form `[category, key, html, replacements, suffix]`, and `v-t:title=...` sets an attribute). Legacy jQuery code typically receives translated strings through `data-*` attributes rendered with `Yii::t()` in the view. ### REST API Disabled by default; enabled per site (`apiEnabled` in the site settings, see `Base::handleRestHeaders()`); authenticated users can use the API even when public API access is disabled. Authentication via JWT bearer tokens (`OptionalHttpBearerAuth`). All endpoints under `/rest`. OpenAPI spec at `docs/openapi.yaml`. API response models live in `models/api/`. The controllers are implemented in `controllers/rest/` and extend from `RestBase.php`. Countroller methods also need to be registered in config/urls.php. The correct approach to make modifications to the API and DTOs is: - First change the docs/openapi.yaml - Then run `VENDOR_DIR=/Users/tobiashossl/Sites/openapi/vendor php docs/openapi-generate-dtos.php docs/openapi.yaml models/api/` to generate the PHP DTOs - Then use those DTOs. It is acceptable to add methods to these DTOs, as only the constructor will be overwritten by the command above. ### PDF generation Two backends: TCPDF (default, PHP-only) and Weasyprint (external binary, nicer output). Configured via `weasyprintPath`/`qpdfPath` in `config.json`. ### Caching - Default: Yii2 file cache. - Redis: enabled via `redis` key in `config.json`. - Aggressive view cache: `viewCacheFilePath` in `config.json`. ### Debug mode Create an empty file `config/DEBUG` to enable Yii2 debug mode. ## Configuration `config/config.json` is the main config file (gitignored, created by installer). Template: `config/config.template.json`. For tests: `config/config_tests.json` (from `config/config_tests.template.json`). All supported settings can alternatively be passed as environment variables (see `docs/environment-variables.md`). ### Technical Implementation Details Topic-specific implementation details are stored under [docs/technical](docs/technical).