From cac37d000bac6ab8a59d6976e5c28eb9965bdf85 Mon Sep 17 00:00:00 2001 From: mark Date: Tue, 27 May 2025 10:10:53 +0800 Subject: [PATCH] initial commit --- app/Http/Controllers/DashboardController.php | 26 +- .../HistoricalSearchController.php | 103 ---- app/Http/Controllers/MigrantController.php | 551 ++++++++++++++++++ app/Http/Controllers/PersonController.php | 175 ------ app/Http/Controllers/PhotoController.php | 200 +++++++ app/Models/Person.php | 23 +- app/Models/Photo.php | 70 +++ .../2025_05_24_133622_create_photos_table.php | 42 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/PersonSeeder.php | 7 +- database/seeders/PhotoSeeder.php | 102 ++++ routes/api.php | 52 +- tests/Feature/MigrantPhotoTest.php | 180 ++++++ 13 files changed, 1217 insertions(+), 315 deletions(-) delete mode 100644 app/Http/Controllers/HistoricalSearchController.php create mode 100644 app/Http/Controllers/MigrantController.php delete mode 100644 app/Http/Controllers/PersonController.php create mode 100644 app/Http/Controllers/PhotoController.php create mode 100644 app/Models/Photo.php create mode 100644 database/migrations/2025_05_24_133622_create_photos_table.php create mode 100644 database/seeders/PhotoSeeder.php create mode 100644 tests/Feature/MigrantPhotoTest.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 34a1044..4e5d3dd 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Person; +use App\Models\Migration; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Carbon\Carbon; @@ -40,6 +41,21 @@ class DashboardController extends Controller }) ->count(); + // Find peak migration period (by year) + $peakMigrationYear = Migration::select(DB::raw('YEAR(date_of_arrival_nt) as year'), DB::raw('COUNT(*) as count')) + ->whereNotNull('date_of_arrival_nt') + ->groupBy(DB::raw('YEAR(date_of_arrival_nt)')) + ->orderBy('count', 'desc') + ->first(); + + // Find most common place of birth + $mostCommonOrigin = Person::select('place_of_birth', DB::raw('COUNT(*) as count')) + ->whereNotNull('place_of_birth') + ->where('place_of_birth', '!=', '') + ->groupBy('place_of_birth') + ->orderBy('count', 'desc') + ->first(); + return response()->json([ 'success' => true, 'data' => [ @@ -47,7 +63,15 @@ class DashboardController extends Controller 'new_this_month' => $newThisMonth, 'recent_additions' => $recentAdditions, 'pending_reviews' => $pendingReviews, - 'incomplete_records' => $incompleteRecords + 'incomplete_records' => $incompleteRecords, + 'peak_migration_year' => $peakMigrationYear ? [ + 'year' => $peakMigrationYear->year, + 'count' => $peakMigrationYear->count + ] : null, + 'most_common_origin' => $mostCommonOrigin ? [ + 'place' => $mostCommonOrigin->place_of_birth, + 'count' => $mostCommonOrigin->count + ] : null ] ]); } diff --git a/app/Http/Controllers/HistoricalSearchController.php b/app/Http/Controllers/HistoricalSearchController.php deleted file mode 100644 index ff652fd..0000000 --- a/app/Http/Controllers/HistoricalSearchController.php +++ /dev/null @@ -1,103 +0,0 @@ -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/MigrantController.php b/app/Http/Controllers/MigrantController.php new file mode 100644 index 0000000..0e64901 --- /dev/null +++ b/app/Http/Controllers/MigrantController.php @@ -0,0 +1,551 @@ +where(function ($outerQuery) use ($request) { + if ($request->filled('full_name')) { + $outerQuery->orWhere('full_name', 'LIKE', '%' . $request->input('full_name') . '%'); + } + + if ($request->filled('christian_name')) { + $outerQuery->orWhere('christian_name', 'LIKE', '%' . $request->input('christian_name') . '%'); + } + + if ($request->filled('surname')) { + $outerQuery->orWhere('surname', 'LIKE', '%' . $request->input('surname') . '%'); + } + + if ($request->filled('place_of_birth')) { + $outerQuery->orWhere('place_of_birth', 'LIKE', '%' . $request->input('place_of_birth') . '%'); + } + + if ($request->filled('occupation')) { + $outerQuery->orWhere('occupation', 'LIKE', '%' . $request->input('occupation') . '%'); + } + + if ($request->filled('date_of_birth')) { + $outerQuery->orWhereDate('date_of_birth', $request->input('date_of_birth')); + } + + if ($request->filled('arrival_from') || $request->filled('arrival_to')) { + $outerQuery->orWhereHas('migration', function ($mq) use ($request) { + if ($request->filled('arrival_from') && $request->filled('arrival_to')) { + $mq->whereBetween('date_of_arrival_nt', [ + $request->input('arrival_from'), + $request->input('arrival_to') + ]); + } elseif ($request->filled('arrival_from')) { + $mq->where('date_of_arrival_nt', '>=', $request->input('arrival_from')); + } elseif ($request->filled('arrival_to')) { + $mq->where('date_of_arrival_nt', '<=', $request->input('arrival_to')); + } + }); + } + + if ($request->filled('town_or_city')) { + $outerQuery->orWhereHas('residence', function ($rq) use ($request) { + $rq->where('town_or_city', 'LIKE', '%' . $request->input('town_or_city') . '%'); + }); + } + }); + + // Sorting + $sortBy = $request->input('sort_by', 'created_at'); + $sortOrder = $request->input('sort_order', 'desc'); + $secondarySortBy = $request->input('secondary_sort_by'); + $secondarySortOrder = $request->input('secondary_sort_order', 'asc'); + + if ($sortBy === 'date_of_arrival_nt') { + $query->leftJoin('migration', 'migration.person_id', '=', 'person.person_id'); + $joinedMigration = true; + $query->select('person.*')->orderBy('migration.date_of_arrival_nt', $sortOrder); + } elseif (in_array($sortBy, ['full_name', 'christian_name', 'surname'])) { + $query->orderBy("person.$sortBy", $sortOrder); + } else { + $query->orderBy('person.created_at', 'desc'); + } + + if ($secondarySortBy === 'date_of_arrival_nt') { + if (!$joinedMigration) { + $query->leftJoin('migration', 'migration.person_id', '=', 'person.person_id'); + $query->select('person.*'); + } + $query->orderBy('migration.date_of_arrival_nt', $secondarySortOrder); + } elseif (in_array($secondarySortBy, ['full_name', 'christian_name', 'surname'])) { + $query->orderBy("person.$secondarySortBy", $secondarySortOrder); + } + + // Pagination + $perPage = $request->input('per_page', 10); + $results = $query->paginate($perPage); + + // Optionally map photo URLs (if you need full URLs or extra processing) + $results->getCollection()->transform(function ($person) { + $person->photos->transform(function ($photo) { + $photo->url = asset('storage/' . $photo->path); // or whatever logic you have + return $photo; + }); + return $person; + }); + + return response()->json([ + 'success' => true, + 'data' => $results, + 'message' => 'Persons retrieved successfully', + ]); + } catch (Exception $e) { + \Log::error('Error retrieving persons: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to retrieve persons', + 'error' => $e->getMessage(), + ], 500); + } + } + + public function show(string $id): JsonResponse + { + try { + $person = Person::with(array_merge($this->relations, ['photos']))->find($id); + + if ($person) { + // Add full URLs to photos + $person->photos->transform(function ($photo) { + $photo->url = asset('storage/' . $photo->path); // Adjust if needed + return $photo; + }); + + return $this->successResponse($person, 'Person retrieved successfully'); + } + + return $this->notFoundResponse('Person not found'); + } catch (Exception $e) { + 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); + + // Handle related data + foreach ($this->relations as $relation) { + if ($request->has($relation)) { + $person->$relation()->create($request->$relation); + } + } + + // Handle photo uploads + $uploadedPhotos = []; + if ($request->hasFile('photos')) { + $photoController = new PhotoController(); // Or inject this via DI + $uploadedPhotos = $photoController->handlePhotoUpload( + $request->file('photos'), + $person->person_id, + $request->input('captions', []), + $request->input('main_photo_index') // Pass the index directly + ); + } + + // Load all relations including photos + $person->load(array_merge($this->relations, ['photos'])); + + return $this->successResponse([ + 'person' => $person, + 'uploaded_photos' => $uploadedPhotos + ], 'Person created successfully', 201); + } catch (Exception $e) { + return $this->errorResponse('Failed to create person', $e); + } + } + + public function update(UpdatePersonRequest $request, string $id): JsonResponse + { + try { + $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); + } + } + + // Handle photo uploads during update + $uploadedPhotos = []; + if ($request->hasFile('photos')) { + $uploadedPhotos = $this->handlePhotoUpload($request, $person->person_id); + } + + // Handle profile photo setting + if ($request->input('set_as_profile')) { + $profilePhotoSet = false; + + // Try to set profile photo for new uploads first + if ($request->has('profile_photo_index')) { + $index = (int) $request->input('profile_photo_index'); + + if (isset($uploadedPhotos[$index])) { + // Clear all profile photos first + Photo::where('person_id', $person->person_id)->update(['is_profile_photo' => false]); + + // Set the new uploaded photo as profile + $uploadedPhotos[$index]->is_profile_photo = true; + $uploadedPhotos[$index]->save(); + $profilePhotoSet = true; + } + } + + // If no new photo was set as profile, try existing photo + if (!$profilePhotoSet && $request->has('profile_photo_id')) { + $photoId = $request->input('profile_photo_id'); + $photo = Photo::where('id', $photoId)->where('person_id', $person->person_id)->first(); + + if ($photo) { + // Clear all profile photos first + Photo::where('person_id', $person->person_id)->update(['is_profile_photo' => false]); + + // Set the existing photo as profile + $photo->is_profile_photo = true; + $photo->save(); + $profilePhotoSet = true; + } + } + + // Log if profile photo couldn't be set (for debugging) + if (!$profilePhotoSet) { + \Log::warning('Failed to set profile photo', [ + 'person_id' => $person->person_id, + 'profile_photo_index' => $request->input('profile_photo_index'), + 'profile_photo_id' => $request->input('profile_photo_id'), + 'uploaded_photos_count' => count($uploadedPhotos) + ]); + } + } + + // Handle existing photo updates (captions, etc.) + if ($request->has('existing_photos')) { + $existingPhotos = $request->input('existing_photos'); + + if (is_array($existingPhotos)) { + foreach ($existingPhotos as $photoData) { + if (isset($photoData['id'])) { + $photo = Photo::where('id', $photoData['id']) + ->where('person_id', $person->person_id) + ->first(); + + if ($photo && isset($photoData['caption'])) { + $photo->caption = $photoData['caption']; + $photo->save(); + } + } + } + } + } + + return $this->successResponse([ + 'person' => $person->load(array_merge($this->relations, ['photos'])), + 'uploaded_photos' => $uploadedPhotos + ], 'Person updated successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to update person', $e); + } + } + + public function destroy(string $id): JsonResponse + { + try { + $person = Person::with($this->relations)->findOrFail($id); + + foreach ($this->relations as $relation) { + $person->$relation?->delete(); + } + + // Delete associated photos + if ($person->photos) { + foreach ($person->photos as $photo) { + $path = 'public/photos/' . $person->person_id . '/' . $photo->filename; + if (Storage::exists($path)) { + Storage::delete($path); + } + $photo->delete(); + } + } + + $person->delete(); + + return $this->successResponse(null, 'Person deleted successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to delete person', $e); + } + } + + // ========== PHOTO MANAGEMENT METHODS ========== + + /** + * Get all photos for a specific person + */ + public function getPhotos($personId): JsonResponse + { + try { + $person = Person::findOrFail($personId); + $photos = $person->photos()->get(); + + return $this->successResponse([ + 'photos' => $photos, + 'profile_photo' => $person->photos()->where('is_profile_photo', true)->first() + ], 'Photos retrieved successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to retrieve photos', $e); + } + } + + /** + * Upload photos for a person + */ + public function uploadPhotos(Request $request, $personId): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'photos' => 'required|array', + 'photos.*' => 'required|image|max:10240', // Max 10MB per image + 'captions' => 'nullable|array', + 'captions.*' => 'nullable|string|max:255', + 'set_as_profile' => 'nullable|boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors() + ], 422); + } + + try { + // Find the person + $person = Person::findOrFail($personId); + $uploadedPhotos = $this->handlePhotoUpload($request, $personId); + + return $this->successResponse([ + 'uploaded_photos' => $uploadedPhotos, + 'total_photos' => $person->photos()->count() + ], 'Photos uploaded successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to upload photos', $e); + } + } + + /** + * Set a photo as the profile photo + */ + public function setAsProfilePhoto($photoId): JsonResponse + { + try { + $photo = Photo::findOrFail($photoId); + + // Remove profile photo flag from other photos of the same person + Photo::where('person_id', $photo->person_id) + ->update(['is_profile_photo' => false]); + + // Set this photo as profile photo + $photo->is_profile_photo = true; + $result = $photo->save(); + + return $this->successResponse($photo, + $result ? 'Profile photo set successfully' : 'Failed to set profile photo'); + } catch (Exception $e) { + return $this->errorResponse('Failed to set profile photo', $e); + } + } + + /** + * Update photo caption + */ + public function updatePhotoCaption(Request $request, $photoId): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'caption' => 'required|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors() + ], 422); + } + + try { + $photo = Photo::findOrFail($photoId); + $photo->caption = $request->input('caption'); + $photo->save(); + + return $this->successResponse($photo, 'Caption updated successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to update caption', $e); + } + } + + /** + * Delete a photo + */ + public function deletePhoto($photoId): JsonResponse + { + try { + $photo = Photo::findOrFail($photoId); + + // Delete the physical file + $path = 'public/photos/' . $photo->person_id . '/' . $photo->filename; + if (Storage::exists($path)) { + Storage::delete($path); + } + + // If this was a profile photo, try to set another one + if ($photo->is_profile_photo) { + $nextPhoto = Photo::where('person_id', $photo->person_id) + ->where('id', '!=', $photo->id) + ->first(); + + if ($nextPhoto) { + $nextPhoto->is_profile_photo = true; + $nextPhoto->save(); + } + } + + // Delete the database record + $photo->delete(); + + return $this->successResponse(null, 'Photo deleted successfully'); + } catch (Exception $e) { + return $this->errorResponse('Failed to delete photo', $e); + } + } + + // ========== HELPER METHODS ========== + + /** + * Handle photo upload for person creation/update + */ + private function handlePhotoUpload(Request $request, $personId): array + { + $uploadedPhotos = []; + + if ($request->hasFile('photos')) { + $files = $request->file('photos'); + $captions = $request->input('photo_captions', []); + $setAsProfile = $request->input('set_as_profile', false); + $profilePhotoSet = false; + + foreach ($files as $index => $file) { + // Validate file + if (!$file->isValid() || !in_array($file->getMimeType(), ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) { + continue; + } + + // Generate a unique filename + $extension = $file->getClientOriginalExtension(); + $filename = Str::uuid() . '.' . $extension; + + // Store the file + $path = $file->storeAs('photos/' . $personId, $filename, 'public'); + + // Create photo record + $photo = new Photo([ + 'person_id' => $personId, + 'filename' => $filename, + 'original_filename' => $file->getClientOriginalName(), + 'file_path' => Storage::url($path), + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize() / 1024, // Convert to KB + 'caption' => $captions[$index] ?? null, + 'is_profile_photo' => false + ]); + + $photo->save(); + $uploadedPhotos[] = $photo; + + // Set as profile photo if requested and it's the first photo + if ($setAsProfile && !$profilePhotoSet) { + // Remove profile photo flag from other photos + Photo::where('person_id', $personId)->update(['is_profile_photo' => false]); + $photo->is_profile_photo = true; + $photo->save(); + $profilePhotoSet = true; + } + } + } + + return $uploadedPhotos; + } + + // 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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php deleted file mode 100644 index 7bd1106..0000000 --- a/app/Http/Controllers/PersonController.php +++ /dev/null @@ -1,175 +0,0 @@ -relations); - - if ($search = $request->search) { - $query->where(fn($q) => - $q->where('full_name', 'LIKE', "%$search%") - ->orWhere('surname', 'LIKE', "%$search%") - ->orWhere('occupation', 'LIKE', "%$search%") - ); - } - - return response()->json([ - 'success' => true, - 'data' => $query->paginate(10), - 'message' => 'Persons retrieved successfully' - ]); - } catch (Exception $e) { - return $this->errorResponse('Failed to retrieve persons', $e); - } - } - - public function show(string $id): JsonResponse - { - try { - $person = Person::with($this->relations)->find($id); - - return $person - ? $this->successResponse($person, 'Person retrieved successfully') - : $this->notFoundResponse('Person not found'); - } catch (Exception $e) { - 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 { - $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 $this->errorResponse('Failed to update person', $e); - } - } - - public function destroy(string $id): JsonResponse - { - try { - $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 $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/PhotoController.php b/app/Http/Controllers/PhotoController.php new file mode 100644 index 0000000..e074bf4 --- /dev/null +++ b/app/Http/Controllers/PhotoController.php @@ -0,0 +1,200 @@ +photos()->get(); + + return response()->json([ + 'success' => true, + 'data' => $photos, + 'profile_photo' => $person->photos()->where('is_profile_photo', true)->first() + ]); + } + + public function handlePhotoUpload($files, $personId, $captions = [], $mainPhotoIndex = null) + { + $uploadedPhotos = []; + + foreach ($files as $index => $file) { + $extension = $file->getClientOriginalExtension(); + $filename = Str::uuid() . '.' . $extension; + $path = $file->storeAs('photos/' . $personId, $filename, 'public'); + + $photo = new Photo([ + 'person_id' => $personId, + 'filename' => $filename, + 'original_filename' => $file->getClientOriginalName(), + 'file_path' => Storage::url($path), + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize() / 1024, // KB + 'caption' => $captions[$index] ?? null, + 'is_profile_photo' => ($mainPhotoIndex !== null && $index == $mainPhotoIndex) + ]); + + $photo->save(); + $uploadedPhotos[] = $photo; + } + + return $uploadedPhotos; + } + + + /** + * Upload photos for a person + */ + public function upload(Request $request, $personId) + { + $validator = Validator::make($request->all(), [ + 'photos' => 'required|array', + 'photos.*' => 'required|image|max:10240', // Max 10MB per image + 'captions' => 'nullable|array', + 'captions.*' => 'nullable|string|max:255', + 'set_as_profile' => 'nullable|boolean', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors() + ], 422); + } + + // Find the person + $person = Person::findOrFail($personId); + $uploadedPhotos = []; + + // Process each uploaded photo + if ($request->hasFile('photos')) { + $files = $request->file('photos'); + $captions = $request->input('captions', []); + $setAsProfile = $request->input('set_as_profile', false); + $profilePhotoSet = false; + + foreach ($files as $index => $file) { + // Generate a unique filename + $extension = $file->getClientOriginalExtension(); + $filename = Str::uuid() . '.' . $extension; + + // Store the file + $path = $file->storeAs('photos/' . $personId, $filename, 'public'); + + // Create photo record + $photo = new Photo([ + 'person_id' => $personId, + 'filename' => $filename, + 'original_filename' => $file->getClientOriginalName(), + 'file_path' => Storage::url($path), + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize() / 1024, // Convert to KB + 'caption' => $captions[$index] ?? null, + 'is_profile_photo' => false + ]); + + $photo->save(); + $uploadedPhotos[] = $photo; + + // Set as profile photo if requested and it's the first photo + if ($setAsProfile && !$profilePhotoSet) { + $photo->setAsProfilePhoto(); + $profilePhotoSet = true; + } + } + } + + return response()->json([ + 'success' => true, + 'message' => 'Photos uploaded successfully', + 'data' => $uploadedPhotos + ]); + } + + /** + * Set a photo as the profile photo + */ + public function setAsProfilePhoto($photoId) + { + $photo = Photo::findOrFail($photoId); + $result = $photo->setAsProfilePhoto(); + + return response()->json([ + 'success' => $result, + 'message' => $result ? 'Profile photo set successfully' : 'Failed to set profile photo', + 'data' => $photo + ]); + } + + /** + * Update photo caption + */ + public function updateCaption(Request $request, $photoId) + { + $validator = Validator::make($request->all(), [ + 'caption' => 'required|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'errors' => $validator->errors() + ], 422); + } + + $photo = Photo::findOrFail($photoId); + $photo->caption = $request->input('caption'); + $photo->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Caption updated successfully', + 'data' => $photo + ]); + } + + /** + * Delete a photo + */ + public function delete($photoId) + { + $photo = Photo::findOrFail($photoId); + + // Delete the physical file + $path = 'public/photos/' . $photo->person_id . '/' . $photo->filename; + if (Storage::exists($path)) { + Storage::delete($path); + } + + // If this was a profile photo, try to set another one + if ($photo->is_profile_photo) { + $nextPhoto = Photo::where('person_id', $photo->person_id) + ->where('id', '!=', $photo->id) + ->first(); + + if ($nextPhoto) { + $nextPhoto->setAsProfilePhoto(); + } + } + + // Delete the database record + $photo->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Photo deleted successfully' + ]); + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php index a760d93..2e57ef7 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -11,7 +11,7 @@ use Spatie\Activitylog\LogOptions; class Person extends Model { - use HasFactory, SoftDeletes, Searchable, LogsActivity; + use HasFactory, SoftDeletes, LogsActivity; protected $table = 'person'; protected $primaryKey = 'person_id'; @@ -79,16 +79,17 @@ class Person extends Model { return $this->hasOne(Internment::class, 'person_id'); } - - public function toSearchableArray() + + public function photos() { - 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, - ]; + return $this->hasMany(Photo::class, 'person_id', 'person_id'); + } + + public function profilePhoto() + { + return $this->hasMany(Photo::class, 'person_id', 'person_id') + ->where('is_profile_photo', true) + ->latest() + ->first(); } } diff --git a/app/Models/Photo.php b/app/Models/Photo.php new file mode 100644 index 0000000..78c07c3 --- /dev/null +++ b/app/Models/Photo.php @@ -0,0 +1,70 @@ + 'boolean', + ]; + + // 🔧 Configure Spatie logging + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->useLogName('photo') + ->logFillable() + ->logOnlyDirty(); + } + + // 📝 Custom activity description for events + public function getDescriptionForEvent(string $eventName): string + { + return match ($eventName) { + 'created' => 'Added new photo', + 'updated' => 'Updated photo details', + 'deleted' => 'Deleted photo', + default => ucfirst($eventName) . ' photo record', + }; + } + + // 🔗 Relationships + public function person() + { + return $this->belongsTo(Person::class, 'person_id', 'person_id'); + } + + // Helper method to set a photo as profile photo + public function setAsProfilePhoto(): bool + { + // First unset any existing profile photo for this person + if ($this->person_id) { + self::where('person_id', $this->person_id) + ->where('is_profile_photo', true) + ->update(['is_profile_photo' => false]); + } + + // Set this photo as profile photo + $this->is_profile_photo = true; + return $this->save(); + } +} diff --git a/database/migrations/2025_05_24_133622_create_photos_table.php b/database/migrations/2025_05_24_133622_create_photos_table.php new file mode 100644 index 0000000..44e1eb4 --- /dev/null +++ b/database/migrations/2025_05_24_133622_create_photos_table.php @@ -0,0 +1,42 @@ +id(); + $table->unsignedBigInteger('person_id'); + $table->string('filename'); + $table->string('original_filename')->nullable(); + $table->string('file_path'); + $table->string('mime_type')->nullable(); + $table->integer('file_size')->nullable(); // in KB + $table->boolean('is_profile_photo')->default(false); + $table->text('caption')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + // Foreign key constraint + $table->foreign('person_id') + ->references('person_id') + ->on('person') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('photos'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 40b4bb5..1ce5637 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ class DatabaseSeeder extends Seeder $this->call([ AdminUserSeeder::class, PersonSeeder::class, // Seed 100 sample Person records + PhotoSeeder::class, // Seed photos for the Person records ]); // Create a regular user for testing diff --git a/database/seeders/PersonSeeder.php b/database/seeders/PersonSeeder.php index 3af91d5..8121ba5 100644 --- a/database/seeders/PersonSeeder.php +++ b/database/seeders/PersonSeeder.php @@ -65,10 +65,13 @@ class PersonSeeder extends Seeder // Create Residence data (70% chance) if ($faker->boolean(70)) { + // Use only specific Northern Territory locations + $ntLocations = ['Darwin', 'Tennant Creek', 'Katherine', 'Alice Springs']; + Residence::create([ 'person_id' => $person->person_id, - 'town_or_city' => $faker->city, - 'home_at_death' => $isDeceased ? $faker->streetAddress . ', ' . $faker->city : null, + 'town_or_city' => $faker->randomElement($ntLocations), + 'home_at_death' => $isDeceased ? $faker->streetAddress . ', ' . $faker->randomElement($ntLocations) : null, ]); } diff --git a/database/seeders/PhotoSeeder.php b/database/seeders/PhotoSeeder.php new file mode 100644 index 0000000..75db6f2 --- /dev/null +++ b/database/seeders/PhotoSeeder.php @@ -0,0 +1,102 @@ +person_id); + if (!File::exists($personDir)) { + File::makeDirectory($personDir, 0755, true); + } + + // Generate a unique filename + $filename = Str::uuid() . '.jpg'; + $fullPath = $personDir . '/' . $filename; + + // Save the image file + file_put_contents($fullPath, $imageData); + + // Choose if this should be a profile photo + // First photo has 70% chance, otherwise 0% chance + $isProfilePhoto = !$profilePhotoSet && (rand(1, 10) <= 7); + + if ($isProfilePhoto) { + $profilePhotoSet = true; + } + + // Create the photo record + $photo = new Photo([ + 'person_id' => $person->person_id, + 'filename' => $filename, + 'original_filename' => 'sample_' . rand(1000, 9999) . '.jpg', + 'file_path' => '/storage/photos/' . $person->person_id . '/' . $filename, + 'mime_type' => 'image/jpeg', + 'file_size' => strlen($imageData) / 1024, // Convert to KB + 'caption' => $sampleCaptions[array_rand($sampleCaptions)], + 'is_profile_photo' => $isProfilePhoto + ]); + + $photo->save(); + } + } + + $this->command->info('Created photos for ' . Photo::count() . ' person records'); + } +} diff --git a/routes/api.php b/routes/api.php index e4212e0..e4f7962 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,40 +1,46 @@ name('login'); Route::post('/register', [AuthController::class, 'register'])->name('register'); +Route::get('/migrants', [MigrantController::class, 'index']); +Route::get('/migrants/{id}', [MigrantController::class, 'show']); +Route::get('/migrants/{id}/photos', [MigrantController::class, 'getPhotos']); -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::get('/persons/search', [PersonController::class, 'search']); - Route::get('/persons/{id}', [PersonController::class, 'show']); +// User routes +Route::get('/user', [AuthController::class, 'me'])->name('user.profile'); +Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); + +// Dashboard routes +Route::get('/dashboard/stats', [DashboardController::class, 'getStats']); +Route::get('/activity-logs', [ActivityLogController::class, 'index']); + +// Migrant CRUD routes (now includes photo uploads) +Route::post('/migrants', [MigrantController::class, 'store']); // Can include photos +Route::put('/migrants/{id}', [MigrantController::class, 'update']); // Can include photos +Route::patch('/migrants/{id}', [MigrantController::class, 'update']); // Can include photos +Route::delete('/migrants/{id}', [MigrantController::class, 'destroy']); // Deletes photos too + +// Photo management routes (all handled by MigrantController) +Route::post('/migrants/{id}/photos', [MigrantController::class, 'uploadPhotos']); +Route::post('/migrants/photos/{photoId}/set-as-profile', [MigrantController::class, 'setAsProfilePhoto']); +Route::put('/migrants/photos/{photoId}/caption', [MigrantController::class, 'updatePhotoCaption']); +Route::delete('/migrants/photos/{photoId}', [MigrantController::class, 'deletePhoto']); - 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'); }); + +// Admin-only routes +// Route::middleware('ability:admin')->group(function () { +// Route::post('/register', [AuthController::class, 'register'])->name('register'); +// }); \ No newline at end of file diff --git a/tests/Feature/MigrantPhotoTest.php b/tests/Feature/MigrantPhotoTest.php new file mode 100644 index 0000000..7d30b9f --- /dev/null +++ b/tests/Feature/MigrantPhotoTest.php @@ -0,0 +1,180 @@ +create([ + 'email' => 'test.admin@example.com', + 'is_admin' => true + ]); + + Sanctum::actingAs($user, ['*']); + } + + /** + * Test creating a migrant with multiple photos and setting one as profile. + */ + public function test_can_create_migrant_with_multiple_photos_and_set_profile(): void + { + // Create fake storage disk for testing + Storage::fake('public'); + + // Create test photo files (using create instead of image to avoid GD dependency) + $photo1 = UploadedFile::fake()->create('photo1.jpg', 100, 'image/jpeg'); + $photo2 = UploadedFile::fake()->create('photo2.jpg', 100, 'image/jpeg'); + $photo3 = UploadedFile::fake()->create('photo3.jpg', 100, 'image/jpeg'); + + // Create migrant data + $migrantData = [ + 'surname' => $this->faker->lastName, + 'christian_name' => $this->faker->firstName, + 'date_of_birth' => $this->faker->date(), + 'place_of_birth' => $this->faker->city, + 'occupation' => $this->faker->jobTitle, + 'id_card_no' => (string)$this->faker->unique()->randomNumber(6), + 'migration' => [ + 'date_of_arrival_aus' => $this->faker->date(), + 'date_of_arrival_nt' => $this->faker->date(), + 'arrival_period' => '1950-1960', + ], + // Add photos to the request + 'photos' => [$photo1, $photo2, $photo3], + 'captions' => ['First photo', 'Second photo', 'Third photo'], + 'set_as_profile' => 1, // Set the first photo as profile + ]; + + // Make the API request to create a migrant with photos + $response = $this->postJson('/api/migrants', $migrantData); + + // Assert the response is successful + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'data' => [ + 'person', + 'uploaded_photos', + ], + 'message', + ]) + ->assertJson([ + 'success' => true, + 'message' => 'Person created successfully', + ]); + + // Get the created person ID + $personId = $response->json('data.person.person_id'); + + // Assert that the person was created in the database + $this->assertDatabaseHas('person', [ + 'surname' => $migrantData['surname'], + 'christian_name' => $migrantData['christian_name'], + ]); + + // Assert that 3 photos were created for this person + $this->assertEquals(3, Photo::where('person_id', $personId)->count()); + + // Assert that exactly one photo is set as profile + $this->assertEquals(1, Photo::where('person_id', $personId) + ->where('is_profile_photo', true) + ->count()); + + // Get the profile photo + $profilePhoto = Photo::where('person_id', $personId) + ->where('is_profile_photo', true) + ->first(); + + // Assert that the first photo is the profile (based on caption) + $this->assertEquals('First photo', $profilePhoto->caption); + + // Assert that the files were stored in the storage + foreach (Photo::where('person_id', $personId)->get() as $photo) { + Storage::disk('public')->assertExists('photos/' . $personId . '/' . $photo->filename); + } + } + + /** + * Test updating the profile photo of an existing migrant. + */ + public function test_can_change_profile_photo(): void + { + // Create fake storage disk for testing + Storage::fake('public'); + + // Create a migrant + $person = Person::factory()->create(); + + // Create three photos for this migrant + $photos = []; + $captions = ['Photo 1', 'Photo 2', 'Photo 3']; + + for ($i = 0; $i < 3; $i++) { + $filename = "photo{$i}.jpg"; + $file = UploadedFile::fake()->create($filename, 100, 'image/jpeg'); + + // Upload the file to the storage + $path = $file->storeAs("photos/{$person->person_id}", $filename, 'public'); + + // Create the photo record + $photo = Photo::create([ + 'person_id' => $person->person_id, + 'filename' => $filename, + 'original_filename' => $filename, + 'file_path' => Storage::url($path), + 'mime_type' => 'image/jpeg', + 'file_size' => $file->getSize() / 1024, + 'caption' => $captions[$i], + 'is_profile_photo' => $i === 0 // Make the first one the profile initially + ]); + + $photos[] = $photo; + } + + // Now try to set the second photo as the profile + $response = $this->postJson("/api/migrants/photos/{$photos[1]->id}/set-as-profile"); + + // Assert response is successful + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Profile photo set successfully', + ]); + + // Assert that now the second photo is the profile + $this->assertDatabaseHas('photos', [ + 'id' => $photos[1]->id, + 'is_profile_photo' => true + ]); + + // Assert that the first photo is no longer the profile + $this->assertDatabaseHas('photos', [ + 'id' => $photos[0]->id, + 'is_profile_photo' => false + ]); + + // Assert that only one photo is set as profile + $this->assertEquals(1, Photo::where('person_id', $person->person_id) + ->where('is_profile_photo', true) + ->count()); + } +}