Api fix remove Resources

This commit is contained in:
mark 2025-05-21 21:20:40 +08:00
parent 7925015da1
commit 6848722da3
31 changed files with 1496 additions and 1288 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity;
class ActivityLogController extends Controller
{
public function index()
{
$logs = Activity::with('causer')
->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,
]);
}
}

View File

@ -12,33 +12,26 @@ use Illuminate\Validation\ValidationException;
class AuthController extends Controller class AuthController extends Controller
{ {
/** /**
* Register a new user (Admin only) * Register a new user
* *
* @param Request $request * @param Request $request
* @return JsonResponse * @return JsonResponse
*/ */
public function register(Request $request): 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([ $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users', 'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8', 'password' => 'required|string|min:8',
'is_admin' => 'boolean' // 'is_admin' => 'boolean'
]); ]);
$user = User::create([ $user = User::create([
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'is_admin' => $request->is_admin ?? false, // 'is_admin' => $request->is_admin ?? false,
]); ]);
return response()->json([ return response()->json([

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Models\Person;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardController extends Controller
{
public function getStats()
{
// Total migrants count
$totalMigrants = Person::count();
// New migrants in the current month
$currentMonthStart = Carbon::now()->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
]
]);
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use ProtoneMedia\LaravelCrossEloquentSearch\Search;
use App\Models\Person;
use App\Models\Migration;
use App\Models\Residence;
use Exception;
class HistoricalSearchController extends Controller
{
public function search(Request $request): JsonResponse
{
$query = $request->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);
}
}

View File

@ -4,309 +4,172 @@ namespace App\Http\Controllers;
use App\Http\Requests\StorePersonRequest; use App\Http\Requests\StorePersonRequest;
use App\Http\Requests\UpdatePersonRequest; use App\Http\Requests\UpdatePersonRequest;
use App\Http\Resources\PersonResource;
use App\Http\Resources\PersonCollection;
use App\Models\Person; 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\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Carbon\Carbon;
use Exception; use Exception;
class PersonController extends Controller class PersonController extends Controller
{ {
protected array $relations = ['migration', 'naturalization', 'residence', 'family', 'internment'];
// 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);
}
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = Person::query(); $query = Person::with($this->relations);
// Apply search filters if provided if ($search = $request->search) {
if ($request->has('search')) { $query->where(fn($q) =>
$searchTerm = $request->search; $q->where('full_name', 'LIKE', "%$search%")
$query->where(function($q) use ($searchTerm) { ->orWhere('surname', 'LIKE', "%$search%")
$q->where('full_name', 'LIKE', "%{$searchTerm}%") ->orWhere('occupation', 'LIKE', "%$search%")
->orWhere('surname', 'LIKE', "%{$searchTerm}%") );
->orWhere('occupation', 'LIKE', "%{$searchTerm}%");
});
} }
// 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([ return response()->json([
'success' => true, 'success' => true,
'data' => new PersonCollection($persons), 'data' => $query->paginate(10),
'message' => 'Persons retrieved successfully' 'message' => 'Persons retrieved successfully'
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
return response()->json([ return $this->errorResponse('Failed to retrieve persons', $e);
'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);
} }
} }
public function show(string $id): JsonResponse public function show(string $id): JsonResponse
{ {
try { try {
// Find person by ID and eager load all relationships $person = Person::with($this->relations)->find($id);
$person = Person::with(['migration', 'naturalization', 'residence', 'family', 'internment'])
->find($id);
if (!$person) { return $person
return response()->json([ ? $this->successResponse($person, 'Person retrieved successfully')
'success' => false, : $this->notFoundResponse('Person not found');
'message' => 'Person not found' } catch (Exception $e) {
], 404); return $this->errorResponse('Failed to retrieve person', $e);
}
} }
return response()->json([ public function store(StorePersonRequest $request): JsonResponse
'success' => true, {
'data' => new PersonResource($person), try {
'message' => 'Person retrieved successfully' $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) { } catch (Exception $e) {
return response()->json([ return $this->errorResponse('Failed to create person', $e);
'success' => false,
'message' => 'Failed to retrieve person',
'error' => $e->getMessage()
], 500);
} }
} }
public function update(UpdatePersonRequest $request, string $id): JsonResponse public function update(UpdatePersonRequest $request, string $id): JsonResponse
{ {
try { try {
// Use DB transaction for atomic operations
$result = DB::transaction(function () use ($request, $id) {
// Find person by ID
$person = Person::findOrFail($id); $person = Person::findOrFail($id);
// Update person record $data = $request->only([
$person->update($request->only([ 'surname', 'christian_name', 'date_of_birth', 'place_of_birth',
'surname', 'christian_name', 'full_name', 'date_of_birth', 'date_of_death', 'occupation', 'additional_notes', 'reference', 'id_card_no'
'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'
]); ]);
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) { } catch (Exception $e) {
return response()->json([ return $this->errorResponse('Failed to update person', $e);
'success' => false,
'message' => 'Failed to update person',
'error' => $e->getMessage()
], 500);
} }
} }
public function destroy(string $id): JsonResponse public function destroy(string $id): JsonResponse
{ {
try { try {
// Use DB transaction for atomic operations $person = Person::with($this->relations)->findOrFail($id);
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 foreach ($this->relations as $relation) {
if ($person->migration) { $person->$relation?->delete();
$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(); $person->delete();
});
return response()->json([ return $this->successResponse(null, 'Person deleted successfully');
'success' => true,
'message' => 'Person deleted successfully'
]);
} catch (Exception $e) { } catch (Exception $e) {
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([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Failed to delete person', 'message' => $message,
'error' => $e->getMessage() 'error' => $e->getMessage()
], 500); ], 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);
} }
} }
}

