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/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/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/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/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/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/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/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 @@ + [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')); @@ -657,7 +728,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 +769,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']); diff --git a/tests/Unit/Http/CommentControllerTest.php b/tests/Unit/Http/CommentControllerTest.php index e2691d0c..0d4b8d78 100644 --- a/tests/Unit/Http/CommentControllerTest.php +++ b/tests/Unit/Http/CommentControllerTest.php @@ -432,3 +432,226 @@ function comment_controller_payload($resource): array ->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(); +}); 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(); +});