Compare commits

...

7 Commits

Author SHA1 Message Date
philipp lang 3b654b0446 --wip-- [skip ci] 2025-07-05 17:01:32 +02:00
philipp lang 63048380e3 Add zip and location to default model 2025-07-05 16:40:49 +02:00
philipp lang d0a0e91080 Add frontend for form zip and location 2025-07-05 04:45:44 +02:00
philipp lang 55008fe971 Add backend for zip and location form 2025-07-05 04:19:14 +02:00
philipp lang 8470236124 Lint 2025-07-04 20:54:52 +02:00
philipp lang 6fe565cfd2 Lint 2025-07-04 20:36:03 +02:00
philipp lang c671a4c22c Lint 2025-07-04 20:20:59 +02:00
19 changed files with 430 additions and 18 deletions

View File

@ -16,7 +16,7 @@ class GenerateRequest extends ActionRequest implements HasContributionData {
/**
* @return array<string, string>
*/
public function payload(): array
protected function payloada(): array
{
return json_decode(rawurldecode(base64_decode($this->input('payload', ''))), true);
}

View File

@ -36,6 +36,8 @@ class FormStoreAction
'needs_prevention' => 'present|boolean',
'prevention_text' => 'array',
'prevention_conditions' => 'array',
'zip' => 'present|nullable|string',
'location' => 'present|nullable|string',
];
}

View File

@ -3,7 +3,6 @@
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Editor\Condition;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
@ -36,6 +35,8 @@ class FormUpdateAction
'needs_prevention' => 'present|boolean',
'prevention_text' => 'array',
'prevention_conditions' => 'array',
'location' => 'present|nullable|string',
'zip' => 'present|nullable|string',
];
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Form\Actions;
use App\Contribution\Contracts\HasContributionData;
use App\Contribution\ContributionFactory;
use App\Form\Models\Form;
use App\Form\Requests\FormCompileRequest;
use App\Rules\JsonBase64Rule;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class GenerateContributionAction
{
use AsAction;
public function handle(HasContributionData $request): BaseCompiler
{
return Tex::compile($request->type()::fromPayload($request));
}
public function asController(ActionRequest $request, Form $form): BaseCompiler|JsonResponse
{
$r = FormCompileRequest::from(['form' => $form]);
app(ContributionFactory::class)->validateType($r);
$r->validateContribution();
return $request->input('validate')
? response()->json([])
: $this->handle($r);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'payload' => [new JsonBase64Rule()],
];
}
}

View File

@ -114,7 +114,12 @@ class FieldCollection extends Collection
return $this->map(fn ($field) => $field->presentRaw())->toArray();
}
private function findBySpecialType(SpecialType $specialType): ?Field
public function hasSpecialType(SpecialType $specialType): bool
{
return $this->findBySpecialType($specialType) !== null;
}
public function findBySpecialType(SpecialType $specialType): ?Field
{
return $this->first(fn ($field) => $field->specialType === $specialType);
}

View File

