diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ea0665b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,59 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +LOG_CHANNEL=stack +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=laravel +DB_USERNAME=root +DB_PASSWORD= + +BROADCAST_DRIVER=log +CACHE_DRIVER=file +FILESYSTEM_DISK=local +QUEUE_CONNECTION=sync +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +MEMCACHED_HOST=127.0.0.1 + +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_HOST=mailpit +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +PUSHER_HOST= +PUSHER_PORT=443 +PUSHER_SCHEME=https +PUSHER_APP_CLUSTER=mt1 + +VITE_APP_NAME="${APP_NAME}" +VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +VITE_PUSHER_HOST="${PUSHER_HOST}" +VITE_PUSHER_PORT="${PUSHER_PORT}" +VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" +VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..7fe978f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,19 @@ +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.env.production +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +/.fleet +/.idea +/.vscode diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1a4c26b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,66 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com/)** +- **[Tighten Co.](https://tighten.co)** +- **[WebReinvent](https://webreinvent.com/)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel/)** +- **[Cyber-Duck](https://cyber-duck.co.uk)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Jump24](https://jump24.co.uk)** +- **[Redberry](https://redberry.international/laravel/)** +- **[Active Logic](https://activelogic.com)** +- **[byte5](https://byte5.de)** +- **[OP.GG](https://op.gg)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php new file mode 100644 index 0000000..e6b9960 --- /dev/null +++ b/backend/app/Console/Kernel.php @@ -0,0 +1,27 @@ +command('inspire')->hourly(); + } + + /** + * Register the commands for the application. + */ + protected function commands(): void + { + $this->load(__DIR__.'/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/backend/app/Exceptions/Handler.php b/backend/app/Exceptions/Handler.php new file mode 100644 index 0000000..56af264 --- /dev/null +++ b/backend/app/Exceptions/Handler.php @@ -0,0 +1,30 @@ + + */ + protected $dontFlash = [ + 'current_password', + 'password', + 'password_confirmation', + ]; + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + // + }); + } +} diff --git a/backend/app/Filament/Resources/UserResource.php b/backend/app/Filament/Resources/UserResource.php new file mode 100644 index 0000000..8366b99 --- /dev/null +++ b/backend/app/Filament/Resources/UserResource.php @@ -0,0 +1,55 @@ +schema([ + TextInput::make('name')->required()->maxLength(255), + TextInput::make('email')->email()->required()->maxLength(255), + TextInput::make('password')->password()->dehydrateStateUsing(fn ($state) => bcrypt($state)) + ->required(fn (string $context) => $context === 'create'), + Select::make('role') + ->options(['admin' => 'Admin', 'staff' => 'Mitarbeiter']) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table->columns([ + TextColumn::make('name')->searchable(), + TextColumn::make('email')->searchable(), + TextColumn::make('role')->badge() + ->color(fn (string $state) => $state === 'admin' ? 'danger' : 'gray'), + TextColumn::make('created_at')->dateTime()->sortable(), + ])->actions([EditAction::make(), DeleteAction::make()]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUsers::route('/'), + 'create' => Pages\CreateUser::route('/create'), + 'edit' => Pages\EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php b/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php new file mode 100644 index 0000000..78a3894 --- /dev/null +++ b/backend/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,11 @@ +expectsJson() ? null : route('login'); + } +} diff --git a/backend/app/Http/Middleware/EncryptCookies.php b/backend/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..867695b --- /dev/null +++ b/backend/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 0000000..74cbd9a --- /dev/null +++ b/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/backend/app/Http/Middleware/RedirectIfAuthenticated.php b/backend/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..afc78c4 --- /dev/null +++ b/backend/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,30 @@ +check()) { + return redirect(RouteServiceProvider::HOME); + } + } + + return $next($request); + } +} diff --git a/backend/app/Http/Middleware/TrimStrings.php b/backend/app/Http/Middleware/TrimStrings.php new file mode 100644 index 0000000..88cadca --- /dev/null +++ b/backend/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,19 @@ + + */ + protected $except = [ + 'current_password', + 'password', + 'password_confirmation', + ]; +} diff --git a/backend/app/Http/Middleware/TrustHosts.php b/backend/app/Http/Middleware/TrustHosts.php new file mode 100644 index 0000000..c9c58bd --- /dev/null +++ b/backend/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,20 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/backend/app/Http/Middleware/TrustProxies.php b/backend/app/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000..3391630 --- /dev/null +++ b/backend/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,28 @@ +|string|null + */ + protected $proxies; + + /** + * The headers that should be used to detect proxies. + * + * @var int + */ + protected $headers = + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/backend/app/Http/Middleware/ValidateSignature.php b/backend/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000..093bf64 --- /dev/null +++ b/backend/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_campaign', + // 'utm_content', + // 'utm_medium', + // 'utm_source', + // 'utm_term', + ]; +} diff --git a/backend/app/Http/Middleware/VerifyCsrfToken.php b/backend/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..9e86521 --- /dev/null +++ b/backend/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php new file mode 100644 index 0000000..df27263 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,27 @@ + 'datetime', + 'password' => 'hashed', + ]; + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } +} diff --git a/backend/app/Modules/Client/Actions/CreateClientAction.php b/backend/app/Modules/Client/Actions/CreateClientAction.php new file mode 100644 index 0000000..29971bf --- /dev/null +++ b/backend/app/Modules/Client/Actions/CreateClientAction.php @@ -0,0 +1,18 @@ + $data->clientNumber, + 'name' => $data->name, + 'notes' => $data->notes, + ]); + } +} diff --git a/backend/app/Modules/Client/Actions/GetClientAction.php b/backend/app/Modules/Client/Actions/GetClientAction.php new file mode 100644 index 0000000..db267e2 --- /dev/null +++ b/backend/app/Modules/Client/Actions/GetClientAction.php @@ -0,0 +1,19 @@ +paginate(50); + } + + public function find(int $id): Client + { + return Client::findOrFail($id); + } +} diff --git a/backend/app/Modules/Client/Actions/UpdateClientAction.php b/backend/app/Modules/Client/Actions/UpdateClientAction.php new file mode 100644 index 0000000..b002e39 --- /dev/null +++ b/backend/app/Modules/Client/Actions/UpdateClientAction.php @@ -0,0 +1,19 @@ +update(array_filter([ + 'name' => $data->name, + 'notes' => $data->notes, + ], fn ($v) => $v !== null)); + + return $client->fresh(); + } +} diff --git a/backend/app/Modules/Client/Controllers/ClientController.php b/backend/app/Modules/Client/Controllers/ClientController.php new file mode 100644 index 0000000..4d8a703 --- /dev/null +++ b/backend/app/Modules/Client/Controllers/ClientController.php @@ -0,0 +1,45 @@ +authorize('viewAny', Client::class); + return ClientResource::collection($action->list()); + } + + public function store(CreateClientRequest $request, CreateClientAction $action): JsonResponse + { + $this->authorize('create', Client::class); + $client = $action->handle(ClientData::fromCreateRequest($request)); + return ClientResource::make($client)->response()->setStatusCode(201); + } + + public function show(int $id, GetClientAction $action): ClientResource + { + $client = $action->find($id); + $this->authorize('view', $client); + return ClientResource::make($client); + } + + public function update(UpdateClientRequest $request, Client $client, UpdateClientAction $action): ClientResource + { + $this->authorize('update', $client); + $updated = $action->handle($client, ClientData::fromUpdateRequest($request)); + return ClientResource::make($updated); + } +} diff --git a/backend/app/Modules/Client/DTOs/ClientData.php b/backend/app/Modules/Client/DTOs/ClientData.php new file mode 100644 index 0000000..efb88a0 --- /dev/null +++ b/backend/app/Modules/Client/DTOs/ClientData.php @@ -0,0 +1,33 @@ +validated('client_number'), + name: $request->validated('name'), + notes: $request->validated('notes'), + ); + } + + public static function fromUpdateRequest(UpdateClientRequest $request): self + { + return new self( + clientNumber: null, + name: $request->validated('name'), + notes: $request->validated('notes'), + ); + } +} diff --git a/backend/app/Modules/Client/Models/Client.php b/backend/app/Modules/Client/Models/Client.php new file mode 100644 index 0000000..08116b7 --- /dev/null +++ b/backend/app/Modules/Client/Models/Client.php @@ -0,0 +1,28 @@ + 'array']; + + protected static function newFactory(): ClientFactory + { + return ClientFactory::new(); + } + + public function historyEntries(): HasMany + { + return $this->hasMany(HistoryEntry::class); + } +} diff --git a/backend/app/Modules/Client/Policies/ClientPolicy.php b/backend/app/Modules/Client/Policies/ClientPolicy.php new file mode 100644 index 0000000..ec31235 --- /dev/null +++ b/backend/app/Modules/Client/Policies/ClientPolicy.php @@ -0,0 +1,15 @@ +isAdmin(); } +} diff --git a/backend/app/Modules/Client/Requests/CreateClientRequest.php b/backend/app/Modules/Client/Requests/CreateClientRequest.php new file mode 100644 index 0000000..6abaa1b --- /dev/null +++ b/backend/app/Modules/Client/Requests/CreateClientRequest.php @@ -0,0 +1,19 @@ + ['required', 'string', 'max:50', 'unique:clients,client_number'], + 'name' => ['required', 'string', 'max:255'], + 'notes' => ['nullable', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Client/Requests/UpdateClientRequest.php b/backend/app/Modules/Client/Requests/UpdateClientRequest.php new file mode 100644 index 0000000..7d4768b --- /dev/null +++ b/backend/app/Modules/Client/Requests/UpdateClientRequest.php @@ -0,0 +1,18 @@ + ['sometimes', 'required', 'string', 'max:255'], + 'notes' => ['nullable', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Client/Resources/ClientResource.php b/backend/app/Modules/Client/Resources/ClientResource.php new file mode 100644 index 0000000..e790f75 --- /dev/null +++ b/backend/app/Modules/Client/Resources/ClientResource.php @@ -0,0 +1,21 @@ + $this->id, + 'client_number' => $this->client_number, + 'name' => $this->name, + 'notes' => $this->notes ?? [], + 'created_at' => $this->created_at->toISOString(), + 'updated_at' => $this->updated_at->toISOString(), + ]; + } +} diff --git a/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php b/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php new file mode 100644 index 0000000..2e73df4 --- /dev/null +++ b/backend/app/Modules/History/Actions/CreateHistoryEntryAction.php @@ -0,0 +1,21 @@ + $client->id, + 'type' => $data->type, + 'body' => $data->body, + 'author_id' => $author->id, + ]); + } +} diff --git a/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php b/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php new file mode 100644 index 0000000..4da7ded --- /dev/null +++ b/backend/app/Modules/History/Actions/UpdateHistoryEntryAction.php @@ -0,0 +1,20 @@ +update([ + 'body' => $data->body, + 'updated_by' => $editor->id, + ]); + + return $entry->fresh(); + } +} diff --git a/backend/app/Modules/History/Controllers/HistoryController.php b/backend/app/Modules/History/Controllers/HistoryController.php new file mode 100644 index 0000000..dbb7559 --- /dev/null +++ b/backend/app/Modules/History/Controllers/HistoryController.php @@ -0,0 +1,55 @@ +authorize('viewAny', HistoryEntry::class); + $entries = $client->historyEntries() + ->with(['author', 'updatedBy']) + ->orderByDesc('created_at') + ->get(); + return HistoryEntryResource::collection($entries); + } + + public function store( + CreateHistoryEntryRequest $request, + Client $client, + CreateHistoryEntryAction $action + ): JsonResponse { + $this->authorize('create', HistoryEntry::class); + $entry = $action->handle($client, HistoryEntryData::fromCreateRequest($request), $request->user()); + return HistoryEntryResource::make($entry->load(['author']))->response()->setStatusCode(201); + } + + public function update( + UpdateHistoryEntryRequest $request, + HistoryEntry $historyEntry, + UpdateHistoryEntryAction $action + ): HistoryEntryResource { + $this->authorize('update', $historyEntry); + $updated = $action->handle($historyEntry, HistoryEntryData::fromUpdateRequest($request), $request->user()); + return HistoryEntryResource::make($updated->load(['author', 'updatedBy'])); + } + + public function destroy(HistoryEntry $historyEntry): JsonResponse + { + $this->authorize('delete', $historyEntry); + $historyEntry->delete(); + return response()->json(null, 204); + } +} diff --git a/backend/app/Modules/History/DTOs/HistoryEntryData.php b/backend/app/Modules/History/DTOs/HistoryEntryData.php new file mode 100644 index 0000000..be2a249 --- /dev/null +++ b/backend/app/Modules/History/DTOs/HistoryEntryData.php @@ -0,0 +1,30 @@ +validated('type'), + body: $request->validated('body'), + ); + } + + public static function fromUpdateRequest(UpdateHistoryEntryRequest $request): self + { + return new self( + type: null, + body: $request->validated('body'), + ); + } +} diff --git a/backend/app/Modules/History/Models/HistoryEntry.php b/backend/app/Modules/History/Models/HistoryEntry.php new file mode 100644 index 0000000..ac8808f --- /dev/null +++ b/backend/app/Modules/History/Models/HistoryEntry.php @@ -0,0 +1,40 @@ + HistoryEntryType::class]; + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + protected static function newFactory(): HistoryEntryFactory + { + return HistoryEntryFactory::new(); + } +} diff --git a/backend/app/Modules/History/Models/HistoryEntryType.php b/backend/app/Modules/History/Models/HistoryEntryType.php new file mode 100644 index 0000000..4d34534 --- /dev/null +++ b/backend/app/Modules/History/Models/HistoryEntryType.php @@ -0,0 +1,13 @@ + ['required', new Enum(HistoryEntryType::class)], + 'body' => ['required', 'string', 'max:65535'], + ]; + } +} diff --git a/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php b/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php new file mode 100644 index 0000000..aa1a3d4 --- /dev/null +++ b/backend/app/Modules/History/Requests/UpdateHistoryEntryRequest.php @@ -0,0 +1,17 @@ + ['required', 'string', 'max:65535'], + ]; + } +} diff --git a/backend/app/Modules/History/Resources/HistoryEntryResource.php b/backend/app/Modules/History/Resources/HistoryEntryResource.php new file mode 100644 index 0000000..7476ec2 --- /dev/null +++ b/backend/app/Modules/History/Resources/HistoryEntryResource.php @@ -0,0 +1,25 @@ + $this->id, + 'client_id' => $this->client_id, + 'type' => $this->type->value, + 'body' => $this->body, + 'author_id' => $this->author_id, + 'author_name' => $this->author?->name, + 'updated_by' => $this->updated_by, + 'updated_by_name' => $this->updatedBy?->name, + 'created_at' => $this->created_at->toISOString(), + 'updated_at' => $this->updated_at->toISOString(), + ]; + } +} diff --git a/backend/app/Modules/Search/Actions/SearchClientsAction.php b/backend/app/Modules/Search/Actions/SearchClientsAction.php new file mode 100644 index 0000000..130e24a --- /dev/null +++ b/backend/app/Modules/Search/Actions/SearchClientsAction.php @@ -0,0 +1,25 @@ +getDriverName() === 'sqlite') { + return Client::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(); + } +} diff --git a/backend/app/Modules/Search/Controllers/SearchController.php b/backend/app/Modules/Search/Controllers/SearchController.php new file mode 100644 index 0000000..06dc28a --- /dev/null +++ b/backend/app/Modules/Search/Controllers/SearchController.php @@ -0,0 +1,21 @@ +validate(['q' => ['required', 'string', 'min:1', 'max:100']]); + + $clients = $action->handle($validated['q']); + + return ClientResource::collection($clients); + } +} diff --git a/backend/app/Modules/User/Policies/UserPolicy.php b/backend/app/Modules/User/Policies/UserPolicy.php new file mode 100644 index 0000000..a05928e --- /dev/null +++ b/backend/app/Modules/User/Policies/UserPolicy.php @@ -0,0 +1,28 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function update(User $user, User $target): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, User $target): bool + { + return $user->isAdmin() && $user->id !== $target->id; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ + + */ + protected $policies = [ + User::class => UserPolicy::class, + Client::class => ClientPolicy::class, + HistoryEntry::class => HistoryEntryPolicy::class, + ]; + + /** + * Register any authentication / authorization services. + */ + public function boot(): void + { + // + } +} diff --git a/backend/app/Providers/BroadcastServiceProvider.php b/backend/app/Providers/BroadcastServiceProvider.php new file mode 100644 index 0000000..2be04f5 --- /dev/null +++ b/backend/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,19 @@ +> + */ + protected $listen = [ + Registered::class => [ + SendEmailVerificationNotification::class, + ], + ]; + + /** + * Register any events for your application. + */ + public function boot(): void + { + // + } + + /** + * Determine if events and listeners should be automatically discovered. + */ + public function shouldDiscoverEvents(): bool + { + return false; + } +} diff --git a/backend/app/Providers/RouteServiceProvider.php b/backend/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1cf5f15 --- /dev/null +++ b/backend/app/Providers/RouteServiceProvider.php @@ -0,0 +1,40 @@ +by($request->user()?->id ?: $request->ip()); + }); + + $this->routes(function () { + Route::middleware('api') + ->prefix('api') + ->group(base_path('routes/api.php')); + + Route::middleware('web') + ->group(base_path('routes/web.php')); + }); + } +} diff --git a/backend/artisan b/backend/artisan new file mode 100755 index 0000000..67a3329 --- /dev/null +++ b/backend/artisan @@ -0,0 +1,53 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running, we will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status); diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..037e17d --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,55 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + App\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + App\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + App\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/backend/database/migrations/2026_06_23_095329_create_clients_table.php b/backend/database/migrations/2026_06_23_095329_create_clients_table.php index 6475674..9f5fc47 100644 --- a/backend/database/migrations/2026_06_23_095329_create_clients_table.php +++ b/backend/database/migrations/2026_06_23_095329_create_clients_table.php @@ -2,13 +2,11 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('clients', function (Blueprint $table) { @@ -18,7 +16,9 @@ public function up(): void $table->json('notes')->nullable(); $table->timestamps(); - $table->fullText(['client_number', 'name']); + if (DB::connection()->getDriverName() !== 'sqlite') { + $table->fullText(['client_number', 'name']); + } }); } diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..56f5ddc --- /dev/null +++ b/backend/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "axios": "^1.6.4", + "laravel-vite-plugin": "^1.0.0", + "vite": "^5.0.0" + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml index c90ec4f..7970dae 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -23,7 +23,7 @@ - + diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/backend/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..1d69f3a --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,55 @@ +make(Kernel::class); + +$response = $kernel->handle( + $request = Request::capture() +)->send(); + +$kernel->terminate($request, $response); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..846d350 --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,32 @@ +/** + * We'll load the axios HTTP library which allows us to easily issue requests + * to our Laravel back-end. This library automatically handles sending the + * CSRF token as a header based on the value of the "XSRF" token cookie. + */ + +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + +/** + * Echo exposes an expressive API for subscribing to channels and listening + * for events that are broadcast by Laravel. Echo and event broadcasting + * allows your team to easily build robust real-time web applications. + */ + +// import Echo from 'laravel-echo'; + +// import Pusher from 'pusher-js'; +// window.Pusher = Pusher; + +// window.Echo = new Echo({ +// broadcaster: 'pusher', +// key: import.meta.env.VITE_PUSHER_APP_KEY, +// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1', +// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, +// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, +// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, +// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', +// enabledTransports: ['ws', 'wss'], +// }); diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..3353350 --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,133 @@ + + + + + + + Laravel + + + + + + + + + + + + diff --git a/backend/routes/api.php b/backend/routes/api.php index c780b14..fd2d8d8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,5 +1,8 @@ json(['data' => auth()->user()]); }); -Route::post('/logout', function (Request $request) { - auth()->logout(); - if ($request->hasSession()) { - $request->session()->invalidate(); - $request->session()->regenerateToken(); - } - return response()->json([], 204); -})->middleware('auth:sanctum'); +Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', function (Request $request) { + auth()->logout(); + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + return response()->json(null, 204); + }); -Route::get('/user', function (Request $request) { - return response()->json(['data' => $request->user()]); -})->middleware('auth:sanctum'); + Route::get('/user', fn (Request $request) => response()->json(['data' => $request->user()])); -// Module routes added in Task 10 + Route::get('search', SearchController::class); + + Route::apiResource('clients', ClientController::class)->except(['destroy']); + + Route::get('clients/{client}/history', [HistoryController::class, 'index']); + Route::post('clients/{client}/history', [HistoryController::class, 'store']); + Route::put('history/{historyEntry}', [HistoryController::class, 'update']); + Route::delete('history/{historyEntry}', [HistoryController::class, 'destroy']); +}); diff --git a/backend/routes/channels.php b/backend/routes/channels.php new file mode 100644 index 0000000..5d451e1 --- /dev/null +++ b/backend/routes/channels.php @@ -0,0 +1,18 @@ +id === (int) $id; +}); diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..e05f4c9 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,19 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..d259f33 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,18 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/backend/tests/Feature/Modules/Client/ClientTest.php b/backend/tests/Feature/Modules/Client/ClientTest.php new file mode 100644 index 0000000..5933590 --- /dev/null +++ b/backend/tests/Feature/Modules/Client/ClientTest.php @@ -0,0 +1,67 @@ +create(['role' => 'staff']); + Client::factory(3)->create(); + + $response = $this->actingAs($user)->getJson('/api/clients'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); +}); + +it('staff can create a client', function () { + $user = User::factory()->create(['role' => 'staff']); + + $response = $this->actingAs($user)->postJson('/api/clients', [ + 'client_number' => 'K001', + 'name' => 'Müller GmbH', + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.client_number', 'K001') + ->assertJsonPath('data.name', 'Müller GmbH'); + + $this->assertDatabaseHas('clients', ['client_number' => 'K001']); +}); + +it('rejects duplicate client_number', function () { + $user = User::factory()->create(['role' => 'staff']); + Client::factory()->create(['client_number' => 'K001']); + + $response = $this->actingAs($user)->postJson('/api/clients', [ + 'client_number' => 'K001', + 'name' => 'Other GmbH', + ]); + + $response->assertStatus(422); +}); + +it('staff can update a client', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + + $response = $this->actingAs($user)->putJson("/api/clients/{$client->id}", [ + 'name' => 'Updated Name', + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Updated Name'); +}); + +it('returns 404 for nonexistent client', function () { + $user = User::factory()->create(['role' => 'staff']); + + $this->actingAs($user)->getJson('/api/clients/9999') + ->assertStatus(404); +}); + +it('unauthenticated request returns 401', function () { + $this->getJson('/api/clients')->assertStatus(401); +}); diff --git a/backend/tests/Feature/Modules/History/HistoryTest.php b/backend/tests/Feature/Modules/History/HistoryTest.php new file mode 100644 index 0000000..3c3e3e3 --- /dev/null +++ b/backend/tests/Feature/Modules/History/HistoryTest.php @@ -0,0 +1,77 @@ +create(['role' => 'staff']); + $client = Client::factory()->create(); + HistoryEntry::factory(3)->create(['client_id' => $client->id, 'author_id' => $user->id]); + + $response = $this->actingAs($user)->getJson("/api/clients/{$client->id}/history"); + + $response->assertStatus(200)->assertJsonCount(3, 'data'); +}); + +it('staff can add a history entry', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + + $response = $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ + 'type' => 'phone_call', + 'body' => 'Rückruf vereinbart.', + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.type', 'phone_call') + ->assertJsonPath('data.body', 'Rückruf vereinbart.') + ->assertJsonPath('data.author_id', $user->id); + + $this->assertDatabaseHas('history_entries', [ + 'client_id' => $client->id, + 'type' => 'phone_call', + ]); +}); + +it('staff can update 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, + 'body' => 'Original', + ]); + + $response = $this->actingAs($user)->putJson("/api/history/{$entry->id}", [ + 'body' => 'Updated body', + ]); + + $response->assertStatus(200)->assertJsonPath('data.body', 'Updated body'); + $this->assertDatabaseHas('history_entries', ['id' => $entry->id, 'updated_by' => $user->id]); +}); + +it('staff can soft-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]); + + $response = $this->actingAs($user)->deleteJson("/api/history/{$entry->id}"); + + $response->assertStatus(204); + $this->assertSoftDeleted('history_entries', ['id' => $entry->id]); +}); + +it('rejects invalid history entry type', function () { + $user = User::factory()->create(['role' => 'staff']); + $client = Client::factory()->create(); + + $this->actingAs($user)->postJson("/api/clients/{$client->id}/history", [ + 'type' => 'invalid_type', + 'body' => 'test', + ])->assertStatus(422); +}); diff --git a/backend/tests/Feature/Modules/Search/SearchTest.php b/backend/tests/Feature/Modules/Search/SearchTest.php new file mode 100644 index 0000000..81785a6 --- /dev/null +++ b/backend/tests/Feature/Modules/Search/SearchTest.php @@ -0,0 +1,49 @@ +create(['role' => 'staff']); + Client::factory()->create(['name' => 'Müller GmbH', 'client_number' => 'K001']); + Client::factory()->create(['name' => 'Schmidt AG', 'client_number' => 'K002']); + + $response = $this->actingAs($user)->getJson('/api/search?q=Müller'); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'Müller GmbH'); +}); + +it('returns clients matching client_number', function () { + $user = User::factory()->create(['role' => 'staff']); + Client::factory()->create(['name' => 'Alpha GmbH', 'client_number' => 'K999']); + Client::factory()->create(['name' => 'Beta GmbH', 'client_number' => 'K001']); + + $response = $this->actingAs($user)->getJson('/api/search?q=K999'); + + $response->assertStatus(200) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.client_number', 'K999'); +}); + +it('returns empty array for no match', function () { + $user = User::factory()->create(['role' => 'staff']); + Client::factory()->create(['name' => 'Alpha GmbH', 'client_number' => 'K001']); + + $response = $this->actingAs($user)->getJson('/api/search?q=ZZZ'); + + $response->assertStatus(200)->assertJsonCount(0, 'data'); +}); + +it('requires auth', function () { + $this->getJson('/api/search?q=test')->assertStatus(401); +}); + +it('requires q parameter', function () { + $user = User::factory()->create(['role' => 'staff']); + $this->actingAs($user)->getJson('/api/search')->assertStatus(422); +}); diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..2932d4a --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..421b569 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + ], +});