Compare commits

..

3 Commits

Author SHA1 Message Date
philipp lang 7f38d1b4ed WIP: Add cleanup for forms
continuous-integration/drone/push Build is failing Details
@todo add cleanup icon as svg (broom.svg)
2026-08-27 21:31:02 +02:00
philipp lang 13b244ae33 remove first-attribute-linebreak in eslint 2026-08-27 21:31:02 +02:00
philipp lang d4d4df099c Add claude instructions 2026-08-27 21:31:02 +02:00
9 changed files with 282 additions and 58 deletions

57
CLAUDE.md Normal file
View File

@ -0,0 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
AdReMa ("AddressManagement") is a Laravel 11 + Inertia/Vue 3 app for German Scout groups (DPSG) to manage member data, courses, invoicing/contributions, and syncs with the external NaMi member database. Backend is PHP 8.3, frontend is Vue 3 + TypeScript rendered via Inertia (no separate SPA API).
## Commands
### PHP / backend
- Run all tests: `php artisan test` (or `vendor/bin/pest`)
- Run a single test file: `php artisan test tests/Feature/Member/SomeTest.php`
- Run a single test by name: `php artisan test --filter=test_name`
- Static analysis: `vendor/bin/phpstan analyse` (larastan, level 6, config in `phpstan.neon`)
- Test suites are split in `phpunit.xml`: `Unit`, `Feature`, `Fileshare`, `Arch` (architecture rules in `tests/Arch.php`), `NamiUnit` (packages/laravel-nami), `EndToEnd`
### JS / frontend
- Dev server: `npm run dev` (alias for `vite`)
- Production build: `npm run prod`
- Lint: `npm run lint`, autofix: `npm run fix`
- Rebuild SVG sprite: `npm run img`
- The `packages/adrema-form` package (public event-registration form, embeddable) has its own `npm run build` / `build-import` (import mode is used when embedded in the main app) and is built separately.
### Environment
- Local dev runs via Docker Compose (`docker-compose.yml`); `.app.env` holds environment config, copied from `.app.env.example`.
- Submodules must be initialized: `git submodule update --init`.
- CI (`.drone.yml`) runs: composer install → npm build (main app + `adrema-form`) → `php artisan migrate``php artisan test``vendor/bin/phpstan analyse`.
## Architecture
### Backend structure
Code under `app/` is organized by **domain module**, not by technical layer (no global `app/Http/Controllers` for most features). Each domain (e.g. `Member`, `Activity`, `Form`, `Invoice`, `Course`, `Fileshare`, `Contribution`, `Mailman`, `Mailgateway`, `Efz`, `Prevention`) typically contains its own `Actions/`, models, requests, and resources together. Older/shared code still lives in `app/Actions`.
- **Actions pattern**: business logic lives in single-purpose Action classes (`lorisleiva/laravel-actions`, `use AsAction`) with a `handle()` method, invoked directly from routes in `routes/web.php` rather than through traditional controllers.
- **NaMi integration**: `packages/laravel-nami` is a local Composer package (symlinked via `repositories` path in `composer.json`) wrapping the external NaMi API (members, courses, memberships, confessions, fees, regions, etc.). App-level `Pull*Action`/`Insert*Action` classes (`app/Actions/PullMemberAction.php`, `InsertMemberAction.php`, etc.) sync NaMi data into local models. - Please never edit files here, unless explicitly specified.
- **Local packages** (all under `packages/`, wired via Composer path repositories): `laravel-nami` (NaMi API client), `table-document` (PDF/table document generation), `flysystem-webdav` (WebDAV filesystem driver, used for cloud file storage/CardDAV-adjacent features), `tex` (LaTeX rendering, used for invoices/Bescheinigungen), `medialibrary-helper` (Vue components + backend helpers for file uploads, e.g. `FSinglefile`/`FMultiplefiles`), `adrema-form` (standalone Vite/Vue app for public event registration forms, built separately and embedded/imported into the main app). - Please never edit files here, unless explicitly specified
- **Modules feature flag**: `App\Module\Module` enum (`bill`, `course`, `event`) + `ModuleSettings` gate optional features per-installation; checked server-side via `hasModule()` and client-side via the `hasModule` Vue mixin (`resources/js/mixins/hasModule.js`).
- Data transfer uses `spatie/laravel-data` (`Data` classes) in several domains instead of plain arrays/DTOs.
- PHPStan type aliases for complex array shapes (e.g. contribution API payloads) are defined centrally in `phpstan.neon`.
### Frontend structure
- Entry point `resources/js/app.js` bootstraps Inertia + Pinia + Vue; pages are Vue SFCs under `resources/js/views/**`, resolved by path via Inertia's `resolve`.
- `resources/js/layouts/AppLayout.vue` is the default page layout, applied automatically unless a page sets its own `layout`.
- Global mixins (`hasModule`, `hasFlash`) are applied app-wide instead of per-component imports.
- State is managed with Pinia stores (`resources/js/stores`).
### Testing conventions
- Tests use Pest (`pestphp/pest`), with `tests/TestCase.php` / `tests/EndToEndTestCase.php` / `tests/FileshareTestCase.php` as base cases for different suites.
- `tests/RequestFactories` (worksome/request-factories) and `tests/Datasets` provide reusable test data/request builders.
- `tests/Arch.php` enforces architectural constraints via Pest's arch testing.

