From f0bb80707abc4ce865cba8113d64db9792342cc0 Mon Sep 17 00:00:00 2001 From: Tim Stollberg Date: Sat, 18 Jul 2026 16:34:18 +0700 Subject: [PATCH] feat(backend): commit uncommitted CustomField, dynamic HistoryType, and Import modules Mirrors the frontend catch-up in 570f17e: a substantial amount of finished-but-never-committed backend work had accumulated locally. - New CustomField module: admin-managed field definitions per client, reorderable, typed values stored per client (backing the frontend's ClientCustomFieldCard + settings/fields page) - History entries switch from a hardcoded type enum to a dynamic, admin-managed HistoryType module (reorderable, backing settings/history-types) - New Import module: CSV client import (backing settings/import) - Clients table gains soft deletes (deleted_at) - Supporting factories, seeders, policies, and feature tests for all of the above All 63 backend tests pass locally before this commit. --- backend/CLAUDE.md | 2 +- .../Client/Actions/GetClientAction.php | 12 +- .../Client/Controllers/ClientController.php | 7 + backend/app/Modules/Client/Models/Client.php | 9 +- .../Client/Resources/ClientResource.php | 46 ++++ .../CreateCustomFieldDefinitionAction.php | 37 +++ .../ReorderCustomFieldDefinitionsAction.php | 24 ++ .../UpdateCustomFieldDefinitionAction.php | 23 ++ .../Actions/UpdateCustomFieldValueAction.php | 68 ++++++ .../CustomFieldDefinitionController.php | 62 +++++ .../CustomFieldValueController.php | 34 +++ .../DTOs/CustomFieldDefinitionData.php | 36 +++ .../Models/CustomFieldDefinition.php | 32 +++ .../CustomField/Models/CustomFieldType.php | 12 + .../CustomField/Models/CustomFieldValue.php | 28 +++ .../Policies/CustomFieldDefinitionPolicy.php | 18 ++ .../CreateCustomFieldDefinitionRequest.php | 23 ++ .../ReorderCustomFieldDefinitionsRequest.php | 18 ++ .../UpdateCustomFieldDefinitionRequest.php | 23 ++ .../UpdateCustomFieldValueRequest.php | 34 +++ .../CustomFieldDefinitionResource.php | 22 ++ .../Actions/CreateHistoryEntryAction.php | 3 +- .../Actions/CreateHistoryTypeAction.php | 19 ++ .../Actions/ReorderHistoryTypesAction.php | 24 ++ .../Actions/UpdateHistoryEntryAction.php | 1 + .../Actions/UpdateHistoryTypeAction.php | 20 ++ .../History/Controllers/HistoryController.php | 6 +- .../Controllers/HistoryTypeController.php | 62 +++++ .../Modules/History/DTOs/HistoryEntryData.php | 9 +- .../Modules/History/DTOs/HistoryTypeData.php | 30 +++ .../Modules/History/Models/HistoryEntry.php | 26 +- .../History/Models/HistoryEntryType.php | 13 +- .../Modules/History/Models/HistoryType.php | 24 ++ .../History/Policies/HistoryEntryPolicy.php | 15 +- .../History/Policies/HistoryTypePolicy.php | 18 ++ .../Requests/CreateHistoryEntryRequest.php | 27 ++- .../Requests/CreateHistoryTypeRequest.php | 18 ++ .../Requests/ReorderHistoryTypesRequest.php | 18 ++ .../Requests/UpdateHistoryEntryRequest.php | 20 ++ .../Requests/UpdateHistoryTypeRequest.php | 18 ++ .../Resources/HistoryEntryResource.php | 11 +- .../History/Resources/HistoryTypeResource.php | 19 ++ .../Import/Actions/ImportClientsAction.php | 225 ++++++++++++++++++ .../Controllers/ClientImportController.php | 22 ++ .../app/Modules/Import/DTOs/ImportResult.php | 17 ++ .../Import/Requests/ClientImportRequest.php | 40 ++++ .../Import/Resources/ImportResultResource.php | 27 +++ .../Search/Actions/SearchClientsAction.php | 22 +- .../User/Requests/CreateUserRequest.php | 8 + .../User/Requests/UpdateUserRequest.php | 10 +- backend/app/Providers/AuthServiceProvider.php | 6 + .../CustomFieldDefinitionFactory.php | 35 +++ .../factories/HistoryEntryFactory.php | 5 +- .../database/factories/HistoryTypeFactory.php | 25 ++ ...23_095330_create_history_entries_table.php | 19 +- ..._create_custom_field_definitions_table.php | 28 +++ ...00002_create_custom_field_values_table.php | 27 +++ ...6_25_000003_create_history_types_table.php | 27 +++ ...000001_add_deleted_at_to_clients_table.php | 22 ++ .../seeders/CustomFieldDefinitionSeeder.php | 39 +++ backend/database/seeders/DatabaseSeeder.php | 34 ++- backend/phpunit.xml | 5 +- .../Feature/Modules/Client/ClientTest.php | 32 +++ .../Modules/CustomField/CustomFieldTest.php | 158 ++++++++++++ .../Feature/Modules/History/HistoryTest.php | 109 ++++++++- .../Modules/History/HistoryTypeTest.php | 122 ++++++++++ .../Modules/Import/ClientImportTest.php | 222 +++++++++++++++++ 67 files changed, 2199 insertions(+), 58 deletions(-) create mode 100644 backend/app/Modules/CustomField/Actions/CreateCustomFieldDefinitionAction.php create mode 100644 backend/app/Modules/CustomField/Actions/ReorderCustomFieldDefinitionsAction.php create mode 100644 backend/app/Modules/CustomField/Actions/UpdateCustomFieldDefinitionAction.php create mode 100644 backend/app/Modules/CustomField/Actions/UpdateCustomFieldValueAction.php create mode 100644 backend/app/Modules/CustomField/Controllers/CustomFieldDefinitionController.php create mode 100644 backend/app/Modules/CustomField/Controllers/CustomFieldValueController.php create mode 100644 backend/app/Modules/CustomField/DTOs/CustomFieldDefinitionData.php create mode 100644 backend/app/Modules/CustomField/Models/CustomFieldDefinition.php create mode 100644 backend/app/Modules/CustomField/Models/CustomFieldType.php create mode 100644 backend/app/Modules/CustomField/Models/CustomFieldValue.php create mode 100644 backend/app/Modules/CustomField/Policies/CustomFieldDefinitionPolicy.php create mode 100644 backend/app/Modules/CustomField/Requests/CreateCustomFieldDefinitionRequest.php create mode 100644 backend/app/Modules/CustomField/Requests/ReorderCustomFieldDefinitionsRequest.php create mode 100644 backend/app/Modules/CustomField/Requests/UpdateCustomFieldDefinitionRequest.php create mode 100644 backend/app/Modules/CustomField/Requests/UpdateCustomFieldValueRequest.php create mode 100644 backend/app/Modules/CustomField/Resources/CustomFieldDefinitionResource.php create mode 100644 backend/app/Modules/History/Actions/CreateHistoryTypeAction.php create mode 100644 backend/app/Modules/History/Actions/ReorderHistoryTypesAction.php create mode 100644 backend/app/Modules/History/Actions/UpdateHistoryTypeAction.php create mode 100644 backend/app/Modules/History/Controllers/HistoryTypeController.php create mode 100644 backend/app/Modules/History/DTOs/HistoryTypeData.php create mode 100644 backend/app/Modules/History/Models/HistoryType.php create mode 100644 backend/app/Modules/History/Policies/HistoryTypePolicy.php create mode 100644 backend/app/Modules/History/Requests/CreateHistoryTypeRequest.php create mode 100644 backend/app/Modules/History/Requests/ReorderHistoryTypesRequest.php create mode 100644 backend/app/Modules/History/Requests/UpdateHistoryTypeRequest.php create mode 100644 backend/app/Modules/History/Resources/HistoryTypeResource.php create mode 100644 backend/app/Modules/Import/Actions/ImportClientsAction.php create mode 100644 backend/app/Modules/Import/Controllers/ClientImportController.php create mode 100644 backend/app/Modules/Import/DTOs/ImportResult.php create mode 100644 backend/app/Modules/Import/Requests/ClientImportRequest.php create mode 100644 backend/app/Modules/Import/Resources/ImportResultResource.php create mode 100644 backend/database/factories/CustomFieldDefinitionFactory.php create mode 100644 backend/database/factories/HistoryTypeFactory.php create mode 100644 backend/database/migrations/2026_06_25_000001_create_custom_field_definitions_table.php create mode 100644 backend/database/migrations/2026_06_25_000002_create_custom_field_values_table.php create mode 100644 backend/database/migrations/2026_06_25_000003_create_history_types_table.php create mode 100644 backend/database/migrations/2026_06_26_000001_add_deleted_at_to_clients_table.php create mode 100644 backend/database/seeders/CustomFieldDefinitionSeeder.php create mode 100644 backend/tests/Feature/Modules/CustomField/CustomFieldTest.php create mode 100644 backend/tests/Feature/Modules/History/HistoryTypeTest.php create mode 100644 backend/tests/Feature/Modules/Import/ClientImportTest.php diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 577d083..a0b0411 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -14,7 +14,7 @@ ## Module pattern ## Commands ``` composer test # Pest, SQLite in-memory -composer static-analysis # pint --test + phpstan + phpmd (all must pass) +composer static-analysis # pint --test + phpstan + phpmd (user do the static code analysis manually) php artisan migrate # run migrations (against Docker MySQL) ``` diff --git a/backend/app/Modules/Client/Actions/GetClientAction.php b/backend/app/Modules/Client/Actions/GetClientAction.php index db267e2..bf70e27 100644 --- a/backend/app/Modules/Client/Actions/GetClientAction.php +++ b/backend/app/Modules/Client/Actions/GetClientAction.php @@ -3,17 +3,21 @@ namespace App\Modules\Client\Actions; use App\Modules\Client\Models\Client; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; final class GetClientAction { - public function list(): LengthAwarePaginator + public function list(): Collection { - return Client::orderBy('name')->paginate(50); + return Client::query() + ->withCount('historyEntries') + ->withMax('historyEntries', 'created_at') + ->orderBy('name') + ->get(); } public function find(int $id): Client { - return Client::findOrFail($id); + return Client::with('customFieldValues.author')->findOrFail($id); } } diff --git a/backend/app/Modules/Client/Controllers/ClientController.php b/backend/app/Modules/Client/Controllers/ClientController.php index 4d8a703..824c0b0 100644 --- a/backend/app/Modules/Client/Controllers/ClientController.php +++ b/backend/app/Modules/Client/Controllers/ClientController.php @@ -42,4 +42,11 @@ public function update(UpdateClientRequest $request, Client $client, UpdateClien $updated = $action->handle($client, ClientData::fromUpdateRequest($request)); return ClientResource::make($updated); } + + public function destroy(Client $client): JsonResponse + { + $this->authorize('delete', $client); + $client->delete(); + return response()->json(null, 204); + } } diff --git a/backend/app/Modules/Client/Models/Client.php b/backend/app/Modules/Client/Models/Client.php index 08116b7..02dc407 100644 --- a/backend/app/Modules/Client/Models/Client.php +++ b/backend/app/Modules/Client/Models/Client.php @@ -2,15 +2,17 @@ namespace App\Modules\Client\Models; +use App\Modules\CustomField\Models\CustomFieldValue; use App\Modules\History\Models\HistoryEntry; use Database\Factories\ClientFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Client extends Model { - use HasFactory; + use HasFactory, SoftDeletes; protected $fillable = ['client_number', 'name', 'notes']; @@ -25,4 +27,9 @@ public function historyEntries(): HasMany { return $this->hasMany(HistoryEntry::class); } + + public function customFieldValues(): HasMany + { + return $this->hasMany(CustomFieldValue::class); + } } diff --git a/backend/app/Modules/Client/Resources/ClientResource.php b/backend/app/Modules/Client/Resources/ClientResource.php index e790f75..5e6954b 100644 --- a/backend/app/Modules/Client/Resources/ClientResource.php +++ b/backend/app/Modules/Client/Resources/ClientResource.php @@ -2,8 +2,10 @@ namespace App\Modules\Client\Resources; +use App\Modules\CustomField\Models\CustomFieldDefinition; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; class ClientResource extends JsonResource { @@ -16,6 +18,50 @@ public function toArray(Request $request): array 'notes' => $this->notes ?? [], 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), + // List/search endpoints load these via withCount/withMax; absent on + // the detail endpoint, so guard before exposing them. + 'history_count' => $this->whenNotNull($this->history_entries_count), + 'last_history_at' => $this->when( + $this->history_entries_count !== null, + fn () => $this->history_entries_max_created_at + ? Carbon::parse($this->history_entries_max_created_at)->toISOString() + : null, + ), + // Only present on the detail endpoint (where the relation is eager + // loaded); omitted from list responses to avoid per-row queries. + 'custom_fields' => $this->when( + $this->resource->relationLoaded('customFieldValues'), + fn () => $this->buildCustomFields(), + ), ]; } + + /** + * Merge every active field definition (ordered) with this client's value, + * so unset fields still render with an empty state. + * + * @return array> + */ + private function buildCustomFields(): array + { + $valuesByDefinition = $this->customFieldValues->keyBy('custom_field_definition_id'); + + return CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get() + ->map(function (CustomFieldDefinition $definition) use ($valuesByDefinition) { + $value = $valuesByDefinition->get($definition->id); + + return [ + 'definition_id' => $definition->id, + 'key' => $definition->key, + 'label' => $definition->label, + 'type' => $definition->type->value, + 'options' => $definition->options ?? [], + 'is_required' => $definition->is_required, + 'value' => $value?->value, + 'author_name' => $value?->author?->name, + 'updated_at' => $value?->updated_at?->toISOString(), + ]; + }) + ->all(); + } } diff --git a/backend/app/Modules/CustomField/Actions/CreateCustomFieldDefinitionAction.php b/backend/app/Modules/CustomField/Actions/CreateCustomFieldDefinitionAction.php new file mode 100644 index 0000000..9896b5e --- /dev/null +++ b/backend/app/Modules/CustomField/Actions/CreateCustomFieldDefinitionAction.php @@ -0,0 +1,37 @@ + $data->label, + 'key' => $this->uniqueKey($data->label), + 'type' => $data->type, + 'options' => $data->type === CustomFieldType::Select->value ? array_values($data->options ?? []) : null, + 'is_required' => $data->isRequired, + // New entries always land at the end of the list. + 'sort_order' => (CustomFieldDefinition::max('sort_order') ?? -1) + 1, + ]); + } + + private function uniqueKey(string $label): string + { + $base = Str::slug($label, '_') ?: 'field'; + $key = $base; + $i = 1; + + while (CustomFieldDefinition::withTrashed()->where('key', $key)->exists()) { + $key = $base.'_'.(++$i); + } + + return $key; + } +} diff --git a/backend/app/Modules/CustomField/Actions/ReorderCustomFieldDefinitionsAction.php b/backend/app/Modules/CustomField/Actions/ReorderCustomFieldDefinitionsAction.php new file mode 100644 index 0000000..f86779a --- /dev/null +++ b/backend/app/Modules/CustomField/Actions/ReorderCustomFieldDefinitionsAction.php @@ -0,0 +1,24 @@ + $ids + */ + public function handle(array $ids): void + { + DB::transaction(function () use ($ids) { + foreach (array_values($ids) as $index => $id) { + CustomFieldDefinition::where('id', $id)->update(['sort_order' => $index]); + } + }); + } +} diff --git a/backend/app/Modules/CustomField/Actions/UpdateCustomFieldDefinitionAction.php b/backend/app/Modules/CustomField/Actions/UpdateCustomFieldDefinitionAction.php new file mode 100644 index 0000000..3fe13bd --- /dev/null +++ b/backend/app/Modules/CustomField/Actions/UpdateCustomFieldDefinitionAction.php @@ -0,0 +1,23 @@ +update([ + 'label' => $data->label, + 'type' => $data->type, + 'options' => $data->type === CustomFieldType::Select->value ? array_values($data->options ?? []) : null, + 'is_required' => $data->isRequired, + ]); + + return $definition; + } +} diff --git a/backend/app/Modules/CustomField/Actions/UpdateCustomFieldValueAction.php b/backend/app/Modules/CustomField/Actions/UpdateCustomFieldValueAction.php new file mode 100644 index 0000000..1e673d4 --- /dev/null +++ b/backend/app/Modules/CustomField/Actions/UpdateCustomFieldValueAction.php @@ -0,0 +1,68 @@ + $client->id, + 'custom_field_definition_id' => $definition->id, + ]); + + $old = $record->value; + + if ($old === $new) { + return $record; + } + + $record->value = $new; + $record->author_id = $author->id; + $record->save(); + + HistoryEntry::create([ + 'client_id' => $client->id, + 'type' => HistoryEntryType::FieldChange, + 'body' => $this->changeBody($definition->label, $old, $new), + 'custom_field_definition_id' => $definition->id, + 'author_id' => $author->id, + ]); + + return $record; + }); + } + + private function changeBody(string $label, ?string $old, ?string $new): string + { + if ($old === null) { + return "{$label} gesetzt: „{$new}“"; + } + + if ($new === null) { + return "{$label} entfernt (war „{$old}“)"; + } + + return "{$label} geändert: „{$old}“ → „{$new}“"; + } +} diff --git a/backend/app/Modules/CustomField/Controllers/CustomFieldDefinitionController.php b/backend/app/Modules/CustomField/Controllers/CustomFieldDefinitionController.php new file mode 100644 index 0000000..60d67d5 --- /dev/null +++ b/backend/app/Modules/CustomField/Controllers/CustomFieldDefinitionController.php @@ -0,0 +1,62 @@ +authorize('viewAny', CustomFieldDefinition::class); + $definitions = CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get(); + return CustomFieldDefinitionResource::collection($definitions); + } + + public function store( + CreateCustomFieldDefinitionRequest $request, + CreateCustomFieldDefinitionAction $action + ): JsonResponse { + $this->authorize('create', CustomFieldDefinition::class); + $definition = $action->handle(CustomFieldDefinitionData::fromCreateRequest($request)); + return CustomFieldDefinitionResource::make($definition)->response()->setStatusCode(201); + } + + public function update( + UpdateCustomFieldDefinitionRequest $request, + CustomFieldDefinition $customFieldDefinition, + UpdateCustomFieldDefinitionAction $action + ): CustomFieldDefinitionResource { + $this->authorize('update', $customFieldDefinition); + $updated = $action->handle($customFieldDefinition, CustomFieldDefinitionData::fromUpdateRequest($request)); + return CustomFieldDefinitionResource::make($updated); + } + + public function destroy(CustomFieldDefinition $customFieldDefinition): JsonResponse + { + $this->authorize('delete', $customFieldDefinition); + $customFieldDefinition->delete(); + return response()->json(null, 204); + } + + public function reorder( + ReorderCustomFieldDefinitionsRequest $request, + ReorderCustomFieldDefinitionsAction $action + ): AnonymousResourceCollection { + $this->authorize('reorder', CustomFieldDefinition::class); + $action->handle($request->validated('ids')); + $definitions = CustomFieldDefinition::orderBy('sort_order')->orderBy('id')->get(); + return CustomFieldDefinitionResource::collection($definitions); + } +} diff --git a/backend/app/Modules/CustomField/Controllers/CustomFieldValueController.php b/backend/app/Modules/CustomField/Controllers/CustomFieldValueController.php new file mode 100644 index 0000000..00451ec --- /dev/null +++ b/backend/app/Modules/CustomField/Controllers/CustomFieldValueController.php @@ -0,0 +1,34 @@ +authorize('update', $client); + + $action->handle( + $client, + $customFieldDefinition, + $request->validated('value'), + $request->user() + ); + + // Return the full client so the SPA re-renders all field cards in one round-trip. + return ClientResource::make($clients->find($client->id)); + } +} diff --git a/backend/app/Modules/CustomField/DTOs/CustomFieldDefinitionData.php b/backend/app/Modules/CustomField/DTOs/CustomFieldDefinitionData.php new file mode 100644 index 0000000..785b0e7 --- /dev/null +++ b/backend/app/Modules/CustomField/DTOs/CustomFieldDefinitionData.php @@ -0,0 +1,36 @@ +validated('label'), + type: $request->validated('type'), + options: $request->validated('options'), + isRequired: (bool) $request->validated('is_required', false), + ); + } + + public static function fromUpdateRequest(UpdateCustomFieldDefinitionRequest $request): self + { + return new self( + label: $request->validated('label'), + type: $request->validated('type'), + options: $request->validated('options'), + isRequired: (bool) $request->validated('is_required', false), + ); + } +} diff --git a/backend/app/Modules/CustomField/Models/CustomFieldDefinition.php b/backend/app/Modules/CustomField/Models/CustomFieldDefinition.php new file mode 100644 index 0000000..87b9456 --- /dev/null +++ b/backend/app/Modules/CustomField/Models/CustomFieldDefinition.php @@ -0,0 +1,32 @@ + CustomFieldType::class, + 'options' => 'array', + 'is_required' => 'boolean', + ]; + + public function values(): HasMany + { + return $this->hasMany(CustomFieldValue::class); + } + + protected static function newFactory(): CustomFieldDefinitionFactory + { + return CustomFieldDefinitionFactory::new(); + } +} diff --git a/backend/app/Modules/CustomField/Models/CustomFieldType.php b/backend/app/Modules/CustomField/Models/CustomFieldType.php new file mode 100644 index 0000000..9d4a815 --- /dev/null +++ b/backend/app/Modules/CustomField/Models/CustomFieldType.php @@ -0,0 +1,12 @@ +belongsTo(Client::class); + } + + public function definition(): BelongsTo + { + return $this->belongsTo(CustomFieldDefinition::class, 'custom_field_definition_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } +} diff --git a/backend/app/Modules/CustomField/Policies/CustomFieldDefinitionPolicy.php b/backend/app/Modules/CustomField/Policies/CustomFieldDefinitionPolicy.php new file mode 100644 index 0000000..e7fc702 --- /dev/null +++ b/backend/app/Modules/CustomField/Policies/CustomFieldDefinitionPolicy.php @@ -0,0 +1,18 @@ +isAdmin(); } + public function update(User $user, CustomFieldDefinition $definition): bool { return $user->isAdmin(); } + public function delete(User $user, CustomFieldDefinition $definition): bool { return $user->isAdmin(); } + public function reorder(User $user): bool { return $user->isAdmin(); } +} diff --git a/backend/app/Modules/CustomField/Requests/CreateCustomFieldDefinitionRequest.php b/backend/app/Modules/CustomField/Requests/CreateCustomFieldDefinitionRequest.php new file mode 100644 index 0000000..ea91ba2 --- /dev/null +++ b/backend/app/Modules/CustomField/Requests/CreateCustomFieldDefinitionRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:255'], + 'type' => ['required', new Enum(CustomFieldType::class)], + 'options' => ['nullable', 'array', 'required_if:type,select'], + 'options.*' => ['string', 'max:255'], + 'is_required' => ['boolean'], + ]; + } +} diff --git a/backend/app/Modules/CustomField/Requests/ReorderCustomFieldDefinitionsRequest.php b/backend/app/Modules/CustomField/Requests/ReorderCustomFieldDefinitionsRequest.php new file mode 100644 index 0000000..6adb10a --- /dev/null +++ b/backend/app/Modules/CustomField/Requests/ReorderCustomFieldDefinitionsRequest.php @@ -0,0 +1,18 @@ + ['required', 'array'], + 'ids.*' => ['integer', 'exists:custom_field_definitions,id'], + ]; + } +} diff --git a/backend/app/Modules/CustomField/Requests/UpdateCustomFieldDefinitionRequest.php b/backend/app/Modules/CustomField/Requests/UpdateCustomFieldDefinitionRequest.php new file mode 100644 index 0000000..bc5455b --- /dev/null +++ b/backend/app/Modules/CustomField/Requests/UpdateCustomFieldDefinitionRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:255'], + 'type' => ['required', new Enum(CustomFieldType::class)], + 'options' => ['nullable', 'array', 'required_if:type,select'], + 'options.*' => ['string', 'max:255'], + 'is_required' => ['boolean'], + ]; + } +} diff --git a/backend/app/Modules/CustomField/Requests/UpdateCustomFieldValueRequest.php b/backend/app/Modules/CustomField/Requests/UpdateCustomFieldValueRequest.php new file mode 100644 index 0000000..5fffa5e --- /dev/null +++ b/backend/app/Modules/CustomField/Requests/UpdateCustomFieldValueRequest.php @@ -0,0 +1,34 @@ +route('customFieldDefinition'); + + $valueRules = $definition->is_required ? ['required'] : ['nullable']; + + $valueRules[] = match ($definition->type) { + CustomFieldType::Number => 'numeric', + CustomFieldType::Date => 'date', + CustomFieldType::Select => Rule::in($definition->options ?? []), + default => 'string', + }; + + if (in_array($definition->type, [CustomFieldType::Text, CustomFieldType::Textarea], true)) { + $valueRules[] = 'max:65535'; + } + + return ['value' => $valueRules]; + } +} diff --git a/backend/app/Modules/CustomField/Resources/CustomFieldDefinitionResource.php b/backend/app/Modules/CustomField/Resources/CustomFieldDefinitionResource.php new file mode 100644 index 0000000..1c6be24 --- /dev/null +++ b/backend/app/Modules/CustomField/Resources/CustomFieldDefinitionResource.php @@ -0,0 +1,22 @@ + $this->id, + 'label' => $this->label, + 'key' => $this->key, + 'type' => $this->type->value, + 'options' => $this->options ?? [], + 'is_required' => $this->is_required, + 'sort_order' => $this->sort_order, + ]; + } +} diff --git a/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php b/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php index 2e73df4..2344182 100644 --- a/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php +++ b/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php @@ -13,7 +13,8 @@ public function handle(Client $client, HistoryEntryData $data, User $author): Hi { return HistoryEntry::create([ 'client_id' => $client->id, - 'type' => $data->type, + 'history_entry_type_id' => $data->historyEntryTypeId, + 'occurred_at' => $data->occurredAt, 'body' => $data->body, 'author_id' => $author->id, ]); diff --git a/backend/app/Modules/History/Actions/CreateHistoryTypeAction.php b/backend/app/Modules/History/Actions/CreateHistoryTypeAction.php new file mode 100644 index 0000000..aecdeef --- /dev/null +++ b/backend/app/Modules/History/Actions/CreateHistoryTypeAction.php @@ -0,0 +1,19 @@ + $data->name, + 'requires_datetime' => $data->requiresDatetime, + // New entries always land at the end of the list. + 'sort_order' => (HistoryType::max('sort_order') ?? -1) + 1, + ]); + } +} diff --git a/backend/app/Modules/History/Actions/ReorderHistoryTypesAction.php b/backend/app/Modules/History/Actions/ReorderHistoryTypesAction.php new file mode 100644 index 0000000..3fbdf66 --- /dev/null +++ b/backend/app/Modules/History/Actions/ReorderHistoryTypesAction.php @@ -0,0 +1,24 @@ + $ids + */ + public function handle(array $ids): void + { + DB::transaction(function () use ($ids) { + foreach (array_values($ids) as $index => $id) { + HistoryType::where('id', $id)->update(['sort_order' => $index]); + } + }); + } +} diff --git a/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php b/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php index 4da7ded..056dae1 100644 --- a/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php +++ b/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php @@ -12,6 +12,7 @@ public function handle(HistoryEntry $entry, HistoryEntryData $data, User $editor { $entry->update([ 'body' => $data->body, + 'occurred_at' => $data->occurredAt, 'updated_by' => $editor->id, ]); diff --git a/backend/app/Modules/History/Actions/UpdateHistoryTypeAction.php b/backend/app/Modules/History/Actions/UpdateHistoryTypeAction.php new file mode 100644 index 0000000..1e28da7 --- /dev/null +++ b/backend/app/Modules/History/Actions/UpdateHistoryTypeAction.php @@ -0,0 +1,20 @@ +update([ + 'name' => $data->name, + 'requires_datetime' => $data->requiresDatetime, + ]); + + return $type; + } +} diff --git a/backend/app/Modules/History/Controllers/HistoryController.php b/backend/app/Modules/History/Controllers/HistoryController.php index dbb7559..26fedff 100644 --- a/backend/app/Modules/History/Controllers/HistoryController.php +++ b/backend/app/Modules/History/Controllers/HistoryController.php @@ -20,7 +20,7 @@ public function index(Client $client): AnonymousResourceCollection { $this->authorize('viewAny', HistoryEntry::class); $entries = $client->historyEntries() - ->with(['author', 'updatedBy']) + ->with(['author', 'updatedBy', 'entryType']) ->orderByDesc('created_at') ->get(); return HistoryEntryResource::collection($entries); @@ -33,7 +33,7 @@ public function store( ): JsonResponse { $this->authorize('create', HistoryEntry::class); $entry = $action->handle($client, HistoryEntryData::fromCreateRequest($request), $request->user()); - return HistoryEntryResource::make($entry->load(['author']))->response()->setStatusCode(201); + return HistoryEntryResource::make($entry->load(['author', 'entryType']))->response()->setStatusCode(201); } public function update( @@ -43,7 +43,7 @@ public function update( ): HistoryEntryResource { $this->authorize('update', $historyEntry); $updated = $action->handle($historyEntry, HistoryEntryData::fromUpdateRequest($request), $request->user()); - return HistoryEntryResource::make($updated->load(['author', 'updatedBy'])); + return HistoryEntryResource::make($updated->load(['author', 'updatedBy', 'entryType'])); } public function destroy(HistoryEntry $historyEntry): JsonResponse diff --git a/backend/app/Modules/History/Controllers/HistoryTypeController.php b/backend/app/Modules/History/Controllers/HistoryTypeController.php new file mode 100644 index 0000000..c999426 --- /dev/null +++ b/backend/app/Modules/History/Controllers/HistoryTypeController.php @@ -0,0 +1,62 @@ +authorize('viewAny', HistoryType::class); + $types = HistoryType::orderBy('sort_order')->orderBy('id')->get(); + return HistoryTypeResource::collection($types); + } + + public function store( + CreateHistoryTypeRequest $request, + CreateHistoryTypeAction $action + ): JsonResponse { + $this->authorize('create', HistoryType::class); + $type = $action->handle(HistoryTypeData::fromCreateRequest($request)); + return HistoryTypeResource::make($type)->response()->setStatusCode(201); + } + + public function update( + UpdateHistoryTypeRequest $request, + HistoryType $historyType, + UpdateHistoryTypeAction $action + ): HistoryTypeResource { + $this->authorize('update', $historyType); + $updated = $action->handle($historyType, HistoryTypeData::fromUpdateRequest($request)); + return HistoryTypeResource::make($updated); + } + + public function destroy(HistoryType $historyType): JsonResponse + { + $this->authorize('delete', $historyType); + $historyType->delete(); + return response()->json(null, 204); + } + + public function reorder( + ReorderHistoryTypesRequest $request, + ReorderHistoryTypesAction $action + ): AnonymousResourceCollection { + $this->authorize('reorder', HistoryType::class); + $action->handle($request->validated('ids')); + $types = HistoryType::orderBy('sort_order')->orderBy('id')->get(); + return HistoryTypeResource::collection($types); + } +} diff --git a/backend/app/Modules/History/DTOs/HistoryEntryData.php b/backend/app/Modules/History/DTOs/HistoryEntryData.php index be2a249..e7243d6 100644 --- a/backend/app/Modules/History/DTOs/HistoryEntryData.php +++ b/backend/app/Modules/History/DTOs/HistoryEntryData.php @@ -8,23 +8,26 @@ final class HistoryEntryData { public function __construct( - public readonly ?string $type, + public readonly ?int $historyEntryTypeId, public readonly ?string $body, + public readonly ?string $occurredAt, ) {} public static function fromCreateRequest(CreateHistoryEntryRequest $request): self { return new self( - type: $request->validated('type'), + historyEntryTypeId: (int) $request->validated('history_entry_type_id'), body: $request->validated('body'), + occurredAt: $request->validated('occurred_at'), ); } public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self { return new self( - type: null, + historyEntryTypeId: null, body: $request->validated('body'), + occurredAt: $request->validated('occurred_at'), ); } } diff --git a/backend/app/Modules/History/DTOs/HistoryTypeData.php b/backend/app/Modules/History/DTOs/HistoryTypeData.php new file mode 100644 index 0000000..1c7d124 --- /dev/null +++ b/backend/app/Modules/History/DTOs/HistoryTypeData.php @@ -0,0 +1,30 @@ +validated('name'), + requiresDatetime: (bool) $request->validated('requires_datetime', false), + ); + } + + public static function fromUpdateRequest(UpdateHistoryTypeRequest $request): self + { + return new self( + name: $request->validated('name'), + requiresDatetime: (bool) $request->validated('requires_datetime', false), + ); + } +} diff --git a/backend/app/Modules/History/Models/HistoryEntry.php b/backend/app/Modules/History/Models/HistoryEntry.php index ac8808f..b45eeac 100644 --- a/backend/app/Modules/History/Models/HistoryEntry.php +++ b/backend/app/Modules/History/Models/HistoryEntry.php @@ -14,15 +14,37 @@ class HistoryEntry extends Model { use HasFactory, SoftDeletes; - protected $fillable = ['client_id', 'type', 'body', 'author_id', 'updated_by']; + protected $fillable = [ + 'client_id', + 'type', + 'history_entry_type_id', + 'occurred_at', + 'body', + 'custom_field_definition_id', + 'author_id', + 'updated_by', + ]; - protected $casts = ['type' => HistoryEntryType::class]; + protected $casts = [ + 'type' => HistoryEntryType::class, + 'occurred_at' => 'datetime', + ]; + + public function isFieldChange(): bool + { + return $this->type === HistoryEntryType::FieldChange; + } public function client(): BelongsTo { return $this->belongsTo(Client::class); } + public function entryType(): BelongsTo + { + return $this->belongsTo(HistoryType::class, 'history_entry_type_id'); + } + public function author(): BelongsTo { return $this->belongsTo(User::class, 'author_id'); diff --git a/backend/app/Modules/History/Models/HistoryEntryType.php b/backend/app/Modules/History/Models/HistoryEntryType.php index 4d34534..17797ec 100644 --- a/backend/app/Modules/History/Models/HistoryEntryType.php +++ b/backend/app/Modules/History/Models/HistoryEntryType.php @@ -2,12 +2,13 @@ namespace App\Modules\History\Models; +/** + * System kind flag for auto-generated audit entries. User-selectable Verlauf + * types are no longer hardcoded here — they live in the configurable + * `history_types` table (see HistoryType). Only the field-change audit kind + * remains a fixed system value. + */ enum HistoryEntryType: string { - case PhoneCall = 'phone_call'; - case Consultation = 'consultation'; - case Recommendation = 'recommendation'; - case Instruction = 'instruction'; - case Remark = 'remark'; - case Other = 'other'; + case FieldChange = 'field_change'; } diff --git a/backend/app/Modules/History/Models/HistoryType.php b/backend/app/Modules/History/Models/HistoryType.php new file mode 100644 index 0000000..3e046fc --- /dev/null +++ b/backend/app/Modules/History/Models/HistoryType.php @@ -0,0 +1,24 @@ + 'boolean', + ]; + + protected static function newFactory(): HistoryTypeFactory + { + return HistoryTypeFactory::new(); + } +} diff --git a/backend/app/Modules/History/Policies/HistoryEntryPolicy.php b/backend/app/Modules/History/Policies/HistoryEntryPolicy.php index bee4af7..d83bcf4 100644 --- a/backend/app/Modules/History/Policies/HistoryEntryPolicy.php +++ b/backend/app/Modules/History/Policies/HistoryEntryPolicy.php @@ -9,6 +9,17 @@ class HistoryEntryPolicy { public function viewAny(User $user): bool { return true; } public function create(User $user): bool { return true; } - public function update(User $user, HistoryEntry $entry): bool { return true; } - public function delete(User $user, HistoryEntry $entry): bool { return true; } + + // Only the entry's author may edit their own note; auto-generated change + // entries are read-only audit records and cannot be edited by anyone. + public function update(User $user, HistoryEntry $entry): bool + { + return ! $entry->isFieldChange() && $user->id === $entry->author_id; + } + + // Only admins may delete Verlauf entries (manual notes and change entries alike). + public function delete(User $user, HistoryEntry $entry): bool + { + return $user->isAdmin(); + } } diff --git a/backend/app/Modules/History/Policies/HistoryTypePolicy.php b/backend/app/Modules/History/Policies/HistoryTypePolicy.php new file mode 100644 index 0000000..d383fdc --- /dev/null +++ b/backend/app/Modules/History/Policies/HistoryTypePolicy.php @@ -0,0 +1,18 @@ +isAdmin(); } + public function update(User $user, HistoryType $type): bool { return $user->isAdmin(); } + public function delete(User $user, HistoryType $type): bool { return $user->isAdmin(); } + public function reorder(User $user): bool { return $user->isAdmin(); } +} diff --git a/backend/app/Modules/History/Requests/CreateHistoryEntryRequest.php b/backend/app/Modules/History/Requests/CreateHistoryEntryRequest.php index 230bb4c..b54a932 100644 --- a/backend/app/Modules/History/Requests/CreateHistoryEntryRequest.php +++ b/backend/app/Modules/History/Requests/CreateHistoryEntryRequest.php @@ -2,9 +2,9 @@ namespace App\Modules\History\Requests; -use App\Modules\History\Models\HistoryEntryType; +use App\Modules\History\Models\HistoryType; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Enum; class CreateHistoryEntryRequest extends FormRequest { @@ -13,8 +13,29 @@ public function authorize(): bool { return true; } public function rules(): array { return [ - 'type' => ['required', new Enum(HistoryEntryType::class)], + 'history_entry_type_id' => ['required', 'integer', 'exists:history_types,id'], 'body' => ['required', 'string', 'max:65535'], + // The picker emits a naive local datetime; presence is enforced + // conditionally below based on the chosen type. + 'occurred_at' => ['nullable', 'date_format:Y-m-d\TH:i:s'], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $typeId = $this->input('history_entry_type_id'); + if (! $typeId) { + return; + } + + $type = HistoryType::find($typeId); + if ($type && $type->requires_datetime && ! $this->filled('occurred_at')) { + $validator->errors()->add( + 'occurred_at', + 'Für diesen Verlaufstyp sind Datum und Uhrzeit erforderlich.' + ); + } + }); + } } diff --git a/backend/app/Modules/History/Requests/CreateHistoryTypeRequest.php b/backend/app/Modules/History/Requests/CreateHistoryTypeRequest.php new file mode 100644 index 0000000..2709611 --- /dev/null +++ b/backend/app/Modules/History/Requests/CreateHistoryTypeRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], + 'requires_datetime' => ['boolean'], + ]; + } +} diff --git a/backend/app/Modules/History/Requests/ReorderHistoryTypesRequest.php b/backend/app/Modules/History/Requests/ReorderHistoryTypesRequest.php new file mode 100644 index 0000000..9fe49bd --- /dev/null +++ b/backend/app/Modules/History/Requests/ReorderHistoryTypesRequest.php @@ -0,0 +1,18 @@ + ['required', 'array'], + 'ids.*' => ['integer', 'exists:history_types,id'], + ]; + } +} diff --git a/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php b/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php index aa1a3d4..ebf94f9 100644 --- a/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php +++ b/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php @@ -2,6 +2,8 @@ namespace App\Modules\History\Requests; +use App\Modules\History\Models\HistoryEntry; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; class UpdateHistoryEntryRequest extends FormRequest @@ -12,6 +14,24 @@ public function rules(): array { return [ 'body' => ['required', 'string', 'max:65535'], + 'occurred_at' => ['nullable', 'date_format:Y-m-d\TH:i:s'], ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $entry = $this->route('historyEntry'); + + if ($entry instanceof HistoryEntry + && $entry->entryType?->requires_datetime + && ! $this->filled('occurred_at') + ) { + $validator->errors()->add( + 'occurred_at', + 'Für diesen Verlaufstyp sind Datum und Uhrzeit erforderlich.' + ); + } + }); + } } diff --git a/backend/app/Modules/History/Requests/UpdateHistoryTypeRequest.php b/backend/app/Modules/History/Requests/UpdateHistoryTypeRequest.php new file mode 100644 index 0000000..72450c2 --- /dev/null +++ b/backend/app/Modules/History/Requests/UpdateHistoryTypeRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], + 'requires_datetime' => ['boolean'], + ]; + } +} diff --git a/backend/app/Modules/History/Resources/HistoryEntryResource.php b/backend/app/Modules/History/Resources/HistoryEntryResource.php index 7476ec2..7d48bba 100644 --- a/backend/app/Modules/History/Resources/HistoryEntryResource.php +++ b/backend/app/Modules/History/Resources/HistoryEntryResource.php @@ -12,8 +12,17 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'client_id' => $this->client_id, - 'type' => $this->type->value, + 'is_field_change' => $this->isFieldChange(), + 'history_entry_type_id' => $this->history_entry_type_id, + // Field-change audit rows render under a fixed system label; user + // entries take their configured type name. + 'type_name' => $this->isFieldChange() ? 'Stammdatenänderung' : $this->entryType?->name, + 'requires_datetime' => $this->entryType?->requires_datetime ?? false, + // Naive local datetime (no Z/offset) so the user-typed HH:MM + // round-trips without timezone drift. + 'occurred_at' => $this->occurred_at?->format('Y-m-d\TH:i:s'), 'body' => $this->body, + 'custom_field_definition_id' => $this->custom_field_definition_id, 'author_id' => $this->author_id, 'author_name' => $this->author?->name, 'updated_by' => $this->updated_by, diff --git a/backend/app/Modules/History/Resources/HistoryTypeResource.php b/backend/app/Modules/History/Resources/HistoryTypeResource.php new file mode 100644 index 0000000..806f007 --- /dev/null +++ b/backend/app/Modules/History/Resources/HistoryTypeResource.php @@ -0,0 +1,19 @@ + $this->id, + 'name' => $this->name, + 'requires_datetime' => $this->requires_datetime, + 'sort_order' => $this->sort_order, + ]; + } +} diff --git a/backend/app/Modules/Import/Actions/ImportClientsAction.php b/backend/app/Modules/Import/Actions/ImportClientsAction.php new file mode 100644 index 0000000..009c301 --- /dev/null +++ b/backend/app/Modules/Import/Actions/ImportClientsAction.php @@ -0,0 +1,225 @@ + $definitions */ + $definitions = CustomFieldDefinition::query() + ->whereIn('key', self::FIELD_KEYS) + ->get() + ->keyBy('key'); + + $missing = array_diff(self::FIELD_KEYS, $definitions->keys()->all()); + if ($missing !== []) { + throw new RuntimeException( + 'Fehlende Custom-Field-Definitionen: '.implode(', ', $missing). + '. Bitte den CustomFieldDefinitionSeeder ausführen.' + ); + } + + $importUser = $this->systemUser(); + + $rows = $this->readRows($path); + if ($rows === []) { + return new ImportResult(0, 0, 0, 0, []); + } + + $header = array_map(static fn (string $h): string => trim($h), array_shift($rows)); + /** @var array $index header name → column offset */ + $index = array_flip($header); + + $created = 0; + $updated = 0; + $skipped = 0; + /** @var array $errors */ + $errors = []; + + foreach ($rows as $offset => $row) { + $rowNumber = (int) $offset + 2; // +1 for header, +1 for 1-based + $clientNumber = null; + + try { + $clientNumber = $this->value($row, $index, 'Mandantennr.'); + if ($clientNumber === null) { + $errors[] = ['row' => $rowNumber, 'client_number' => null, 'message' => 'Keine Mandantennummer.']; + + continue; + } + + $mapped = $this->mapFields($row, $index); + $client = Client::query()->where('client_number', $clientNumber)->first(); + + if ($client !== null) { + if (! $this->isOverwrite($row, $index)) { + $skipped++; + + continue; + } + + foreach (self::FIELD_KEYS as $key) { + if ($mapped[$key] === null) { + continue; // skip blanks — never wipe existing data + } + $this->updateValue->handle($client, $definitions[$key], $mapped[$key], $importUser); + } + $updated++; + + continue; + } + + $client = Client::create([ + 'client_number' => $clientNumber, + 'name' => $this->value($row, $index, 'Mandant') ?? $clientNumber, + ]); + + foreach (self::FIELD_KEYS as $key) { + if ($mapped[$key] === null) { + continue; + } + CustomFieldValue::create([ + 'client_id' => $client->id, + 'custom_field_definition_id' => $definitions[$key]->id, + 'value' => $mapped[$key], + 'author_id' => $importUser->id, + ]); + } + $created++; + } catch (Throwable $e) { + $errors[] = ['row' => $rowNumber, 'client_number' => $clientNumber, 'message' => $e->getMessage()]; + } + } + + return new ImportResult(count($rows), $created, $updated, $skipped, $errors); + } + + /** + * Read the CSV into numeric record arrays (no header offset) so ragged data + * rows — far shorter than the ~66-column header — never throw. Decodes the + * legacy ISO-8859-1 encoding to UTF-8 and strips a UTF-8 BOM if present. + * + * @return array> + */ + private function readRows(string $path): array + { + $contents = (string) file_get_contents($path); + $contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents; + + if (! mb_check_encoding($contents, 'UTF-8')) { + $contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252'); + } + + $csv = Reader::createFromString($contents); + $csv->setDelimiter(';'); + + return array_values(iterator_to_array($csv->getRecords(), false)); + } + + /** + * Map the four target custom fields from a row. Anschrift 1–8 are joined + * with newlines, blank parts dropped. + * + * @param array $row + * @param array $index + * @return array + */ + private function mapFields(array $row, array $index): array + { + $address = []; + for ($n = 1; $n <= 8; $n++) { + $part = $this->value($row, $index, "Anschrift {$n}"); + if ($part !== null) { + $address[] = $part; + } + } + + return [ + 'telefonnummer' => $this->value($row, $index, 'Telefon'), + 'e_mail' => $this->value($row, $index, 'E-Mail'), + 'ansprechpartner' => $this->value($row, $index, 'Ansprechpartner 1'), + 'anschrift' => $address === [] ? null : implode("\n", $address), + ]; + } + + /** + * @param array $row + * @param array $index + */ + private function isOverwrite(array $row, array $index): bool + { + $flag = $this->value($row, $index, 'Ueberschreiben'); + + return $flag !== null && mb_strtolower($flag) === self::OVERWRITE_TRIGGER; + } + + /** + * Resolve a cell by header name (bounds-safe for ragged rows), trim it, + * strip a single leading Excel apostrophe, and normalise empty → null. + * + * @param array $row + * @param array $index + */ + private function value(array $row, array $index, string $name): ?string + { + if (! array_key_exists($name, $index)) { + return null; + } + + $raw = $row[$index[$name]] ?? ''; + $raw = trim($raw); + if ($raw !== '' && $raw[0] === "'") { + $raw = trim(substr($raw, 1)); + } + + return $raw === '' ? null : $raw; + } + + private function systemUser(): User + { + return User::firstOrCreate( + ['email' => self::IMPORT_USER_EMAIL], + [ + 'name' => self::IMPORT_USER_NAME, + 'password' => Hash::make(Str::random(40)), + 'role' => 'staff', + ], + ); + } +} diff --git a/backend/app/Modules/Import/Controllers/ClientImportController.php b/backend/app/Modules/Import/Controllers/ClientImportController.php new file mode 100644 index 0000000..c7dfe9c --- /dev/null +++ b/backend/app/Modules/Import/Controllers/ClientImportController.php @@ -0,0 +1,22 @@ +user()?->isAdmin(), 403); + + $result = $action->handle((string) $request->file('file')->getRealPath()); + + return new ImportResultResource($result); + } +} diff --git a/backend/app/Modules/Import/DTOs/ImportResult.php b/backend/app/Modules/Import/DTOs/ImportResult.php new file mode 100644 index 0000000..7322e75 --- /dev/null +++ b/backend/app/Modules/Import/DTOs/ImportResult.php @@ -0,0 +1,17 @@ + $errors + */ + public function __construct( + public readonly int $total, + public readonly int $created, + public readonly int $updated, + public readonly int $skipped, + public readonly array $errors, + ) {} +} diff --git a/backend/app/Modules/Import/Requests/ClientImportRequest.php b/backend/app/Modules/Import/Requests/ClientImportRequest.php new file mode 100644 index 0000000..de2df94 --- /dev/null +++ b/backend/app/Modules/Import/Requests/ClientImportRequest.php @@ -0,0 +1,40 @@ + + */ + public function rules(): array + { + return [ + // Validate by extension only — Windows CSV exports arrive with a + // variety of MIME types (text/plain, application/vnd.ms-excel), so a + // strict `mimes:csv` rule would reject legitimate files. + 'file' => [ + 'required', + 'file', + 'max:10240', + function (string $attribute, mixed $value, Closure $fail): void { + $ext = $value instanceof UploadedFile + ? strtolower($value->getClientOriginalExtension()) + : ''; + if (! in_array($ext, ['csv', 'txt'], true)) { + $fail('Bitte eine CSV-Datei (.csv oder .txt) hochladen.'); + } + }, + ], + ]; + } +} diff --git a/backend/app/Modules/Import/Resources/ImportResultResource.php b/backend/app/Modules/Import/Resources/ImportResultResource.php new file mode 100644 index 0000000..d703254 --- /dev/null +++ b/backend/app/Modules/Import/Resources/ImportResultResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'total' => $this->resource->total, + 'created' => $this->resource->created, + 'updated' => $this->resource->updated, + 'skipped' => $this->resource->skipped, + 'errors' => $this->resource->errors, + ]; + } +} diff --git a/backend/app/Modules/Search/Actions/SearchClientsAction.php b/backend/app/Modules/Search/Actions/SearchClientsAction.php index 130e24a..c80f8b9 100644 --- a/backend/app/Modules/Search/Actions/SearchClientsAction.php +++ b/backend/app/Modules/Search/Actions/SearchClientsAction.php @@ -11,15 +11,25 @@ final class SearchClientsAction public function handle(string $query): Collection { if (DB::connection()->getDriverName() === 'sqlite') { - return Client::where('name', 'like', "%{$query}%") - ->orWhere('client_number', 'like', "%{$query}%") + return Client::query() + ->withCount('historyEntries') + ->withMax('historyEntries', 'created_at') + ->where(function ($q) use ($query) { + $q->where('name', 'like', "%{$query}%") + ->orWhere('client_number', 'like', "%{$query}%"); + }) ->limit(50) ->get(); } - return Client::whereRaw( - 'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)', - [$query . '*'] - )->limit(50)->get(); + return Client::query() + ->withCount('historyEntries') + ->withMax('historyEntries', 'created_at') + ->whereRaw( + 'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)', + [$query.'*'] + ) + ->limit(50) + ->get(); } } diff --git a/backend/app/Modules/User/Requests/CreateUserRequest.php b/backend/app/Modules/User/Requests/CreateUserRequest.php index b4cd52a..378b76b 100644 --- a/backend/app/Modules/User/Requests/CreateUserRequest.php +++ b/backend/app/Modules/User/Requests/CreateUserRequest.php @@ -3,6 +3,7 @@ namespace App\Modules\User\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class CreateUserRequest extends FormRequest @@ -12,6 +13,13 @@ public function authorize(): bool return true; } + protected function prepareForValidation(): void + { + if ($this->has('email')) { + $this->merge(['email' => Str::lower($this->input('email'))]); + } + } + public function rules(): array { return [ diff --git a/backend/app/Modules/User/Requests/UpdateUserRequest.php b/backend/app/Modules/User/Requests/UpdateUserRequest.php index 5085739..339d1e2 100644 --- a/backend/app/Modules/User/Requests/UpdateUserRequest.php +++ b/backend/app/Modules/User/Requests/UpdateUserRequest.php @@ -3,6 +3,7 @@ namespace App\Modules\User\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; class UpdateUserRequest extends FormRequest @@ -12,6 +13,13 @@ public function authorize(): bool return true; } + protected function prepareForValidation(): void + { + if ($this->has('email')) { + $this->merge(['email' => Str::lower($this->input('email'))]); + } + } + public function rules(): array { return [ @@ -19,7 +27,7 @@ public function rules(): array 'email' => [ 'required', 'email', - Rule::unique('users')->whereNull('deleted_at')->ignore($this->user('id')), + Rule::unique('users')->whereNull('deleted_at')->ignore($this->route('user')), ], 'password' => ['nullable', 'string', 'min:8', 'confirmed'], 'role' => ['required', 'string', 'in:admin,staff'], diff --git a/backend/app/Providers/AuthServiceProvider.php b/backend/app/Providers/AuthServiceProvider.php index db0d377..c252e3a 100644 --- a/backend/app/Providers/AuthServiceProvider.php +++ b/backend/app/Providers/AuthServiceProvider.php @@ -6,8 +6,12 @@ use App\Models\User; use App\Modules\Client\Models\Client; use App\Modules\Client\Policies\ClientPolicy; +use App\Modules\CustomField\Models\CustomFieldDefinition; +use App\Modules\CustomField\Policies\CustomFieldDefinitionPolicy; use App\Modules\History\Models\HistoryEntry; +use App\Modules\History\Models\HistoryType; use App\Modules\History\Policies\HistoryEntryPolicy; +use App\Modules\History\Policies\HistoryTypePolicy; use App\Modules\User\Policies\UserPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; @@ -21,7 +25,9 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ User::class => UserPolicy::class, Client::class => ClientPolicy::class, + CustomFieldDefinition::class => CustomFieldDefinitionPolicy::class, HistoryEntry::class => HistoryEntryPolicy::class, + HistoryType::class => HistoryTypePolicy::class, ]; /** diff --git a/backend/database/factories/CustomFieldDefinitionFactory.php b/backend/database/factories/CustomFieldDefinitionFactory.php new file mode 100644 index 0000000..413386b --- /dev/null +++ b/backend/database/factories/CustomFieldDefinitionFactory.php @@ -0,0 +1,35 @@ +faker->unique()->words(2, true); + + return [ + 'label' => ucfirst($label), + 'key' => Str::slug($label, '_'), + 'type' => CustomFieldType::Text->value, + 'options' => null, + 'is_required' => false, + 'sort_order' => 0, + ]; + } + + public function select(array $options): static + { + return $this->state(fn () => [ + 'type' => CustomFieldType::Select->value, + 'options' => $options, + ]); + } +} diff --git a/backend/database/factories/HistoryEntryFactory.php b/backend/database/factories/HistoryEntryFactory.php index a951149..65b22ba 100644 --- a/backend/database/factories/HistoryEntryFactory.php +++ b/backend/database/factories/HistoryEntryFactory.php @@ -5,7 +5,7 @@ use App\Models\User; use App\Modules\Client\Models\Client; use App\Modules\History\Models\HistoryEntry; -use App\Modules\History\Models\HistoryEntryType; +use App\Modules\History\Models\HistoryType; use Illuminate\Database\Eloquent\Factories\Factory; class HistoryEntryFactory extends Factory @@ -16,7 +16,8 @@ public function definition(): array { return [ 'client_id' => Client::factory(), - 'type' => $this->faker->randomElement(HistoryEntryType::cases())->value, + 'history_entry_type_id' => HistoryType::factory(), + 'occurred_at' => null, 'body' => $this->faker->paragraph(), 'author_id' => User::factory(), 'updated_by' => null, diff --git a/backend/database/factories/HistoryTypeFactory.php b/backend/database/factories/HistoryTypeFactory.php new file mode 100644 index 0000000..9dec216 --- /dev/null +++ b/backend/database/factories/HistoryTypeFactory.php @@ -0,0 +1,25 @@ + $this->faker->unique()->word(), + 'requires_datetime' => false, + 'sort_order' => 0, + ]; + } + + public function requiresDatetime(): static + { + return $this->state(fn () => ['requires_datetime' => true]); + } +} diff --git a/backend/database/migrations/2026_06_23_095330_create_history_entries_table.php b/backend/database/migrations/2026_06_23_095330_create_history_entries_table.php index 545f09d..9ae1fc3 100644 --- a/backend/database/migrations/2026_06_23_095330_create_history_entries_table.php +++ b/backend/database/migrations/2026_06_23_095330_create_history_entries_table.php @@ -14,11 +14,22 @@ public function up(): void Schema::create('history_entries', function (Blueprint $table) { $table->id(); $table->foreignId('client_id')->constrained()->cascadeOnDelete(); - $table->enum('type', [ - 'phone_call', 'consultation', 'recommendation', - 'instruction', 'remark', 'other', - ]); + // System kind flag: only set for auto-generated audit entries + // ('field_change'). User entries leave this null and reference a + // configurable history_types row via history_entry_type_id below. + $table->enum('type', ['field_change'])->nullable(); + // Configurable type for user entries. Plain nullable column (no FK): + // history_types is created in a later migration and soft-deletes, + // mirroring the custom_field_definition_id pattern. + $table->foreignId('history_entry_type_id')->nullable(); + // User-entered event time (naive local, no TZ conversion). Null for + // entries whose type does not require a date/time. + $table->dateTime('occurred_at')->nullable(); $table->text('body'); + // Audit link for auto-generated custom-field change entries. Plain + // nullable column (no FK): the definitions table is created in a + // later migration and definitions soft-delete, so no cascade needed. + $table->foreignId('custom_field_definition_id')->nullable(); $table->foreignId('author_id')->constrained('users'); $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); $table->softDeletes(); diff --git a/backend/database/migrations/2026_06_25_000001_create_custom_field_definitions_table.php b/backend/database/migrations/2026_06_25_000001_create_custom_field_definitions_table.php new file mode 100644 index 0000000..8cd1f8b --- /dev/null +++ b/backend/database/migrations/2026_06_25_000001_create_custom_field_definitions_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('label'); + $table->string('key')->unique(); + $table->enum('type', ['text', 'textarea', 'number', 'date', 'select']); + $table->json('options')->nullable(); + $table->boolean('is_required')->default(false); + $table->integer('sort_order')->default(0); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_field_definitions'); + } +}; diff --git a/backend/database/migrations/2026_06_25_000002_create_custom_field_values_table.php b/backend/database/migrations/2026_06_25_000002_create_custom_field_values_table.php new file mode 100644 index 0000000..19984ac --- /dev/null +++ b/backend/database/migrations/2026_06_25_000002_create_custom_field_values_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('client_id')->constrained()->cascadeOnDelete(); + $table->foreignId('custom_field_definition_id')->constrained()->cascadeOnDelete(); + $table->text('value')->nullable(); + $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['client_id', 'custom_field_definition_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_field_values'); + } +}; diff --git a/backend/database/migrations/2026_06_25_000003_create_history_types_table.php b/backend/database/migrations/2026_06_25_000003_create_history_types_table.php new file mode 100644 index 0000000..c578779 --- /dev/null +++ b/backend/database/migrations/2026_06_25_000003_create_history_types_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + // When true, the add-entry form requires a user-entered date/time + // (occurred_at) and the entry renders as "Name – DD.MM.YYYY HH:MM". + $table->boolean('requires_datetime')->default(false); + $table->integer('sort_order')->default(0); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('history_types'); + } +}; diff --git a/backend/database/migrations/2026_06_26_000001_add_deleted_at_to_clients_table.php b/backend/database/migrations/2026_06_26_000001_add_deleted_at_to_clients_table.php new file mode 100644 index 0000000..f0dfce5 --- /dev/null +++ b/backend/database/migrations/2026_06_26_000001_add_deleted_at_to_clients_table.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('clients', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/backend/database/seeders/CustomFieldDefinitionSeeder.php b/backend/database/seeders/CustomFieldDefinitionSeeder.php new file mode 100644 index 0000000..546e115 --- /dev/null +++ b/backend/database/seeders/CustomFieldDefinitionSeeder.php @@ -0,0 +1,39 @@ + 'telefonnummer', 'label' => 'Telefon 1', 'type' => CustomFieldType::Text, 'sort_order' => 1], + ['key' => 'e_mail', 'label' => 'E-Mail', 'type' => CustomFieldType::Text, 'sort_order' => 2], + ['key' => 'ansprechpartner', 'label' => 'Ansprechpartner', 'type' => CustomFieldType::Text, 'sort_order' => 3], + ['key' => 'anschrift', 'label' => 'Anschrift', 'type' => CustomFieldType::Textarea, 'sort_order' => 4], + ]; + + foreach ($fields as $field) { + CustomFieldDefinition::firstOrCreate( + ['key' => $field['key']], + [ + 'label' => $field['label'], + 'type' => $field['type'], + 'is_required' => false, + 'sort_order' => $field['sort_order'], + ], + ); + } + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index ee1a533..7c72116 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -4,7 +4,10 @@ use App\Models\User; use App\Modules\Client\Models\Client; +use App\Modules\CustomField\Models\CustomFieldDefinition; +use App\Modules\CustomField\Models\CustomFieldType; use App\Modules\History\Models\HistoryEntry; +use App\Modules\History\Models\HistoryType; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -18,13 +21,34 @@ public function run(): void 'role' => 'admin', ]); + // Base-data custom fields targeted by the Mandanten-Import (idempotent). + $this->call(CustomFieldDefinitionSeeder::class); + + + // Configurable Verlauf types. Telefonat & Beratungsgespräch carry a + // user-entered event date/time; the rest render as the plain name. + $types = [ + ['name' => 'Telefonat', 'requires_datetime' => true, 'sort_order' => 1], + ['name' => 'Beratungsgespräch', 'requires_datetime' => true, 'sort_order' => 2], + ['name' => 'Empfehlung', 'requires_datetime' => false, 'sort_order' => 3], + ['name' => 'Anweisung', 'requires_datetime' => false, 'sort_order' => 4], + ['name' => 'Bemerkung', 'requires_datetime' => false, 'sort_order' => 5], + ['name' => 'Sonstiges', 'requires_datetime' => false, 'sort_order' => 6], + ]; + + $historyTypes = collect($types)->map(fn (array $t) => HistoryType::create($t)); + $clients = Client::factory(10)->create(); - $clients->each(function (Client $client) use ($admin) { - HistoryEntry::factory(3)->create([ - 'client_id' => $client->id, - 'author_id' => $admin->id, - ]); + $clients->each(function (Client $client) use ($admin, $historyTypes) { + $historyTypes->random(3)->each(function (HistoryType $type) use ($client, $admin) { + HistoryEntry::factory()->create([ + 'client_id' => $client->id, + 'author_id' => $admin->id, + 'history_entry_type_id' => $type->id, + 'occurred_at' => $type->requires_datetime ? now()->subDays(rand(1, 30)) : null, + ]); + }); }); } } diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 7970dae..055a4ee 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -22,7 +22,10 @@ - + + + diff --git a/backend/tests/Feature/Modules/Client/ClientTest.php b/backend/tests/Feature/Modules/Client/ClientTest.php index 5933590..6b57a0e 100644 --- a/backend/tests/Feature/Modules/Client/ClientTest.php +++ b/backend/tests/Feature/Modules/Client/ClientTest.php @@ -65,3 +65,35 @@ it('unauthenticated request returns 401', function () { $this->getJson('/api/clients')->assertStatus(401); }); + +it('admin can soft delete a client', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(); + + $response = $this->actingAs($admin)->deleteJson("/api/clients/{$client->id}"); + + $response->assertStatus(204); + // Row is retained, only soft-deleted (deleted_at set) + $this->assertSoftDeleted('clients', ['id' => $client->id]); + $this->assertDatabaseHas('clients', ['id' => $client->id]); +}); + +it('staff cannot delete a client', function () { + $staff = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + + $this->actingAs($staff)->deleteJson("/api/clients/{$client->id}") + ->assertStatus(403); + + $this->assertNotSoftDeleted('clients', ['id' => $client->id]); +}); + +it('soft deleted clients are excluded from listings', function () { + $admin = User::factory()->create(['role' => 'admin']); + Client::factory(2)->create(); + Client::factory()->create()->delete(); + + $this->actingAs($admin)->getJson('/api/clients') + ->assertStatus(200) + ->assertJsonCount(2, 'data'); +}); diff --git a/backend/tests/Feature/Modules/CustomField/CustomFieldTest.php b/backend/tests/Feature/Modules/CustomField/CustomFieldTest.php new file mode 100644 index 0000000..e13ceea --- /dev/null +++ b/backend/tests/Feature/Modules/CustomField/CustomFieldTest.php @@ -0,0 +1,158 @@ +create(['role' => 'staff']); + CustomFieldDefinition::factory(2)->create(); + + $this->actingAs($user)->getJson('/api/custom-fields') + ->assertStatus(200) + ->assertJsonCount(2, 'data'); +}); + +it('lets an admin create a field definition and derives a unique key', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->postJson('/api/custom-fields', [ + 'label' => 'Beratungsschwerpunkt', + 'type' => 'select', + 'options' => ['Steuerrecht', 'Erbrecht'], + ])->assertStatus(201)->assertJsonPath('data.key', 'beratungsschwerpunkt'); + + $this->assertDatabaseHas('custom_field_definitions', ['label' => 'Beratungsschwerpunkt']); +}); + +it('appends new field definitions to the end of the list', function () { + $admin = User::factory()->create(['role' => 'admin']); + CustomFieldDefinition::factory()->create(['sort_order' => 2]); + + $this->actingAs($admin)->postJson('/api/custom-fields', [ + 'label' => 'Zuletzt', + 'type' => 'text', + ])->assertStatus(201)->assertJsonPath('data.sort_order', 3); +}); + +it('lets an admin reorder field definitions', function () { + $admin = User::factory()->create(['role' => 'admin']); + $a = CustomFieldDefinition::factory()->create(['sort_order' => 0]); + $b = CustomFieldDefinition::factory()->create(['sort_order' => 1]); + $c = CustomFieldDefinition::factory()->create(['sort_order' => 2]); + + $this->actingAs($admin)->postJson('/api/custom-fields/reorder', [ + 'ids' => [$c->id, $a->id, $b->id], + ])->assertStatus(200) + ->assertJsonPath('data.0.id', $c->id) + ->assertJsonPath('data.1.id', $a->id) + ->assertJsonPath('data.2.id', $b->id); + + $this->assertDatabaseHas('custom_field_definitions', ['id' => $c->id, 'sort_order' => 0]); + $this->assertDatabaseHas('custom_field_definitions', ['id' => $a->id, 'sort_order' => 1]); + $this->assertDatabaseHas('custom_field_definitions', ['id' => $b->id, 'sort_order' => 2]); +}); + +it('forbids staff from reordering field definitions', function () { + $user = User::factory()->create(['role' => 'staff']); + $definition = CustomFieldDefinition::factory()->create(); + + $this->actingAs($user)->postJson('/api/custom-fields/reorder', [ + 'ids' => [$definition->id], + ])->assertStatus(403); +}); + +it('forbids staff from creating field definitions', function () { + $user = User::factory()->create(['role' => 'staff']); + + $this->actingAs($user)->postJson('/api/custom-fields', [ + 'label' => 'Heimlich', + 'type' => 'text', + ])->assertStatus(403); +}); + +it('forbids staff from deleting field definitions', function () { + $user = User::factory()->create(['role' => 'staff']); + $definition = CustomFieldDefinition::factory()->create(); + + $this->actingAs($user)->deleteJson("/api/custom-fields/{$definition->id}")->assertStatus(403); +}); + +it('requires options when the type is select', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->postJson('/api/custom-fields', [ + 'label' => 'Ohne Optionen', + 'type' => 'select', + ])->assertStatus(422); +}); + +it('records a Verlauf entry when a field value changes', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $definition = CustomFieldDefinition::factory()->create(['label' => 'Beratungsschwerpunkt']); + + $this->actingAs($user)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [ + 'value' => 'Steuerrecht', + ])->assertStatus(200); + + $this->assertDatabaseHas('custom_field_values', [ + 'client_id' => $client->id, + 'custom_field_definition_id' => $definition->id, + 'value' => 'Steuerrecht', + 'author_id' => $user->id, + ]); + + $this->assertDatabaseHas('history_entries', [ + 'client_id' => $client->id, + 'type' => 'field_change', + 'custom_field_definition_id' => $definition->id, + 'author_id' => $user->id, + ]); +}); + +it('does not record a Verlauf entry when the value is unchanged', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $definition = CustomFieldDefinition::factory()->create(); + + $url = "/api/clients/{$client->id}/custom-fields/{$definition->id}"; + $this->actingAs($user)->putJson($url, ['value' => 'Hallo'])->assertStatus(200); + $this->actingAs($user)->putJson($url, ['value' => 'Hallo'])->assertStatus(200); + + expect(\App\Modules\History\Models\HistoryEntry::where('client_id', $client->id)->count())->toBe(1); +}); + +it('validates select values against the defined options', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $definition = CustomFieldDefinition::factory()->select(['Steuerrecht', 'Erbrecht'])->create(); + + $this->actingAs($user)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [ + 'value' => 'Strafrecht', + ])->assertStatus(422); +}); + +it('validates number values', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(); + $definition = CustomFieldDefinition::factory()->create(['type' => 'number']); + + $this->actingAs($admin)->putJson("/api/clients/{$client->id}/custom-fields/{$definition->id}", [ + 'value' => 'not-a-number', + ])->assertStatus(422); +}); + +it('embeds custom fields on the client detail response', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + CustomFieldDefinition::factory()->create(['label' => 'Mandant seit', 'sort_order' => 1]); + + $this->actingAs($user)->getJson("/api/clients/{$client->id}") + ->assertStatus(200) + ->assertJsonPath('data.custom_fields.0.label', 'Mandant seit') + ->assertJsonPath('data.custom_fields.0.value', null); +}); diff --git a/backend/tests/Feature/Modules/History/HistoryTest.php b/backend/tests/Feature/Modules/History/HistoryTest.php index 3c3e3e3..1e89d2c 100644 --- a/backend/tests/Feature/Modules/History/HistoryTest.php +++ b/backend/tests/Feature/Modules/History/HistoryTest.php @@ -4,6 +4,7 @@ use App\Modules\Client\Models\Client; use App\Modules\History\Models\HistoryEntry; use App\Modules\History\Models\HistoryEntryType; +use App\Modules\History\Models\HistoryType; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -21,24 +22,65 @@ it('staff can add a history entry', function () { $user = User::factory()->create(['role' => 'staff']); $client = Client::factory()->create(); + $type = HistoryType::factory()->create(['name' => 'Bemerkung']); $response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ - 'type' => 'phone_call', + 'history_entry_type_id' => $type->id, 'body' => 'Rückruf vereinbart.', ]); $response->assertStatus(201) - ->assertJsonPath('data.type', 'phone_call') + ->assertJsonPath('data.history_entry_type_id', $type->id) + ->assertJsonPath('data.type_name', 'Bemerkung') + ->assertJsonPath('data.requires_datetime', false) ->assertJsonPath('data.body', 'Rückruf vereinbart.') ->assertJsonPath('data.author_id', $user->id); $this->assertDatabaseHas('history_entries', [ 'client_id' => $client->id, - 'type' => 'phone_call', + 'history_entry_type_id' => $type->id, ]); }); -it('staff can update a history entry', function () { +it('stores and echoes back the occurred_at datetime unchanged', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $type = HistoryType::factory()->requiresDatetime()->create(['name' => 'Telefonat']); + + $response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ + 'history_entry_type_id' => $type->id, + 'body' => 'Telefonat geführt.', + 'occurred_at' => '2026-06-25T14:30:00', + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.type_name', 'Telefonat') + ->assertJsonPath('data.requires_datetime', true) + ->assertJsonPath('data.occurred_at', '2026-06-25T14:30:00'); +}); + +it('requires occurred_at when the type requires a datetime', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $type = HistoryType::factory()->requiresDatetime()->create(); + + $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ + 'history_entry_type_id' => $type->id, + 'body' => 'Ohne Zeit.', + ])->assertStatus(422)->assertJsonValidationErrors('occurred_at'); +}); + +it('rejects an invalid history_entry_type_id', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + + $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ + 'history_entry_type_id' => 99999, + 'body' => 'test', + ])->assertStatus(422)->assertJsonValidationErrors('history_entry_type_id'); +}); + +it('lets the author edit their own entry', function () { $user = User::factory()->create(['role' => 'staff']); $client = Client::factory()->create(); $entry = HistoryEntry::factory()->create([ @@ -55,23 +97,66 @@ $this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'updated_by' => $user->id]); }); -it('staff can soft-delete a history entry', function () { +it('forbids a non-author from editing an entry', function () { + $author = User::factory()->create(['role' => 'staff']); + $other = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + $entry = HistoryEntry::factory()->create([ + 'client_id' => $client->id, + 'author_id' => $author->id, + 'body' => 'Original', + ]); + + $this->actingAs($other)->putJson("/api/history/{$entry->id}", [ + 'body' => 'tampered', + ])->assertStatus(403); +}); + +it('nobody can edit a field_change audit entry', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(); + $entry = HistoryEntry::factory()->create([ + 'client_id' => $client->id, + 'author_id' => $admin->id, + 'history_entry_type_id' => null, + 'type' => HistoryEntryType::FieldChange->value, + ]); + + $this->actingAs($admin)->putJson("/api/history/{$entry->id}", ['body' => 'tampered'])->assertStatus(403); +}); + +it('renders a field_change entry as Stammdatenänderung', function () { $user = User::factory()->create(['role' => 'staff']); $client = Client::factory()->create(); - $entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $user->id]); + HistoryEntry::factory()->create([ + 'client_id' => $client->id, + 'author_id' => $user->id, + 'history_entry_type_id' => null, + 'type' => HistoryEntryType::FieldChange->value, + ]); - $response = $this->actingAs($user)->deleteJson("/api/history/{$entry->id}"); + $this->actingAs($user)->getJson("/api/clients/{$client->id}/history") + ->assertStatus(200) + ->assertJsonPath('data.0.is_field_change', true) + ->assertJsonPath('data.0.type_name', 'Stammdatenänderung'); +}); + +it('admin can soft-delete a history entry', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(); + $entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $admin->id]); + + $response = $this->actingAs($admin)->deleteJson("/api/history/{$entry->id}"); $response->assertStatus(204); $this->assertSoftDeleted('history_entries', ['id' => $entry->id]); }); -it('rejects invalid history entry type', function () { +it('staff cannot delete a history entry', function () { $user = User::factory()->create(['role' => 'staff']); $client = Client::factory()->create(); + $entry = HistoryEntry::factory()->create(['client_id' => $client->id, 'author_id' => $user->id]); - $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ - 'type' => 'invalid_type', - 'body' => 'test', - ])->assertStatus(422); + $this->actingAs($user)->deleteJson("/api/history/{$entry->id}")->assertStatus(403); + $this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'deleted_at' => null]); }); diff --git a/backend/tests/Feature/Modules/History/HistoryTypeTest.php b/backend/tests/Feature/Modules/History/HistoryTypeTest.php new file mode 100644 index 0000000..b4bc2e3 --- /dev/null +++ b/backend/tests/Feature/Modules/History/HistoryTypeTest.php @@ -0,0 +1,122 @@ +create(['role' => 'staff']); + HistoryType::factory(3)->create(); + + $this->actingAs($user)->getJson('/api/history-types') + ->assertStatus(200) + ->assertJsonCount(3, 'data'); +}); + +it('lets an admin create a history type', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $response = $this->actingAs($admin)->postJson('/api/history-types', [ + 'name' => 'Telefonat', + 'requires_datetime' => true, + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Telefonat') + ->assertJsonPath('data.requires_datetime', true) + ->assertJsonPath('data.sort_order', 0); + + $this->assertDatabaseHas('history_types', ['name' => 'Telefonat', 'requires_datetime' => true]); +}); + +it('appends new history types to the end of the list', function () { + $admin = User::factory()->create(['role' => 'admin']); + HistoryType::factory()->create(['sort_order' => 3]); + + $this->actingAs($admin)->postJson('/api/history-types', ['name' => 'Zuletzt']) + ->assertStatus(201) + ->assertJsonPath('data.sort_order', 4); +}); + +it('forbids staff from creating a history type', function () { + $user = User::factory()->create(['role' => 'staff']); + + $this->actingAs($user)->postJson('/api/history-types', [ + 'name' => 'Telefonat', + ])->assertStatus(403); +}); + +it('lets an admin update a history type without changing its order', function () { + $admin = User::factory()->create(['role' => 'admin']); + $type = HistoryType::factory()->create(['name' => 'Alt', 'requires_datetime' => false, 'sort_order' => 7]); + + $this->actingAs($admin)->putJson("/api/history-types/{$type->id}", [ + 'name' => 'Neu', + 'requires_datetime' => true, + ])->assertStatus(200)->assertJsonPath('data.name', 'Neu'); + + // sort_order is managed only via the reorder endpoint, so it must be untouched. + $this->assertDatabaseHas('history_types', ['id' => $type->id, 'name' => 'Neu', 'requires_datetime' => true, 'sort_order' => 7]); +}); + +it('lets an admin reorder history types', function () { + $admin = User::factory()->create(['role' => 'admin']); + $a = HistoryType::factory()->create(['sort_order' => 0]); + $b = HistoryType::factory()->create(['sort_order' => 1]); + $c = HistoryType::factory()->create(['sort_order' => 2]); + + $this->actingAs($admin)->postJson('/api/history-types/reorder', [ + 'ids' => [$c->id, $a->id, $b->id], + ])->assertStatus(200) + ->assertJsonPath('data.0.id', $c->id) + ->assertJsonPath('data.1.id', $a->id) + ->assertJsonPath('data.2.id', $b->id); + + $this->assertDatabaseHas('history_types', ['id' => $c->id, 'sort_order' => 0]); + $this->assertDatabaseHas('history_types', ['id' => $a->id, 'sort_order' => 1]); + $this->assertDatabaseHas('history_types', ['id' => $b->id, 'sort_order' => 2]); +}); + +it('forbids staff from reordering history types', function () { + $user = User::factory()->create(['role' => 'staff']); + $type = HistoryType::factory()->create(); + + $this->actingAs($user)->postJson('/api/history-types/reorder', [ + 'ids' => [$type->id], + ])->assertStatus(403); +}); + +it('forbids staff from updating a history type', function () { + $user = User::factory()->create(['role' => 'staff']); + $type = HistoryType::factory()->create(); + + $this->actingAs($user)->putJson("/api/history-types/{$type->id}", [ + 'name' => 'Neu', + ])->assertStatus(403); +}); + +it('lets an admin soft-delete a history type', function () { + $admin = User::factory()->create(['role' => 'admin']); + $type = HistoryType::factory()->create(); + + $this->actingAs($admin)->deleteJson("/api/history-types/{$type->id}")->assertStatus(204); + $this->assertSoftDeleted('history_types', ['id' => $type->id]); +}); + +it('forbids staff from deleting a history type', function () { + $user = User::factory()->create(['role' => 'staff']); + $type = HistoryType::factory()->create(); + + $this->actingAs($user)->deleteJson("/api/history-types/{$type->id}")->assertStatus(403); + $this->assertDatabaseHas('history_types', ['id' => $type->id, 'deleted_at' => null]); +}); + +it('requires a name when creating a history type', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->postJson('/api/history-types', [ + 'requires_datetime' => true, + ])->assertStatus(422)->assertJsonValidationErrors('name'); +}); diff --git a/backend/tests/Feature/Modules/Import/ClientImportTest.php b/backend/tests/Feature/Modules/Import/ClientImportTest.php new file mode 100644 index 0000000..6819bcd --- /dev/null +++ b/backend/tests/Feature/Modules/Import/ClientImportTest.php @@ -0,0 +1,222 @@ +seed(CustomFieldDefinitionSeeder::class); +}); + +/** + * @param array> $rows + */ +function importCsv(array $rows): string +{ + return implode("\n", array_map(static fn (array $r): string => implode(';', $r), $rows))."\n"; +} + +function importFile(string $contents, string $name = 'import.csv'): UploadedFile +{ + return UploadedFile::fake()->createWithContent($name, $contents); +} + +function postImport($test, UploadedFile $file, User $user) +{ + return $test->actingAs($user)->post('/api/clients/import', ['file' => $file], ['Accept' => 'application/json']); +} + +const IMPORT_HEADER = [ + 'Mandantennr.', 'Mandant', 'Anschrift 1', 'Anschrift 2', + 'Telefon', 'E-Mail', 'Ansprechpartner 1', 'Ueberschreiben', +]; + +it('creates a new client with mapped custom fields and writes no history', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $csv = importCsv([ + IMPORT_HEADER, + ['1001', 'Mustermann GmbH', 'Hauptstr. 1', '12345 Musterstadt', '0123456', 'a@b.de', 'Herr Müller', ''], + ]); + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.created', 1) + ->assertJsonPath('data.skipped', 0) + ->assertJsonPath('data.updated', 0); + + $client = Client::where('client_number', '1001')->firstOrFail(); + expect($client->name)->toBe('Mustermann GmbH'); + + $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); + $address = CustomFieldDefinition::where('key', 'anschrift')->firstOrFail(); + + $this->assertDatabaseHas('custom_field_values', [ + 'client_id' => $client->id, + 'custom_field_definition_id' => $tel->id, + 'value' => '0123456', + ]); + $this->assertDatabaseHas('custom_field_values', [ + 'client_id' => $client->id, + 'custom_field_definition_id' => $address->id, + 'value' => "Hauptstr. 1\n12345 Musterstadt", + ]); + + // New clients are imported silently. + $this->assertDatabaseCount('history_entries', 0); +}); + +it('skips an existing client when the overwrite flag is absent', function () { + $admin = User::factory()->create(['role' => 'admin']); + Client::factory()->create(['client_number' => '2002', 'name' => 'Bestand AG']); + + $csv = importCsv([ + IMPORT_HEADER, + ['2002', 'Geändert AG', '', '', '0999', 'neu@x.de', 'Herr Neu', ''], + ]); + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.skipped', 1) + ->assertJsonPath('data.created', 0) + ->assertJsonPath('data.updated', 0); + + expect(Client::where('client_number', '2002')->first()->name)->toBe('Bestand AG'); + $this->assertDatabaseCount('custom_field_values', 0); + $this->assertDatabaseCount('history_entries', 0); +}); + +it('overwrites custom fields of an existing client and audits each change', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(['client_number' => '3003', 'name' => 'Original Name']); + + $csv = importCsv([ + IMPORT_HEADER, + ['3003', 'Neuer Name', 'Weg 5', '', '0555', 'mail@z.de', 'Frau Schmidt', 'Ja'], + ]); + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.updated', 1) + ->assertJsonPath('data.created', 0) + ->assertJsonPath('data.skipped', 0); + + // Name / number are never touched on overwrite — custom fields only. + expect($client->fresh()->name)->toBe('Original Name'); + + // Four non-blank fields → four field-change audit rows. + $this->assertDatabaseCount('history_entries', 4); + + $importUser = User::where('name', 'Mandanten-Import')->firstOrFail(); + $this->assertDatabaseHas('history_entries', [ + 'client_id' => $client->id, + 'type' => 'field_change', + 'author_id' => $importUser->id, + ]); + + // Author renders as "Mandanten-Import" through the history API. + $this->actingAs($admin)->getJson("/api/clients/{$client->id}/history") + ->assertStatus(200) + ->assertJsonPath('data.0.author_name', 'Mandanten-Import') + ->assertJsonPath('data.0.type_name', 'Stammdatenänderung'); +}); + +it('preserves existing values when an overwrite row has blanks', function () { + $admin = User::factory()->create(['role' => 'admin']); + $client = Client::factory()->create(['client_number' => '4004', 'name' => 'Firma']); + $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); + CustomFieldValue::create([ + 'client_id' => $client->id, + 'custom_field_definition_id' => $tel->id, + 'value' => 'ALT-TELEFON', + 'author_id' => $admin->id, + ]); + + // Telefon column blank → must not wipe the existing value. + $csv = importCsv([ + IMPORT_HEADER, + ['4004', 'Firma', '', '', '', 'only@mail.de', '', 'ja'], + ]); + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.updated', 1); + + $this->assertDatabaseHas('custom_field_values', [ + 'client_id' => $client->id, + 'custom_field_definition_id' => $tel->id, + 'value' => 'ALT-TELEFON', + ]); +}); + +it('strips Excel leading apostrophes and decodes ISO-8859-1 umlauts', function () { + $admin = User::factory()->create(['role' => 'admin']); + + $csv = importCsv([ + IMPORT_HEADER, + ['5005', 'Bäcker Schröder', 'Straße 1', '', "'0301234", 'm@n.de', 'Herr Groß', ''], + ]); + $latin1 = mb_convert_encoding($csv, 'ISO-8859-1', 'UTF-8'); + + postImport($this, importFile($latin1), $admin) + ->assertStatus(200) + ->assertJsonPath('data.created', 1); + + $client = Client::where('client_number', '5005')->firstOrFail(); + expect($client->name)->toBe('Bäcker Schröder'); + + $tel = CustomFieldDefinition::where('key', 'telefonnummer')->firstOrFail(); + $this->assertDatabaseHas('custom_field_values', [ + 'client_id' => $client->id, + 'custom_field_definition_id' => $tel->id, + 'value' => '0301234', // leading apostrophe stripped + ]); +}); + +it('handles ragged rows shorter than the header', function () { + $admin = User::factory()->create(['role' => 'admin']); + + // Row stops after "Mandant" — every mapped column beyond it is missing. + $csv = "Mandantennr.;Mandant;Anschrift 1;Telefon;E-Mail;Ansprechpartner 1;Ueberschreiben\n6006;Kurz GmbH\n"; + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.created', 1); + + expect(Client::where('client_number', '6006')->first()->name)->toBe('Kurz GmbH'); +}); + +it('forbids non-admin users', function () { + $staff = User::factory()->create(['role' => 'staff']); + $csv = importCsv([IMPORT_HEADER, ['7007', 'X', '', '', '', '', '', '']]); + + postImport($this, importFile($csv), $staff)->assertStatus(403); + $this->assertDatabaseCount('clients', 0); +}); + +it('rejects a non-csv upload', function () { + $admin = User::factory()->create(['role' => 'admin']); + $file = UploadedFile::fake()->createWithContent('data.pdf', 'irrelevant'); + + postImport($this, $file, $admin)->assertStatus(422); +}); + +it('reports an error row for a missing client number', function () { + $admin = User::factory()->create(['role' => 'admin']); + $csv = importCsv([ + IMPORT_HEADER, + ['', 'Ohne Nummer', '', '', '', '', '', ''], + ]); + + postImport($this, importFile($csv), $admin) + ->assertStatus(200) + ->assertJsonPath('data.created', 0) + ->assertJsonCount(1, 'data.errors') + ->assertJsonPath('data.errors.0.row', 2); +});