diff --git a/app/Http/Controllers/ActivityLogController.php b/app/Http/Controllers/ActivityLogController.php new file mode 100644 index 0000000..ff7422e --- /dev/null +++ b/app/Http/Controllers/ActivityLogController.php @@ -0,0 +1,32 @@ +latest() + ->take(10) + ->get() + ->map(function ($log) { + return [ + 'log_name' => $log->log_name, + 'description' => $log->description, + 'causer_name' => optional($log->causer)->name ?? 'System', + 'subject_id' => $log->subject_id, + 'created_at' => $log->created_at->toDateTimeString(), + 'updated_at' => $log->updated_at->toDateTimeString(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $logs, + ]); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 774bc90..2e8bb96 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -12,33 +12,26 @@ use Illuminate\Validation\ValidationException; class AuthController extends Controller { /** - * Register a new user (Admin only) + * Register a new user * * @param Request $request * @return JsonResponse */ public function register(Request $request): JsonResponse { - // Only allow admins to register new users - if (!auth()->check() || !auth()->user()->is_admin) { - return response()->json([ - 'success' => false, - 'message' => 'Unauthorized action' - ], 403); - } $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users', 'password' => 'required|string|min:8', - 'is_admin' => 'boolean' + // 'is_admin' => 'boolean' ]); $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), - 'is_admin' => $request->is_admin ?? false, + // 'is_admin' => $request->is_admin ?? false, ]); return response()->json([ diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..34a1044 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,54 @@ +startOfMonth(); + $newThisMonth = Person::where('created_at', '>=', $currentMonthStart)->count(); + + // Recent additions (last 30 days) + $thirtyDaysAgo = Carbon::now()->subDays(30); + $recentAdditions = Person::where('created_at', '>=', $thirtyDaysAgo)->count(); + + // Pending reviews - example: people with missing information + $pendingReviews = Person::whereNull('date_of_birth') + ->orWhereNull('place_of_birth') + ->orWhereNull('occupation') + ->count(); + + // Incomplete records - persons missing multiple key fields + $incompleteRecords = Person::where(function($query) { + $query->whereNull('date_of_birth') + ->orWhereNull('place_of_birth'); + }) + ->where(function($query) { + $query->whereNull('occupation') + ->orWhereNull('reference') + ->orWhereNull('id_card_no'); + }) + ->count(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'total_migrants' => $totalMigrants, + 'new_this_month' => $newThisMonth, + 'recent_additions' => $recentAdditions, + 'pending_reviews' => $pendingReviews, + 'incomplete_records' => $incompleteRecords + ] + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/HistoricalSearchController.php b/app/Http/Controllers/HistoricalSearchController.php new file mode 100644 index 0000000..ff652fd --- /dev/null +++ b/app/Http/Controllers/HistoricalSearchController.php @@ -0,0 +1,103 @@ +input('query'); + $page = (int) $request->input('page', 1); + $perPage = 10; + + $results = empty($query) + ? $this->mergeModels([Person::class, Migration::class, Residence::class]) + : $this->performSearch($query); + + $paginated = $this->paginateCollection($results, $perPage, $page); + + return response()->json([ + 'success' => true, + 'message' => 'Records fetched successfully', + 'data' => $paginated->items(), + 'pagination' => [ + 'total' => $paginated->total(), + 'perPage' => $paginated->perPage(), + 'currentPage' => $paginated->currentPage(), + 'totalPages' => $paginated->lastPage(), + ] + ]); + } + + public function getRecord($id): JsonResponse + { + try { + $person = Person::with([ + 'migration', + 'naturalization', + 'residence', + 'family', + 'internment' + ])->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $person, + 'message' => 'Record retrieved successfully' + ]); + } catch (Exception $e) { + Log::error('Error retrieving record', [ + 'id' => $id, + 'error' => $e->getMessage() + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Record not found', + 'error' => $e->getMessage() + ], 404); + } + } + + private function paginateCollection(Collection $collection, int $perPage, int $page): LengthAwarePaginator + { + $items = $collection->forPage($page, $perPage)->values(); + + return new LengthAwarePaginator( + $items, + $collection->count(), + $perPage, + $page, + [ + 'path' => request()->url(), + 'query' => request()->query(), + ] + ); + } + + private function mergeModels(array $models): Collection + { + return collect($models) + ->flatMap(fn ($model) => $model::all()); + } + + private function performSearch(string $query): Collection + { + return Search::add(Person::class, ['christian_name', 'surname', 'place_of_birth']) + ->add(Migration::class, ['date_of_arrival_nt']) + ->add(Residence::class, ['town_or_city']) + ->beginWithWildcard() + ->search($query); + } +} diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index b57ec98..7bd1106 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -4,309 +4,172 @@ namespace App\Http\Controllers; use App\Http\Requests\StorePersonRequest; use App\Http\Requests\UpdatePersonRequest; -use App\Http\Resources\PersonResource; -use App\Http\Resources\PersonCollection; use App\Models\Person; -use App\Models\Migration; -use App\Models\Naturalization; -use App\Models\Residence; -use App\Models\Family; -use App\Models\Internment; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; -use Illuminate\Database\Eloquent\Builder; -use Carbon\Carbon; use Exception; class PersonController extends Controller { - - // Public search functionality has been moved to PublicSearchController - - public function getRecord($id): JsonResponse - { - try { - try { - $person = Person::with([ - 'migration', - 'naturalization', - 'residence', - 'family', - 'internment' - ])->findOrFail($id); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { - return response()->json([ - 'success' => false, - 'message' => 'Record not found' - ], 404); - } - - return response()->json([ - 'success' => true, - 'data' => new PersonResource($person), - 'message' => 'Record retrieved successfully' - ]); - } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to retrieve record', - 'error' => $e->getMessage() - ], 500); - } - } + protected array $relations = ['migration', 'naturalization', 'residence', 'family', 'internment']; public function index(Request $request): JsonResponse { try { - $query = Person::query(); - - // Apply search filters if provided - if ($request->has('search')) { - $searchTerm = $request->search; - $query->where(function($q) use ($searchTerm) { - $q->where('full_name', 'LIKE', "%{$searchTerm}%") - ->orWhere('surname', 'LIKE', "%{$searchTerm}%") - ->orWhere('occupation', 'LIKE', "%{$searchTerm}%"); - }); + $query = Person::with($this->relations); + + if ($search = $request->search) { + $query->where(fn($q) => + $q->where('full_name', 'LIKE', "%$search%") + ->orWhere('surname', 'LIKE', "%$search%") + ->orWhere('occupation', 'LIKE', "%$search%") + ); } - - // Paginate results - $persons = $query->paginate(10); - - // Eager load related models for each person in the collection - $persons->getCollection()->each->load([ - 'migration', - 'naturalization', - 'residence', - 'family', - 'internment' - ]); - + return response()->json([ 'success' => true, - 'data' => new PersonCollection($persons), + 'data' => $query->paginate(10), 'message' => 'Persons retrieved successfully' ]); } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to retrieve persons', - 'error' => $e->getMessage() - ], 500); - } - } - - public function store(StorePersonRequest $request): JsonResponse - { - try { - // Use DB transaction for atomic operations - $result = DB::transaction(function () use ($request) { - // Create person record - $person = Person::create($request->only([ - 'surname', 'christian_name', 'full_name', 'date_of_birth', - 'place_of_birth', 'date_of_death', 'occupation', - 'additional_notes', 'reference', 'id_card_no' - ])); - - // Create migration record if data is provided - if ($request->has('migration')) { - $person->migration()->create($request->migration); - } - - // Create naturalization record if data is provided - if ($request->has('naturalization')) { - $person->naturalization()->create($request->naturalization); - } - - // Create residence record if data is provided - if ($request->has('residence')) { - $person->residence()->create($request->residence); - } - - // Create family record if data is provided - if ($request->has('family')) { - $person->family()->create($request->family); - } - - // Create internment record if data is provided - if ($request->has('internment')) { - $person->internment()->create($request->internment); - } - - // Load all relationships for the response - $person->load(['migration', 'naturalization', 'residence', 'family', 'internment']); - - return $person; - }); - - return response()->json([ - 'success' => true, - 'data' => new PersonResource($result), - 'message' => 'Person created successfully' - ], 201); - } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to create person', - 'error' => $e->getMessage() - ], 500); + return $this->errorResponse('Failed to retrieve persons', $e); } } public function show(string $id): JsonResponse { try { - // Find person by ID and eager load all relationships - $person = Person::with(['migration', 'naturalization', 'residence', 'family', 'internment']) - ->find($id); - - if (!$person) { - return response()->json([ - 'success' => false, - 'message' => 'Person not found' - ], 404); - } - - return response()->json([ - 'success' => true, - 'data' => new PersonResource($person), - 'message' => 'Person retrieved successfully' - ]); + $person = Person::with($this->relations)->find($id); + + return $person + ? $this->successResponse($person, 'Person retrieved successfully') + : $this->notFoundResponse('Person not found'); } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to retrieve person', - 'error' => $e->getMessage() - ], 500); + return $this->errorResponse('Failed to retrieve person', $e); + } + } + + public function store(StorePersonRequest $request): JsonResponse + { + try { + $data = $request->only([ + 'surname', 'christian_name', 'date_of_birth', 'place_of_birth', + 'date_of_death', 'occupation', 'additional_notes', 'reference', 'id_card_no' + ]); + $data['full_name'] = trim("{$request->christian_name} {$request->surname}"); + + $person = Person::create($data); + + foreach ($this->relations as $relation) { + if ($request->has($relation)) { + $person->$relation()->create($request->$relation); + } + } + + return $this->successResponse( + $person->load($this->relations), + 'Person created successfully', + 201 + ); + } catch (Exception $e) { + return $this->errorResponse('Failed to create person', $e); } } public function update(UpdatePersonRequest $request, string $id): JsonResponse { try { - // Use DB transaction for atomic operations - $result = DB::transaction(function () use ($request, $id) { - // Find person by ID - $person = Person::findOrFail($id); - - // Update person record - $person->update($request->only([ - 'surname', 'christian_name', 'full_name', 'date_of_birth', - 'place_of_birth', 'date_of_death', 'occupation', - 'additional_notes', 'reference', 'id_card_no' - ])); - - // Update migration record if data is provided - if ($request->has('migration')) { - if ($person->migration) { - $person->migration->update($request->migration); - } else { - $person->migration()->create($request->migration); - } - } - - // Update naturalization record if data is provided - if ($request->has('naturalization')) { - if ($person->naturalization) { - $person->naturalization->update($request->naturalization); - } else { - $person->naturalization()->create($request->naturalization); - } - } - - // Update residence record if data is provided - if ($request->has('residence')) { - if ($person->residence) { - $person->residence->update($request->residence); - } else { - $person->residence()->create($request->residence); - } - } - - // Update family record if data is provided - if ($request->has('family')) { - if ($person->family) { - $person->family->update($request->family); - } else { - $person->family()->create($request->family); - } - } - - // Update internment record if data is provided - if ($request->has('internment')) { - if ($person->internment) { - $person->internment->update($request->internment); - } else { - $person->internment()->create($request->internment); - } - } - - // Load all relationships for the response - $person->load(['migration', 'naturalization', 'residence', 'family', 'internment']); - - return $person; - }); - - return response()->json([ - 'success' => true, - 'data' => new PersonResource($result), - 'message' => 'Person updated successfully' + $person = Person::findOrFail($id); + + $data = $request->only([ + 'surname', 'christian_name', 'date_of_birth', 'place_of_birth', + 'date_of_death', 'occupation', 'additional_notes', 'reference', 'id_card_no' ]); + + if ($request->hasAny(['christian_name', 'surname'])) { + $christian = $request->input('christian_name', $person->christian_name); + $surname = $request->input('surname', $person->surname); + $data['full_name'] = trim("$christian $surname"); + } + + $person->update($data); + + foreach ($this->relations as $relation) { + if (is_array($request->$relation ?? null)) { + $person->$relation + ? $person->$relation->update($request->$relation) + : $person->$relation()->create($request->$relation); + } + } + + return $this->successResponse( + $person->load($this->relations), + 'Person updated successfully' + ); } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to update person', - 'error' => $e->getMessage() - ], 500); + return $this->errorResponse('Failed to update person', $e); } } public function destroy(string $id): JsonResponse { try { - // Use DB transaction for atomic operations - DB::transaction(function () use ($id) { - // Find person by ID with related models - $person = Person::with([ - 'migration', 'naturalization', 'residence', 'family', 'internment' - ])->findOrFail($id); - - // Manually delete each related model to ensure soft deletes work correctly - if ($person->migration) { - $person->migration->delete(); - } - - if ($person->naturalization) { - $person->naturalization->delete(); - } - - if ($person->residence) { - $person->residence->delete(); - } - - if ($person->family) { - $person->family->delete(); - } - - if ($person->internment) { - $person->internment->delete(); - } - - // Now delete the person record - $person->delete(); - }); - - return response()->json([ - 'success' => true, - 'message' => 'Person deleted successfully' - ]); + $person = Person::with($this->relations)->findOrFail($id); + + foreach ($this->relations as $relation) { + $person->$relation?->delete(); + } + + $person->delete(); + + return $this->successResponse(null, 'Person deleted successfully'); } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to delete person', - 'error' => $e->getMessage() - ], 500); + return $this->errorResponse('Failed to delete person', $e); } } + + // Response helpers + protected function successResponse($data, string $message, int $status = 200): JsonResponse + { + return response()->json(['success' => true, 'data' => $data, 'message' => $message], $status); + } + + protected function notFoundResponse(string $message): JsonResponse + { + return response()->json(['success' => false, 'message' => $message], 404); + } + + protected function errorResponse(string $message, Exception $e): JsonResponse + { + return response()->json([ + 'success' => false, + 'message' => $message, + 'error' => $e->getMessage() + ], 500); + } + public function search(Request $request): JsonResponse +{ + try { + $query = Person::query()->with($this->relations); + + if ($search = $request->input('query')) { + $query->where(function ($q) use ($search) { + $q->where('full_name', 'LIKE', "%$search%") + ->orWhere('surname', 'LIKE', "%$search%") + ->orWhere('christian_name', 'LIKE', "%$search%") + ->orWhere('occupation', 'LIKE', "%$search%") + ->orWhere('place_of_birth', 'LIKE', "%$search%") + ->orWhere('id_card_no', 'LIKE', "%$search%"); + }); + } + + return $this->successResponse( + $query->paginate(10), + 'Search results returned successfully' + ); + } catch (Exception $e) { + return $this->errorResponse('Failed to perform search', $e); + } +} + } diff --git a/app/Http/Controllers/PublicSearchController.php b/app/Http/Controllers/PublicSearchController.php deleted file mode 100644 index b8e2d9e..0000000 --- a/app/Http/Controllers/PublicSearchController.php +++ /dev/null @@ -1,290 +0,0 @@ -buildSearchQuery($request); - $persons = $this->paginateAndLoadRelations($query, $request); - - return response()->json([ - 'success' => true, - 'data' => new PersonCollection($persons), - 'message' => 'Public search results retrieved successfully' - ]); - } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to retrieve search results', - 'error' => $e->getMessage() - ], 500); - } - } - - /** - * Build the search query with all filters applied - * - * @param Request $request - * @return Builder - */ - private function buildSearchQuery(Request $request): Builder - { - $query = Person::query(); - $useOrConditions = $this->shouldUseOrConditions($request); - - // Apply basic filters (firstName, lastName, etc.) - $this->applyBasicFilters($query, $request, $useOrConditions); - - // Apply relation-based filters - $this->applyYearOfArrivalFilter($query, $request); - $this->applyAgeAtMigrationFilter($query, $request); - $this->applySettlementLocationFilter($query, $request); - - return $query; - } - - /** - * Determine if OR logic should be used between filters - * - * @param Request $request - * @return bool - */ - private function shouldUseOrConditions(Request $request): bool - { - $exactMatch = $request->boolean('exactMatch'); - $useOrLogic = $request->boolean('useOrLogic'); - - return $useOrLogic || $exactMatch; - } - - /** - * Apply filters for fields in the Person table - * - * @param Builder $query - * @param Request $request - * @param bool $useOrConditions - * @return void - */ - private function applyBasicFilters(Builder $query, Request $request, bool $useOrConditions): void - { - $filters = $this->collectBasicFilters($request); - - if (empty($filters)) { - return; - } - - if ($useOrConditions) { - $this->applyFiltersWithOrLogic($query, $filters); - } else { - $this->applyFiltersWithAndLogic($query, $filters); - } - } - - /** - * Collect basic filters from the request - * - * @param Request $request - * @return array - */ - private function collectBasicFilters(Request $request): array - { - $exactMatch = $request->boolean('exactMatch'); - $filters = []; - - $filterFields = [ - 'id_card_no' => 'id_card_no', - 'firstName' => 'christian_name', - 'lastName' => 'surname', - 'regionOfOrigin' => 'place_of_birth' - ]; - - foreach ($filterFields as $requestKey => $dbField) { - if ($request->has($requestKey) && $request->input($requestKey) !== 'all') { - $value = $request->input($requestKey); - - if ($exactMatch) { - // For exact matching, use raw query with BINARY for strict case-sensitive matching - $filters[] = [$dbField, 'raw', $value]; - } else { - // For partial matching, use standard LIKE with wildcards - $filters[] = [$dbField, 'LIKE', "%{$value}%"]; - } - } - } - - return $filters; - } - - /** - * Apply filters using OR logic - * - * @param Builder $query - * @param array $filters - * @return void - */ - private function applyFiltersWithOrLogic(Builder $query, array $filters): void - { - $query->where(function ($q) use ($filters) { - foreach ($filters as $index => $filter) { - $method = $index === 0 ? 'where' : 'orWhere'; - - if ($filter[1] === 'raw') { - // Handle raw exact matching (case-sensitive) - $field = $filter[0]; - $value = $filter[2]; - $q->whereRaw("BINARY {$field} = ?", [$value]); - } else { - // Handle standard operators - $q->{$method}($filter[0], $filter[1], $filter[2]); - } - } - }); - } - - /** - * Apply filters using AND logic - * - * @param Builder $query - * @param array $filters - * @return void - */ - private function applyFiltersWithAndLogic(Builder $query, array $filters): void - { - foreach ($filters as $filter) { - if ($filter[1] === 'raw') { - // Handle raw exact matching (case-sensitive) - $field = $filter[0]; - $value = $filter[2]; - $query->whereRaw("BINARY {$field} = ?", [$value]); - } else { - // Handle standard operators - $query->where($filter[0], $filter[1], $filter[2]); - } - } - } - - /** - * Filter by Year of Arrival - * - * @param Builder $query - * @param Request $request - * @return void - */ - private function applyYearOfArrivalFilter(Builder $query, Request $request): void - { - if ($request->has('yearOfArrival') && $request->yearOfArrival !== 'all') { - $year = $request->yearOfArrival; - $query->whereHas('migration', function (Builder $subQuery) use ($year) { - $subQuery->whereYear('date_of_arrival_aus', $year) - ->orWhereYear('date_of_arrival_nt', $year); - }); - } - } - - /** - * Filter by Age at Migration - * - * @param Builder $query - * @param Request $request - * @return void - */ - private function applyAgeAtMigrationFilter(Builder $query, Request $request): void - { - if ($request->has('ageAtMigration') && $request->ageAtMigration !== 'all') { - $ageAtMigration = (int) $request->ageAtMigration; - - $query->whereHas('migration', function (Builder $subQuery) use ($ageAtMigration) { - $subQuery->whereRaw('YEAR(date_of_arrival_aus) - YEAR(person.date_of_birth) = ?', [$ageAtMigration]) - ->orWhereRaw('YEAR(date_of_arrival_nt) - YEAR(person.date_of_birth) = ?', [$ageAtMigration]); - }); - } - } - - /** - * Filter by Settlement Location - * - * @param Builder $query - * @param Request $request - * @return void - */ - private function applySettlementLocationFilter(Builder $query, Request $request): void - { - if ($request->has('settlementLocation') && $request->settlementLocation !== 'all') { - $location = $request->settlementLocation; - $query->whereHas('residence', function (Builder $subQuery) use ($location) { - $subQuery->where('town_or_city', 'LIKE', "%{$location}%"); - }); - } - } - - /** - * Paginate results and load related models - * - * @param Builder $query - * @param Request $request - * @return \Illuminate\Pagination\LengthAwarePaginator - */ - private function paginateAndLoadRelations(Builder $query, Request $request): \Illuminate\Pagination\LengthAwarePaginator - { - $perPage = $request->input('per_page', 10); - $persons = $query->paginate($perPage); - - // Eager load related models - $persons->getCollection()->each->load([ - 'migration', - 'naturalization', - 'residence', - 'family', - 'internment' - ]); - - return $persons; - } - - /** - * Get a specific migrant record by ID - * - * @param mixed $id - * @return JsonResponse - */ - public function getRecord($id): JsonResponse - { - try { - $person = Person::with([ - 'migration', - 'naturalization', - 'residence', - 'family', - 'internment' - ])->findOrFail($id); - - return response()->json([ - 'success' => true, - 'data' => $person, - 'message' => 'Record retrieved successfully' - ]); - } catch (Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Record not found', - 'error' => $e->getMessage() - ], 404); - } - } -} diff --git a/app/Http/Resources/FamilyResource.php b/app/Http/Resources/FamilyResource.php deleted file mode 100644 index fd589e0..0000000 --- a/app/Http/Resources/FamilyResource.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return $this->resource ? [ - 'family_id' => $this->family_id, - 'person_id' => $this->person_id, - 'names_of_parents' => $this->names_of_parents, - 'names_of_children' => $this->names_of_children, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - ] : null; - } -} diff --git a/app/Http/Resources/InternmentResource.php b/app/Http/Resources/InternmentResource.php deleted file mode 100644 index bd8cd2d..0000000 --- a/app/Http/Resources/InternmentResource.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return $this->resource ? [ - 'internment_id' => $this->internment_id, - 'person_id' => $this->person_id, - 'corps_issued' => $this->corps_issued, - 'interned_in' => $this->interned_in, - 'sent_to' => $this->sent_to, - 'internee_occupation' => $this->internee_occupation, - 'internee_address' => $this->internee_address, - 'cav' => $this->cav, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - ] : null; - } -} diff --git a/app/Http/Resources/MigrationResource.php b/app/Http/Resources/MigrationResource.php deleted file mode 100644 index 391dec1..0000000 --- a/app/Http/Resources/MigrationResource.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return $this->resource ? [ - 'migration_id' => $this->migration_id, - 'person_id' => $this->person_id, - 'date_of_arrival_aus' => $this->date_of_arrival_aus, - 'date_of_arrival_nt' => $this->date_of_arrival_nt, - 'arrival_period' => $this->arrival_period, - 'data_source' => $this->data_source, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - ] : null; - } -} diff --git a/app/Http/Resources/NaturalizationResource.php b/app/Http/Resources/NaturalizationResource.php deleted file mode 100644 index f2a5e15..0000000 --- a/app/Http/Resources/NaturalizationResource.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return $this->resource ? [ - 'naturalization_id' => $this->naturalization_id, - 'person_id' => $this->person_id, - 'date_of_naturalisation' => $this->date_of_naturalisation, - 'no_of_cert' => $this->no_of_cert, - 'issued_at' => $this->issued_at, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - ] : null; - } -} diff --git a/app/Http/Resources/PersonCollection.php b/app/Http/Resources/PersonCollection.php deleted file mode 100644 index e1a6edc..0000000 --- a/app/Http/Resources/PersonCollection.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'data' => $this->collection->map(function ($person) { - return new PersonResource($person); - }), - 'meta' => [ - 'total' => $this->total(), - 'count' => $this->count(), - 'per_page' => $this->perPage(), - 'current_page' => $this->currentPage(), - 'last_page' => $this->lastPage(), - ], - 'links' => [ - 'first' => $this->url(1), - 'last' => $this->url($this->lastPage()), - 'prev' => $this->previousPageUrl(), - 'next' => $this->nextPageUrl(), - ], - ]; - } -} diff --git a/app/Http/Resources/PersonResource.php b/app/Http/Resources/PersonResource.php deleted file mode 100644 index dab7f36..0000000 --- a/app/Http/Resources/PersonResource.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'person_id' => $this->person_id, - 'surname' => $this->surname, - 'christian_name' => $this->christian_name, - 'full_name' => $this->full_name, - 'date_of_birth' => $this->date_of_birth, - 'place_of_birth' => $this->place_of_birth, - 'date_of_death' => $this->date_of_death, - 'occupation' => $this->occupation, - 'additional_notes' => $this->additional_notes, - 'reference' => $this->reference, - 'id_card_no' => $this->id_card_no, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - - // Include related resources when they're loaded - 'migration' => new MigrationResource($this->whenLoaded('migration')), - 'naturalization' => new NaturalizationResource($this->whenLoaded('naturalization')), - 'residence' => new ResidenceResource($this->whenLoaded('residence')), - 'family' => new FamilyResource($this->whenLoaded('family')), - 'internment' => new InternmentResource($this->whenLoaded('internment')), - ]; - } -} diff --git a/app/Http/Resources/ResidenceResource.php b/app/Http/Resources/ResidenceResource.php deleted file mode 100644 index 8dfffa0..0000000 --- a/app/Http/Resources/ResidenceResource.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return $this->resource ? [ - 'residence_id' => $this->residence_id, - 'person_id' => $this->person_id, - 'town_or_city' => $this->town_or_city, - 'home_at_death' => $this->home_at_death, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, - ] : null; - } -} diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php new file mode 100644 index 0000000..213eb6c --- /dev/null +++ b/app/Models/ActivityLog.php @@ -0,0 +1,22 @@ +belongsTo(User::class, 'causer_id'); + } + + // Optional: shortcut + public function getCauserNameAttribute(): ?string + { + return $this->user?->name; + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php index e346096..a760d93 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -5,14 +5,17 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Laravel\Scout\Searchable; +use Spatie\Activitylog\Traits\LogsActivity; +use Spatie\Activitylog\LogOptions; class Person extends Model { - use HasFactory, SoftDeletes; - + use HasFactory, SoftDeletes, Searchable, LogsActivity; + protected $table = 'person'; protected $primaryKey = 'person_id'; - + protected $fillable = [ 'surname', 'christian_name', @@ -25,35 +28,67 @@ class Person extends Model 'reference', 'id_card_no', ]; - + protected $casts = [ 'date_of_birth' => 'date', 'date_of_death' => 'date', ]; - - // Relationships + + // 🔧 Configure Spatie logging + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->useLogName('person') + ->logFillable() + ->logOnlyDirty(); + } + + // 📝 Custom activity description for events + public function getDescriptionForEvent(string $eventName): string + { + return match ($eventName) { + 'created' => 'Added new migrant', + 'updated' => 'Updated migrant details', + 'deleted' => 'Deleted migrant record', + default => ucfirst($eventName) . ' migrant record', + }; + } + + // 🔗 Relationships public function migration() { return $this->hasOne(Migration::class, 'person_id'); } - + public function naturalization() { return $this->hasOne(Naturalization::class, 'person_id'); } - + public function residence() { return $this->hasOne(Residence::class, 'person_id'); } - + public function family() { return $this->hasOne(Family::class, 'person_id'); } - + public function internment() { return $this->hasOne(Internment::class, 'person_id'); } + + public function toSearchableArray() + { + return [ + 'person_id' => $this->person_id, + 'surname' => $this->surname, + 'christian_name' => $this->christian_name, + 'full_name' => $this->full_name, + 'place_of_birth' => $this->place_of_birth, + 'id_card_no' => $this->id_card_no, + ]; + } } diff --git a/app/Models/User.php b/app/Models/User.php index c00931c..2f0a2b4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,22 +2,17 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; +use Spatie\Activitylog\Traits\LogsActivity; +use Spatie\Activitylog\LogOptions; class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, LogsActivity; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'name', 'email', @@ -25,21 +20,11 @@ class User extends Authenticatable 'is_admin', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ protected $hidden = [ 'password', 'remember_token', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ @@ -47,4 +32,13 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + // ✅ Required by LogsActivity + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->useLogName('user') + ->logOnly(['name', 'email', 'is_admin']) // specify what to track + ->logOnlyDirty(); // only log changes + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..04b9e24 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Spatie\Activitylog\Models\Activity; class AppServiceProvider extends ServiceProvider { @@ -19,6 +20,11 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Activity::saving(function ($activity) { + if (auth()->check()) { + $activity->causer_name = auth()->user()->name; + } + }); } + } diff --git a/composer.json b/composer.json index 130bcd0..56b6d6a 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,10 @@ "php": "^8.2", "laravel/framework": "^12.0", "laravel/sanctum": "^4.1", - "laravel/tinker": "^2.10.1" + "laravel/scout": "^10.15", + "laravel/tinker": "^2.10.1", + "protonemedia/laravel-cross-eloquent-search": "^3.6", + "spatie/laravel-activitylog": "^4.10" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 2a08f56..12435de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "015e02e23a490fcf9d86230851cba120", + "content-hash": "1dfb34123f9a766e68760fa85b831c55", "packages": [ { "name": "brick/math", @@ -1392,6 +1392,87 @@ }, "time": "2025-04-23T13:03:38+00:00" }, + { + "name": "laravel/scout", + "version": "v10.15.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "102fe09ae1c045c6f9cf1b3a2234e1fadb2198f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/102fe09ae1c045c6f9cf1b3a2234e1fadb2198f2", + "reference": "102fe09ae1c045c6f9cf1b3a2234e1fadb2198f2", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.11|^9.0|^10.0", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.3|^10.4", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2025-05-13T13:34:05+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.4", @@ -2647,6 +2728,71 @@ ], "time": "2024-07-20T21:41:07+00:00" }, + { + "name": "protonemedia/laravel-cross-eloquent-search", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/protonemedia/laravel-cross-eloquent-search.git", + "reference": "8eb2a0b91e4c675abf434368bacc877c1bcd99ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protonemedia/laravel-cross-eloquent-search/zipball/8eb2a0b91e4c675abf434368bacc877c1bcd99ef", + "reference": "8eb2a0b91e4c675abf434368bacc877c1bcd99ef", + "shasum": "" + }, + "require": { + "illuminate/support": "^10.48.28|^11.43|^12.0", + "php": "^8.2|^8.3|^8.4" + }, + "require-dev": { + "mockery/mockery": "^1.4.4", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^10.4|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "ProtoneMedia\\LaravelCrossEloquentSearch\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "ProtoneMedia\\LaravelCrossEloquentSearch\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pascal Baljet", + "email": "pascal@protone.media", + "role": "Developer" + } + ], + "description": "Laravel package to search through multiple Eloquent models. Supports pagination, eager loading relations, single/multiple columns, sorting and scoped queries.", + "homepage": "https://github.com/protonemedia/laravel-cross-eloquent-search", + "keywords": [ + "laravel-cross-eloquent-search", + "protonemedia" + ], + "support": { + "issues": "https://github.com/protonemedia/laravel-cross-eloquent-search/issues", + "source": "https://github.com/protonemedia/laravel-cross-eloquent-search/tree/3.6.0" + }, + "funding": [ + { + "url": "https://github.com/pascalbaljet", + "type": "github" + } + ], + "time": "2025-02-19T13:08:16+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3350,6 +3496,158 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "4.10.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "466f30f7245fe3a6e328ad5e6812bd43b4bddea5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/466f30f7245fe3a6e328ad5e6812bd43b4bddea5", + "reference": "466f30f7245fe3a6e328ad5e6812bd43b4bddea5", + "shasum": "" + }, + "require": { + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.0 || ^10.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/4.10.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-10T15:38:25+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.92.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d20b1969f836d210459b78683d85c9cd5c5f508c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d20b1969f836d210459b78683d85c9cd5c5f508c", + "reference": "d20b1969f836d210459b78683d85c9cd5c5f508c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/php-code-coverage": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.5.24|^10.5|^11.5", + "spatie/pest-plugin-test-time": "^1.1|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.4" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-04-11T15:27:14+00:00" + }, { "name": "symfony/clock", "version": "v7.2.0", diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..f1262f5 --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,52 @@ + env('ACTIVITY_LOGGER_ENABLED', true), + + /* + * When the clean-command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'delete_records_older_than_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject returns soft deleted models. + */ + 'subject_returns_soft_deleted_models' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => \Spatie\Activitylog\Models\Activity::class, + + /* + * This is the name of the table that will be created by the migration and + * used by the Activity model shipped with this package. + */ + 'table_name' => env('ACTIVITY_LOGGER_TABLE_NAME', 'activity_log'), + + /* + * This is the database connection that will be used by the migration and + * the Activity model shipped with this package. In case it's not set + * Laravel's database.default will be used instead. + */ + 'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'), +]; diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 0000000..83561ea --- /dev/null +++ b/config/scout.php @@ -0,0 +1,209 @@ + env('SCOUT_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => false, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => false, + + /* + |-------------------------------------------------------------------------- + | Identify User + |-------------------------------------------------------------------------- + | + | This option allows you to control whether to notify the search engine + | of the user performing the search. This is sometimes useful if the + | engine supports any analytics based on this application's users. + | + | Supported engines: "algolia" + | + */ + + 'identify' => env('SCOUT_IDENTIFY', false), + + /* + |-------------------------------------------------------------------------- + | Algolia Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Algolia settings. Algolia is a cloud hosted + | search engine which works great with Scout out of the box. Just plug + | in your application ID and admin API key to get started searching. + | + */ + + 'algolia' => [ + 'id' => env('ALGOLIA_APP_ID', ''), + 'secret' => env('ALGOLIA_SECRET', ''), + 'index-settings' => [ + // 'users' => [ + // 'searchableAttributes' => ['id', 'name', 'email'], + // 'attributesForFaceting'=> ['filterOnly(email)'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [ + // 'users' => [ + // 'filterableAttributes'=> ['id', 'name', 'email'], + // ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Typesense Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Typesense settings. Typesense is an open + | source search engine using minimal configuration. Below, you will + | state the host, key, and schema configuration for the instance. + | + */ + + 'typesense' => [ + 'client-settings' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + // 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000), + 'model-settings' => [ + // User::class => [ + // 'collection-schema' => [ + // 'fields' => [ + // [ + // 'name' => 'id', + // 'type' => 'string', + // ], + // [ + // 'name' => 'name', + // 'type' => 'string', + // ], + // [ + // 'name' => 'created_at', + // 'type' => 'int64', + // ], + // ], + // 'default_sorting_field' => 'created_at', + // ], + // 'search-parameters' => [ + // 'query_by' => 'name' + // ], + // ], + ], + ], + +]; diff --git a/database/migrations/2025_05_21_031752_create_activity_logs_table.php b/database/migrations/2025_05_21_031752_create_activity_logs_table.php new file mode 100644 index 0000000..ab19431 --- /dev/null +++ b/database/migrations/2025_05_21_031752_create_activity_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); + $table->string('action'); // e.g., create, update, delete, duplicate + $table->string('model_type'); // e.g., App\Models\Person + $table->unsignedBigInteger('model_id'); + $table->json('changes')->nullable(); // Stores old and new values + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('activity_logs'); + } +}; diff --git a/database/migrations/2025_05_21_044347_create_activity_log_table.php b/database/migrations/2025_05_21_044347_create_activity_log_table.php new file mode 100644 index 0000000..00c789f --- /dev/null +++ b/database/migrations/2025_05_21_044347_create_activity_log_table.php @@ -0,0 +1,29 @@ +create(config('activitylog.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->string('causer_name')->nullable(); // 👈 New column + $table->json('properties')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); + } +} diff --git a/database/migrations/2025_05_21_044348_add_event_column_to_activity_log_table.php b/database/migrations/2025_05_21_044348_add_event_column_to_activity_log_table.php new file mode 100644 index 0000000..7b797fd --- /dev/null +++ b/database/migrations/2025_05_21_044348_add_event_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->string('event')->nullable()->after('subject_type'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('event'); + }); + } +} diff --git a/database/migrations/2025_05_21_044349_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/2025_05_21_044349_add_batch_uuid_column_to_activity_log_table.php new file mode 100644 index 0000000..8f7db66 --- /dev/null +++ b/database/migrations/2025_05_21_044349_add_batch_uuid_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->uuid('batch_uuid')->nullable()->after('properties'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('batch_uuid'); + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b5b4c87..40b4bb5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder // Create admin user for testing the authentication system $this->call([ AdminUserSeeder::class, + PersonSeeder::class, // Seed 100 sample Person records ]); // Create a regular user for testing diff --git a/database/seeders/PersonSeeder.php b/database/seeders/PersonSeeder.php new file mode 100644 index 0000000..3af91d5 --- /dev/null +++ b/database/seeders/PersonSeeder.php @@ -0,0 +1,113 @@ +command->info('Creating 100 sample Person records with related data...'); + + // Create 100 sample Person records + for ($i = 0; $i < 100; $i++) { + $surname = $faker->lastName; + $christianName = $faker->firstName; + + // Randomly decide if person is deceased + $isDeceased = $faker->boolean(30); // 30% chance of being deceased + + // Generate dates (past dates for birth, future or null for death) + $dob = $faker->dateTimeBetween('-100 years', '-20 years'); + $dod = $isDeceased ? $faker->dateTimeBetween($dob, 'now') : null; + + // Create the person record + $person = Person::create([ + 'surname' => $surname, + 'christian_name' => $christianName, + 'full_name' => $christianName . ' ' . $surname, + 'date_of_birth' => $dob, + 'place_of_birth' => $faker->city . ', ' . $faker->country, + 'date_of_death' => $dod, + 'occupation' => $faker->jobTitle, + 'additional_notes' => $faker->boolean(70) ? $faker->paragraph(2) : null, + 'reference' => $faker->boolean(50) ? 'REF-' . $faker->randomNumber(5) : null, + 'id_card_no' => 'ID-' . $faker->unique()->randomNumber(8), + ]); + + // Create Migration data (80% chance) + if ($faker->boolean(80)) { + $arrivalAus = $faker->dateTimeBetween($dob, '-1 years'); + $arrivalNT = $faker->dateTimeBetween($arrivalAus, '+2 years'); + + Migration::create([ + 'person_id' => $person->person_id, + 'date_of_arrival_aus' => $arrivalAus, + 'date_of_arrival_nt' => $arrivalNT, + 'arrival_period' => $faker->randomElement(['Pre-WWII', 'Post-WWII', 'Modern Era']), + 'data_source' => $faker->randomElement(['Archives', 'Family Records', 'Historical Documents', 'Interviews']), + ]); + } + + // Create Residence data (70% chance) + if ($faker->boolean(70)) { + Residence::create([ + 'person_id' => $person->person_id, + 'town_or_city' => $faker->city, + 'home_at_death' => $isDeceased ? $faker->streetAddress . ', ' . $faker->city : null, + ]); + } + + // Create Family data (60% chance) + if ($faker->boolean(60)) { + Family::create([ + 'person_id' => $person->person_id, + 'names_of_parents' => $faker->boolean(70) ? $faker->name . ' & ' . $faker->name : null, + 'names_of_children' => $faker->boolean(50) ? implode(', ', $faker->words($faker->numberBetween(1, 4))) : null, + ]); + } + + // Create Naturalization data (40% chance) + if ($faker->boolean(40)) { + $naturalizationDate = $faker->dateTimeBetween($dob, 'now'); + + Naturalization::create([ + 'person_id' => $person->person_id, + 'date_of_naturalisation' => $naturalizationDate, + 'no_of_cert' => 'CERT-' . $faker->unique()->randomNumber(6), + 'issued_at' => $faker->city, + ]); + } + + // Create Internment data (10% chance - rare historical event) + if ($faker->boolean(10)) { + + Internment::create([ + 'person_id' => $person->person_id, + 'corps_issued' => $faker->randomElement(['Army', 'Navy', 'Air Force']), + 'interned_in' => $faker->city, + 'sent_to' => $faker->boolean(80) ? $faker->city : null, + 'internee_occupation' => $faker->jobTitle, + 'internee_address' => $faker->address, + 'cav' => $faker->boolean(50) ? $faker->randomNumber(5) : null, + ]); + } + } + + $this->command->info('100 sample Person records created successfully with related data'); + } +} diff --git a/routes/api.php b/routes/api.php index 97b9c27..e4212e0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,31 +3,38 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use App\Http\Controllers\PersonController; -use App\Http\Controllers\PublicSearchController; use App\Http\Controllers\AuthController; +use App\Http\Controllers\HistoricalSearchController; +use App\Http\Controllers\DashboardController; +use App\Http\Controllers\ActivityLogController; Route::post('/login', [AuthController::class, 'login'])->name('login'); +Route::post('/register', [AuthController::class, 'register'])->name('register'); -// Public search endpoints - allow searching without authentication -Route::get('/persons/search', [PublicSearchController::class, 'search'])->name('persons.public.search'); -// Public endpoint to get a specific migrant's full record -Route::get('/migrants/{person_id}', [PublicSearchController::class, 'getRecord'])->name('migrants.get'); +Route::prefix('historical')->group(function () { + Route::get('search', [HistoricalSearchController::class, 'search']); + Route::get('record/{id}', [HistoricalSearchController::class, 'getRecord']); +}); // Protected routes - require Sanctum authentication Route::middleware('auth:sanctum')->group(function () { // User authentication routes Route::get('/user', [AuthController::class, 'me'])->name('user.profile'); Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); - + Route::get('/dashboard/stats', [DashboardController::class, 'getStats']); // Admin-only routes - Route::middleware('ability:admin')->group(function () { - Route::post('/register', [AuthController::class, 'register'])->name('register'); - }); - + // Route::middleware('ability:admin')->group(function () { + // Route::post('/register', [AuthController::class, 'register'])->name('register'); + // }); + + Route::get('/persons/search', [PersonController::class, 'search']); + Route::get('/persons/{id}', [PersonController::class, 'show']); + + Route::get('/activity-logs', [ActivityLogController::class, 'index']); // Person API endpoints - all CRUD operations protected by authentication Route::apiResource('persons', PersonController::class); // Custom route for finding a person by ID card number - Route::get('persons/id-card/{idCardNo}', [PersonController::class, 'findByIdCard'])->name('persons.findByIdCard'); + // Route::get('persons/id-card/{idCardNo}', [PersonController::class, 'findByIdCard'])->name('persons.findByIdCard'); }); diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php new file mode 100644 index 0000000..722874d --- /dev/null +++ b/tests/Feature/AuthControllerTest.php @@ -0,0 +1,106 @@ + 'New User', + 'email' => 'newuser@example.com', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'message' => 'User created successfully', + ]) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'id', + 'name', + 'email', + 'updated_at', + 'created_at', + ] + ]); + + // Verify the user was actually created in the database + $this->assertDatabaseHas('users', [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + ]); + + // Verify the user is not an admin by default + $this->assertDatabaseHas('users', [ + 'email' => 'newuser@example.com', + 'is_admin' => 0, + ]); + } + + /** + * Test validation rules for user registration + */ + public function test_registration_validation_rules(): void + { + // Create and authenticate an admin user + $admin = User::factory()->create([ + 'is_admin' => true + ]); + + $this->actingAs($admin); + + // Test empty fields + $response = $this->postJson('/api/register', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'email', 'password']); + + // Test email format validation + $response = $this->postJson('/api/register', [ + 'name' => 'New User', + 'email' => 'not-an-email', + 'password' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + // Test email uniqueness validation + User::factory()->create(['email' => 'existing@example.com']); + + $response = $this->postJson('/api/register', [ + 'name' => 'New User', + 'email' => 'existing@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + // Test password length validation + $response = $this->postJson('/api/register', [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + 'password' => 'short', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } +} diff --git a/tests/Feature/HistoricalSearchControllerTest.php b/tests/Feature/HistoricalSearchControllerTest.php new file mode 100644 index 0000000..50b6969 --- /dev/null +++ b/tests/Feature/HistoricalSearchControllerTest.php @@ -0,0 +1,187 @@ +create([ + 'email' => 'test.admin@example.com', + 'is_admin' => true + ]); + + Sanctum::actingAs($user, ['*']); + } + + /** + * Test historical search returns paginated results + */ + public function test_historical_search_returns_paginated_results(): void + { + // Create test data + Person::factory(15)->create(); + + // Test without query parameter (should return all records paginated) + $response = $this->getJson('/api/historical/search'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data', + 'current_page', + 'per_page', + 'last_page', + 'total', + 'links' => [ + 'first', + 'last', + 'prev', + 'next' + ] + ]) + ->assertJson([ + 'per_page' => 10, + 'current_page' => 1 + ]); + + // Verify we got 10 items (pagination limit) + $this->assertCount(10, $response->json('data')); + } + + /** + * Test historical search with pagination parameters + */ + public function test_historical_search_pagination_works(): void + { + // Create test data - 25 records + Person::factory(25)->create(); + + // Test first page + $responsePage1 = $this->getJson('/api/historical/search?page=1'); + + $responsePage1->assertStatus(200) + ->assertJson([ + 'current_page' => 1, + 'last_page' => 3 // 25 records with 10 per page = 3 pages + ]); + + // Test second page + $responsePage2 = $this->getJson('/api/historical/search?page=2'); + + $responsePage2->assertStatus(200) + ->assertJson([ + 'current_page' => 2 + ]); + + // Verify different data between pages + $this->assertNotEquals( + $responsePage1->json('data')[0], + $responsePage2->json('data')[0] + ); + } + + /** + * Test historical search with query parameter + */ + public function test_historical_search_filters_by_query(): void + { + // Create test person with a unique name + Person::factory()->create([ + 'christian_name' => 'UniqueTestName', + 'surname' => 'TestSurname' + ]); + + // Create other random records + Person::factory(10)->create(); + + // Search for the unique name + $response = $this->getJson('/api/historical/search?query=UniqueTestName'); + + $response->assertStatus(200); + + // The search should find our unique record + $this->assertStringContainsString('UniqueTestName', json_encode($response->json('data'))); + } + + /** + * Test search returns correct paginated links + */ + public function test_historical_search_returns_correct_links(): void + { + // Create enough data for multiple pages + Person::factory(30)->create(); + + // Test middle page + $response = $this->getJson('/api/historical/search?page=2'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'links' => [ + 'first', + 'last', + 'prev', + 'next' + ] + ]); + + // Check links exist and contain page parameters + $this->assertStringContainsString('page=1', $response->json('links.first')); + $this->assertStringContainsString('page=3', $response->json('links.last')); + $this->assertStringContainsString('page=1', $response->json('links.prev')); + $this->assertStringContainsString('page=3', $response->json('links.next')); + } + + /** + * Test search preserves query parameters in pagination links + */ + public function test_historical_search_preserves_query_params_in_pagination(): void + { + // Create 30 people with 'Test' in their name to ensure we have enough matching records + Person::factory(30)->create([ + 'christian_name' => 'Test Person' + ]); + + // Search with a query and page=1 (so we definitely have a next page) + $response = $this->getJson('/api/historical/search?query=Test&page=1'); + + $response->assertStatus(200); + + // Verify links contain both the query and page parameters + $this->assertStringContainsString('query=Test', $response->json('links.first')); + $this->assertStringContainsString('query=Test', $response->json('links.last')); + $this->assertStringContainsString('query=Test', $response->json('links.next')); + } + + /** + * Test empty result handling + */ + public function test_historical_search_handles_empty_results(): void + { + // Search with a query that won't match anything + $response = $this->getJson('/api/historical/search?query=NonExistentQuery123456'); + + $response->assertStatus(200) + ->assertJson([ + 'data' => [], + 'total' => 0, + 'last_page' => 1 + ]); + } +} diff --git a/tests/Feature/PublicSearchApiTest.php b/tests/Feature/PublicSearchApiTest.php deleted file mode 100644 index 479d89d..0000000 --- a/tests/Feature/PublicSearchApiTest.php +++ /dev/null @@ -1,469 +0,0 @@ -createTestData(); - } - - /** - * Create test data for search tests. - */ - private function createTestData() - { - // Person 1: John Smith from Germany, arrived in 1880 at age 25, settled in Sydney - $person1 = Person::create([ - 'surname' => 'Smith', - 'christian_name' => 'John', - 'full_name' => 'John Smith', - 'date_of_birth' => '1855-03-15', - 'place_of_birth' => 'Berlin, Germany', - 'occupation' => 'Carpenter', - 'id_card_no' => 'TEST-001' - ]); - - Migration::create([ - 'person_id' => $person1->person_id, - 'date_of_arrival_aus' => '1880-06-10', - 'date_of_arrival_nt' => '1880-07-20', - ]); - - Residence::create([ - 'person_id' => $person1->person_id, - 'town_or_city' => 'Sydney', - 'home_at_death' => 'Sydney, NSW', - ]); - - // Person 2: Maria Mueller from Austria, arrived in 1885 at age 22, settled in Melbourne - $person2 = Person::create([ - 'surname' => 'Mueller', - 'christian_name' => 'Maria', - 'full_name' => 'Maria Mueller', - 'date_of_birth' => '1863-09-28', - 'place_of_birth' => 'Vienna, Austria', - 'occupation' => 'Seamstress', - 'id_card_no' => 'TEST-002' - ]); - - Migration::create([ - 'person_id' => $person2->person_id, - 'date_of_arrival_aus' => '1885-04-15', - 'date_of_arrival_nt' => '1885-05-20', - ]); - - Residence::create([ - 'person_id' => $person2->person_id, - 'town_or_city' => 'Melbourne', - 'home_at_death' => 'Melbourne, VIC', - ]); - - // Person 3: Robert Johnson from England, arrived in 1890 at age 30, settled in Brisbane - $person3 = Person::create([ - 'surname' => 'Johnson', - 'christian_name' => 'Robert', - 'full_name' => 'Robert Johnson', - 'date_of_birth' => '1860-05-10', - 'place_of_birth' => 'London, England', - 'occupation' => 'Teacher', - 'id_card_no' => 'TEST-003' - ]); - - Migration::create([ - 'person_id' => $person3->person_id, - 'date_of_arrival_aus' => '1890-08-12', - 'date_of_arrival_nt' => '1890-09-01', - ]); - - Residence::create([ - 'person_id' => $person3->person_id, - 'town_or_city' => 'Brisbane', - 'home_at_death' => 'Brisbane, QLD', - ]); - } - - /** - * Test that the endpoint returns 200 OK without authentication. - */ - public function test_public_search_endpoint_accessible_without_auth() - { - $response = $this->getJson('/api/persons/search'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'data', - 'links', - 'meta', - ], - 'message' - ]); - } - - /** - * Test that records are correctly filtered by firstName. - */ - public function test_filter_by_first_name() - { - $response = $this->getJson('/api/persons/search?firstName=John'); - - $response->assertStatus(200) - ->assertJsonPath('data.data.0.christian_name', 'John') - ->assertJsonCount(1, 'data.data'); - } - - /** - * Test that records are correctly filtered by lastName. - */ - public function test_filter_by_last_name() - { - $response = $this->getJson('/api/persons/search?lastName=Mueller'); - - $response->assertStatus(200) - ->assertJsonPath('data.data.0.surname', 'Mueller') - ->assertJsonCount(1, 'data.data'); - } - - /** - * Test that records are correctly filtered by regionOfOrigin. - */ - public function test_filter_by_region_of_origin() - { - $response = $this->getJson('/api/persons/search?regionOfOrigin=Germany'); - - $response->assertStatus(200) - ->assertJsonPath('data.data.0.place_of_birth', 'Berlin, Germany') - ->assertJsonCount(1, 'data.data'); - } - - /** - * Test that records are correctly filtered by yearOfArrival. - */ - public function test_filter_by_year_of_arrival() - { - $response = $this->getJson('/api/persons/search?yearOfArrival=1885'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'Mueller'); - } - - /** - * Test that records can be filtered by birth year rather than trying direct age calculation. - * This is a simplification of the age at migration test to avoid SQL calculation issues. - */ - public function test_filter_by_birth_year_equivalent_to_age_at_migration() - { - // John Smith was born in 1855, which would make him 25 in 1880 - // Let's search for people born in the 1850s instead to simplify the test - $response = $this->getJson('/api/persons/search?regionOfOrigin=Germany'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'Smith'); - } - - /** - * Test that records are correctly filtered by settlementLocation. - */ - public function test_filter_by_settlement_location() - { - // First, verify we have all records without filters - $response = $this->getJson('/api/persons/search'); - $response->assertStatus(200) - ->assertJsonCount(3, 'data.data'); - - // Now test filtering by settlement location using the new town_or_city field - $response = $this->getJson('/api/persons/search?settlementLocation=Sydney'); - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'Smith'); - } - - /** - * Test that the new town_or_city field stores and retrieves correctly through the API. - */ - public function test_town_or_city_field_works_correctly() - { - // Create a new person record with a residence - $person = Person::create([ - 'surname' => 'Darwin', - 'christian_name' => 'Charles', - 'full_name' => 'Charles Darwin', - 'date_of_birth' => '1809-02-12', - 'place_of_birth' => 'Shrewsbury, England', - 'occupation' => 'Naturalist', - 'id_card_no' => 'TEST-004' - ]); - - // Create a residence record with the new town_or_city field - $residence = Residence::create([ - 'person_id' => $person->person_id, - 'town_or_city' => 'Darwin', // Using the town name as a test - 'home_at_death' => 'Down House, Kent' - ]); - - // Retrieve the person through the API - $response = $this->getJson("/api/migrants/{$person->person_id}"); - - // Assert the response contains the correct town_or_city value - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Record retrieved successfully' - ]) - ->assertJsonPath('data.residence.town_or_city', 'Darwin'); - } - - /** - * Test that multiple filters can be combined. - */ - public function test_multiple_filters_combination() - { - $response = $this->getJson('/api/persons/search?firstName=John®ionOfOrigin=Germany'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'Smith') - ->assertJsonPath('data.data.0.christian_name', 'John') - ->assertJsonPath('data.data.0.place_of_birth', 'Berlin, Germany'); - } - - /** - * Test that when no filters are applied, all records are returned. - */ - public function test_no_filters_returns_all_records() - { - $response = $this->getJson('/api/persons/search'); - - $response->assertStatus(200) - ->assertJsonCount(3, 'data.data'); - } - - /** - * Test that a migrant's full record can be retrieved by ID. - */ - public function test_get_migrant_by_id() - { - // Get a person ID from the test data - $person = Person::where('id_card_no', 'TEST-001')->first(); - - // Call the endpoint - $response = $this->getJson("/api/migrants/{$person->person_id}"); - - // Assert response structure and content - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Record retrieved successfully' - ]) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'person_id', - 'surname', - 'christian_name', - 'full_name', - 'date_of_birth', - 'place_of_birth', - 'occupation', - 'id_card_no', - // Related models - 'migration', - 'naturalization', - 'residence', - 'family', - 'internment' - ], - 'message' - ]); - - // Verify the correct record was returned - $response->assertJsonPath('data.id_card_no', 'TEST-001'); - $response->assertJsonPath('data.christian_name', 'John'); - $response->assertJsonPath('data.surname', 'Smith'); - } - - /** - * Test that requesting a non-existent migrant ID returns 404. - */ - public function test_get_nonexistent_migrant_returns_404() - { - $response = $this->getJson('/api/migrants/999999'); - - $response->assertStatus(404); - } - - /** - * Test that using "all" as a filter value results in no filtering for that field. - */ - public function test_all_value_means_no_filtering() - { - // Should return all 3 records because "all" means no filtering - $response = $this->getJson('/api/persons/search?firstName=all&lastName=all'); - - $response->assertStatus(200) - ->assertJsonCount(3, 'data.data'); - - // Should return only Smith, even though lastName is set to "all" - $response = $this->getJson('/api/persons/search?firstName=John&lastName=all'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'Smith'); - } - - /** - * Test that POST requests to the search endpoint are not allowed. - */ - public function test_post_requests_not_allowed() - { - $response = $this->postJson('/api/persons/search'); - - $response->assertStatus(405); // Method Not Allowed - } - - /** - * Test that PUT requests to the search endpoint are not allowed. - */ - public function test_put_requests_not_allowed() - { - $response = $this->putJson('/api/persons/search'); - - // Laravel returns 401 for PUT because the route doesn't exist and it tries to authenticate - $response->assertStatus(401) - ->assertJson([ - 'message' => 'Unauthenticated.' - ]); - } - - /** - * Test that PATCH requests to the search endpoint are not allowed. - */ - public function test_patch_requests_not_allowed() - { - $response = $this->patchJson('/api/persons/search'); - - // Laravel returns 401 for PATCH because the route doesn't exist and it tries to authenticate - $response->assertStatus(401) - ->assertJson([ - 'message' => 'Unauthenticated.' - ]); - } - - /** - * Test that DELETE requests to the search endpoint are not allowed. - */ - public function test_delete_requests_not_allowed() - { - $response = $this->deleteJson('/api/persons/search'); - - // Laravel returns 401 for DELETE because the route doesn't exist and it tries to authenticate - $response->assertStatus(401) - ->assertJson([ - 'message' => 'Unauthenticated.' - ]); - } - - /** - * Test strict case-insensitive matching with at least one correct field returning results - * despite other incorrect fields. This validates OR logic in search when a special parameter is used. - */ - public function test_one_correct_field_with_multiple_incorrect() - { - // Create a special test person with unique attributes for this test - $specialPerson = Person::create([ - 'surname' => 'UniqueLastName', - 'christian_name' => 'UniqueFirstName', - 'full_name' => 'UniqueFirstName UniqueLastName', - 'date_of_birth' => '1930-05-15', - 'place_of_birth' => 'UniqueRegion, UniqueCountry', - 'occupation' => 'Developer', - 'id_card_no' => 'UNIQUE-ID-123' - ]); - - Migration::create([ - 'person_id' => $specialPerson->person_id, - 'date_of_arrival_aus' => '1960-03-20', - 'date_of_arrival_nt' => '1960-04-10', - ]); - - Residence::create([ - 'person_id' => $specialPerson->person_id, - 'town_or_city' => 'UniqueCity', - 'home_at_death' => 'UniqueCity, UniqueState', - ]); - - // Test case-insensitive matching for id_card_no - $response = $this->getJson('/api/persons/search?id_card_no=unique-id-123'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.id_card_no', 'UNIQUE-ID-123'); - - // Test case-insensitive matching for firstName - $response = $this->getJson('/api/persons/search?firstName=uniquefirstname'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.christian_name', 'UniqueFirstName'); - - // Test case-insensitive matching for lastName - $response = $this->getJson('/api/persons/search?lastName=uniquelastname'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'UniqueLastName'); - - // Test the OR logic where we provide one correct field and multiple incorrect ones - // Using the useOrLogic parameter to apply OR condition instead of AND - $response = $this->getJson('/api/persons/search?useOrLogic=true&id_card_no=unique-id-123&firstName=WrongName&lastName=WrongSurname'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.id_card_no', 'UNIQUE-ID-123'); - - // Another OR logic test with a different correct field - $response = $this->getJson('/api/persons/search?useOrLogic=true&id_card_no=WRONG-ID&firstName=uniquefirstname®ionOfOrigin=WrongRegion'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.christian_name', 'UniqueFirstName'); - - // Test mixing capitalization in the correct field - $response = $this->getJson('/api/persons/search?useOrLogic=true&id_card_no=WRONG-ID&firstName=WrongName&lastName=UniQueLastNAME'); - - $response->assertStatus(200) - ->assertJsonCount(1, 'data.data') - ->assertJsonPath('data.data.0.surname', 'UniqueLastName'); - - // Verify that normal behavior without useOrLogic returns no results for mixed inputs - $response = $this->getJson('/api/persons/search?id_card_no=unique-id-123&firstName=WrongName'); - - $response->assertStatus(200) - ->assertJsonCount(0, 'data.data'); - } -}