From 1d1050632fffd4622ddb8c9010b51f2dd902a6f6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 26 Mar 2025 18:30:37 +0800 Subject: [PATCH 1/2] Several critical patches and improvements --- composer.json | 2 +- .../Api/v1/ChatChannelController.php | 36 ++-- .../Controllers/Api/v1/CommentController.php | 166 ++++++++++++++++++ src/Http/Filter/CommentFilter.php | 8 + src/Http/Middleware/LogApiRequests.php | 7 +- src/Http/Requests/CreateCommentRequest.php | 42 +++++ src/Http/Requests/UpdateCommentRequest.php | 7 + src/Http/Resources/ChatChannel.php | 2 +- src/Http/Resources/Comment.php | 2 +- src/Models/ChatMessage.php | 2 +- src/Models/File.php | 36 ++++ src/Notifications/TestPushNotification.php | 36 +--- src/Support/PushNotification.php | 128 ++++++++++++++ src/Support/Utils.php | 24 +++ src/routes.php | 16 +- 15 files changed, 462 insertions(+), 52 deletions(-) create mode 100644 src/Http/Controllers/Api/v1/CommentController.php create mode 100644 src/Http/Requests/CreateCommentRequest.php create mode 100644 src/Http/Requests/UpdateCommentRequest.php create mode 100644 src/Support/PushNotification.php diff --git a/composer.json b/composer.json index 4299537d..1e85be7e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.5.32", + "version": "1.6.0", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Http/Controllers/Api/v1/ChatChannelController.php b/src/Http/Controllers/Api/v1/ChatChannelController.php index a8a0fc46..c5ec1a70 100644 --- a/src/Http/Controllers/Api/v1/ChatChannelController.php +++ b/src/Http/Controllers/Api/v1/ChatChannelController.php @@ -28,15 +28,28 @@ class ChatChannelController extends Controller public function create(CreateChatChannelRequest $request) { // get request input - $input = $request->only(['name']); + $name = $request->input('name'); + $participants = $request->array('participants'); // create the chat channel $chatChannel = ChatChannel::create([ 'company_uuid' => session('company'), 'created_by_uuid' => session('user'), - 'name' => strtoupper($input['name']), + 'name' => $name, ]); + // If participants provided add them + foreach ($participants as $userId) { + $user = User::where('public_id', $userId)->first(); + if ($user) { + ChatParticipant::create([ + 'company_uuid' => session('company'), + 'user_uuid' => $user->uuid, + 'chat_channel_uuid' => $chatChannel->uuid, + ]); + } + } + // response the driver resource return new ChatChannelResource($chatChannel); } @@ -134,20 +147,23 @@ public function delete($id) } /** - * Query for Fleetbase Chat Channel resources. + * Query for available chat participants. * * @return \Fleetbase\Http\Resources\ChatChannelCollection */ - public function getAvailablePartificants($id) + public function getAvailablePartificants(Request $request) { - $chatChannel = ChatChannel::findRecordOrFail($id); - $users = User::where('company_uuid', session('company'))->get(); + $chatChannelId = $request->input('channel'); + $chatChannel = $chatChannelId ? ChatChannel::where('public_id', $chatChannelId)->first() : null; + $users = User::where('company_uuid', session('company'))->get(); - $users->filter(function ($user) use ($chatChannel) { - $isPartificant = $chatChannel->participants->firstWhere('user_uuid', $user->uuid); + if ($chatChannel) { + $users = $users->filter(function ($user) use ($chatChannel) { + $isParticipant = $chatChannel->participants->firstWhere('user_uuid', $user->uuid); - return !$isPartificant; - }); + return !$isParticipant; + }); + } return UserResource::collection($users); } diff --git a/src/Http/Controllers/Api/v1/CommentController.php b/src/Http/Controllers/Api/v1/CommentController.php new file mode 100644 index 00000000..fc2f8540 --- /dev/null +++ b/src/Http/Controllers/Api/v1/CommentController.php @@ -0,0 +1,166 @@ +input('content'); + $subject = $request->input('subject', [ + 'id' => $request->input('subject_id'), + 'type' => $request->input('subject_type'), + ]); + $parent = $request->input('parent'); + + // Prepare comment creation data + $data = [ + 'company_uuid' => session('company'), + 'author_uuid' => session('user'), + 'content' => $content, + ]; + + // Resolve the parent + $parentComment = null; + if ($parent) { + $parentComment = Comment::where(['public_id' => $parent, 'company_uuid' => session('company')])->first(); + if ($parentComment) { + $data['parent_comment_uuid'] = $parentComment->uuid; + $data['subject_uuid'] = $parentComment->subject_uuid; + $data['subject_type'] = $parentComment->subject_type; + } + } + + // Resolve the subject + if ($subject && !$parentComment) { + $subjectClass = Utils::getMutationType(data_get($subject, 'type')); + $subjectUuid = null; + if ($subjectClass) { + $subjectUuid = Utils::getUuid(app($subjectClass)->getTable(), [ + 'public_id' => data_get($subject, 'id'), + 'company_uuid' => session('company'), + ]); + } + + // If on subject found + if ((!$subjectClass || !$subjectUuid) && !$parentComment) { + return response()->apiError('Invalid subject provided for comment.'); + } + + $data['subject_uuid'] = $subjectUuid; + $data['subject_type'] = $subjectClass; + } + + // create the comment + try { + $comment = Comment::publish($data); + } catch (\Throwable $e) { + return response()->apiError('Uknown error attempting to create comment.'); + } + + // response the new comment + return new CommentResource($comment); + } + + /** + * Updates a Fleetbase Comment resource. + * + * @param string $id + * + * @return CommentResource + */ + public function update($id, UpdateCommentRequest $request) + { + // find for the comment + try { + $comment = Comment::findRecordOrFail($id); + } catch (ModelNotFoundException $exception) { + return response()->json( + [ + 'error' => 'Chat channel resource not found.', + ], + 404 + ); + } + + try { + $content = $request->input('content'); + $comment->update(['content' => $content]); + } catch (\Throwable $e) { + return response()->apiError('Uknown error attempting to update comment.'); + } + + // response the comment resource + return new CommentResource($comment); + } + + /** + * Query for Fleetbase Comment resources. + * + * @return \Fleetbase\Http\Resources\CommentResourceCollection + */ + public function query(Request $request) + { + $results = Comment::queryWithRequest($request); + + return CommentResource::collection($results); + } + + /** + * Finds a single Fleetbase Chat Channel resources. + * + * @return \Fleetbase\Http\Resources\ChatChannelCollection + */ + public function find($id) + { + // find for the Comment + try { + $comment = Comment::findRecordOrFail($id); + } catch (ModelNotFoundException $exception) { + return response()->apiError('Chat channel resource not found.', 404); + } catch (\Throwable $e) { + return response()->apiError('Uknown error occured trying to find the comment.', 404); + } + + // response the comment resource + return new CommentResource($comment); + } + + /** + * Deletes a Fleetbase Comment resources. + * + * @return DeletedResource + */ + public function delete($id) + { + // find for the comment + try { + $comment = Comment::findRecordOrFail($id); + } catch (ModelNotFoundException $exception) { + return response()->apiError('Chat channel resource not found.', 404); + } catch (\Throwable $e) { + return response()->apiError('Uknown error occured trying to find the comment.', 404); + } + + // delete the comment + $comment->delete(); + + // response the comment resource + return new DeletedResource($comment); + } +} diff --git a/src/Http/Filter/CommentFilter.php b/src/Http/Filter/CommentFilter.php index d9e5d1e8..84513383 100644 --- a/src/Http/Filter/CommentFilter.php +++ b/src/Http/Filter/CommentFilter.php @@ -17,6 +17,14 @@ public function queryForPublic() $this->queryForInternal(); } + public function subject(string $id) + { + $this->builder->whereHas('subject', function ($query) use ($id) { + $query->where('uuid', $id); + $query->orWhere('public_id', $id); + }); + } + public function parent(string $id) { if (Str::isUuid($id)) { diff --git a/src/Http/Middleware/LogApiRequests.php b/src/Http/Middleware/LogApiRequests.php index 378f5a0b..f4d217b1 100644 --- a/src/Http/Middleware/LogApiRequests.php +++ b/src/Http/Middleware/LogApiRequests.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Middleware; use Fleetbase\Jobs\LogApiRequest; +use Fleetbase\Support\Http; use Fleetbase\Traits\CustomMiddleware; class LogApiRequests @@ -23,8 +24,10 @@ public function handle($request, \Closure $next) // get the response $response = $next($request); - // log api request - $this->logApiRequest($request, $response); + // Only log public api request - do not log internal requests + if (Http::isPublicRequest($request)) { + $this->logApiRequest($request, $response); + } return $response; } diff --git a/src/Http/Requests/CreateCommentRequest.php b/src/Http/Requests/CreateCommentRequest.php new file mode 100644 index 00000000..2de63141 --- /dev/null +++ b/src/Http/Requests/CreateCommentRequest.php @@ -0,0 +1,42 @@ +session()->has('api_credential'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'subject' => [Rule::requiredIf(function () { + return !$this->filled('subject_id') && !$this->filled('subject_type') && !$this->filled('parent') && $this->isMethod('POST'); + })], + 'subject_id' => [Rule::requiredIf(function () { + return !$this->filled('parent') && !$this->filled('subject') && $this->isMethod('POST'); + })], + 'subject_type' => [Rule::requiredIf(function () { + return !$this->filled('parent') && !$this->filled('subject') && $this->isMethod('POST'); + })], + 'parent' => [Rule::requiredIf(function () { + return !$this->filled('subject') && !$this->filled('subject_type') && !$this->filled('subject_id') && $this->isMethod('POST'); + })], + 'content' => ['required'], + ]; + } +} diff --git a/src/Http/Requests/UpdateCommentRequest.php b/src/Http/Requests/UpdateCommentRequest.php new file mode 100644 index 00000000..aabe672a --- /dev/null +++ b/src/Http/Requests/UpdateCommentRequest.php @@ -0,0 +1,7 @@ + $this->when(Http::isInternalRequest(), $this->public_id), 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), 'created_by_uuid' => $this->when(Http::isInternalRequest(), $this->created_by_uuid), - 'created_by' => $this->when(Http::isInternalRequest(), $this->createdBy ? $this->createdBy->public_id : null), + 'created_by' => $this->when(Http::isPublicRequest(), fn () => $this->createdBy ? $this->createdBy->public_id : null), 'name' => $this->name, 'title' => $this->title, 'last_message' => new ChatMessage($this->last_message), diff --git a/src/Http/Resources/Comment.php b/src/Http/Resources/Comment.php index 197f0978..5655de17 100644 --- a/src/Http/Resources/Comment.php +++ b/src/Http/Resources/Comment.php @@ -29,7 +29,7 @@ public function toArray($request) 'meta' => $this->meta, 'author' => new Author($this->author), 'replies' => static::collection($this->replies), - 'editable' => $this->when(Http::isInternalRequest(), $request->hasSession() && session('user') === $this->author_uuid), + 'editable' => $request->hasSession() && session('user') === $this->author_uuid, 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, 'deleted_at' => $this->deleted_at, diff --git a/src/Models/ChatMessage.php b/src/Models/ChatMessage.php index d7d38911..bebedb6b 100644 --- a/src/Models/ChatMessage.php +++ b/src/Models/ChatMessage.php @@ -47,7 +47,7 @@ class ChatMessage extends Model */ public function sender() { - return $this->belongsTo(ChatParticipant::class, 'sender_uuid', 'uuid'); + return $this->belongsTo(ChatParticipant::class, 'sender_uuid', 'uuid')->withTrashed(); } /** diff --git a/src/Models/File.php b/src/Models/File.php index 81bec6ca..e7dff010 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -360,6 +360,42 @@ public static function createFromUpload(UploadedFile $file, $path, $type = null, return static::create($data); } + public static function createFromBase64(string $base64, ?string $fileName = null, string $path = 'uploads', ?string $type = 'image', ?string $contentType = 'image/png', ?int $size = null, ?string $disk = null, ?string $bucket = null): bool|File + { + $disk = is_null($disk) ? config('filesystems.default') : $disk; + $bucket = is_null($bucket) ? config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')) : $bucket; + $size = is_null($size) ? Utils::getBase64ImageSize($base64) : $size; + $fileName = is_null($fileName) ? static::randomFileName() : $fileName; + + // Correct $path for uploads + if (Str::startsWith($path, 'uploads') && $disk === 'uploads') { + $path = str_replace('uploads/', '', $path); + } + + // Set the full file path + $fullPath = $path . '/' . $fileName; + $uploaded = Storage::disk($disk)->put($fullPath, base64_decode($base64)); + if (!$uploaded) { + return false; + } + + // Prepare file data + $data = [ + 'company_uuid' => session('company'), + 'uploader_uuid' => session('user'), + 'disk' => $disk, + 'original_filename' => basename($fullPath), + 'extension' => 'png', + 'content_type' => $contentType, + 'path' => $fullPath, + 'bucket' => $bucket, + 'type' => $type, + 'size' => $size, + ]; + + return static::create($data); + } + /** * Retrieves the hash name of the file based on its path. * diff --git a/src/Notifications/TestPushNotification.php b/src/Notifications/TestPushNotification.php index 0a625028..783b20bc 100644 --- a/src/Notifications/TestPushNotification.php +++ b/src/Notifications/TestPushNotification.php @@ -2,14 +2,12 @@ namespace Fleetbase\Notifications; +use Fleetbase\Support\PushNotification; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Notification; use Illuminate\Support\Carbon; use NotificationChannels\Apn\ApnChannel; -use NotificationChannels\Apn\ApnMessage; use NotificationChannels\Fcm\FcmChannel; -use NotificationChannels\Fcm\FcmMessage; -use NotificationChannels\Fcm\Resources\Notification as FcmNotification; /** * Class TestPushNotification. @@ -70,26 +68,7 @@ public function via() */ public function toFcm($notifiable) { - return (new FcmMessage(notification: new FcmNotification( - title: $this->title, - body: $this->message, - ))) - ->data($this->data) - ->custom([ - 'android' => [ - 'notification' => [ - 'color' => '#4391EA', - ], - 'fcm_options' => [ - 'analytics_label' => 'analytics', - ], - ], - 'apns' => [ - 'fcm_options' => [ - 'analytics_label' => 'analytics', - ], - ], - ]); + return PushNotification::createFcmMessage($this->title, $this->message, $this->data); } /** @@ -99,15 +78,6 @@ public function toFcm($notifiable) */ public function toApn($notifiable) { - $message = ApnMessage::create() - ->badge(1) - ->title($this->title) - ->body($this->message); - - foreach ($this->data as $key => $value) { - $message->custom($key, $value); - } - - return $message; + return PushNotification::createApnMessage($this->title, $this->message, $this->data, 'test_push_notification'); } } diff --git a/src/Support/PushNotification.php b/src/Support/PushNotification.php new file mode 100644 index 00000000..46055a59 --- /dev/null +++ b/src/Support/PushNotification.php @@ -0,0 +1,128 @@ +badge(1) + ->title($title) + ->body($body); + + foreach ($data as $key => $value) { + $message->custom($key, $value); + } + + if ($action) { + $message->action($action, $data); + } + + $message->via($client); + + return $message; + } + + public static function createFcmMessage(string $title, string $body, array $data = []): FcmMessage + { + // Configure FCM + static::configureFcmClient(); + + // Get FCM Client using Notification Channel + $container = Container::getInstance(); + $projectManager = new FirebaseProjectManager($container); + $client = $projectManager->project('app')->messaging(); + + // Create Notification + $notification = new FcmNotification( + title: $title, + body: $body + ); + + return (new FcmMessage(notification: $notification)) + ->data($data) + ->custom([ + 'android' => [ + 'notification' => [ + 'color' => '#4391EA', + 'sound' => 'default', + ], + 'fcm_options' => [ + 'analytics_label' => 'analytics', + ], + ], + 'apns' => [ + 'payload' => [ + 'aps' => [ + 'sound' => 'default', + ], + ], + 'fcm_options' => [ + 'analytics_label' => 'analytics', + ], + ], + ]) + ->usingClient($client); + } + + public static function getApnClient(): PushOkClient + { + $config = config('broadcasting.connections.apn'); + + // Get the APN key file and it's contents and store to config + if (is_array($config) && isset($config['private_key_file_id']) && Str::isUuid($config['private_key_file_id'])) { + $apnKeyFile = File::where('uuid', $config['private_key_file_id'])->first(); + if ($apnKeyFile) { + $apnKeyFileContents = Storage::disk($apnKeyFile->disk)->get($apnKeyFile->path); + if ($apnKeyFileContents) { + $config['private_key_content'] = str_replace('\\n', "\n", trim($apnKeyFileContents)); + } + } + } + + // Always unsetset apn `private_key_path` and `private_key_file` + unset($config['private_key_path'], $config['private_key_file']); + + return new PushOkClient(PuskOkToken::create($config)); + } + + public static function configureFcmClient() + { + $config = config('firebase.projects.app'); + + if (is_array($config) && isset($config['credentials_file_id']) && Str::isUuid($config['credentials_file_id'])) { + $firebaseCredentialsFile = File::where('uuid', $config['credentials_file_id'])->first(); + if ($firebaseCredentialsFile) { + $firebaseCredentialsContent = Storage::disk($firebaseCredentialsFile->disk)->get($firebaseCredentialsFile->path); + if ($firebaseCredentialsContent) { + $firebaseCredentialsContentArray = json_decode($firebaseCredentialsContent, true); + if (is_array($firebaseCredentialsContentArray)) { + $firebaseCredentialsContentArray['private_key'] = str_replace('\\n', "\n", trim($firebaseCredentialsContentArray['private_key'])); + } + $config['credentials'] = $firebaseCredentialsContentArray; + } + } + } + + // Always unset apn `credentials_file` + unset($config['credentials_file']); + + // Update config + config(['firebase.projects.app' => $config]); + + return $config; + } +} diff --git a/src/Support/Utils.php b/src/Support/Utils.php index 7830bab8..7d0c0a19 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1010,6 +1010,30 @@ public static function isPublicId($string) return is_string($string) && Str::contains($string, ['_']) && strlen(explode('_', $string)[1]) === 7; } + /** + * Checks if string is base64 encoded. + * + * @param Base64 data string $string + * + * @return bool + */ + public static function isBase64String($string) + { + if (!is_string($string) || trim($string) === '') { + return false; + } + + // Check if string matches Base64 pattern + if (!preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $string)) { + return false; + } + + // Decode and check if it encodes back correctly + $decoded = base64_decode($string, true); + + return $decoded !== false && base64_encode($decoded) === $string; + } + /** * Checks if target is iterable and gets the count. * diff --git a/src/routes.php b/src/routes.php index aafd4a87..778d3b17 100644 --- a/src/routes.php +++ b/src/routes.php @@ -53,6 +53,9 @@ function ($router) { $router->group( ['prefix' => 'chat-channels'], function ($router) { + $router->get('available-participants', 'ChatChannelController@getAvailablePartificants'); + $router->post('{id}/send-message', 'ChatChannelController@sendMessage'); + $router->delete('delete-message/{messageId}', 'ChatChannelController@deleteMessage'); $router->post('/', 'ChatChannelController@create'); $router->put('{id}', 'ChatChannelController@update'); $router->get('/', 'ChatChannelController@query'); @@ -60,9 +63,16 @@ function ($router) { $router->delete('{id}', 'ChatChannelController@delete'); $router->post('{id}/add-participant', 'ChatChannelController@addParticipant'); $router->delete('remove-participant/{participantId}', 'ChatChannelController@removeParticipant'); - $router->get('{id}/available-participants', 'ChatChannelController@getAvailablePartificants'); - $router->post('{id}/send-message', 'ChatChannelController@sendMessage'); - $router->delete('delete-message/{messageId}', 'ChatChannelController@deleteMessage'); + } + ); + $router->group( + ['prefix' => 'comments'], + function ($router) { + $router->post('/', 'CommentController@create'); + $router->put('{id}', 'CommentController@update'); + $router->get('/', 'CommentController@query'); + $router->get('{id}', 'CommentController@find'); + $router->delete('{id}', 'CommentController@delete'); } ); }); From 657e4cbe39189607290ca3397d3e53c6281abd97 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 26 Mar 2025 18:33:36 +0800 Subject: [PATCH 2/2] patch terminology used in comments controller --- src/Http/Controllers/Api/v1/CommentController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Http/Controllers/Api/v1/CommentController.php b/src/Http/Controllers/Api/v1/CommentController.php index fc2f8540..8c648140 100644 --- a/src/Http/Controllers/Api/v1/CommentController.php +++ b/src/Http/Controllers/Api/v1/CommentController.php @@ -92,7 +92,7 @@ public function update($id, UpdateCommentRequest $request) } catch (ModelNotFoundException $exception) { return response()->json( [ - 'error' => 'Chat channel resource not found.', + 'error' => 'Comment resource not found.', ], 404 ); @@ -122,9 +122,9 @@ public function query(Request $request) } /** - * Finds a single Fleetbase Chat Channel resources. + * Finds a single Fleetbase Comment resources. * - * @return \Fleetbase\Http\Resources\ChatChannelCollection + * @return \Fleetbase\Http\Resources\CommentCollection */ public function find($id) { @@ -132,7 +132,7 @@ public function find($id) try { $comment = Comment::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->apiError('Chat channel resource not found.', 404); + return response()->apiError('Comment resource not found.', 404); } catch (\Throwable $e) { return response()->apiError('Uknown error occured trying to find the comment.', 404); } @@ -152,7 +152,7 @@ public function delete($id) try { $comment = Comment::findRecordOrFail($id); } catch (ModelNotFoundException $exception) { - return response()->apiError('Chat channel resource not found.', 404); + return response()->apiError('Comment resource not found.', 404); } catch (\Throwable $e) { return response()->apiError('Uknown error occured trying to find the comment.', 404); }