diff --git a/apps/backend/app/Http/Controllers/Api/RegionController.php b/apps/backend/app/Http/Controllers/Api/RegionController.php index 7445a4db..f3e70033 100644 --- a/apps/backend/app/Http/Controllers/Api/RegionController.php +++ b/apps/backend/app/Http/Controllers/Api/RegionController.php @@ -5,7 +5,9 @@ use App\Http\Controllers\Controller; use App\Models\Citizen; use App\Models\Hamlet; +use App\Models\Rw; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class RegionController extends Controller { @@ -50,7 +52,7 @@ public function updateHamlet(Request $request, Hamlet $hamlet) 'is_active' => 'sometimes|boolean', ]); - $this->guardDeactivation($data, $hamlet, Citizen::where('hamlet_id', $hamlet->id)); + $this->guardDeactivation($data, $hamlet, Citizen::where('hamlet_id', $hamlet->id), 'Dusun tidak bisa dinonaktifkan karena masih ada warga aktif terdaftar di wilayah ini.'); $hamlet->update($data); @@ -72,6 +74,89 @@ public function destroyHamlet(Request $request, Hamlet $hamlet) return response()->json(['message' => 'Dusun berhasil dihapus.']); } + // ========================================== + // RWS + // ========================================== + + public function indexRws(Request $request) + { + $query = Rw::query(); + + if ($request->filled('hamlet_id')) { + $query->where('hamlet_id', $request->query('hamlet_id')); + } + + return response()->json([ + 'data' => $query->orderBy('number')->get(), + ]); + } + + public function storeRw(Request $request) + { + $this->authorizePetugasDesa($request); + + $data = $request->validate([ + 'hamlet_id' => 'required|exists:hamlets,id', + 'number' => [ + 'required', + 'string', + 'max:10', + Rule::unique('rws')->where(fn ($q) => $q->where('hamlet_id', $request->input('hamlet_id'))), + ], + ], [ + 'number.unique' => 'RW dengan nomor ini sudah ada di dusun tersebut', + ]); + + $rw = Rw::create([ + 'hamlet_id' => $data['hamlet_id'], + 'number' => $data['number'], + 'full_label' => "RW {$data['number']}", + 'is_active' => true, + ]); + + return response()->json(['data' => $rw], 201); + } + + public function updateRw(Request $request, Rw $rw) + { + $this->authorizePetugasDesa($request); + + $data = $request->validate([ + 'number' => [ + 'sometimes', + 'string', + 'max:10', + Rule::unique('rws')->where(fn ($q) => $q->where('hamlet_id', $rw->hamlet_id))->ignore($rw->id), + ], + 'is_active' => 'sometimes|boolean', + ], [ + 'number.unique' => 'RW dengan nomor ini sudah ada di dusun tersebut', + ]); + + $this->guardDeactivation($data, $rw, Citizen::where('rw_id', $rw->id), 'RW tidak bisa dinonaktifkan karena masih ada warga aktif terdaftar di wilayah ini.'); + + if (isset($data['number'])) { + $data['full_label'] = "RW {$data['number']}"; + } + + $rw->update($data); + + return response()->json(['data' => $rw]); + } + + public function destroyRw(Request $request, Rw $rw) + { + $this->authorizePetugasDesa($request); + + if (Citizen::where('rw_id', $rw->id)->exists()) { + abort(409, 'RW tidak bisa dihapus karena masih ada warga terdaftar di wilayah ini.'); + } + + $rw->delete(); + + return response()->json(['message' => 'RW berhasil dihapus.']); + } + // ========================================== // HELPERS // ========================================== @@ -83,14 +168,14 @@ private function authorizePetugasDesa(Request $request) } } - private function guardDeactivation(array $data, $region, $citizenQuery) + private function guardDeactivation(array $data, $region, $citizenQuery, string $message) { $isDeactivating = array_key_exists('is_active', $data) && ! $data['is_active'] && $region->is_active; if ($isDeactivating && $citizenQuery->where('is_active', true)->exists()) { - abort(409, 'Dusun tidak bisa dinonaktifkan karena masih ada warga aktif terdaftar di wilayah ini.'); + abort(409, $message); } } } diff --git a/apps/backend/routes/api.php b/apps/backend/routes/api.php index 12dc772c..774d15a1 100644 --- a/apps/backend/routes/api.php +++ b/apps/backend/routes/api.php @@ -98,6 +98,11 @@ Route::patch('/hamlets/{hamlet}', [RegionController::class, 'updateHamlet']); Route::delete('/hamlets/{hamlet}', [RegionController::class, 'destroyHamlet']); + Route::get('/rws', [RegionController::class, 'indexRws']); + Route::post('/rws', [RegionController::class, 'storeRw']); + Route::patch('/rws/{rw}', [RegionController::class, 'updateRw']); + Route::delete('/rws/{rw}', [RegionController::class, 'destroyRw']); + Route::post('/letters', [LetterController::class, 'store']); Route::get('/letters', [LetterController::class, 'index']); diff --git a/apps/backend/tests/Feature/RegionControllerTest.php b/apps/backend/tests/Feature/RegionControllerTest.php index 845ba560..57b78b89 100644 --- a/apps/backend/tests/Feature/RegionControllerTest.php +++ b/apps/backend/tests/Feature/RegionControllerTest.php @@ -4,6 +4,7 @@ use App\Models\Citizen; use App\Models\Hamlet; +use App\Models\Rw; use App\Models\User; use App\Models\Village; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -102,4 +103,86 @@ public function test_deactivating_hamlet_without_active_citizens_succeeds(): voi ->assertOk() ->assertJsonPath('data.is_active', false); } + + public function test_petugas_desa_can_create_list_update_and_delete_rws(): void + { + $village = Village::create(['name' => 'Desa Cibenda', 'code' => 'CBD']); + $user = $this->petugasDesa($village); + $hamlet = Hamlet::create(['name' => 'Dusun A', 'code' => 'PTR', 'village_id' => $village->id, 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/rws', ['hamlet_id' => $hamlet->id, 'number' => '001']) + ->assertCreated() + ->assertJsonPath('data.full_label', 'RW 001'); + + $rw = Rw::firstWhere('hamlet_id', $hamlet->id); + + $this->actingAs($user) + ->getJson("/api/rws?hamlet_id={$hamlet->id}") + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->actingAs($user) + ->patchJson("/api/rws/{$rw->id}", ['number' => '002']) + ->assertOk() + ->assertJsonPath('data.full_label', 'RW 002'); + + $this->actingAs($user) + ->deleteJson("/api/rws/{$rw->id}") + ->assertOk(); + + $this->assertNull(Rw::find($rw->id)); + } + + public function test_non_petugas_desa_cannot_create_rw(): void + { + $village = Village::create(['name' => 'Desa Cibenda', 'code' => 'CBD']); + $user = User::factory()->create(['village_id' => $village->id, 'role' => 'rt']); + $hamlet = Hamlet::create(['name' => 'Dusun A', 'code' => 'PTR', 'village_id' => $village->id, 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/rws', ['hamlet_id' => $hamlet->id, 'number' => '001']) + ->assertForbidden(); + } + + public function test_duplicate_rw_number_within_same_hamlet_is_rejected(): void + { + $village = Village::create(['name' => 'Desa Cibenda', 'code' => 'CBD']); + $user = $this->petugasDesa($village); + $hamlet = Hamlet::create(['name' => 'Dusun A', 'code' => 'PTR', 'village_id' => $village->id, 'is_active' => true]); + Rw::create(['hamlet_id' => $hamlet->id, 'number' => '001', 'full_label' => 'RW 001', 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/rws', ['hamlet_id' => $hamlet->id, 'number' => '001']) + ->assertUnprocessable() + ->assertJsonValidationErrors('number'); + } + + public function test_deactivating_rw_with_active_citizens_is_blocked(): void + { + $village = Village::create(['name' => 'Desa Cibenda', 'code' => 'CBD']); + $user = $this->petugasDesa($village); + $hamlet = Hamlet::create(['name' => 'Dusun A', 'code' => 'PTR', 'village_id' => $village->id, 'is_active' => true]); + $rw = Rw::create(['hamlet_id' => $hamlet->id, 'number' => '001', 'full_label' => 'RW 001', 'is_active' => true]); + + Citizen::create([ + 'village_id' => $village->id, + 'nik' => '3218030101010002', + 'nik_hash' => hash('sha256', '3218030101010002'), + 'name' => 'Warga Aktif RW', + 'date_of_birth' => '1990-01-01', + 'gender' => 'L', + 'address' => 'Desa Cibenda', + 'rw_id' => $rw->id, + 'is_active' => true, + ]); + + $this->actingAs($user) + ->patchJson("/api/rws/{$rw->id}", ['is_active' => false]) + ->assertStatus(409); + + $this->actingAs($user) + ->deleteJson("/api/rws/{$rw->id}") + ->assertStatus(409); + } }