diff --git a/app/Http/Controllers/Api/NotesController.php b/app/Http/Controllers/Api/NotesController.php new file mode 100644 index 0000000000..023c399ad5 --- /dev/null +++ b/app/Http/Controllers/Api/NotesController.php @@ -0,0 +1,119 @@ +authorize('update', Asset::class); + + if ($request->input('note', '') == '') { + return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422); + } + + try { + $asset = Asset::findOrFail($asset_id); + } catch (ModelNotFoundException $e) { + return response()->json(Helper::formatStandardApiResponse('error', null, 'Asset not found'), 404); + } catch (\Exception $e) { + Log::debug('Error fetching asset: ' . $e->getMessage()); + + // Return generic server error response since something unexpected happened + return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/settings/message.webhook.500')), 500); + } + + $this->authorize('update', $asset); + + // Create the note + $logaction = new ActionLog(); + $logaction->item_type = get_class($asset); + $logaction->created_by = Auth::id(); + $logaction->item_id = $asset->id; + $logaction->note = $request->input('note', ''); + + if($logaction->logaction('note added')) { + // Return a success response + return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $asset->id], trans('general.note_added'))); + } + + // Return an error response if something went wrong + return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500); + } + + /** + * Retrieve a list of manual notes (action logs) for a given asset. + * + * Checks authorization to view assets, attempts to find the asset by ID, + * and fetches related action log entries of type 'note added', including + * user information for each note. Returns a JSON response with the notes or errors. + * + * @param \Illuminate\Http\Request $request The incoming HTTP request. + * @param int|string $asset_id The ID of the asset whose notes to retrieve. + * @return \Illuminate\Http\JsonResponse + */ + public function getList(Request $request, $asset_id): JsonResponse + { + $this->authorize('view', Asset::class); + + try { + $asset = Asset::findOrFail($asset_id); + } catch (ModelNotFoundException $e) { + return response()->json(Helper::formatStandardApiResponse('error', null, $e->getMessage()), 404); + } catch (\Exception $e) { + // Return generic server error response since something unexpected happened + return response()->json(Helper::formatStandardApiResponse('error', null, $e->getMessage()), 500); + } + + $this->authorize('view', $asset); + + // Get the manual notes for the asset + $notes = ActionLog::with('user:id,username') + ->where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->where('action_type', 'note added') + ->orderBy('created_at', 'desc') + ->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type', 'target_id', 'target_type']); + + $notesArray = $notes->map(function ($note) { + return [ + 'id' => $note->id, + 'created_at' => $note->created_at, + 'note' => $note->note, + 'created_by' => $note->created_by, + 'username' => $note->user?->username, // adding the username + 'item_id' => $note->item_id, + 'item_type' => $note->item_type, + 'action_type' => $note->action_type, + ]; + }); + + // Return a success response + return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'asset_id' => $asset->id])); + } +} diff --git a/database/factories/ActionlogFactory.php b/database/factories/ActionlogFactory.php index ad07f7082b..9c41f2c2d5 100644 --- a/database/factories/ActionlogFactory.php +++ b/database/factories/ActionlogFactory.php @@ -84,6 +84,28 @@ class ActionlogFactory extends Factory }); } + /** + * This sets up an ActionLog representing a manually added note tied to an Asset, + * with an optional User as the creator. If no User is provided, one is generated. + * + * @param User|null $user Optional user to associate as the creator of the note. + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public function assetNote(?User $user = null) + { + return $this + ->state(function (array $attributes) use ($user) { + return [ + 'action_type' => 'note added', + 'item_type' => Asset::class, + 'target_type' => 'asset', + 'note' => 'Factory-generated manual note', + 'created_by' => $user?->id ?? User::factory(), + ]; + }) + ->for($user ?? User::factory(), 'user'); + } + public function licenseCheckoutToUser() { return $this->state(function () { diff --git a/routes/api.php b/routes/api.php index eeb644d13a..5609508526 100644 --- a/routes/api.php +++ b/routes/api.php @@ -841,6 +841,28 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu ); // end asset models API routes + /** + * Asset notes API routes + */ + Route::group(['prefix' => 'notes'], function () { + + Route::post( + '{asset_id}/store', + [ + Api\NotesController::class, + 'store' + ] + )->name('api.notes.store'); + + Route::get( + '{asset_id}/getList', + [ + Api\NotesController::class, + 'getList' + ] + )->name('api.notes.getList'); + } + ); // end asset notes API routes /** * Settings API routes diff --git a/tests/Feature/Assets/Api/AssetNotesTest.php b/tests/Feature/Assets/Api/AssetNotesTest.php new file mode 100644 index 0000000000..906c5debf7 --- /dev/null +++ b/tests/Feature/Assets/Api/AssetNotesTest.php @@ -0,0 +1,88 @@ +actingAsForApi(User::factory()->editAssets()->create()) + ->postJson(route('api.notes.store', 123456789)) + ->assertStatusMessageIs('error'); + } + + public function testRequiresPermissionToAddNoteToAssetAsset() + { + $asset = Asset::factory()->create(); + + $this->actingAsForApi(User::factory()->create()) + ->postJson(route('api.notes.store', $asset->id), [ + 'note' => 'test' + ]) + ->assertForbidden(); + } + + public function testAssetNoteIsSaved() + { + $asset = Asset::factory()->create(); + + $this->actingAsForApi(User::factory()->editAssets()->create()) + ->postJson(route('api.notes.store', ['asset_id' => $asset->id]), [ + 'note' => 'This is a test note.' + ]) + ->assertStatusMessageIs('success') + ->assertJson([ + 'messages' => trans('general.note_added'), + 'payload' => [ + 'note' => 'This is a test note.', + 'item_id' => e($asset->id), + ], + ]) + ->assertStatus(200); + + $note = ActionLog::where('item_id', $asset->id) + ->where('action_type', 'note added') + ->first(); + + $this->assertNotNull($note, 'The note was not saved in the database.'); + $this->assertEquals('This is a test note.', $note->note, 'The note content does not match.'); + } + + public function testAssetNotesAreRetrievable() + { + $asset = Asset::factory()->create(); + + $user = User::factory()->viewAssets()->create(); + + $assetNote = Actionlog::factory() + ->assetNote($user) + ->create([ + 'item_id' => $asset->id, + 'note' => 'This is a test note.', + ]); + + $this->actingAsForApi($user) + ->getJson(route('api.notes.getList', ['asset_id' => $asset->id])) + ->assertOk() + ->assertJson([ + 'messages' => null, + 'payload' => [ + 'notes' => [ + [ + 'note' => 'This is a test note.', + 'created_by' => $assetNote->created_by, + 'username' => $user->username, + 'item_id' => $assetNote->item_id, + 'item_type' => Asset::class, + 'action_type' => 'note added', + ] + ] + ], + ]); + } +}