@ -7,6 +7,12 @@ enum SpecialType: string
case FIRSTNAME = 'Vorname';
case LASTNAME = 'Nachname';
case EMAIL = 'E-Mail-Adresse';
case BIRTHDAY = 'Geburtsdatum';
case ZIP = 'PLZ';
case LOCATION = 'Ort';
case ADDRESS = 'Adresse';
case GENDER = 'Geschlecht';
case LEADER = 'LeiterIn';
/**
* @return array<int, array{name: string, id: string}>

View File

@ -18,7 +18,6 @@ use Laravel\Scout\Searchable;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Zoomyboy\MedialibraryHelper\DefersUploads;
/** @todo replace editor content with EditorData cast */
@ -74,20 +73,20 @@ class Form extends Model implements HasMedia
{
$this->addMediaCollection('headerImage')
->singleFile()
->maxWidth(fn () => 500)
->forceFileName(fn (Form $model, string $name) => $model->slug)
->convert(fn () => 'jpg')
->registerMediaConversions(function (Media $media) {
->maxWidth(fn() => 500)
->forceFileName(fn(Form $model) => $model->slug)
->convert(fn() => 'jpg')
->registerMediaConversions(function () {
$this->addMediaConversion('square')->fit(Fit::Crop, 400, 400);
});
$this->addMediaCollection('mailattachments')
->withDefaultProperties(fn () => [
->withDefaultProperties(fn() => [
'conditions' => [
'mode' => 'all',
'ifs' => []
],
])
->withPropertyValidation(fn () => [
->withPropertyValidation(fn() => [
'conditions.mode' => 'required|string|in:all,any',
'conditions.ifs' => 'array',
'conditions.ifs.*.field' => 'required',
@ -101,7 +100,7 @@ class Form extends Model implements HasMedia
*/
public function getRegistrationRules(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
return $this->getFields()->reduce(fn($carry, $field) => [
...$carry,
...$field->getRegistrationRules($this),
], []);
@ -112,7 +111,7 @@ class Form extends Model implements HasMedia
*/
public function getRegistrationMessages(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
return $this->getFields()->reduce(fn($carry, $field) => [
...$carry,
...$field->getRegistrationMessages($this),
], []);
@ -123,7 +122,7 @@ class Form extends Model implements HasMedia
*/
public function getRegistrationAttributes(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
return $this->getFields()->reduce(fn($carry, $field) => [
...$carry,
...$field->getRegistrationAttributes($this),
], []);

View File

@ -0,0 +1,101 @@
<?php
namespace App\Form\Requests;
use App\Contribution\Contracts\HasContributionData;
use App\Contribution\Data\MemberData;
use App\Contribution\Documents\ContributionDocument;
use App\Country;
use App\Form\Enums\SpecialType;
use App\Form\Models\Form;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Spatie\LaravelData\Data;
class FormCompileRequest extends Data implements HasContributionData {
public function __construct(public Form $form) {}
/**
* @return class-string<ContributionDocument>
*/
public function type(): string
{
return request()->input('type');
}
public function dateFrom(): Carbon
{
return $this->form->from;
}
public function dateUntil(): Carbon
{
return $this->form->to;
}
public function zipLocation(): string
{
return $this->form->zip.' '.$this->form->location;
}
public function eventName(): string
{
return $this->form->name;
}
public function members(): Collection
{
$members = [];
$fields = [
[SpecialType::FIRSTNAME, 'firstname'],
[SpecialType::LASTNAME, 'lastname'],
[SpecialType::BIRTHDAY, 'birthday'],
[SpecialType::ADDRESS, 'address'],
[SpecialType::ZIP, 'zip'],
[SpecialType::LOCATION, 'location']
];
foreach ($this->form->participants as $participant) {
$member = [];
foreach ($fields as [$type, $name]) {
$f = $this->form->getFields()->findBySpecialType($type);
$member[$name] = $participant->getFields()->find($f)->value;
}
$members[] = [
'is_leader' => false,
'gender' => 'weiblich',
...$member,
];
}
return MemberData::fromApi($members);
}
public function country(): ?Country
{
return Country::first();
}
public function validateContribution(): void
{
Validator::make($this->form->toArray(), [
'zip' => 'required',
'location' => 'required'
])
->after(function($validator) {
foreach ($this->type()::requiredFormSpecialTypes() as $type) {
if (!$this->form->getFields()->hasSpecialType($type)) {
$validator->errors()->add($type->name, 'Kein Feld für ' . $type->value . ' vorhanden.');
}
}
if ($this->form->participants->count() === 0) {
$validator->errors()->add('participants', 'Veranstaltung besitzt noch keine Teilnehmer*innen.');
}
})
->validate();
}
}

View File

@ -53,6 +53,8 @@ class FormResource extends JsonResource
'needs_prevention' => $this->needs_prevention,
'prevention_text' => $this->prevention_text,
'prevention_conditions' => $this->prevention_conditions,
'zip' => $this->zip,
'location' => $this->location,
'links' => [
'participant_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => null]),
'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]),
@ -102,6 +104,8 @@ class FormResource extends JsonResource
'id' => null,
'export' => ExportData::from([]),
'prevention_conditions' => ['mode' => 'all', 'ifs' => []],
'zip' => '',
'location' => '',
],
'section_default' => [
'name' => '',

View File

@ -57,6 +57,8 @@ class FormFactory extends Factory
'is_private' => false,
'export' => ExportData::from([]),
'prevention_conditions' => Condition::defaults(),
'zip' => $this->faker->numberBetween(1100, 99999),
'location' => $this->faker->city(),
];
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('forms', function (Blueprint $table) {
$table->string('zip')->nullable()->after('name');
$table->string('location')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('forms', function (Blueprint $table) {
$table->dropColumn('zip');
$table->dropColumn('location');
});
}
};

View File

@ -92,6 +92,8 @@ services:
socketi:
image: quay.io/soketi/soketi:89604f268623cf799573178a7ba56b7491416bde-16-debian
ports:
- "6001:6001"
environment:
SOKETI_DEFAULT_APP_ID: adremaid
SOKETI_DEFAULT_APP_KEY: adremakey
@ -104,6 +106,8 @@ services:
meilisearch:
image: getmeili/meilisearch:v1.6
ports:
- "7700:7700"
volumes:
- ./data/meilisearch:/meili_data
env_file:

View File

@ -35,8 +35,14 @@
collection="headerImage"
required
/>
<f-text id="from" v-model="single.from" type="date" label="Von" required />
<f-text id="to" v-model="single.to" type="date" label="Bis" required />
<div class="grid gap-3 grid-cols-2">
<f-text id="from" v-model="single.from" type="date" label="Von" required />
<f-text id="to" v-model="single.to" type="date" label="Bis" required />
</div>
<div class="grid gap-3 grid-cols-2">
<f-text id="zip" v-model="single.zip" label="PLZ" />
<f-text id="location" v-model="single.location" label="Ort" />
</div>
<f-text id="registration_from" v-model="single.registration_from" type="datetime-local" label="Registrierung von" required />
<f-text id="registration_until" v-model="single.registration_until" type="datetime-local" label="Registrierung bis" required />
<f-textarea id="excerpt"

View File

@ -100,3 +100,13 @@ dataset('contribution-assertions', fn () => [
[BdkjHesse::class, ["Max", "Muster", "Jane"]],
[WuppertalDocument::class, ["Max", "Muster", "Jane", "42777 SG", "15.06.1991", "16.06.1991"]],
]);
dataset('contribution-documents', fn () => [
CitySolingenDocument::class,
RdpNrwDocument::class,
CityRemscheidDocument::class,
CityFrankfurtMainDocument::class,
BdkjHesse::class,
WuppertalDocument::class,
]);

View File

@ -31,6 +31,8 @@ class FormIndexActionTest extends FormTestCase
->registrationUntil('2023-04-01 05:00:00')
->sections([FormtemplateSectionRequest::new()->name('sname')->fields([$this->textField()])])
->has(Participant::factory()->count(5))
->zip('12345')
->location('SG')
->create();
sleep(1);
@ -46,6 +48,8 @@ class FormIndexActionTest extends FormTestCase
->assertInertiaPath('data.data.0.from_human', '05.05.2023')
->assertInertiaPath('data.data.0.to_human', '07.06.2023')
->assertInertiaPath('data.data.0.from', '2023-05-05')
->assertInertiaPath('data.data.0.zip', '12345')
->assertInertiaPath('data.data.0.location', 'SG')
->assertInertiaPath('data.data.0.participants_count', 5)
->assertInertiaPath('data.data.0.to', '2023-06-07')
->assertInertiaPath('data.data.0.is_active', true)
@ -70,7 +74,9 @@ class FormIndexActionTest extends FormTestCase
->assertInertiaPath('data.meta.base_url', url(''))
->assertInertiaPath('data.meta.namiTypes.0', ['id' => 'Vorname', 'name' => 'Vorname'])
->assertInertiaPath('data.meta.specialTypes.0', ['id' => 'Vorname', 'name' => 'Vorname'])
->assertInertiaPath('data.meta.section_default.name', '');
->assertInertiaPath('data.meta.section_default.name', '')
->assertInertiaPath('data.meta.default.zip', '')
->assertInertiaPath('data.meta.default.location', '');
}
public function testFormtemplatesHaveData(): void

View File

@ -53,6 +53,8 @@ class FormRequest extends RequestFactory
'export' => ExportData::from([])->toArray(),
'needs_prevention' => $this->faker->boolean(),
'prevention_text' => EditorRequestFactory::new()->create(),
'zip' => (string) $this->faker->numberBetween(10, 6666),
'location' => (string) $this->faker->city(),
'prevention_conditions' => Condition::defaults()->toArray(),
];
}

View File

@ -32,6 +32,8 @@ it('testItStoresForm', function () {
->mailTop(EditorRequestFactory::new()->text(11, 'lala'))
->mailBottom(EditorRequestFactory::new()->text(12, 'lalab'))
->headerImage('htzz.jpg')
->zip('12345')
->location('Solingen')
->sections([FormtemplateSectionRequest::new()->name('sname')->fields([$this->textField()->namiType(NamiType::BIRTHDAY)->forMembers(false)->hint('hhh')])])
->fake();
@ -55,6 +57,8 @@ it('testItStoresForm', function () {
$this->assertFalse($form->config->sections->get(0)->fields->get(0)->forMembers);
$this->assertCount(1, $form->getMedia('headerImage'));
$this->assertEquals('formname.jpg', $form->getMedia('headerImage')->first()->file_name);
$this->assertEquals('Solingen', $form->location);
$this->assertEquals('12345', $form->zip);
Event::assertDispatched(Succeeded::class, fn(Succeeded $event) => $event->message === 'Veranstaltung gespeichert.');
$this->assertFrontendCacheCleared();
});
@ -71,14 +75,16 @@ it('testItStoresDefaultSorting', function () {
$this->assertFalse(false, $form->meta['sorting']['direction']);
});
it('testRegistrationDatesCanBeNull', function () {
it('testValuesCanBeNull', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$this->postJson(route('form.store'), FormRequest::new()->registrationFrom(null)->registrationUntil(null)->create())->assertOk();
$this->postJson(route('form.store'), FormRequest::new()->registrationFrom(null)->registrationUntil(null)->location(null)->zip(null)->create())->assertOk();
$this->assertDatabaseHas('forms', [
'registration_until' => null,
'registration_from' => null,
'zip' => null,
'location' => null,
]);
});

View File

@ -118,6 +118,19 @@ it('testItUpdatesActiveState', function () {
$this->assertTrue($form->fresh()->is_active);
});
it('updates zip and location', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->create();
$request = FormRequest::new()->zip('12345')->location('Musterstadt')->create();
$this->patchJson(route('form.update', ['form' => $form]), $request)->assertOk();
test()->assertDatabaseHas('forms', [
'id' => $form->id,
'zip' => '12345',
'location' => 'Musterstadt',
]);
});
it('testItUpdatesPrivateState', function () {
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->create();

View File

@ -0,0 +1,170 @@
<?php
namespace Tests\Feature\Form;
use App\Contribution\Documents\CitySolingenDocument;
use App\Contribution\Documents\RdpNrwDocument;
use App\Country;
use App\Form\Enums\SpecialType;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Gender;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Lib\CreatesFormFields;
use Zoomyboy\Tex\Tex;
uses(DatabaseTransactions::class);
uses(CreatesFormFields::class);
beforeEach(function() {
Country::factory()->create();
Gender::factory()->male()->create();
Gender::factory()->female()->create();
});
/**
* @param array<int, FormtemplateFieldRequest> $fields
*/
it('doesnt create document when no special fields given', function (array $fields, string $field, string $message, string $type) {
$this->login()->loginNami();
$form = Form::factory()
->fields($fields)
->has(Participant::factory())
->create();
$this->json('GET', route('form.contribution', [
'type' => $type,
'form' => $form,
'validate' => '1',
]))->assertJsonValidationErrors([$field => $message]);
})
->with([
[fn() => [], 'FIRSTNAME', 'Kein Feld für Vorname vorhanden.'],
[fn() => [test()->textField('f')->specialType(SpecialType::FIRSTNAME)], 'LASTNAME', 'Kein Feld für Nachname vorhanden.'],
[fn() => [test()->textField('f')->specialType(SpecialType::FIRSTNAME), test()->textField('l')->specialType(SpecialType::LASTNAME)], 'BIRTHDAY', 'Kein Feld für Geburtsdatum vorhanden.'],
[fn() => [test()->textField('f')->specialType(SpecialType::FIRSTNAME), test()->textField('l')->specialType(SpecialType::LASTNAME), test()->dateField('b')->specialType(SpecialType::BIRTHDAY)], 'ZIP', 'Kein Feld für PLZ vorhanden.'],
[fn() => [test()->textField('f')->specialType(SpecialType::FIRSTNAME), test()->textField('l')->specialType(SpecialType::LASTNAME), test()->dateField('b')->specialType(SpecialType::BIRTHDAY), test()->dateField('p')->specialType(SpecialType::ZIP)], 'LOCATION', 'Kein Feld für Ort vorhanden.'],
])->with('contribution-documents');
/**
* @param array<int, FormtemplateFieldRequest> $fields
*/
it('validates special types of each document', function (string $type, array $fields, string $field, string $message) {
$this->login()->loginNami();
$form = Form::factory()->fields([
test()->textField('f')->specialType(SpecialType::FIRSTNAME),
test()->textField('l')->specialType(SpecialType::LASTNAME),
test()->dateField('b')->specialType(SpecialType::BIRTHDAY),
test()->dateField('p')->specialType(SpecialType::ZIP),
test()->dateField('l')->specialType(SpecialType::LOCATION),
...$fields,
])
->has(Participant::factory())
->create();
$this->json('GET', route('form.contribution', [
'type' => $type,
'form' => $form,
'validate' => '1',
]))->assertJsonValidationErrors([$field => $message]);
})
->with([
[CitySolingenDocument::class, [], 'ADDRESS', 'Kein Feld für Adresse vorhanden.'],
[RdpNrwDocument::class, [], 'GENDER', 'Kein Feld für Geschlecht vorhanden.'],
]);
it('throws error when not validating but fields are not present', function () {
$this->login()->loginNami();
$form = Form::factory()->fields([])
->has(Participant::factory())
->create();
$this->json('GET', route('form.contribution', [
'type' => CitySolingenDocument::class,
'form' => $form,
]))->assertStatus(422);
});
it('throws error when form doesnt have meta', function () {
$this->login()->loginNami();
$form = Form::factory()->fields([])
->has(Participant::factory())
->zip('')
->location('')
->create();
$this->json('GET', route('form.contribution', [
'type' => CitySolingenDocument::class,
'form' => $form,
]))->assertStatus(422)->assertJsonValidationErrors([
'zip' => 'PLZ ist erforderlich.',
'location' => 'Ort ist erforderlich.'
]);
});
it('throws error when form doesnt have participants', function () {
$this->login()->loginNami();
$form = Form::factory()->fields([])->create();
$this->json('GET', route('form.contribution', [
'type' => CitySolingenDocument::class,
'form' => $form,
'validate' => '1',
]))->assertJsonValidationErrors(['participants' => 'Veranstaltung besitzt noch keine Teilnehmer*innen.']);
});
it('creates document when fields are present', function () {
Tex::spy();
$this->login()->loginNami();
$form = Form::factory()->fields([
test()->textField('fn')->specialType(SpecialType::FIRSTNAME),
test()->textField('ln')->specialType(SpecialType::LASTNAME),
test()->dateField('bd')->specialType(SpecialType::BIRTHDAY),
test()->dateField('zip')->specialType(SpecialType::ZIP),
test()->dateField('loc')->specialType(SpecialType::LOCATION),
test()->dateField('add')->specialType(SpecialType::ADDRESS),
])
->has(Participant::factory()->data(['fn' => 'Baum', 'ln' => 'Muster', 'bd' => '1991-05-06', 'zip' => '33333', 'loc' => 'Musterstadt', 'add' => 'Laastr 4']))
->create();
$this->json('GET', route('form.contribution', [
'type' => CitySolingenDocument::class,
'form' => $form,
]))->assertOk();
Tex::assertCompiled(CitySolingenDocument::class, fn($document) => $document->hasAllContent(['Baum', 'Muster', '1991', 'Musterstadt', 'Laastr 4', '33333']));
});
it('creates document with form meta', function () {
Tex::spy();
$this->login()->loginNami();
$form = Form::factory()->fields([
test()->textField('fn')->specialType(SpecialType::FIRSTNAME),
test()->textField('ln')->specialType(SpecialType::LASTNAME),
test()->dateField('bd')->specialType(SpecialType::BIRTHDAY),
test()->dateField('zip')->specialType(SpecialType::ZIP),
test()->dateField('loc')->specialType(SpecialType::LOCATION),
test()->dateField('add')->specialType(SpecialType::ADDRESS),
test()->dateField('gen')->specialType(SpecialType::GENDER),
])
->has(Participant::factory()->data(['fn' => 'Baum', 'ln' => 'Muster', 'bd' => '1991-05-06', 'zip' => '33333', 'loc' => 'Musterstadt', 'add' => 'Laastr 4', 'gen' => 'weiblich']))
->name('Sommerlager')
->from('2008-06-20')
->to('2008-06-22')
->zip('12345')
->location('Frankfurt')
->create();
$this->json('GET', route('form.contribution', [
'type' => RdpNrwDocument::class,
'form' => $form,
]))->assertOk();
Tex::assertCompiled(RdpNrwDocument::class, fn($document) => $document->hasAllContent(['20.06.2008', '22.06.2008', '12345 Frankfurt']));
});