View File

@ -1,290 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PersonCollection;
use App\Models\Person;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Exception;
class PublicSearchController extends Controller
{
/**
* Search for persons with various filters
*
* @param Request $request
* @return JsonResponse
*/
public function search(Request $request): JsonResponse
{
try {
$query = $this->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);
}
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FamilyResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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;
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InternmentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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;
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MigrationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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;
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class NaturalizationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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;
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PersonCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
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(),
],
];
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PersonResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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')),
];
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ResidenceResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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;
}
}

View File

@ -0,0 +1,22 @@
<?php
// app/Models/Activity.php
namespace App\Models;
use Spatie\Activitylog\Models\Activity as SpatieActivity;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Activity extends SpatieActivity
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'causer_id');
}
// Optional: shortcut
public function getCauserNameAttribute(): ?string
{
return $this->user?->name;
}
}

View File

@ -5,10 +5,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Person extends Model class Person extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes, Searchable, LogsActivity;
protected $table = 'person'; protected $table = 'person';
protected $primaryKey = 'person_id'; protected $primaryKey = 'person_id';
@ -31,7 +34,27 @@ class Person extends Model
'date_of_death' => '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() public function migration()
{ {
return $this->hasOne(Migration::class, 'person_id'); return $this->hasOne(Migration::class, 'person_id');
@ -56,4 +79,16 @@ class Person extends Model
{ {
return $this->hasOne(Internment::class, 'person_id'); 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,
];
}
} }

View File

@ -2,22 +2,17 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable, LogsActivity;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
@ -25,21 +20,11 @@ class User extends Authenticatable
'is_admin', 'is_admin',
]; ];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -47,4 +32,13 @@ class User extends Authenticatable
'password' => 'hashed', '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
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Spatie\Activitylog\Models\Activity;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +20,11 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Activity::saving(function ($activity) {
if (auth()->check()) {
$activity->causer_name = auth()->user()->name;
} }
});
}
} }

View File

@ -9,7 +9,10 @@
"php": "^8.2", "php": "^8.2",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.1", "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": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

