From b264bbf69fc3126cc88598797b176c88bb87de52 Mon Sep 17 00:00:00 2001 From: Nicky West Date: Mon, 28 Jul 2025 15:55:37 -0700 Subject: [PATCH 1/4] feat(api): Add API endpoints for managing asset history notes - Add POST endpoint to create a history note attached to an asset - Add GET endpoint to retrieve history notes for an asset - Add ActionLog factory state for manual notes - Implement controller methods with authorization checks - Add feature tests for note creation, retrieval, and access control - Register new API routes for these endpoints Supports automation by enabling programmatic asset history note management. --- app/Http/Controllers/Api/NotesController.php | 119 +++++++++++++++++++ database/factories/ActionlogFactory.php | 22 ++++ routes/api.php | 22 ++++ tests/Feature/Assets/Api/AssetNotesTest.php | 88 ++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 app/Http/Controllers/Api/NotesController.php create mode 100644 tests/Feature/Assets/Api/AssetNotesTest.php 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', + ] + ] + ], + ]); + } +} From 822f9a6f283930791576d465077b916733f82e62 Mon Sep 17 00:00:00 2001 From: Nicky West Date: Mon, 28 Jul 2025 16:37:08 -0700 Subject: [PATCH 2/4] Fixed deviations from code standards --- app/Http/Controllers/Api/NotesController.php | 14 +++++++------- database/factories/ActionlogFactory.php | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/NotesController.php b/app/Http/Controllers/Api/NotesController.php index 023c399ad5..9f5f5415a6 100644 --- a/app/Http/Controllers/Api/NotesController.php +++ b/app/Http/Controllers/Api/NotesController.php @@ -26,10 +26,10 @@ class NotesController extends Controller * Returns JSON responses indicating success or failure with appropriate HTTP status codes. * * @param \Illuminate\Http\Request $request The incoming HTTP request containing the 'note'. - * @param int|string $asset_id The ID of the asset to attach the note to. + * @param int|string $assetId The ID of the asset to attach the note to. * @return \Illuminate\Http\JsonResponse */ - public function store(Request $request, $asset_id): JsonResponse + public function store(Request $request, $assetId): JsonResponse { $this->authorize('update', Asset::class); @@ -38,7 +38,7 @@ class NotesController extends Controller } try { - $asset = Asset::findOrFail($asset_id); + $asset = Asset::findOrFail($assetId); } catch (ModelNotFoundException $e) { return response()->json(Helper::formatStandardApiResponse('error', null, 'Asset not found'), 404); } catch (\Exception $e) { @@ -57,7 +57,7 @@ class NotesController extends Controller $logaction->item_id = $asset->id; $logaction->note = $request->input('note', ''); - if($logaction->logaction('note added')) { + 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'))); } @@ -74,15 +74,15 @@ class NotesController extends Controller * 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. + * @param int|string $assetId The ID of the asset whose notes to retrieve. * @return \Illuminate\Http\JsonResponse */ - public function getList(Request $request, $asset_id): JsonResponse + public function getList(Request $request, $assetId): JsonResponse { $this->authorize('view', Asset::class); try { - $asset = Asset::findOrFail($asset_id); + $asset = Asset::findOrFail($assetId); } catch (ModelNotFoundException $e) { return response()->json(Helper::formatStandardApiResponse('error', null, $e->getMessage()), 404); } catch (\Exception $e) { diff --git a/database/factories/ActionlogFactory.php b/database/factories/ActionlogFactory.php index 9c41f2c2d5..4773b39532 100644 --- a/database/factories/ActionlogFactory.php +++ b/database/factories/ActionlogFactory.php @@ -91,10 +91,10 @@ class ActionlogFactory extends Factory * @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) + public function assetNote(?User $user=null) { return $this - ->state(function (array $attributes) use ($user) { + ->state(function () use ($user) { return [ 'action_type' => 'note added', 'item_type' => Asset::class, From 16fdb16a5699cf1bb3e149874d26450f3e082633 Mon Sep 17 00:00:00 2001 From: Nicky West Date: Mon, 28 Jul 2025 16:55:11 -0700 Subject: [PATCH 3/4] Changed over to route model binding and simplified logic & gates --- app/Http/Controllers/Api/NotesController.php | 34 +++----------------- routes/api.php | 4 +-- tests/Feature/Assets/Api/AssetNotesTest.php | 8 ++--- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Api/NotesController.php b/app/Http/Controllers/Api/NotesController.php index 9f5f5415a6..965eec96dd 100644 --- a/app/Http/Controllers/Api/NotesController.php +++ b/app/Http/Controllers/Api/NotesController.php @@ -26,30 +26,17 @@ class NotesController extends Controller * Returns JSON responses indicating success or failure with appropriate HTTP status codes. * * @param \Illuminate\Http\Request $request The incoming HTTP request containing the 'note'. - * @param int|string $assetId The ID of the asset to attach the note to. + * @param Asset $asset The ID of the asset to attach the note to. * @return \Illuminate\Http\JsonResponse */ - public function store(Request $request, $assetId): JsonResponse + public function store(Request $request, Asset $asset): JsonResponse { - $this->authorize('update', Asset::class); + $this->authorize('update', $asset); if ($request->input('note', '') == '') { return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422); } - try { - $asset = Asset::findOrFail($assetId); - } 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); @@ -74,22 +61,11 @@ class NotesController extends Controller * 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 $assetId The ID of the asset whose notes to retrieve. + * @param Asset $asset The ID of the asset whose notes to retrieve. * @return \Illuminate\Http\JsonResponse */ - public function getList(Request $request, $assetId): JsonResponse + public function getList(Asset $asset): JsonResponse { - $this->authorize('view', Asset::class); - - try { - $asset = Asset::findOrFail($assetId); - } 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 diff --git a/routes/api.php b/routes/api.php index 5609508526..001061fc9f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -847,7 +847,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu Route::group(['prefix' => 'notes'], function () { Route::post( - '{asset_id}/store', + '{asset}/store', [ Api\NotesController::class, 'store' @@ -855,7 +855,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu )->name('api.notes.store'); Route::get( - '{asset_id}/getList', + '{asset}/getList', [ Api\NotesController::class, 'getList' diff --git a/tests/Feature/Assets/Api/AssetNotesTest.php b/tests/Feature/Assets/Api/AssetNotesTest.php index 906c5debf7..5096546af7 100644 --- a/tests/Feature/Assets/Api/AssetNotesTest.php +++ b/tests/Feature/Assets/Api/AssetNotesTest.php @@ -12,7 +12,7 @@ class AssetNotesTest extends TestCase public function testThatANonExistentAssetIdReturnsError() { $this->actingAsForApi(User::factory()->editAssets()->create()) - ->postJson(route('api.notes.store', 123456789)) + ->postJson(route('api.notes.store', ['asset' => 123456789])) ->assertStatusMessageIs('error'); } @@ -21,7 +21,7 @@ class AssetNotesTest extends TestCase $asset = Asset::factory()->create(); $this->actingAsForApi(User::factory()->create()) - ->postJson(route('api.notes.store', $asset->id), [ + ->postJson(route('api.notes.store', $asset), [ 'note' => 'test' ]) ->assertForbidden(); @@ -32,7 +32,7 @@ class AssetNotesTest extends TestCase $asset = Asset::factory()->create(); $this->actingAsForApi(User::factory()->editAssets()->create()) - ->postJson(route('api.notes.store', ['asset_id' => $asset->id]), [ + ->postJson(route('api.notes.store', $asset), [ 'note' => 'This is a test note.' ]) ->assertStatusMessageIs('success') @@ -67,7 +67,7 @@ class AssetNotesTest extends TestCase ]); $this->actingAsForApi($user) - ->getJson(route('api.notes.getList', ['asset_id' => $asset->id])) + ->getJson(route('api.notes.getList', $asset)) ->assertOk() ->assertJson([ 'messages' => null, From c94a8c42f4f699ba910f84e9b89cccfcc59ead3d Mon Sep 17 00:00:00 2001 From: Nicky West Date: Mon, 28 Jul 2025 16:57:46 -0700 Subject: [PATCH 4/4] Changed NotesController::getList() to NotesController::index() & reordered methods for consistency --- app/Http/Controllers/Api/NotesController.php | 80 ++++++++++---------- routes/api.php | 6 +- tests/Feature/Assets/Api/AssetNotesTest.php | 2 +- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/app/Http/Controllers/Api/NotesController.php b/app/Http/Controllers/Api/NotesController.php index 965eec96dd..c1a16fd4d6 100644 --- a/app/Http/Controllers/Api/NotesController.php +++ b/app/Http/Controllers/Api/NotesController.php @@ -18,6 +18,46 @@ use Illuminate\Support\Facades\Log; */ class NotesController extends Controller { + /** + * 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 Asset $asset The ID of the asset whose notes to retrieve. + * @return \Illuminate\Http\JsonResponse + */ + public function index(Asset $asset): JsonResponse + { + $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])); + } + /** * Store a manual note on a specified asset and log the action. * @@ -52,44 +92,4 @@ class NotesController extends Controller // 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 Asset $asset The ID of the asset whose notes to retrieve. - * @return \Illuminate\Http\JsonResponse - */ - public function getList(Asset $asset): JsonResponse - { - $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/routes/api.php b/routes/api.php index 001061fc9f..b139f3e81d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -855,12 +855,12 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu )->name('api.notes.store'); Route::get( - '{asset}/getList', + '{asset}/index', [ Api\NotesController::class, - 'getList' + 'index' ] - )->name('api.notes.getList'); + )->name('api.notes.index'); } ); // end asset notes API routes diff --git a/tests/Feature/Assets/Api/AssetNotesTest.php b/tests/Feature/Assets/Api/AssetNotesTest.php index 5096546af7..b3076c2852 100644 --- a/tests/Feature/Assets/Api/AssetNotesTest.php +++ b/tests/Feature/Assets/Api/AssetNotesTest.php @@ -67,7 +67,7 @@ class AssetNotesTest extends TestCase ]); $this->actingAsForApi($user) - ->getJson(route('api.notes.getList', $asset)) + ->getJson(route('api.notes.index', $asset)) ->assertOk() ->assertJson([ 'messages' => null,