From 54b281bb7e449ed0a79010f74408858f1903701d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:07:50 +0800 Subject: [PATCH 1/3] fix(events): read the snake case foreign key for relationship uuid channels addRelationshipSpecificChannels checked isset($model->driver_assigned_uuid) but then built the channel from $model->driverAssigned_uuid, an attribute that never exists, so every order lifecycle event broadcast on a dead 'driverAssigned.' channel instead of 'driverAssigned.{driver uuid}'. Read the same snake case key it checks. --- src/Events/ResourceLifecycleEvent.php | 6 ++- tests/Unit/EventsAndExceptionsTest.php | 51 +++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index cddc2f8c..5a9e444c 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -225,8 +225,10 @@ protected function addRelationshipSpecificChannels($model, &$channels) { $relationships = ['driverAssigned', 'customer', 'facilitator', 'vendor']; foreach ($relationships as $relationship) { - if ($model && isset($model->{Str::snake($relationship) . '_uuid'})) { - $channels[] = new Channel($relationship . '.' . $model->{$relationship . '_uuid'}); + // Foreign keys are snake case (`driver_assigned_uuid`), the relationship names are camel case. + $foreignKey = Str::snake($relationship) . '_uuid'; + if ($model && isset($model->{$foreignKey})) { + $channels[] = new Channel($relationship . '.' . $model->{$foreignKey}); } if ($model && isset($model->{$relationship})) { $channels[] = new Channel($relationship . '.' . $model->{$relationship}->public_id); diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index e4ccefee..4a8d39ab 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -657,7 +657,6 @@ public function toArray($request): array $channel->setRelation('driverAssigned', $driver); $channel->setAttribute('customer_uuid', 'customer-uuid'); $channel->setAttribute('driver_assigned_uuid', 'driver-assigned-uuid'); - $channel->setAttribute('driverAssigned_uuid', 'driver-assigned-uuid'); $event = EventsAndExceptionsLifecycleEvent::fake([ 'modelName' => 'chat_channel', @@ -699,6 +698,56 @@ public function toArray($request): array ->and($channels)->toContain('chat.33333333-3333-4333-8333-333333333333'); }); +test('resource lifecycle events read relationship uuid channels from the snake case foreign key', function () { + bind_test_container(); + session()->flush(); + session(['company' => 'session-company', 'user' => 'session-user']); + + // Only the real foreign key columns are set, no relations are loaded: the uuid channel must come + // from `driver_assigned_uuid`, not from a `driverAssigned_uuid` attribute that never exists. + $channel = new ChatChannel(); + $channel->setRawAttributes([ + 'uuid' => '44444444-4444-4444-8444-444444444444', + 'public_id' => 'chat_1111111', + 'company_uuid' => 'model-company', + 'created_by_uuid' => 'creator-user', + 'driver_assigned_uuid' => 'driver-uuid-only', + 'customer_uuid' => 'customer-uuid-only', + ], true); + $channel->setRelation('company', null); + + $event = EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'chat_channel', + 'modelClassNamespace' => ChatChannel::class, + 'modelClassName' => 'ChatChannel', + 'modelHumanName' => 'chat channel', + 'modelRecordName' => null, + 'modelUuid' => '44444444-4444-4444-8444-444444444444', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'updated', + 'sentAt' => '2026-07-18 12:00:00', + 'eventId' => 'event_driver_channel', + 'apiVersion' => 'v1', + 'requestMethod' => 'PUT', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => 'session-user', + 'companySession' => 'session-company', + ], $channel); + + $channels = array_map(fn ($channel) => (string) $channel, $event->broadcastOn()); + + expect($channels)->toContain('driverAssigned.driver-uuid-only') + ->and($channels)->toContain('customer.customer-uuid-only') + ->and($channels)->not->toContain('driverAssigned.') + ->and($channels)->not->toContain('customer.'); +}); + test('resource lifecycle events prefer webhook payloads when resources provide them', function () { bind_test_container(['api.version' => 'v1']); From ec5efe4ac2ae68cb74a21f8873c5e20ad2d67e0c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:08:30 +0800 Subject: [PATCH 2/3] feat(comments): internal and customer visibility on comments, broadcast comment.created Comments carry meta.visibility = internal | customer (a 'visibility' shorthand is accepted on create). When none is given: replies follow their parent, comments on orders are internal when written through an internal (console) route and customer from anywhere else, and comments on other subjects keep no visibility, which stays visible, so existing data and callers are unaffected. - Comment::scopeVisibleToCustomers() (everything but internal) and scopeInternal(), getVisibility(), isInternal(), isVisibleToCustomers() - the public v1 comments API excludes internal comments from the listing (CommentFilter queryForPublic) and find, accepts an optional visibility on create, and the Comment resource hides internal replies outside internal requests and exposes a top level 'visibility' - new CommentCreated event broadcasts 'comment.created' with the comment resource on company.{company_uuid} for every comment and, for customer visible comments only, on the subject's channels ({subject type}.{public_id} and {subject type}.{uuid}, e.g. order.order_x); a broadcast failure is logged and never fails the comment --- src/Events/CommentCreated.php | 132 +++++++++++ .../Controllers/Api/v1/CommentController.php | 9 +- src/Http/Filter/CommentFilter.php | 3 + src/Http/Requests/CreateCommentRequest.php | 2 + src/Http/Resources/Comment.php | 16 +- src/Models/Comment.php | 156 ++++++++++++ tests/Fixtures/Models/Order.php | 18 ++ tests/Unit/Http/CommentControllerTest.php | 223 ++++++++++++++++++ 8 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 src/Events/CommentCreated.php create mode 100644 tests/Fixtures/Models/Order.php diff --git a/src/Events/CommentCreated.php b/src/Events/CommentCreated.php new file mode 100644 index 00000000..269e2a62 --- /dev/null +++ b/src/Events/CommentCreated.php @@ -0,0 +1,132 @@ +eventId = uniqid('event_'); + $this->createdAt = Carbon::now(); + $this->comment = $comment; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'comment.created'; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + $channels = []; + + if ($this->comment->company_uuid) { + $channels[] = new Channel('company.' . $this->comment->company_uuid); + } + + if ($this->comment->isVisibleToCustomers()) { + foreach ($this->subjectChannelNames() as $name) { + $channels[] = new Channel($name); + } + } + + return $channels; + } + + /** + * The subject's channels: `{subject type}.{public_id}` and `{subject type}.{uuid}`, the same names + * the subject's own lifecycle events use (`order.order_x`). + * + * @return array + */ + public function subjectChannelNames(): array + { + $subjectType = $this->comment->subject_type; + if (!$subjectType || !$this->comment->subject_uuid) { + return []; + } + + $prefix = Str::snake(class_basename(ltrim($subjectType, '\\'))); + $subject = $this->resolveSubject(); + $publicId = $subject ? data_get($subject, 'public_id') : null; + if (!$publicId) { + return []; + } + + return [$prefix . '.' . $publicId, $prefix . '.' . $this->comment->subject_uuid]; + } + + /** + * Resolve the commented subject, ignoring global scopes and failures (the broadcast is best effort). + */ + protected function resolveSubject(): mixed + { + try { + if ($this->comment->relationLoaded('subject')) { + return $this->comment->getRelation('subject'); + } + + $subjectType = ltrim($this->comment->subject_type, '\\'); + if (!class_exists($subjectType)) { + return null; + } + + return (new $subjectType())->newQuery()->withoutGlobalScopes()->where('uuid', $this->comment->subject_uuid)->first(); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get the data to broadcast. + */ + public function broadcastWith(): array + { + $resource = new CommentResource($this->comment); + + return [ + 'id' => $this->eventId, + 'event' => $this->broadcastAs(), + 'created_at' => $this->createdAt->toDateTimeString(), + 'subject_id' => data_get($this->resolveSubject(), 'public_id'), + 'data' => $resource->resolveFor(ResourceTransformerContext::BROADCAST, request()), + ]; + } +} diff --git a/src/Http/Controllers/Api/v1/CommentController.php b/src/Http/Controllers/Api/v1/CommentController.php index 24fd93ce..88cfbdec 100644 --- a/src/Http/Controllers/Api/v1/CommentController.php +++ b/src/Http/Controllers/Api/v1/CommentController.php @@ -35,6 +35,11 @@ public function create(CreateCommentRequest $request) 'content' => $content, ]; + // Optional `internal` | `customer`; unset falls back to the subject's default visibility. + if (in_array($request->input('visibility'), Comment::VISIBILITIES, true)) { + $data['visibility'] = $request->input('visibility'); + } + // Resolve the parent $parentComment = null; if ($parent) { @@ -144,9 +149,9 @@ public function query(Request $request) */ public function find($id) { - // find for the Comment + // find for the Comment, internal notes are not exposed on the public API try { - $comment = Comment::findRecordOrFail($id); + $comment = Comment::findRecordOrFail($id, [], ['*'], fn ($query) => $query->visibleToCustomers()); } catch (ModelNotFoundException $exception) { return response()->apiError('Comment resource not found.', 404); } catch (\Throwable $e) { diff --git a/src/Http/Filter/CommentFilter.php b/src/Http/Filter/CommentFilter.php index da327424..153396e6 100644 --- a/src/Http/Filter/CommentFilter.php +++ b/src/Http/Filter/CommentFilter.php @@ -15,6 +15,9 @@ public function queryForInternal() public function queryForPublic() { $this->queryForInternal(); + + // Internal notes (`meta.visibility = internal`) never leave the console. + $this->builder->visibleToCustomers(); } public function subject(string $id) diff --git a/src/Http/Requests/CreateCommentRequest.php b/src/Http/Requests/CreateCommentRequest.php index 6d16696e..00497754 100644 --- a/src/Http/Requests/CreateCommentRequest.php +++ b/src/Http/Requests/CreateCommentRequest.php @@ -2,6 +2,7 @@ namespace Fleetbase\Http\Requests; +use Fleetbase\Models\Comment; use Illuminate\Validation\Rule; class CreateCommentRequest extends FleetbaseRequest @@ -43,6 +44,7 @@ public function rules() return !$this->filled('parent') && !$this->filled('subject') && !$this->filled('subject_type') && !$this->filled('subject_id') && !$this->filled('subject_uuid') && $this->isMethod('POST'); })], 'content' => ['required'], + 'visibility' => ['nullable', Rule::in(Comment::VISIBILITIES)], ]; } } diff --git a/src/Http/Resources/Comment.php b/src/Http/Resources/Comment.php index 0facfa62..8db9e54e 100644 --- a/src/Http/Resources/Comment.php +++ b/src/Http/Resources/Comment.php @@ -28,12 +28,26 @@ public function toArray($request) 'content' => $this->content, 'tags' => $this->tags, 'meta' => data_get($this, 'meta', Utils::createObject()), + 'visibility' => method_exists($this->resource, 'getVisibility') ? $this->resource->getVisibility() : null, 'author' => new Author($this->author), - 'replies' => static::collection($this->replies), + 'replies' => static::collection($this->visibleReplies()), 'editable' => $request->hasSession() && session('user') === $this->author_uuid, 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, 'deleted_at' => $this->deleted_at, ]; } + + /** + * Replies to show: all of them for the console, only customer visible ones anywhere else. + */ + protected function visibleReplies(): mixed + { + $replies = $this->replies; + if (!$replies || Http::isInternalRequest()) { + return $replies; + } + + return $replies->filter(fn ($reply) => !method_exists($reply, 'isVisibleToCustomers') || $reply->isVisibleToCustomers())->values(); + } } diff --git a/src/Models/Comment.php b/src/Models/Comment.php index f6f9c333..1c98a0c3 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -4,9 +4,15 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; +use Fleetbase\Events\CommentCreated; +use Fleetbase\Support\Http; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class Comment extends Model { @@ -14,6 +20,30 @@ class Comment extends Model use HasPublicId; use HasApiModelBehavior; + /** + * Visible to the company's own users only (console notes). + */ + public const VISIBILITY_INTERNAL = 'internal'; + + /** + * Visible to the customer as well (customer facing APIs and channels). + */ + public const VISIBILITY_CUSTOMER = 'customer'; + + /** + * The values `meta.visibility` accepts. + */ + public const VISIBILITIES = [self::VISIBILITY_INTERNAL, self::VISIBILITY_CUSTOMER]; + + /** + * Subject types (snake cased class basenames) whose comments default to `internal` when they are + * written through the internal (console) API, and to `customer` otherwise. Comments on any other + * subject keep no visibility unless one is given, which is treated as visible. + * + * @var array + */ + public static array $visibilityAwareSubjectTypes = ['order']; + /** * The database table used by the model. * @@ -74,6 +104,118 @@ class Comment extends Model 'subject_type' => PolymorphicType::class, ]; + /** + * Default the comment visibility and broadcast new comments. + */ + protected static function booted(): void + { + static::creating(function (Comment $comment) { + $comment->applyDefaultVisibility(); + }); + + static::created(function (Comment $comment) { + // A broadcast failure (socket server down, missing broadcaster) must never fail the comment. + try { + Event::dispatch(new CommentCreated($comment)); + } catch (\Throwable $e) { + Log::warning('Unable to broadcast comment.created', ['comment' => $comment->public_id, 'error' => $e->getMessage()]); + } + }); + } + + /** + * Only comments a customer may see: everything except `internal` ones. Comments with no + * visibility (written before visibility existed, or on subjects without a default) stay visible. + */ + public function scopeVisibleToCustomers(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->whereNull('meta') + ->orWhereNull('meta->visibility') + ->orWhere('meta->visibility', '!=', self::VISIBILITY_INTERNAL); + }); + } + + /** + * Only `internal` comments. + */ + public function scopeInternal(Builder $query): Builder + { + return $query->where('meta->visibility', self::VISIBILITY_INTERNAL); + } + + /** + * The effective visibility: `internal` when marked so, `customer` otherwise. + */ + public function getVisibility(): string + { + return data_get($this->meta, 'visibility') === self::VISIBILITY_INTERNAL ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER; + } + + public function isInternal(): bool + { + return $this->getVisibility() === self::VISIBILITY_INTERNAL; + } + + public function isVisibleToCustomers(): bool + { + return !$this->isInternal(); + } + + /** + * Whether a subject type (class name or short type) defaults its comments' visibility. + */ + public static function isVisibilityAwareSubjectType(?string $subjectType): bool + { + if (!$subjectType) { + return false; + } + + return in_array(Str::snake(class_basename(ltrim($subjectType, '\\'))), static::$visibilityAwareSubjectTypes, true); + } + + /** + * Fills `meta.visibility` when it is missing or not a known value: replies follow their parent, + * comments on visibility aware subjects (orders) are `internal` from the console and `customer` + * from everywhere else, and other comments are left without one (visible). + */ + public function applyDefaultVisibility(): void + { + $meta = is_array($this->meta) ? $this->meta : []; + $visibility = $meta['visibility'] ?? null; + + if (in_array($visibility, self::VISIBILITIES, true)) { + return; + } + + $default = null; + if ($this->parent_comment_uuid) { + $parentMeta = static::query() + ->withoutGlobalScopes() + ->where('uuid', $this->parent_comment_uuid) + ->value('meta'); + $default = data_get(Json::decode($parentMeta), 'visibility'); + } + + if (!in_array($default, self::VISIBILITIES, true)) { + $default = static::isVisibilityAwareSubjectType($this->subject_type) + ? (Http::isInternalRequest() ? self::VISIBILITY_INTERNAL : self::VISIBILITY_CUSTOMER) + : null; + } + + if ($default === null && $visibility === null) { + // Nothing to set: leave meta exactly as given (a null column stays null). + return; + } + + unset($meta['visibility']); + if ($default !== null) { + $meta['visibility'] = $default; + } + + $this->meta = $meta; + } + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -122,6 +264,20 @@ public static function publish(array $attributes): Comment // If replies or parent is set somehow unset it unset($attributes['replies'], $attributes['parent']); + // `visibility` is accepted as a shorthand for `meta.visibility`, it is not a column. + if (array_key_exists('visibility', $attributes)) { + $visibility = $attributes['visibility']; + unset($attributes['visibility']); + + if (in_array($visibility, self::VISIBILITIES, true)) { + $meta = $attributes['meta'] ?? []; + $meta = is_string($meta) ? (json_decode($meta, true) ?: []) : (array) $meta; + + $meta['visibility'] = $visibility; + $attributes['meta'] = $meta; + } + } + // set author if unset if (empty($attributes['author_uuid'])) { $attributes['author_uuid'] = session('user'); diff --git a/tests/Fixtures/Models/Order.php b/tests/Fixtures/Models/Order.php new file mode 100644 index 00000000..569bb607 --- /dev/null +++ b/tests/Fixtures/Models/Order.php @@ -0,0 +1,18 @@ +and($withoutParent->collection->pluck('public_id')->all())->toEqualCanonicalizing(['comment_root', 'comment_other_subject']) ->and($parentScoped->collection->pluck('public_id')->all())->toBe(['comment_reply']); }); + +class CommentVisibilityRoute +{ + public object $controller; + + public function __construct(private string $uri = 'int/v1/comments', private string $method = 'create') + { + $this->controller = new class { + }; + } + + public function getAction(?string $key = null): mixed + { + $action = [ + 'controller' => CommentController::class . '@' . $this->method, + ]; + + return $key ? $action[$key] ?? null : $action; + } + + public function getActionMethod(): string + { + return $this->method; + } + + public function uri(): string + { + return $this->uri; + } +} + +function comment_visibility_database(): Capsule +{ + $capsule = comment_controller_database(); + $capsule->getConnection('mysql')->getSchemaBuilder()->create('orders', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable()->index(); + $table->string('company_uuid')->nullable()->index(); + $table->timestamps(); + }); + $capsule->getConnection('mysql')->table('orders')->insert([ + ['uuid' => 'order-uuid-1', 'public_id' => 'order_1234567', 'company_uuid' => 'company-1'], + ]); + + return $capsule; +} + +function comment_visibility_use_route(string $uri): void +{ + $request = Request::create('/' . $uri, 'POST'); + $request->setRouteResolver(fn () => new CommentVisibilityRoute($uri)); + Container::getInstance()->instance('request', $request); +} + +function comment_visibility_publish(array $attributes = []): Comment +{ + return Comment::publish(array_merge([ + 'subject_uuid' => 'order-uuid-1', + 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, + 'content' => 'Leave at the side door.', + ], $attributes)); +} + +test('comments on orders default to internal from the console and customer from everywhere else', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $console = comment_visibility_publish(); + + comment_visibility_use_route('storefront/int/v1/orders/order_1234567/notes'); + $storefrontConsole = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $publicApi = comment_visibility_publish(); + + comment_visibility_use_route('int/v1/comments'); + $explicit = comment_visibility_publish(['visibility' => 'customer']); + $viaMeta = comment_visibility_publish(['meta' => ['visibility' => 'customer', 'pinned' => true]]); + $invalid = comment_visibility_publish(['meta' => ['visibility' => 'everyone']]); + + expect(Comment::find($console->uuid)->meta)->toBe(['visibility' => 'internal']) + ->and($console->isInternal())->toBeTrue() + ->and($storefrontConsole->getVisibility())->toBe('internal') + ->and(Comment::find($publicApi->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and($publicApi->isVisibleToCustomers())->toBeTrue() + ->and(Comment::find($explicit->uuid)->meta)->toBe(['visibility' => 'customer']) + ->and(Comment::find($viaMeta->uuid)->meta)->toBe(['visibility' => 'customer', 'pinned' => true]) + ->and(Comment::find($invalid->uuid)->meta)->toBe(['visibility' => 'internal']); +}); + +test('comments on other subjects keep no visibility unless given and replies follow their parent', function () { + $capsule = comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $userComment = Comment::publish([ + 'subject_uuid' => 'user-subject', + 'subject_type' => User::class, + 'content' => 'Note on a user', + ]); + $internalRoot = comment_visibility_publish(); + $reply = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + comment_visibility_use_route('v1/comments'); + $publicReplyToInternal = comment_visibility_publish(['parent_comment_uuid' => $internalRoot->uuid]); + + expect($capsule->getConnection('mysql')->table('comments')->where('uuid', $userComment->uuid)->value('meta'))->toBeNull() + ->and($userComment->getVisibility())->toBe('customer') + ->and($reply->getVisibility())->toBe('internal') + ->and($publicReplyToInternal->getVisibility())->toBe('internal'); +}); + +test('visible to customers scope excludes only internal comments', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_customer', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Customer', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_other_meta', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Other meta', 'meta' => '{"pinned":true}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 13, 'uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'public_id' => 'comment_json_null', 'company_uuid' => 'company-1', 'subject_uuid' => 'order-uuid-1', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'author_uuid' => 'user-author', 'content' => 'Json null', 'meta' => 'null', 'created_at' => $now, 'updated_at' => $now], + ]); + + $visible = Comment::query()->without(['author', 'replies'])->visibleToCustomers()->pluck('public_id')->all(); + $internal = Comment::query()->without(['author', 'replies'])->internal()->pluck('public_id')->all(); + + expect($visible)->toEqualCanonicalizing(['comment_root', 'comment_foreign', 'comment_customer', 'comment_other_meta', 'comment_json_null']) + ->and($internal)->toBe(['comment_internal']); +}); + +test('public comment api hides internal comments and internal replies and accepts a visibility', function () { + $capsule = comment_visibility_database(); + $now = '2026-07-18 00:10:00'; + $capsule->getConnection('mysql')->table('comments')->insert([ + ['id' => 10, 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'comment_internal', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => null, 'content' => 'Internal', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 11, 'uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'comment_internal_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Internal reply', 'meta' => '{"visibility":"internal"}', 'created_at' => $now, 'updated_at' => $now], + ['id' => 12, 'uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'comment_customer_reply', 'company_uuid' => 'company-1', 'subject_uuid' => 'user-subject', 'subject_type' => '\\' . User::class, 'author_uuid' => 'user-author', 'parent_comment_uuid' => '11111111-1111-4111-8111-111111111111', 'content' => 'Customer reply', 'meta' => '{"visibility":"customer"}', 'created_at' => $now, 'updated_at' => $now], + ]); + + $listed = comment_controller()->query(comment_controller_query_request(['limit' => -1, 'without_parent' => '1'])); + $hidden = comment_controller()->find('comment_internal'); + + // resources resolve replies against the current request: a public route hides internal replies + comment_visibility_use_route('v1/comments'); + $root = collect($listed->collection)->firstWhere('public_id', 'comment_root'); + $publicReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + comment_visibility_use_route('int/v1/comments'); + $internalReplies = collect((new Fleetbase\Http\Resources\Comment($root->resource))->resolve(request())['replies'])->map(fn ($reply) => $reply->resource->public_id)->all(); + + $created = comment_controller()->create(comment_controller_create_request([ + 'subject' => ['id' => 'user_subject', 'type' => 'user'], + 'content' => 'Only for the team', + 'visibility' => 'internal', + ])); + + expect($listed->collection->pluck('public_id')->all())->not->toContain('comment_internal') + ->and($listed->collection->pluck('public_id')->all())->toContain('comment_root') + ->and($hidden->getStatusCode())->toBe(404) + ->and($publicReplies)->toBe(['comment_customer_reply']) + ->and($internalReplies)->toEqualCanonicalizing(['comment_customer_reply', 'comment_internal_reply']) + ->and($created->resource->getVisibility())->toBe('internal') + ->and((new CreateCommentRequest())->rules()['visibility'])->toContain('nullable'); +}); + +test('comment created broadcasts internal notes to the company only and customer comments to the subject too', function () { + comment_visibility_database(); + + comment_visibility_use_route('int/v1/comments'); + $internal = comment_visibility_publish(); + + comment_visibility_use_route('v1/comments'); + $customer = comment_visibility_publish(); + $unknown = Comment::publish(['subject_uuid' => 'missing-order', 'subject_type' => Fleetbase\Tests\Fixtures\Models\Order::class, 'content' => 'Lost', 'visibility' => 'customer']); + + $channels = fn (Comment $comment) => array_map(fn ($channel) => (string) $channel, (new Fleetbase\Events\CommentCreated($comment))->broadcastOn()); + + $event = new Fleetbase\Events\CommentCreated($customer); + $payload = $event->broadcastWith(); + + expect($event->broadcastAs())->toBe('comment.created') + ->and($channels($internal))->toBe(['company.company-1']) + ->and($channels($customer))->toBe(['company.company-1', 'order.order_1234567', 'order.order-uuid-1']) + ->and($channels($unknown))->toBe(['company.company-1']) + ->and($payload['event'])->toBe('comment.created') + ->and($payload['subject_id'])->toBe('order_1234567') + ->and($payload['data']['content'])->toBe('Leave at the side door.') + ->and($payload['data']['visibility'])->toBe('customer'); +}); + +test('creating a comment dispatches comment created and never fails when broadcasting does', function () { + comment_visibility_database(); + + $dispatched = new ArrayObject(); + Container::getInstance()->instance('events', new class($dispatched) { + public function __construct(private ArrayObject $dispatched) + { + } + + public function dispatch($event, $payload = [], $halt = false) + { + $this->dispatched->append($event); + + return null; + } + }); + Facade::clearResolvedInstance('events'); + + comment_visibility_use_route('v1/comments'); + $comment = comment_visibility_publish(); + + Container::getInstance()->instance('events', new class { + public function dispatch($event, $payload = [], $halt = false) + { + throw new RuntimeException('socket server unavailable'); + } + }); + Facade::clearResolvedInstance('events'); + $stillCreated = comment_visibility_publish(['content' => 'Second note']); + + expect($dispatched)->toHaveCount(1) + ->and($dispatched[0])->toBeInstanceOf(Fleetbase\Events\CommentCreated::class) + ->and($dispatched[0]->comment->is($comment))->toBeTrue() + ->and($stillCreated->exists)->toBeTrue(); +}); From 7bec840746150ce97badc6cf3c6500bcb4ec9c88 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 17:08:31 +0800 Subject: [PATCH 3/3] feat(chat): participant roles and system message meta Adds a nullable json meta column to chat_participants and chat_messages (guarded migration). - ChatParticipant: meta (fillable, json cast), ROLE_* constants (customer, driver, store, system) and getRole() reading meta.role; the resource adds 'role' and 'meta', so a message's sender.role reaches the apps over HTTP and the chat_message broadcasts - ChatMessage: meta (fillable, json cast), isSystem() (meta.system) and getSystemKind() (meta.kind); the resource adds 'system' and 'meta' and renders a missing sender as null instead of failing (the duplicate public 'sender' key it dropped was always overwritten by the participant resource) The public send-message endpoint still builds its own attributes, so customers cannot post system messages or set roles; existing chat behaviour is unchanged. --- ..._participants_and_chat_messages_tables.php | 42 +++++++++++ src/Http/Resources/ChatMessage.php | 6 +- src/Http/Resources/ChatParticipant.php | 3 + src/Models/ChatMessage.php | 30 +++++++- src/Models/ChatParticipant.php | 26 ++++++- tests/Unit/EventsAndExceptionsTest.php | 71 +++++++++++++++++++ tests/Unit/Models/ChatModelsTest.php | 47 ++++++++++++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php diff --git a/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php new file mode 100644 index 00000000..569b83f7 --- /dev/null +++ b/migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php @@ -0,0 +1,42 @@ +json('meta')->nullable()->after('chat_channel_uuid'); + }); + } + } + + public function down(): void + { + foreach (['chat_participants', 'chat_messages'] as $tableName) { + if (!Schema::hasColumn($tableName, 'meta')) { + continue; + } + + Schema::table($tableName, function (Blueprint $table) { + $table->dropColumn('meta'); + }); + } + } +}; diff --git a/src/Http/Resources/ChatMessage.php b/src/Http/Resources/ChatMessage.php index da8cee77..e433c3e9 100644 --- a/src/Http/Resources/ChatMessage.php +++ b/src/Http/Resources/ChatMessage.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatMessage extends FleetbaseResource { @@ -21,9 +22,10 @@ public function toArray($request) 'chat_channel_uuid' => $this->when(Http::isInternalRequest(), $this->chat_channel_uuid), 'chat_channel' => $this->when(Http::isPublicRequest(), $this->chatChannel->public_id), 'sender_uuid' => $this->when(Http::isInternalRequest(), $this->sender_uuid), - 'sender' => $this->when(Http::isPublicRequest(), $this->sender->public_id), 'content' => $this->content, - 'sender' => new ChatParticipant($this->sender), + 'sender' => $this->sender ? new ChatParticipant($this->sender) : null, + 'system' => method_exists($this->resource, 'isSystem') && $this->resource->isSystem(), + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'attachments' => ChatAttachment::collection($this->attachments), 'receipts' => ChatReceipt::collection($this->receipts), 'updated_at' => $this->updated_at, diff --git a/src/Http/Resources/ChatParticipant.php b/src/Http/Resources/ChatParticipant.php index 5ad1a912..6ae8ced0 100644 --- a/src/Http/Resources/ChatParticipant.php +++ b/src/Http/Resources/ChatParticipant.php @@ -3,6 +3,7 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\Utils; class ChatParticipant extends FleetbaseResource { @@ -27,6 +28,8 @@ public function toArray($request) 'email' => $this->whenLoaded('user', fn () => $this->user->email), 'phone' => $this->whenLoaded('user', fn () => $this->user->phone), 'avatar_url' => $this->whenLoaded('user', fn () => $this->user->avatar_url), + 'role' => method_exists($this->resource, 'getRole') ? $this->resource->getRole() : null, + 'meta' => data_get($this, 'meta') ?: Utils::createObject(), 'is_online' => $this->is_online, 'last_seen_at' => $this->last_seen_at, 'updated_at' => $this->updated_at, diff --git a/src/Models/ChatMessage.php b/src/Models/ChatMessage.php index e2eafc1b..ffca18f7 100644 --- a/src/Models/ChatMessage.php +++ b/src/Models/ChatMessage.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Notifications\ChatMessageReceived; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -34,7 +35,7 @@ class ChatMessage extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'sender_uuid', 'content', 'meta']; /** * The relationships to always load along with the model. @@ -43,6 +44,15 @@ class ChatMessage extends Model */ protected $with = ['sender', 'attachments', 'receipts']; + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'meta' => Json::class, + ]; + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ @@ -75,6 +85,24 @@ public function receipts() return $this->hasMany(ChatReceipt::class, 'chat_message_uuid', 'uuid')->whereHas('participant'); } + /** + * Whether this is a system message (`meta.system = true`), e.g. "order updated". + */ + public function isSystem(): bool + { + return (bool) data_get($this->meta, 'system', false); + } + + /** + * The system message kind (`meta.kind`), e.g. `order_updated` or `address_change_requested`. + */ + public function getSystemKind(): ?string + { + $kind = $this->isSystem() ? data_get($this->meta, 'kind') : null; + + return is_string($kind) ? $kind : null; + } + /** * Notify all participants in the chat channel (excluding the sender) of a new chat message. * diff --git a/src/Models/ChatParticipant.php b/src/Models/ChatParticipant.php index 985168c3..cb9c8638 100644 --- a/src/Models/ChatParticipant.php +++ b/src/Models/ChatParticipant.php @@ -2,6 +2,7 @@ namespace Fleetbase\Models; +use Fleetbase\Casts\Json; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -17,6 +18,15 @@ class ChatParticipant extends Model use HasApiModelBehavior; use SendsWebhooks; + /** + * Participant roles (`meta.role`), used by order chats to tell apps who is speaking. + */ + public const ROLE_CUSTOMER = 'customer'; + public const ROLE_DRIVER = 'driver'; + public const ROLE_STORE = 'store'; + public const ROLE_SYSTEM = 'system'; + public const ROLES = [self::ROLE_CUSTOMER, self::ROLE_DRIVER, self::ROLE_STORE, self::ROLE_SYSTEM]; + /** * The database table used by the model. * @@ -36,7 +46,7 @@ class ChatParticipant extends Model * * @var array */ - protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid']; + protected $fillable = ['company_uuid', 'chat_channel_uuid', 'user_uuid', 'meta']; /** * Dynamic attributes that are appended to object. @@ -57,7 +67,9 @@ class ChatParticipant extends Model * * @var array */ - protected $casts = []; + protected $casts = [ + 'meta' => Json::class, + ]; public function user(): BelongsTo|Builder { @@ -107,6 +119,16 @@ public function getIsOnlineAttribute(): bool return $this->user ? $this->user->isOnline() : false; } + /** + * The participant's role (`meta.role`) when it is one of the known roles. + */ + public function getRole(): ?string + { + $role = data_get($this->meta, 'role'); + + return in_array($role, self::ROLES, true) ? $role : null; + } + /** * Get the chat participant's user name. */ diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index 4a8d39ab..1a1e5274 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -20,6 +20,7 @@ use Fleetbase\Exceptions\UnauthorizedRequestException; use Fleetbase\Listeners\SendResourceLifecycleWebhook; use Fleetbase\Models\ChatChannel; +use Fleetbase\Models\ChatMessage; use Fleetbase\Models\ChatParticipant; use Fleetbase\Models\Company; use Fleetbase\Models\Model as FleetbaseModel; @@ -429,6 +430,76 @@ public function getAction(string $key): ?string 'participant removed' => [ChatParticipantRemoved::class, 'chat.removed_participant'], ]); +test('chat participant and message resources expose the participant role and message meta', function () { + bind_test_container(); + + $channel = new ChatChannel(); + $channel->setRawAttributes(['uuid' => 'channel-uuid', 'public_id' => 'chat_channel_public'], true); + + $user = new EventsAndExceptionsUser(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'public_id' => 'user_public', 'name' => 'Corner Cafe'], true); + + $participant = new EventsAndExceptionsChatParticipant(); + $participant->setRawAttributes([ + 'uuid' => 'participant-uuid', + 'public_id' => 'participant_public', + 'chat_channel_uuid' => 'channel-uuid', + 'user_uuid' => 'user-uuid', + 'meta' => '{"role":"store"}', + ], true); + $participant->setRelation('chatChannel', $channel); + $participant->setRelation('user', $user); + + $message = new ChatMessage(); + $message->setRawAttributes([ + 'uuid' => 'message-uuid', + 'public_id' => 'message_public', + 'chat_channel_uuid' => 'channel-uuid', + 'sender_uuid' => 'participant-uuid', + 'content' => 'Drop-off changed to 8 Marina View', + 'meta' => '{"system":true,"kind":"order_updated"}', + ], true); + $message->setRelation('chatChannel', $channel); + $message->setRelation('sender', $participant); + $message->setRelation('attachments', collect()); + $message->setRelation('receipts', collect()); + + // a system message may have no sender at all + $senderless = new ChatMessage(); + $senderless->setRawAttributes(['uuid' => 'message-2', 'public_id' => 'message_2', 'content' => 'Order updated', 'meta' => '{"system":true,"kind":"order_updated"}'], true); + $senderless->setRelation('chatChannel', $channel); + $senderless->setRelation('sender', null); + $senderless->setRelation('attachments', collect()); + $senderless->setRelation('receipts', collect()); + + $plain = new ChatMessage(); + $plain->setRawAttributes(['uuid' => 'message-3', 'public_id' => 'message_3', 'content' => 'Hi'], true); + $plain->setRelation('chatChannel', $channel); + $plain->setRelation('sender', $participant); + $plain->setRelation('attachments', collect()); + $plain->setRelation('receipts', collect()); + + $request = Illuminate\Http\Request::create('/v1/chat-channels/chat_channel_public'); + $request->setRouteResolver(fn () => new EventsAndExceptionsRoute('v1/chat-channels/{id}')); + app()->instance('request', $request); + + $resolve = fn ($resource) => json_decode(json_encode($resource->resolve($request)), true); + $publicMessage = $resolve(new Fleetbase\Http\Resources\ChatMessage($message)); + $publicNoSender = $resolve(new Fleetbase\Http\Resources\ChatMessage($senderless)); + $publicPlain = $resolve(new Fleetbase\Http\Resources\ChatMessage($plain)); + + expect($publicMessage['id'])->toBe('message_public') + ->and($publicMessage['system'])->toBeTrue() + ->and($publicMessage['meta'])->toBe(['system' => true, 'kind' => 'order_updated']) + ->and($publicMessage['sender']['id'])->toBe('participant_public') + ->and($publicMessage['sender']['role'])->toBe('store') + ->and($publicMessage['sender']['meta'])->toBe(['role' => 'store']) + ->and($publicNoSender['sender'])->toBeNull() + ->and($publicNoSender['system'])->toBeTrue() + ->and($publicPlain['system'])->toBeFalse() + ->and($publicPlain['meta'])->toBe([]); +}); + test('resource lifecycle events normalize payload children without dropping chat message relations', function () { bind_test_container(['api.version' => 'v1']); Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00')); diff --git a/tests/Unit/Models/ChatModelsTest.php b/tests/Unit/Models/ChatModelsTest.php index 2e39b922..2a0cb2d9 100644 --- a/tests/Unit/Models/ChatModelsTest.php +++ b/tests/Unit/Models/ChatModelsTest.php @@ -542,3 +542,50 @@ function chat_participant_with_name(string $uuid, ?string $name): ChatParticipan ->and($channel->logs()->getRelated())->toBeInstanceOf(ChatLog::class) ->and($channel->logs()->getForeignKeyName())->toBe('chat_channel_uuid'); }); + +it('adds a nullable meta column for participant roles and system messages', function () { + $capsule = chat_models_database(); + $connection = $capsule->getConnection('testing'); + $schema = $connection->getSchemaBuilder(); + app()->instance('db.schema', $schema); + Facade::clearResolvedInstance('db.schema'); + + $migration = require __DIR__ . '/../../../migrations/2026_10_13_000000_add_meta_to_chat_participants_and_chat_messages_tables.php'; + $migration->up(); + // guarded: running it twice is a no-op + $migration->up(); + + $now = '2026-07-18 12:00:00'; + $connection->table('chat_channels')->insert(['uuid' => 'channel-1', 'public_id' => 'chat_1', 'created_at' => $now, 'updated_at' => $now]); + $connection->table('chat_participants')->insert([ + ['uuid' => 'participant-store', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-store', 'meta' => '{"role":"store"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-legacy', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-legacy', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'participant-odd', 'chat_channel_uuid' => 'channel-1', 'user_uuid' => 'user-odd', 'meta' => '{"role":"admin"}', 'created_at' => $now, 'updated_at' => $now], + ]); + $connection->table('chat_messages')->insert([ + ['uuid' => 'message-system', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-store', 'content' => 'Drop-off changed', 'meta' => '{"system":true,"kind":"order_updated"}', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'message-plain', 'chat_channel_uuid' => 'channel-1', 'sender_uuid' => 'participant-legacy', 'content' => 'Hello', 'meta' => null, 'created_at' => $now, 'updated_at' => $now], + ]); + + $store = ChatParticipant::query()->without('user')->whereKey('participant-store')->firstOrFail(); + $legacy = ChatParticipant::query()->without('user')->whereKey('participant-legacy')->firstOrFail(); + $odd = ChatParticipant::query()->without('user')->whereKey('participant-odd')->firstOrFail(); + $system = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-system')->firstOrFail(); + $plain = ChatMessage::query()->without(['sender', 'attachments', 'receipts'])->whereKey('message-plain')->firstOrFail(); + + $migration->down(); + + expect($store->meta)->toBe(['role' => 'store']) + ->and($store->getRole())->toBe(ChatParticipant::ROLE_STORE) + ->and($legacy->getRole())->toBeNull() + ->and($odd->getRole())->toBeNull() + ->and(ChatParticipant::ROLES)->toBe(['customer', 'driver', 'store', 'system']) + ->and($system->isSystem())->toBeTrue() + ->and($system->getSystemKind())->toBe('order_updated') + ->and($plain->isSystem())->toBeFalse() + ->and($plain->getSystemKind())->toBeNull() + ->and((new ChatParticipant())->isFillable('meta'))->toBeTrue() + ->and((new ChatMessage())->isFillable('meta'))->toBeTrue() + ->and($schema->hasColumn('chat_participants', 'meta'))->toBeFalse() + ->and($schema->hasColumn('chat_messages', 'meta'))->toBeFalse(); +});