300
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "015e02e23a490fcf9d86230851cba120", "content-hash": "1dfb34123f9a766e68760fa85b831c55",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -1392,6 +1392,87 @@
}, },
"time": "2025-04-23T13:03:38+00:00" "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", "name": "laravel/serializable-closure",
"version": "v2.0.4", "version": "v2.0.4",
@ -2647,6 +2728,71 @@
], ],
"time": "2024-07-20T21:41:07+00:00" "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", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
@ -3350,6 +3496,158 @@
], ],
"time": "2024-04-27T21:32:50+00:00" "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", "name": "symfony/clock",
"version": "v7.2.0", "version": "v7.2.0",

52
config/activitylog.php Normal file
View File

@ -0,0 +1,52 @@
<?php
return [
/*
* If set to false, no activities will be saved to the database.
*/
'enabled' => 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'),
];

209
config/scout.php Normal file
View File

@ -0,0 +1,209 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => 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'
// ],
// ],
],
],
];

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->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'));
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddEventColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->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');
});
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBatchUuidColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->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');
});
}
}

View File

@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
// Create admin user for testing the authentication system // Create admin user for testing the authentication system
$this->call([ $this->call([
AdminUserSeeder::class, AdminUserSeeder::class,
PersonSeeder::class, // Seed 100 sample Person records
]); ]);
// Create a regular user for testing // Create a regular user for testing

View File

@ -0,0 +1,113 @@
<?php
namespace Database\Seeders;
use App\Models\Person;
use App\Models\Migration;
use App\Models\Residence;
use App\Models\Family;
use App\Models\Naturalization;
use App\Models\Internment;
use Illuminate\Database\Seeder;
use Faker\Factory as Faker;
use Carbon\Carbon;
class PersonSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$faker = Faker::create();
$this->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');
}
}

View File

@ -3,31 +3,38 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PersonController; use App\Http\Controllers\PersonController;
use App\Http\Controllers\PublicSearchController;
use App\Http\Controllers\AuthController; 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('/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::prefix('historical')->group(function () {
Route::get('/migrants/{person_id}', [PublicSearchController::class, 'getRecord'])->name('migrants.get'); Route::get('search', [HistoricalSearchController::class, 'search']);
Route::get('record/{id}', [HistoricalSearchController::class, 'getRecord']);
});
// Protected routes - require Sanctum authentication // Protected routes - require Sanctum authentication
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
// User authentication routes // User authentication routes
Route::get('/user', [AuthController::class, 'me'])->name('user.profile'); Route::get('/user', [AuthController::class, 'me'])->name('user.profile');
Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/dashboard/stats', [DashboardController::class, 'getStats']);
// Admin-only routes // Admin-only routes
Route::middleware('ability:admin')->group(function () { // Route::middleware('ability:admin')->group(function () {
Route::post('/register', [AuthController::class, 'register'])->name('register'); // 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 // Person API endpoints - all CRUD operations protected by authentication
Route::apiResource('persons', PersonController::class); Route::apiResource('persons', PersonController::class);
// Custom route for finding a person by ID card number // 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');
}); });

View File

@ -0,0 +1,106 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Test that anyone can register as a new user
*/
public function test_public_user_registration(): void
{
$userData = [
'name' => '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']);
}
}

View File

@ -0,0 +1,187 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Person;
use App\Models\Migration;
use App\Models\Residence;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class HistoricalSearchControllerTest extends TestCase
{
use RefreshDatabase, WithFaker;
/**
* Set up authentication for each test
*/
protected function setUp(): void
{
parent::setUp();
// Create and authenticate as admin user for all tests
$user = User::factory()->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
]);
}
}

View File

@ -1,469 +0,0 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Person;
use App\Models\Migration;
use App\Models\Residence;
use App\Models\Naturalization;
use App\Models\Family;
use App\Models\Internment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\DB;
class PublicSearchApiTest extends TestCase
{
use RefreshDatabase;
/**
* Set up test data before each test.
*/
protected function setUp(): void
{
parent::setUp();
// Create test persons with related data for filtering tests
$this->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&regionOfOrigin=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&regionOfOrigin=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');
}
}