From 4935b0df78dd4c4a53e1d3aac14b7003f181e612 Mon Sep 17 00:00:00 2001 From: Revi Permana Date: Thu, 3 Sep 2026 17:56:27 +0700 Subject: [PATCH] feat(rt): crud RT --- .../Http/Controllers/Api/RegionController.php | 87 +++++++++++++++++++ apps/backend/routes/api.php | 5 ++ .../tests/Feature/RegionControllerTest.php | 87 +++++++++++++++++++ 3 files changed, 179 insertions(+) diff --git a/apps/backend/app/Http/Controllers/Api/RegionController.php b/apps/backend/app/Http/Controllers/Api/RegionController.php index f3e70033..0abb2fcd 100644 --- a/apps/backend/app/Http/Controllers/Api/RegionController.php +++ b/apps/backend/app/Http/Controllers/Api/RegionController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Models\Citizen; use App\Models\Hamlet; +use App\Models\Rt; use App\Models\Rw; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -157,6 +158,92 @@ public function destroyRw(Request $request, Rw $rw) return response()->json(['message' => 'RW berhasil dihapus.']); } + // ========================================== + // RTS + // ========================================== + + public function indexRts(Request $request) + { + $query = Rt::query(); + + if ($request->filled('rw_id')) { + $query->where('rw_id', $request->query('rw_id')); + } + + return response()->json([ + 'data' => $query->orderBy('number')->get(), + ]); + } + + public function storeRt(Request $request) + { + $this->authorizePetugasDesa($request); + + $data = $request->validate([ + 'rw_id' => 'required|exists:rws,id', + 'number' => [ + 'required', + 'string', + 'max:10', + Rule::unique('rts')->where(fn ($q) => $q->where('rw_id', $request->input('rw_id'))), + ], + ], [ + 'number.unique' => 'RT dengan nomor ini sudah ada di RW tersebut', + ]); + + $rw = Rw::findOrFail($data['rw_id']); + + $rt = Rt::create([ + 'rw_id' => $data['rw_id'], + 'number' => $data['number'], + 'full_label' => "RT {$data['number']} / RW {$rw->number}", + 'is_active' => true, + ]); + + return response()->json(['data' => $rt], 201); + } + + public function updateRt(Request $request, Rt $rt) + { + $this->authorizePetugasDesa($request); + + $data = $request->validate([ + 'number' => [ + 'sometimes', + 'string', + 'max:10', + Rule::unique('rts')->where(fn ($q) => $q->where('rw_id', $rt->rw_id))->ignore($rt->id), + ], + 'is_active' => 'sometimes|boolean', + ], [ + 'number.unique' => 'RT dengan nomor ini sudah ada di RW tersebut', + ]); + + $this->guardDeactivation($data, $rt, Citizen::where('rt_id', $rt->id), 'RT tidak bisa dinonaktifkan karena masih ada warga aktif terdaftar di wilayah ini.'); + + if (isset($data['number'])) { + $rt->loadMissing('rw'); + $data['full_label'] = "RT {$data['number']} / RW {$rt->rw->number}"; + } + + $rt->update($data); + + return response()->json(['data' => $rt]); + } + + public function destroyRt(Request $request, Rt $rt) + { + $this->authorizePetugasDesa($request); + + if (Citizen::where('rt_id', $rt->id)->exists()) { + abort(409, 'RT tidak bisa dihapus karena masih ada warga terdaftar di wilayah ini.'); + } + + $rt->delete(); + + return response()->json(['message' => 'RT berhasil dihapus.']); + } + // ========================================== // HELPERS // ========================================== diff --git a/apps/backend/routes/api.php b/apps/backend/routes/api.php index 774d15a1..777a082b 100644 --- a/apps/backend/routes/api.php +++ b/apps/backend/routes/api.php @@ -103,6 +103,11 @@ Route::patch('/rws/{rw}', [RegionController::class, 'updateRw']); Route::delete('/rws/{rw}', [RegionController::class, 'destroyRw']); + Route::get('/rts', [RegionController::class, 'indexRts']); + Route::post('/rts', [RegionController::class, 'storeRt']); + Route::patch('/rts/{rt}', [RegionController::class, 'updateRt']); + Route::delete('/rts/{rt}', [RegionController::class, 'destroyRt']); + 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 57b78b89..5f3ba8e0 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\Rt; use App\Models\Rw; use App\Models\User; use App\Models\Village; @@ -185,4 +186,90 @@ public function test_deactivating_rw_with_active_citizens_is_blocked(): void ->deleteJson("/api/rws/{$rw->id}") ->assertStatus(409); } + + public function test_petugas_desa_can_create_list_update_and_delete_rts(): 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]); + + $this->actingAs($user) + ->postJson('/api/rts', ['rw_id' => $rw->id, 'number' => '001']) + ->assertCreated() + ->assertJsonPath('data.full_label', 'RT 001 / RW 001'); + + $rt = Rt::firstWhere('rw_id', $rw->id); + + $this->actingAs($user) + ->getJson("/api/rts?rw_id={$rw->id}") + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->actingAs($user) + ->patchJson("/api/rts/{$rt->id}", ['number' => '002']) + ->assertOk() + ->assertJsonPath('data.full_label', 'RT 002 / RW 001'); + + $this->actingAs($user) + ->deleteJson("/api/rts/{$rt->id}") + ->assertOk(); + + $this->assertNull(Rt::find($rt->id)); + } + + public function test_non_petugas_desa_cannot_create_rt(): 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]); + $rw = Rw::create(['hamlet_id' => $hamlet->id, 'number' => '001', 'full_label' => 'RW 001', 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/rts', ['rw_id' => $rw->id, 'number' => '001']) + ->assertForbidden(); + } + + public function test_duplicate_rt_number_within_same_rw_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 = Rw::create(['hamlet_id' => $hamlet->id, 'number' => '001', 'full_label' => 'RW 001', 'is_active' => true]); + Rt::create(['rw_id' => $rw->id, 'number' => '001', 'full_label' => 'RT 001 / RW 001', 'is_active' => true]); + + $this->actingAs($user) + ->postJson('/api/rts', ['rw_id' => $rw->id, 'number' => '001']) + ->assertUnprocessable() + ->assertJsonValidationErrors('number'); + } + + public function test_deactivating_rt_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]); + $rt = Rt::create(['rw_id' => $rw->id, 'number' => '001', 'full_label' => 'RT 001 / RW 001', 'is_active' => true]); + + Citizen::create([ + 'village_id' => $village->id, + 'nik' => '3218030101010003', + 'nik_hash' => hash('sha256', '3218030101010003'), + 'name' => 'Warga Aktif RT', + 'date_of_birth' => '1990-01-01', + 'gender' => 'L', + 'address' => 'Desa Cibenda', + 'rt_id' => $rt->id, + 'is_active' => true, + ]); + + $this->actingAs($user) + ->patchJson("/api/rts/{$rt->id}", ['is_active' => false]) + ->assertStatus(409); + + $this->actingAs($user) + ->deleteJson("/api/rts/{$rt->id}") + ->assertStatus(409); + } }