View File

@ -0,0 +1,28 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class FormCleanupAction
{
use AsAction;
public function handle(Form $form): void
{
$form->participants()->get()->each->delete();
$form->save();
ClearFrontendCacheAction::run();
}
public function asController(Form $form): JsonResponse
{
$this->handle($form);
Succeeded::message('Teilnehmende gelöscht.')->dispatch();
return response()->json([]);
}
}

View File

@ -190,7 +190,8 @@ class Form extends Model implements HasMedia
return Sorting::from($this->meta['sorting']);
}
public function isInDates(): bool {
public function isInDates(): bool
{
if ($this->registration_from && $this->registration_from->gt(now())) {
return false;
}

View File

@ -65,6 +65,7 @@ class FormResource extends JsonResource
'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]),
'update' => route('form.update', $this->getModel()),
'destroy' => route('form.destroy', $this->getModel()),
'cleanup' => route('form.cleanup', $this->getModel()),
'is_dirty' => route('form.is-dirty', $this->getModel()),
'frontend' => str(app(FormSettings::class)->registerUrl)->replace('{slug}', $this->slug),
'export' => route('form.export', $this->getModel()),

View File

@ -30,10 +30,7 @@ export default typescriptEslint.config(
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
"vue/first-attribute-linebreak": ["error", {
"singleline": "beside",
"multiline": "beside"
}],
'vue/first-attribute-linebreak': 'off',
'vue/no-undef-properties': ['error', {
'ignores': ['/^\\$/']
}]

View File

@ -0,0 +1,3 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M236,40 L276,40 L276,270 L372,300 L372,340 L296,320 L296,460 L216,460 L216,320 L140,340 L140,300 L236,270 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@ -23,10 +23,23 @@
<div v-show="active === 0" class="grid grid-cols-4 gap-3">
<div class="flex space-x-3 col-span-2">
<f-text id="name" v-model="single.name" class="grow" label="Name" required />
<f-switch id="is_active" v-model="single.is_active" name="is_active" label="Aktiv" hint="Inaktive Veranstaltungen werden außerhalb von Adrema wie nicht existierende Veranstaltungen betrachtet. Insbesondere ist eine Anmeldung dann nicht möglich und die Veranstaltung erscheint auch nicht in der Veranstaltungs-Übersicht." />
<f-switch id="is_private" v-model="single.is_private" name="is_private" label="Privat" hint="Ist eine Veranstaltung privat, so wird diese nicht auf der Website angezeigt. Eine Anmeldung ist jedoch trotzdem möglich, wenn man über den Anmelde-Link verfügt." />
<f-switch
id="is_active"
v-model="single.is_active"
name="is_active"
label="Aktiv"
hint="Inaktive Veranstaltungen werden außerhalb von Adrema wie nicht existierende Veranstaltungen betrachtet. Insbesondere ist eine Anmeldung dann nicht möglich und die Veranstaltung erscheint auch nicht in der Veranstaltungs-Übersicht."
/>
<f-switch
id="is_private"
v-model="single.is_private"
name="is_private"
label="Privat"
hint="Ist eine Veranstaltung privat, so wird diese nicht auf der Website angezeigt. Eine Anmeldung ist jedoch trotzdem möglich, wenn man über den Anmelde-Link verfügt."
/>
</div>
<f-singlefile id="header_image"
<f-singlefile
id="header_image"
v-model="single.header_image"
class="col-span-2"
label="Bild"
@ -41,9 +54,17 @@
<f-text id="zip" v-model="single.zip" label="PLZ" />
<f-text id="location" v-model="single.location" label="Ort" />
<f-select id="country" v-model="single.country" class="col-span-2" name="country" label="Land" :options="meta.countries" />
<f-text id="registration_from" v-model="single.registration_from" type="datetime-local" label="Registrierung von" hint="Ist eine Anmeldung laut dieser zwei Datumsangaben möglich, kann man sich anmelden. Andernfalls wird die Veranstaltung (mit Beschreibungstext) auf der Übersichtsseite angezeigt, man kommt allerdings nicht zum Anmeldeformular." required />
<f-text
id="registration_from"
v-model="single.registration_from"
type="datetime-local"
label="Registrierung von"
hint="Ist eine Anmeldung laut dieser zwei Datumsangaben möglich, kann man sich anmelden. Andernfalls wird die Veranstaltung (mit Beschreibungstext) auf der Übersichtsseite angezeigt, man kommt allerdings nicht zum Anmeldeformular."
required
/>
<f-text id="registration_until" v-model="single.registration_until" type="datetime-local" label="Registrierung bis" required />
<f-textarea id="excerpt"
<f-textarea
id="excerpt"
v-model="single.excerpt"
hint="Gebe hier eine kurze Beschreibung für die Veranstaltungs-Übersicht ein (Maximal 130 Zeichen)."
label="Auszug"
@ -61,9 +82,9 @@
</div>
<div v-show="active === 3" class="grid grid-cols-[1fr_300px] gap-3">
<ui-note class="mt-2 col-span-full">
Hier kannst du die E-Mail anpassen, die nach der Anmeldung an den Teilnehmer verschickt wird.<br>
Es gibt dafür einen ersten E-Mail-Teil und einen zweiten E-Mail-Teil. Dazwischen werden die Daten des Teilnehmers aufgelistet.<br>
Die Anrede ("Hallo Max Mustermann") wird automatisch an den Anfang gesetzt.<br>
Hier kannst du die E-Mail anpassen, die nach der Anmeldung an den Teilnehmer verschickt wird.<br />
Es gibt dafür einen ersten E-Mail-Teil und einen zweiten E-Mail-Teil. Dazwischen werden die Daten des Teilnehmers aufgelistet.<br />
Die Anrede ("Hallo Max Mustermann") wird automatisch an den Anfang gesetzt.<br />
Außerdem kannst du Dateien hochladen, die automatisch mit angehangen werden.
</ui-note>
<div>
@ -79,7 +100,8 @@
</template>
</f-editor>
</div>
<f-multiplefiles id="mailattachments"
<f-multiplefiles
id="mailattachments"
v-model="single.mailattachments"
label="Anhänge"
name="mailattachments"
@ -105,7 +127,8 @@
</div>
<div v-show="active === 5" class="grid grid-cols-2 gap-3">
<f-switch id="needs_prevention" v-model="single.needs_prevention" name="needs_prevention" label="Prävention" />
<f-editor id="prevention_text"
<f-editor
id="prevention_text"
v-model="single.prevention_text"
hint="Wird an die Präventions-Email angehangen, die Teilnehmende dieser Veranstaltung erhalten"
:rows="6"
@ -182,7 +205,8 @@
<ui-action-button tooltip="Nachmelde-Link kopieren" class="btn-info" icon="externallink" @click="copyLaterLink(form)" />
<ui-action-button tooltip="Zuschuss-Liste erstellen" class="btn-info" icon="contribution" @click="onGenerateContribution(form)" />
<ui-action-button :href="form.links.export" target="_BLANK" tooltip="als Tabellendokument exportieren" class="btn-info" icon="document" />
<ui-action-button tooltip="Löschen" class="btn-danger" icon="trash" @click.prevent="onDelete(form)" />
<ui-action-button tooltip="alle Teilnehmende löschen" class="btn-danger" icon="broom" @click.prevent="onCleanup(form)" />
<ui-action-button tooltip="Veranstaltung löschen" class="btn-danger" icon="trash" @click.prevent="onDelete(form)" />
</div>
</td>
</tr>
@ -238,21 +262,28 @@ const allFields = computed(() => {
});
async function onCopy(form) {
await swal.confirm('Diese Veranstaltung kopieren?', 'Nach dem Kopieren wird die Veranstaltung auf inaktiv gesetzt. Bitte aktiviere den Filter "inaktive zeigen", um die kopierte Veranstaltung zu sehen.');
await swal.confirm(
'Diese Veranstaltung kopieren?',
'Nach dem Kopieren wird die Veranstaltung auf inaktiv gesetzt. Bitte aktiviere den Filter "inaktive zeigen", um die kopierte Veranstaltung zu sehen.',
);
await axios.post(form.links.copy, {});
reload(false);
}
async function onGenerateContribution(form) {
const response = await swal.ask('Zuschussliste erstellen', 'Hiermit erstellst du eine Zuschussliste mit allen angemeldeten Mitgliedern. Bite wähle aus, für welche Organisation du eine Liste erstellen willst.', [
const response = await swal.ask(
'Zuschussliste erstellen',
'Hiermit erstellst du eine Zuschussliste mit allen angemeldeten Mitgliedern. Bite wähle aus, für welche Organisation du eine Liste erstellen willst.',
[
{
name: 'type',
label: 'Organisation',
required: true,
type: 'select',
options: meta.value.contribution_types,
}
]);
},
],
);
await download(form.links.contribution, {type: response.type, validate: '1'});
await download(form.links.contribution, {type: response.type});
}
@ -262,6 +293,12 @@ async function onDelete(form) {
await remove(form);
}
async function onCleanup(form) {
await swal.confirm('Teilnehmende löschen?', `Alle Teilnehmenden der Veranstaltung ${form.name} werden unwiderruflich gelöscht. Die Veranstaltung selbst bleibt erhalten.`);
await axios.delete(form.links.cleanup);
reload(false);
}
function setTemplate(template) {
active.value = 0;
single.value.config = template.config;

View File

@ -22,6 +22,7 @@ use App\Fileshare\Actions\FileshareStoreAction;
use App\Fileshare\Actions\FileshareUpdateAction;
use App\Fileshare\Actions\ListFilesAction;
use App\Form\Actions\ExportAction as ActionsExportAction;
use App\Form\Actions\FormCleanupAction;
use App\Form\Actions\FormCopyAction;
use App\Form\Actions\FormDestroyAction;
use App\Form\Actions\FormGenerateLaterlinkAction;
@ -167,6 +168,7 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/form', FormIndexAction::class)->name('form.index');
Route::patch('/form/{form}', FormUpdateAction::class)->name('form.update');
Route::delete('/form/{form}', FormDestroyAction::class)->name('form.destroy');
Route::delete('/form/{form}/cleanup', FormCleanupAction::class)->name('form.cleanup');
Route::post('/formtemplate', FormtemplateStoreAction::class)->name('formtemplate.store');
Route::patch('/formtemplate/{formtemplate}', FormtemplateUpdateAction::class)->name('formtemplate.update');
Route::delete('/formtemplate/{formtemplate}', FormtemplateDestroyAction::class)->name('formtemplate.destroy');

View File

@ -0,0 +1,98 @@
<?php
namespace Tests\Feature\Form;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Lib\CreatesFormFields;
uses(DatabaseTransactions::class);
uses(CreatesFormFields::class);
beforeEach(function () {
test()->fakeMessages();
test()->setUpForm();
});
it('deletes all participants of a form', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory()->count(2))
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 0);
});
it('does not delete the form itself', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseHas('forms', ['id' => $form->id]);
});
it('does not delete participants of other forms', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
$otherForm = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 1);
test()->assertDatabaseHas('participants', ['id' => $otherForm->participants->first()->id]);
});
it('deletes participants that are children of other participants', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->sections([])->create();
$parent = Participant::factory()->for($form)->create();
Participant::factory()->for($form)->create(['parent_id' => $parent->id]);
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertDatabaseCount('participants', 0);
});
it('clears the frontend cache', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertFrontendCacheCleared();
});
it('shows success message', function () {
test()->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->has(Participant::factory())
->sections([])
->create();
test()->deleteJson(route('form.cleanup', ['form' => $form]))
->assertOk();
test()->assertSuccessMessage('Teilnehmende gelöscht.');
});