Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Free form metadata for chat participants and messages: a participant's
* `meta.role` (customer, driver, store, system) tells apps who is speaking
* in an order chat, and a message's `meta.system` / `meta.kind` mark
* system messages (order updated, address change requested).
*
* Every column is guarded so the migration is safe to run against tables
* that already have it.
*/
public function up(): void
{
foreach (['chat_participants', 'chat_messages'] as $tableName) {
if (Schema::hasColumn($tableName, 'meta')) {
continue;
}

Schema::table($tableName, function (Blueprint $table) {
$table->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');
});
}
}
};
132 changes: 132 additions & 0 deletions src/Events/CommentCreated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace Fleetbase\Events;

use Fleetbase\Http\Resources\Comment as CommentResource;
use Fleetbase\Models\Comment;
use Fleetbase\Support\ResourceTransformerContext;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;

/**
* Broadcasts `comment.created` when a comment is written on a subject.
*
* Every comment goes to the company channel (console). Comments a customer may see (anything but
* `meta.visibility = internal`) also go to the subject's own channels, e.g. `order.order_x`, so
* customer and driver apps following the subject receive them; internal notes never do.
*/
class CommentCreated implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;

public string $eventId;
public Carbon $createdAt;
public Comment $comment;

/**
* Create a new event instance.
*/
public function __construct(Comment $comment)
{
$this->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<int, Channel>
*/
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<int, string>
*/
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()),
];
}
}
6 changes: 4 additions & 2 deletions src/Events/ResourceLifecycleEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions src/Http/Controllers/Api/v1/CommentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/Http/Filter/CommentFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Requests/CreateCommentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Http\Requests;

use Fleetbase\Models\Comment;
use Illuminate\Validation\Rule;

class CreateCommentRequest extends FleetbaseRequest
Expand Down Expand Up @@ -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)],
];
}
}
6 changes: 4 additions & 2 deletions src/Http/Resources/ChatMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Http\Resources;

use Fleetbase\Support\Http;
use Fleetbase\Support\Utils;

class ChatMessage extends FleetbaseResource
{
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/Http/Resources/ChatParticipant.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Http\Resources;

use Fleetbase\Support\Http;
use Fleetbase\Support\Utils;

class ChatParticipant extends FleetbaseResource
{
Expand All @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/Http/Resources/Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
30 changes: 29 additions & 1 deletion src/Models/ChatMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Models;

use Fleetbase\Casts\Json;
use Fleetbase\Notifications\ChatMessageReceived;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasPublicId;
Expand Down Expand Up @@ -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.
Expand All @@ -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
*/
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading