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.
This commit is contained in:
Tim Stollberg 2026-07-18 16:34:18 +07:00
parent 570f17e8b8
commit f0bb80707a
67 changed files with 2199 additions and 58 deletions

View file

@ -14,7 +14,7 @@ ## Module pattern
## Commands ## Commands
``` ```
composer test # Pest, SQLite in-memory 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) php artisan migrate # run migrations (against Docker MySQL)
``` ```

View file

@ -3,17 +3,21 @@
namespace App\Modules\Client\Actions; namespace App\Modules\Client\Actions;
use App\Modules\Client\Models\Client; use App\Modules\Client\Models\Client;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection;
final class GetClientAction 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 public function find(int $id): Client
{ {
return Client::findOrFail($id); return Client::with('customFieldValues.author')->findOrFail($id);
} }
} }

View file

@ -42,4 +42,11 @@ public function update(UpdateClientRequest $request, Client $client, UpdateClien
$updated = $action->handle($client, ClientData::fromUpdateRequest($request)); $updated = $action->handle($client, ClientData::fromUpdateRequest($request));
return ClientResource::make($updated); return ClientResource::make($updated);
} }
public function destroy(Client $client): JsonResponse
{
$this->authorize('delete', $client);
$client->delete();
return response()->json(null, 204);
}
} }

View file

@ -2,15 +2,17 @@
namespace App\Modules\Client\Models; namespace App\Modules\Client\Models;
use App\Modules\CustomField\Models\CustomFieldValue;
use App\Modules\History\Models\HistoryEntry; use App\Modules\History\Models\HistoryEntry;
use Database\Factories\ClientFactory; use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Client extends Model class Client extends Model
{ {
use HasFactory; use HasFactory, SoftDeletes;
protected $fillable = ['client_number', 'name', 'notes']; protected $fillable = ['client_number', 'name', 'notes'];
@ -25,4 +27,9 @@ public function historyEntries(): HasMany
{ {
return $this->hasMany(HistoryEntry::class); return $this->hasMany(HistoryEntry::class);
} }
public function customFieldValues(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
} }

View file

@ -2,8 +2,10 @@
namespace App\Modules\Client\Resources; namespace App\Modules\Client\Resources;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
class ClientResource extends JsonResource class ClientResource extends JsonResource
{ {
@ -16,6 +18,50 @@ public function toArray(Request $request): array
'notes' => $this->notes ?? [], 'notes' => $this->notes ?? [],
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_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<int, array<string, mixed>>
*/
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();
}
} }

View file

@ -0,0 +1,37 @@
<?php
namespace App\Modules\CustomField\Actions;
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Support\Str;
final class CreateCustomFieldDefinitionAction
{
public function handle(CustomFieldDefinitionData $data): CustomFieldDefinition
{
return CustomFieldDefinition::create([
'label' => $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;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Modules\CustomField\Actions;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use Illuminate\Support\Facades\DB;
final class ReorderCustomFieldDefinitionsAction
{
/**
* Reassign sort_order to match the given ordered list of ids,
* normalizing positions to a contiguous 0..n-1 sequence.
*
* @param array<int, int> $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]);
}
});
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Modules\CustomField\Actions;
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldType;
final class UpdateCustomFieldDefinitionAction
{
public function handle(CustomFieldDefinition $definition, CustomFieldDefinitionData $data): CustomFieldDefinition
{
// sort_order is managed exclusively via the reorder endpoint.
$definition->update([
'label' => $data->label,
'type' => $data->type,
'options' => $data->type === CustomFieldType::Select->value ? array_values($data->options ?? []) : null,
'is_required' => $data->isRequired,
]);
return $definition;
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Modules\CustomField\Actions;
use App\Models\User;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldValue;
use App\Modules\History\Models\HistoryEntry;
use App\Modules\History\Models\HistoryEntryType;
use Illuminate\Support\Facades\DB;
final class UpdateCustomFieldValueAction
{
/**
* Upsert a client's value for one custom field. When the value actually
* changes, append a read-only entry to the Verlauf log (history_entries)
* so every base-data change is audited alongside the manual notes.
*/
public function handle(
Client $client,
CustomFieldDefinition $definition,
?string $value,
User $author
): CustomFieldValue {
$new = $value === null || $value === '' ? null : (string) $value;
return DB::transaction(function () use ($client, $definition, $new, $author) {
$record = CustomFieldValue::firstOrNew([
'client_id' => $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}";
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Modules\CustomField\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\CustomField\Actions\CreateCustomFieldDefinitionAction;
use App\Modules\CustomField\Actions\ReorderCustomFieldDefinitionsAction;
use App\Modules\CustomField\Actions\UpdateCustomFieldDefinitionAction;
use App\Modules\CustomField\DTOs\CustomFieldDefinitionData;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Requests\CreateCustomFieldDefinitionRequest;
use App\Modules\CustomField\Requests\ReorderCustomFieldDefinitionsRequest;
use App\Modules\CustomField\Requests\UpdateCustomFieldDefinitionRequest;
use App\Modules\CustomField\Resources\CustomFieldDefinitionResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CustomFieldDefinitionController extends Controller
{
public function index(): AnonymousResourceCollection
{
$this->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);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Modules\CustomField\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Actions\UpdateCustomFieldValueAction;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Requests\UpdateCustomFieldValueRequest;
use App\Modules\Client\Resources\ClientResource;
use App\Modules\Client\Actions\GetClientAction;
class CustomFieldValueController extends Controller
{
public function update(
UpdateCustomFieldValueRequest $request,
Client $client,
CustomFieldDefinition $customFieldDefinition,
UpdateCustomFieldValueAction $action,
GetClientAction $clients
): ClientResource {
$this->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));
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Modules\CustomField\DTOs;
use App\Modules\CustomField\Requests\CreateCustomFieldDefinitionRequest;
use App\Modules\CustomField\Requests\UpdateCustomFieldDefinitionRequest;
final class CustomFieldDefinitionData
{
public function __construct(
public readonly ?string $label,
public readonly ?string $type,
public readonly ?array $options,
public readonly bool $isRequired,
) {}
public static function fromCreateRequest(CreateCustomFieldDefinitionRequest $request): self
{
return new self(
label: $request->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),
);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Modules\CustomField\Models;
use Database\Factories\CustomFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CustomFieldDefinition extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['label', 'key', 'type', 'options', 'is_required', 'sort_order'];
protected $casts = [
'type' => CustomFieldType::class,
'options' => 'array',
'is_required' => 'boolean',
];
public function values(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
protected static function newFactory(): CustomFieldDefinitionFactory
{
return CustomFieldDefinitionFactory::new();
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Modules\CustomField\Models;
enum CustomFieldType: string
{
case Text = 'text';
case Textarea = 'textarea';
case Number = 'number';
case Date = 'date';
case Select = 'select';
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Modules\CustomField\Models;
use App\Models\User;
use App\Modules\Client\Models\Client;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CustomFieldValue extends Model
{
protected $fillable = ['client_id', 'custom_field_definition_id', 'value', 'author_id'];
public function client(): BelongsTo
{
return $this->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');
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\CustomField\Policies;
use App\Models\User;
use App\Modules\CustomField\Models\CustomFieldDefinition;
class CustomFieldDefinitionPolicy
{
// Every authenticated user needs the definitions to render the client page.
public function viewAny(User $user): bool { return true; }
// Configuration is admin-only.
public function create(User $user): bool { return $user->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(); }
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Modules\CustomField\Requests;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class CreateCustomFieldDefinitionRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'label' => ['required', 'string', 'max:255'],
'type' => ['required', new Enum(CustomFieldType::class)],
'options' => ['nullable', 'array', 'required_if:type,select'],
'options.*' => ['string', 'max:255'],
'is_required' => ['boolean'],
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\CustomField\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ReorderCustomFieldDefinitionsRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'ids' => ['required', 'array'],
'ids.*' => ['integer', 'exists:custom_field_definitions,id'],
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Modules\CustomField\Requests;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class UpdateCustomFieldDefinitionRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'label' => ['required', 'string', 'max:255'],
'type' => ['required', new Enum(CustomFieldType::class)],
'options' => ['nullable', 'array', 'required_if:type,select'],
'options.*' => ['string', 'max:255'],
'is_required' => ['boolean'],
];
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Modules\CustomField\Requests;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCustomFieldValueRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
/** @var CustomFieldDefinition $definition */
$definition = $this->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];
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\CustomField\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CustomFieldDefinitionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}

View file

@ -13,7 +13,8 @@ public function handle(Client $client, HistoryEntryData $data, User $author): Hi
{ {
return HistoryEntry::create([ return HistoryEntry::create([
'client_id' => $client->id, 'client_id' => $client->id,
'type' => $data->type, 'history_entry_type_id' => $data->historyEntryTypeId,
'occurred_at' => $data->occurredAt,
'body' => $data->body, 'body' => $data->body,
'author_id' => $author->id, 'author_id' => $author->id,
]); ]);

View file

@ -0,0 +1,19 @@
<?php
namespace App\Modules\History\Actions;
use App\Modules\History\DTOs\HistoryTypeData;
use App\Modules\History\Models\HistoryType;
final class CreateHistoryTypeAction
{
public function handle(HistoryTypeData $data): HistoryType
{
return HistoryType::create([
'name' => $data->name,
'requires_datetime' => $data->requiresDatetime,
// New entries always land at the end of the list.
'sort_order' => (HistoryType::max('sort_order') ?? -1) + 1,
]);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Modules\History\Actions;
use App\Modules\History\Models\HistoryType;
use Illuminate\Support\Facades\DB;
final class ReorderHistoryTypesAction
{
/**
* Reassign sort_order to match the given ordered list of ids,
* normalizing positions to a contiguous 0..n-1 sequence.
*
* @param array<int, int> $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]);
}
});
}
}

View file

@ -12,6 +12,7 @@ public function handle(HistoryEntry $entry, HistoryEntryData $data, User $editor
{ {
$entry->update([ $entry->update([
'body' => $data->body, 'body' => $data->body,
'occurred_at' => $data->occurredAt,
'updated_by' => $editor->id, 'updated_by' => $editor->id,
]); ]);

View file

@ -0,0 +1,20 @@
<?php
namespace App\Modules\History\Actions;
use App\Modules\History\DTOs\HistoryTypeData;
use App\Modules\History\Models\HistoryType;
final class UpdateHistoryTypeAction
{
public function handle(HistoryType $type, HistoryTypeData $data): HistoryType
{
// sort_order is managed exclusively via the reorder endpoint.
$type->update([
'name' => $data->name,
'requires_datetime' => $data->requiresDatetime,
]);
return $type;
}
}

View file

@ -20,7 +20,7 @@ public function index(Client $client): AnonymousResourceCollection
{ {
$this->authorize('viewAny', HistoryEntry::class); $this->authorize('viewAny', HistoryEntry::class);
$entries = $client->historyEntries() $entries = $client->historyEntries()
->with(['author', 'updatedBy']) ->with(['author', 'updatedBy', 'entryType'])
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(); ->get();
return HistoryEntryResource::collection($entries); return HistoryEntryResource::collection($entries);
@ -33,7 +33,7 @@ public function store(
): JsonResponse { ): JsonResponse {
$this->authorize('create', HistoryEntry::class); $this->authorize('create', HistoryEntry::class);
$entry = $action->handle($client, HistoryEntryData::fromCreateRequest($request), $request->user()); $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( public function update(
@ -43,7 +43,7 @@ public function update(
): HistoryEntryResource { ): HistoryEntryResource {
$this->authorize('update', $historyEntry); $this->authorize('update', $historyEntry);
$updated = $action->handle($historyEntry, HistoryEntryData::fromUpdateRequest($request), $request->user()); $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 public function destroy(HistoryEntry $historyEntry): JsonResponse

View file

@ -0,0 +1,62 @@
<?php
namespace App\Modules\History\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\History\Actions\CreateHistoryTypeAction;
use App\Modules\History\Actions\ReorderHistoryTypesAction;
use App\Modules\History\Actions\UpdateHistoryTypeAction;
use App\Modules\History\DTOs\HistoryTypeData;
use App\Modules\History\Models\HistoryType;
use App\Modules\History\Requests\CreateHistoryTypeRequest;
use App\Modules\History\Requests\ReorderHistoryTypesRequest;
use App\Modules\History\Requests\UpdateHistoryTypeRequest;
use App\Modules\History\Resources\HistoryTypeResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class HistoryTypeController extends Controller
{
public function index(): AnonymousResourceCollection
{
$this->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);
}
}

View file

@ -8,23 +8,26 @@
final class HistoryEntryData final class HistoryEntryData
{ {
public function __construct( public function __construct(
public readonly ?string $type, public readonly ?int $historyEntryTypeId,
public readonly ?string $body, public readonly ?string $body,
public readonly ?string $occurredAt,
) {} ) {}
public static function fromCreateRequest(CreateHistoryEntryRequest $request): self public static function fromCreateRequest(CreateHistoryEntryRequest $request): self
{ {
return new self( return new self(
type: $request->validated('type'), historyEntryTypeId: (int) $request->validated('history_entry_type_id'),
body: $request->validated('body'), body: $request->validated('body'),
occurredAt: $request->validated('occurred_at'),
); );
} }
public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self
{ {
return new self( return new self(
type: null, historyEntryTypeId: null,
body: $request->validated('body'), body: $request->validated('body'),
occurredAt: $request->validated('occurred_at'),
); );
} }
} }

View file

@ -0,0 +1,30 @@
<?php
namespace App\Modules\History\DTOs;
use App\Modules\History\Requests\CreateHistoryTypeRequest;
use App\Modules\History\Requests\UpdateHistoryTypeRequest;
final class HistoryTypeData
{
public function __construct(
public readonly ?string $name,
public readonly bool $requiresDatetime,
) {}
public static function fromCreateRequest(CreateHistoryTypeRequest $request): self
{
return new self(
name: $request->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),
);
}
}

View file

@ -14,15 +14,37 @@ class HistoryEntry extends Model
{ {
use HasFactory, SoftDeletes; 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 public function client(): BelongsTo
{ {
return $this->belongsTo(Client::class); return $this->belongsTo(Client::class);
} }
public function entryType(): BelongsTo
{
return $this->belongsTo(HistoryType::class, 'history_entry_type_id');
}
public function author(): BelongsTo public function author(): BelongsTo
{ {
return $this->belongsTo(User::class, 'author_id'); return $this->belongsTo(User::class, 'author_id');

View file

@ -2,12 +2,13 @@
namespace App\Modules\History\Models; 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 enum HistoryEntryType: string
{ {
case PhoneCall = 'phone_call'; case FieldChange = 'field_change';
case Consultation = 'consultation';
case Recommendation = 'recommendation';
case Instruction = 'instruction';
case Remark = 'remark';
case Other = 'other';
} }

View file

@ -0,0 +1,24 @@
<?php
namespace App\Modules\History\Models;
use Database\Factories\HistoryTypeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class HistoryType extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'requires_datetime', 'sort_order'];
protected $casts = [
'requires_datetime' => 'boolean',
];
protected static function newFactory(): HistoryTypeFactory
{
return HistoryTypeFactory::new();
}
}

View file

@ -9,6 +9,17 @@ class HistoryEntryPolicy
{ {
public function viewAny(User $user): bool { return true; } public function viewAny(User $user): bool { return true; }
public function create(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();
}
} }

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\History\Policies;
use App\Models\User;
use App\Modules\History\Models\HistoryType;
class HistoryTypePolicy
{
// Every authenticated user needs the types to render the Verlauf form.
public function viewAny(User $user): bool { return true; }
// Configuration is admin-only.
public function create(User $user): bool { return $user->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(); }
}

View file

@ -2,9 +2,9 @@
namespace App\Modules\History\Requests; 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\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
class CreateHistoryEntryRequest extends FormRequest class CreateHistoryEntryRequest extends FormRequest
{ {
@ -13,8 +13,29 @@ public function authorize(): bool { return true; }
public function rules(): array public function rules(): array
{ {
return [ return [
'type' => ['required', new Enum(HistoryEntryType::class)], 'history_entry_type_id' => ['required', 'integer', 'exists:history_types,id'],
'body' => ['required', 'string', 'max:65535'], '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.'
);
}
});
}
} }

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\History\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateHistoryTypeRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'requires_datetime' => ['boolean'],
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\History\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ReorderHistoryTypesRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'ids' => ['required', 'array'],
'ids.*' => ['integer', 'exists:history_types,id'],
];
}
}

View file

@ -2,6 +2,8 @@
namespace App\Modules\History\Requests; namespace App\Modules\History\Requests;
use App\Modules\History\Models\HistoryEntry;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class UpdateHistoryEntryRequest extends FormRequest class UpdateHistoryEntryRequest extends FormRequest
@ -12,6 +14,24 @@ public function rules(): array
{ {
return [ return [
'body' => ['required', 'string', 'max:65535'], '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.'
);
}
});
}
} }

View file

@ -0,0 +1,18 @@
<?php
namespace App\Modules\History\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHistoryTypeRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'requires_datetime' => ['boolean'],
];
}
}

View file

@ -12,8 +12,17 @@ public function toArray(Request $request): array
return [ return [
'id' => $this->id, 'id' => $this->id,
'client_id' => $this->client_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, 'body' => $this->body,
'custom_field_definition_id' => $this->custom_field_definition_id,
'author_id' => $this->author_id, 'author_id' => $this->author_id,
'author_name' => $this->author?->name, 'author_name' => $this->author?->name,
'updated_by' => $this->updated_by, 'updated_by' => $this->updated_by,

View file

@ -0,0 +1,19 @@
<?php
namespace App\Modules\History\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class HistoryTypeResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'requires_datetime' => $this->requires_datetime,
'sort_order' => $this->sort_order,
];
}
}

View file

@ -0,0 +1,225 @@
<?php
namespace App\Modules\Import\Actions;
use App\Models\User;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Actions\UpdateCustomFieldValueAction;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldValue;
use App\Modules\Import\DTOs\ImportResult;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use League\Csv\Reader;
use RuntimeException;
use Throwable;
/**
* Imports clients from the legacy semicolon-delimited CSV export.
*
* Per row, keyed on "Mandantennr." (client_number):
* - new number create client + populate custom fields, silently (no history)
* - existing number skip, unless "Ueberschreiben" = "ja" (case-insensitive),
* in which case the mapped custom fields are updated (blanks
* skipped) and each change is audited via the shared
* UpdateCustomFieldValueAction, authored by a system user.
*/
final class ImportClientsAction
{
/** Custom field definition keys the import maps onto. */
private const FIELD_KEYS = ['telefonnummer', 'e_mail', 'ansprechpartner', 'anschrift'];
private const OVERWRITE_TRIGGER = 'ja';
private const IMPORT_USER_NAME = 'Mandanten-Import';
private const IMPORT_USER_EMAIL = 'import@system.local';
public function __construct(
private readonly UpdateCustomFieldValueAction $updateValue,
) {}
public function handle(string $path): ImportResult
{
/** @var \Illuminate\Support\Collection<string, CustomFieldDefinition> $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<string, int> $index header name → column offset */
$index = array_flip($header);
$created = 0;
$updated = 0;
$skipped = 0;
/** @var array<int, array{row: int, client_number: ?string, message: string}> $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<int, array<int, string>>
*/
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 18 are joined
* with newlines, blank parts dropped.
*
* @param array<int, string> $row
* @param array<string, int> $index
* @return array<string, ?string>
*/
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<int, string> $row
* @param array<string, int> $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<int, string> $row
* @param array<string, int> $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',
],
);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Modules\Import\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Import\Actions\ImportClientsAction;
use App\Modules\Import\Requests\ClientImportRequest;
use App\Modules\Import\Resources\ImportResultResource;
class ClientImportController extends Controller
{
public function store(ClientImportRequest $request, ImportClientsAction $action): ImportResultResource
{
// Import is admin-only. The frontend route middleware is not a security
// boundary, and ClientPolicy::create() grants everyone create access.
abort_unless((bool) $request->user()?->isAdmin(), 403);
$result = $action->handle((string) $request->file('file')->getRealPath());
return new ImportResultResource($result);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Modules\Import\DTOs;
final class ImportResult
{
/**
* @param array<int, array{row: int, client_number: ?string, message: string}> $errors
*/
public function __construct(
public readonly int $total,
public readonly int $created,
public readonly int $updated,
public readonly int $skipped,
public readonly array $errors,
) {}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Modules\Import\Requests;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
class ClientImportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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.');
}
},
],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Modules\Import\Resources;
use App\Modules\Import\DTOs\ImportResult;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property ImportResult $resource
*/
class ImportResultResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View file

@ -11,15 +11,25 @@ final class SearchClientsAction
public function handle(string $query): Collection public function handle(string $query): Collection
{ {
if (DB::connection()->getDriverName() === 'sqlite') { if (DB::connection()->getDriverName() === 'sqlite') {
return Client::where('name', 'like', "%{$query}%") return Client::query()
->orWhere('client_number', 'like', "%{$query}%") ->withCount('historyEntries')
->withMax('historyEntries', 'created_at')
->where(function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%")
->orWhere('client_number', 'like', "%{$query}%");
})
->limit(50) ->limit(50)
->get(); ->get();
} }
return Client::whereRaw( return Client::query()
->withCount('historyEntries')
->withMax('historyEntries', 'created_at')
->whereRaw(
'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)', 'MATCH(client_number, name) AGAINST(? IN BOOLEAN MODE)',
[$query . '*'] [$query.'*']
)->limit(50)->get(); )
->limit(50)
->get();
} }
} }

View file

@ -3,6 +3,7 @@
namespace App\Modules\User\Requests; namespace App\Modules\User\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class CreateUserRequest extends FormRequest class CreateUserRequest extends FormRequest
@ -12,6 +13,13 @@ public function authorize(): bool
return true; return true;
} }
protected function prepareForValidation(): void
{
if ($this->has('email')) {
$this->merge(['email' => Str::lower($this->input('email'))]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [

View file

@ -3,6 +3,7 @@
namespace App\Modules\User\Requests; namespace App\Modules\User\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest class UpdateUserRequest extends FormRequest
@ -12,6 +13,13 @@ public function authorize(): bool
return true; return true;
} }
protected function prepareForValidation(): void
{
if ($this->has('email')) {
$this->merge(['email' => Str::lower($this->input('email'))]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [
@ -19,7 +27,7 @@ public function rules(): array
'email' => [ 'email' => [
'required', 'required',
'email', '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'], 'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'role' => ['required', 'string', 'in:admin,staff'], 'role' => ['required', 'string', 'in:admin,staff'],

View file

@ -6,8 +6,12 @@
use App\Models\User; use App\Models\User;
use App\Modules\Client\Models\Client; use App\Modules\Client\Models\Client;
use App\Modules\Client\Policies\ClientPolicy; 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\HistoryEntry;
use App\Modules\History\Models\HistoryType;
use App\Modules\History\Policies\HistoryEntryPolicy; use App\Modules\History\Policies\HistoryEntryPolicy;
use App\Modules\History\Policies\HistoryTypePolicy;
use App\Modules\User\Policies\UserPolicy; use App\Modules\User\Policies\UserPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -21,7 +25,9 @@ class AuthServiceProvider extends ServiceProvider
protected $policies = [ protected $policies = [
User::class => UserPolicy::class, User::class => UserPolicy::class,
Client::class => ClientPolicy::class, Client::class => ClientPolicy::class,
CustomFieldDefinition::class => CustomFieldDefinitionPolicy::class,
HistoryEntry::class => HistoryEntryPolicy::class, HistoryEntry::class => HistoryEntryPolicy::class,
HistoryType::class => HistoryTypePolicy::class,
]; ];
/** /**

View file

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CustomFieldDefinitionFactory extends Factory
{
protected $model = CustomFieldDefinition::class;
public function definition(): array
{
$label = $this->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,
]);
}
}

View file

@ -5,7 +5,7 @@
use App\Models\User; use App\Models\User;
use App\Modules\Client\Models\Client; use App\Modules\Client\Models\Client;
use App\Modules\History\Models\HistoryEntry; use App\Modules\History\Models\HistoryEntry;
use App\Modules\History\Models\HistoryEntryType; use App\Modules\History\Models\HistoryType;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
class HistoryEntryFactory extends Factory class HistoryEntryFactory extends Factory
@ -16,7 +16,8 @@ public function definition(): array
{ {
return [ return [
'client_id' => Client::factory(), 'client_id' => Client::factory(),
'type' => $this->faker->randomElement(HistoryEntryType::cases())->value, 'history_entry_type_id' => HistoryType::factory(),
'occurred_at' => null,
'body' => $this->faker->paragraph(), 'body' => $this->faker->paragraph(),
'author_id' => User::factory(), 'author_id' => User::factory(),
'updated_by' => null, 'updated_by' => null,

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Modules\History\Models\HistoryType;
use Illuminate\Database\Eloquent\Factories\Factory;
class HistoryTypeFactory extends Factory
{
protected $model = HistoryType::class;
public function definition(): array
{
return [
'name' => $this->faker->unique()->word(),
'requires_datetime' => false,
'sort_order' => 0,
];
}
public function requiresDatetime(): static
{
return $this->state(fn () => ['requires_datetime' => true]);
}
}

View file

@ -14,11 +14,22 @@ public function up(): void
Schema::create('history_entries', function (Blueprint $table) { Schema::create('history_entries', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('client_id')->constrained()->cascadeOnDelete(); $table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->enum('type', [ // System kind flag: only set for auto-generated audit entries
'phone_call', 'consultation', 'recommendation', // ('field_change'). User entries leave this null and reference a
'instruction', 'remark', 'other', // 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'); $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('author_id')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->softDeletes(); $table->softDeletes();

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('custom_field_definitions', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('custom_field_values', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('history_types', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('clients', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View file

@ -0,0 +1,39 @@
<?php
namespace Database\Seeders;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldType;
use Illuminate\Database\Seeder;
/**
* Registers the base-data custom fields the Mandanten-Import maps onto.
*
* Idempotent (firstOrCreate by key) so it is safe to run against a real
* database the importer resolves these definitions by key and fails fast
* if they are absent.
*/
class CustomFieldDefinitionSeeder extends Seeder
{
public function run(): void
{
$fields = [
['key' => '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'],
],
);
}
}
}

View file

@ -4,7 +4,10 @@
use App\Models\User; use App\Models\User;
use App\Modules\Client\Models\Client; 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\HistoryEntry;
use App\Modules\History\Models\HistoryType;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@ -18,13 +21,34 @@ public function run(): void
'role' => 'admin', '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 = Client::factory(10)->create();
$clients->each(function (Client $client) use ($admin) { $clients->each(function (Client $client) use ($admin, $historyTypes) {
HistoryEntry::factory(3)->create([ $historyTypes->random(3)->each(function (HistoryType $type) use ($client, $admin) {
HistoryEntry::factory()->create([
'client_id' => $client->id, 'client_id' => $client->id,
'author_id' => $admin->id, 'author_id' => $admin->id,
'history_entry_type_id' => $type->id,
'occurred_at' => $type->requires_datetime ? now()->subDays(rand(1, 30)) : null,
]); ]);
}); });
});
} }
} }

View file

@ -22,7 +22,10 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value=":memory:" force="true"/>
<!-- Container sets DB_DATABASE in the env, which lands in $_SERVER; Laravel's env()
reads $_SERVER before $_ENV, so force it here too to keep tests on :memory:. -->
<server name="DB_DATABASE" value=":memory:" force="true"/>
<env name="APP_KEY" value="base64:dGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q="/> <env name="APP_KEY" value="base64:dGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q="/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>

View file

@ -65,3 +65,35 @@
it('unauthenticated request returns 401', function () { it('unauthenticated request returns 401', function () {
$this->getJson('/api/clients')->assertStatus(401); $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');
});

View file

@ -0,0 +1,158 @@
<?php
use App\Models\User;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets any authenticated user list field definitions', function () {
$user = User::factory()->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);
});

View file

@ -4,6 +4,7 @@
use App\Modules\Client\Models\Client; use App\Modules\Client\Models\Client;
use App\Modules\History\Models\HistoryEntry; use App\Modules\History\Models\HistoryEntry;
use App\Modules\History\Models\HistoryEntryType; use App\Modules\History\Models\HistoryEntryType;
use App\Modules\History\Models\HistoryType;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -21,24 +22,65 @@
it('staff can add a history entry', function () { it('staff can add a history entry', function () {
$user = User::factory()->create(['role' => 'staff']); $user = User::factory()->create(['role' => 'staff']);
$client = Client::factory()->create(); $client = Client::factory()->create();
$type = HistoryType::factory()->create(['name' => 'Bemerkung']);
$response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ $response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [
'type' => 'phone_call', 'history_entry_type_id' => $type->id,
'body' => 'Rückruf vereinbart.', 'body' => 'Rückruf vereinbart.',
]); ]);
$response->assertStatus(201) $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.body', 'Rückruf vereinbart.')
->assertJsonPath('data.author_id', $user->id); ->assertJsonPath('data.author_id', $user->id);
$this->assertDatabaseHas('history_entries', [ $this->assertDatabaseHas('history_entries', [
'client_id' => $client->id, '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']); $user = User::factory()->create(['role' => 'staff']);
$client = Client::factory()->create(); $client = Client::factory()->create();
$entry = HistoryEntry::factory()->create([ $entry = HistoryEntry::factory()->create([
@ -55,23 +97,66 @@
$this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'updated_by' => $user->id]); $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']); $user = User::factory()->create(['role' => 'staff']);
$client = Client::factory()->create(); $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); $response->assertStatus(204);
$this->assertSoftDeleted('history_entries', ['id' => $entry->id]); $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']); $user = User::factory()->create(['role' => 'staff']);
$client = Client::factory()->create(); $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", [ $this->actingAs($user)->deleteJson("/api/history/{$entry->id}")->assertStatus(403);
'type' => 'invalid_type', $this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'deleted_at' => null]);
'body' => 'test',
])->assertStatus(422);
}); });

View file

@ -0,0 +1,122 @@
<?php
use App\Models\User;
use App\Modules\History\Models\HistoryType;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets any authenticated user list history types', function () {
$user = User::factory()->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');
});

View file

@ -0,0 +1,222 @@
<?php
use App\Models\User;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldValue;
use Database\Seeders\CustomFieldDefinitionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(CustomFieldDefinitionSeeder::class);
});
/**
* @param array<int, array<int, string>> $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);
});