-
-
- {{ $commentUser?->name ?? 'Unknown user' }}
+ @if (! $isInternal || $canSeeInternalNotes)
+
+
+
+
+
+
+ {{ $commentUser?->name ?? 'Unknown user' }}
+ {{ $comment->created_at?->format('d M Y H:i') }}
+
+ @if ($commentAttachments->isNotEmpty())
+
+ @endif
+ @if ($isInternal)
+
+ @endif
+
+
+
+ @if (! blank($comment->unid))
+ UNID {{ Str::limit((string) $comment->unid, 16, '') }}
+ @endif
+ @if (! blank($comment->guid))
+ GUID {{ Str::limit((string) $comment->guid, 18, '') }}
+ @endif
+
-
- {{ $comment->created_at?->format('d M Y H:i') }}
+
+
+
+
+
+
+
+
+ {!! $comment->body !!}
+
+ @if ($commentAttachments->isNotEmpty())
+
+ @endif
+
+ @if ($currentTicket !== null)
+
+
+
+
+
+ @endif
+
+ @if ($commentReplies->isNotEmpty())
+
+ @include('helpdesk::partials.comment-thread', [
+ 'comments' => $commentReplies,
+ 'level' => ((int) $level) + 1,
+ 'ticket' => $currentTicket,
+ 'canSeeInternalNotes' => $canSeeInternalNotes,
+ ])
+
+ @endif
-
- @if ($comment->is_internal)
-
Internal
- @endif
-
-
-
- {!! $comment->body !!}
-
-
- @if ($commentAttachments->isNotEmpty())
-
- @endif
-
- @if ($currentTicket !== null)
-
-
-
-
-
- @endif
-
- @if ($commentReplies->isNotEmpty())
-
- @include('helpdesk::partials.comment-thread', [
- 'comments' => $commentReplies,
- 'level' => ((int) $level) + 1,
- 'ticket' => $currentTicket,
- ])
-
- @endif
-
+
+ @endif
@endforeach
diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php
index b9b93070..549aa7df 100644
--- a/Modules/Helpdesk/Services/TicketService.php
+++ b/Modules/Helpdesk/Services/TicketService.php
@@ -8,6 +8,7 @@ use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Status;
@@ -104,12 +105,33 @@ final class TicketService
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment
{
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId): Comment {
+ $parent = $parentId === null
+ ? null
+ : Comment::query()
+ ->where('ticket_id', $ticket->id)
+ ->findOrFail($parentId);
+
+ $guid = (string) Str::uuid();
+ $unid = strtoupper(hash('sha256', $guid.'|'.$ticket->id.'|'.microtime(true)));
+ $parentUnid = $parent?->unid;
+ $rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
+ $plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
+
/** @var Comment $comment */
$comment = $ticket->comments()->create([
'parent_id' => $parentId,
'user_id' => $user->id,
'body' => $body,
+ 'body_text' => $plainTextBody,
'is_internal' => $internal,
+ 'guid' => $guid,
+ 'unid' => $unid,
+ 'parent_unid' => $parentUnid,
+ 'root_unid' => $rootUnid,
+ 'originator_id' => $unid.':1',
+ 'sync_sequence' => 1,
+ 'sync_hash' => hash('sha256', $body),
+ 'source_system' => 'wms-helpdesk',
]);
$ticket->forceFill(['last_activity_at' => now()])->save();
diff --git a/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php
index 31e2bb3f..51677615 100644
--- a/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php
+++ b/Modules/Helpdesk/Tests/Browser/HelpdeskTicketBrowserTest.php
@@ -19,12 +19,14 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Ticket Browser User']);
- $this->browse(function (Browser $browser) use ($user, $fixtures): void {
+ $subject = 'Browser created helpdesk ticket '.uniqid();
+
+ $this->browse(function (Browser $browser) use ($user, $fixtures, $subject): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create')
->assertSee('Create ticket')
- ->type('@ticket-subject', 'Browser created helpdesk ticket');
+ ->type('@ticket-subject', $subject);
$this->setHelpdeskEditor($browser, 'ticket-body-editor', '
This browser ticket proves the form saves correctly.
');
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
@@ -36,7 +38,7 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$browser->pause(500)
->press('@ticket-create-submit')
->waitFor('@helpdesk-ticket-index', 10)
- ->assertSee('Browser created helpdesk ticket')
+ ->assertSee($subject)
->assertSee('High')
->assertSee('Support');
});
diff --git a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php
index b5a8cf1d..0672e0e1 100644
--- a/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php
+++ b/Modules/Helpdesk/Tests/Feature/TicketConversationTest.php
@@ -65,6 +65,48 @@ final class TicketConversationTest extends TestCase
]);
}
+
+ public function test_comment_post_endpoint_creates_lotus_identity_and_search_text(): void
+ {
+ $fixtures = $this->seedLookupData();
+ $owner = $this->user();
+ $ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
+
+ $this->actingAs($owner)
+ ->post(route('helpdesk.tickets.comments.store', $ticket), [
+ 'commentBody' => '
Searchable response
',
+ ])
+ ->assertRedirect(route('helpdesk.tickets.show', $ticket));
+
+ $comment = Comment::query()->where('ticket_id', $ticket->id)->firstOrFail();
+
+ self::assertNotEmpty($comment->guid);
+ self::assertNotEmpty($comment->unid);
+ self::assertSame($comment->unid, $comment->root_unid);
+ self::assertSame('Searchable response', $comment->body_text);
+ self::assertSame(1, $comment->sync_sequence);
+ }
+
+ public function test_nested_reply_post_endpoint_preserves_lotus_parent_identity(): void
+ {
+ $fixtures = $this->seedLookupData();
+ $owner = $this->user();
+ $ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
+ $parent = app(\Modules\Helpdesk\Services\TicketService::class)->comment($ticket, $owner, '
Parent response
');
+
+ $this->actingAs($owner)
+ ->post(route('helpdesk.tickets.comments.store', $ticket), [
+ 'parent_id' => $parent->id,
+ 'commentBody' => '
Child response
',
+ ])
+ ->assertRedirect(route('helpdesk.tickets.show', $ticket));
+
+ $reply = Comment::query()->where('parent_id', $parent->id)->firstOrFail();
+
+ self::assertSame($parent->unid, $reply->parent_unid);
+ self::assertSame($parent->root_unid, $reply->root_unid);
+ }
+
public function test_agent_can_update_status_to_closed_and_priority(): void
{
Mail::fake();
diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml
index 52b941e4..3f1f872b 100644
--- a/Modules/Helpdesk/changelog.yaml
+++ b/Modules/Helpdesk/changelog.yaml
@@ -213,3 +213,15 @@ versions:
- Made Helpdesk settings section navigation use normal query-string links so Dusk and browsers do not depend on fragile Livewire tab clicks.
- Updated browser tests to cover canned-response insertion, parent comment persistence, nested reply persistence and settings tab navigation.
- Kept module.json version in sync with changelog.yaml.
+
+ "1.3.13":
+ changed:
+ - Added Lotus Notes-style collapsible ticket conversation cards with visible response counts, red threaded reply rails, search filtering and expand-all controls.
+ - Added professional HTML helper controls for comments and nested responses while preserving safe HTML rendering.
+ - Fixed canned-response insertion so stored HTML is inserted as real HTML instead of escaped text.
+ - Added professional pinned attachment controls for root comments and response-to-response forms.
+ - Restricted MPBA internal-note visibility and creation to MPBA/core-company users while hiding internal notes from ordinary tenant users.
+ - Added GUID, UNID, parent UNID, root UNID, originator, sync sequence, sync hash and searchable body text fields to Helpdesk comments for future replication and search.
+ - Updated TicketService comment creation to populate Lotus-style replication identity metadata for document responses and nested responses.
+ - Updated Helpdesk conversation tests to cover POST comment storage, nested reply identity metadata and searchable comment text.
+ - Kept module.json version in sync with changelog.yaml.
diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json
index d0d37ece..6d6f4c79 100644
--- a/Modules/Helpdesk/module.json
+++ b/Modules/Helpdesk/module.json
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
- "version": "1.3.12"
+ "version": "1.3.13"
}
diff --git a/tests/Browser/PlatformAdministrationBrowserTest.php b/tests/Browser/PlatformAdministrationBrowserTest.php
index 5ce247f8..a2348a39 100644
--- a/tests/Browser/PlatformAdministrationBrowserTest.php
+++ b/tests/Browser/PlatformAdministrationBrowserTest.php
@@ -17,7 +17,10 @@ class PlatformAdministrationBrowserTest extends DuskTestCase
$admin = $this->platformAdminUser();
$this->seedPlatformDashboardData();
- $this->browse(function (Browser $browser) use ($admin): void {
+ $companyName = 'Abbott Logistics '.uniqid();
+ $billingEmail = 'billing+'.uniqid().'@abbott.example';
+
+ $this->browse(function (Browser $browser) use ($admin, $companyName, $billingEmail): void {
$browser->loginAs($admin)
->visit('/admin');
@@ -32,17 +35,20 @@ class PlatformAdministrationBrowserTest extends DuskTestCase
{
$admin = $this->platformAdminUser();
- $this->browse(function (Browser $browser) use ($admin): void {
+ $companyName = 'Abbott Logistics '.uniqid();
+ $billingEmail = 'billing+'.uniqid().'@abbott.example';
+
+ $this->browse(function (Browser $browser) use ($admin, $companyName, $billingEmail): void {
$browser->loginAs($admin)
->visit('/admin/companies');
$this->assertSingleProfessionalHeader($browser, 'Companies');
- $browser->type('name', 'Abbott Logistics Limited')
- ->type('billing_email', 'billing@abbott.example')
+ $browser->type('name', $companyName)
+ ->type('billing_email', $billingEmail)
->select('status', 'active')
->press('Create')
- ->waitForText('Abbott Logistics Limited', 10)
- ->assertSee('Abbott Logistics Limited');
+ ->waitForText($companyName, 10)
+ ->assertSee($companyName);
});
}
diff --git a/tests/Browser/WarehouseBrowserTest.php b/tests/Browser/WarehouseBrowserTest.php
index 29103bda..240c9d5a 100644
--- a/tests/Browser/WarehouseBrowserTest.php
+++ b/tests/Browser/WarehouseBrowserTest.php
@@ -17,7 +17,10 @@ class WarehouseBrowserTest extends DuskTestCase
$user = $this->tenantUser();
$warehouse = $this->seedWarehouse($user->company);
- $this->browse(function (Browser $browser) use ($user, $warehouse): void {
+ $ruleName = 'Require expiry date '.uniqid();
+ $ruleKey = 'expiry-required-'.uniqid();
+
+ $this->browse(function (Browser $browser) use ($user, $warehouse, $ruleName, $ruleKey): void {
$browser->loginAs($user)
->visit('/dashboard');
@@ -37,14 +40,14 @@ class WarehouseBrowserTest extends DuskTestCase
$browser->assertSee('Require batch number')
->assertSee('batch-required')
->select('warehouse_id', (string) $warehouse->id)
- ->type('name', 'Require expiry date')
- ->type('rule_key', 'expiry-required')
+ ->type('name', $ruleName)
+ ->type('rule_key', $ruleKey)
->select('severity', 'error')
->type('configuration', '{"field":"expires_at"}')
->press('Add rule')
- ->waitForText('Require expiry date', 10)
- ->assertSee('Require expiry date')
- ->assertSee('expiry-required');
+ ->waitForText($ruleName, 10)
+ ->assertSee($ruleName)
+ ->assertSee($ruleKey);
});
}
@@ -52,17 +55,20 @@ class WarehouseBrowserTest extends DuskTestCase
{
$user = $this->tenantUser();
- $this->browse(function (Browser $browser) use ($user): void {
+ $warehouseName = 'East Midlands DC '.uniqid();
+ $warehouseCode = 'E'.substr(strtoupper(uniqid()), -4);
+
+ $this->browse(function (Browser $browser) use ($user, $warehouseName, $warehouseCode): void {
$browser->loginAs($user)
->visit('/warehouses');
$this->assertSingleProfessionalHeader($browser, 'Warehouses');
- $browser->type('code', 'EAST')
- ->type('name', 'East Midlands DC')
+ $browser->type('code', $warehouseCode)
+ ->type('name', $warehouseName)
->type('address', 'Main Road, Nottingham')
->press('Create')
- ->waitForText('East Midlands DC', 10)
- ->assertSee('East Midlands DC');
+ ->waitForText($warehouseName, 10)
+ ->assertSee($warehouseName);
});
}
@@ -71,19 +77,22 @@ class WarehouseBrowserTest extends DuskTestCase
$user = $this->tenantUser();
$warehouse = $this->seedWarehouse($user->company);
- $this->browse(function (Browser $browser) use ($user, $warehouse): void {
+ $ruleName = 'Require serial number '.uniqid();
+ $ruleKey = 'serial-required-'.uniqid();
+
+ $this->browse(function (Browser $browser) use ($user, $warehouse, $ruleName, $ruleKey): void {
$browser->loginAs($user)
->visit('/warehouse-rules');
$this->assertSingleProfessionalHeader($browser, 'Validation Rules');
$browser->select('warehouse_id', (string) $warehouse->id)
- ->type('name', 'Require serial number')
- ->type('rule_key', 'serial-required')
+ ->type('name', $ruleName)
+ ->type('rule_key', $ruleKey)
->select('severity', 'error')
->type('configuration', '{"field":"serial_no"}')
->press('Add rule')
- ->waitForText('Require serial number', 10)
- ->assertSee('serial-required');
+ ->waitForText($ruleName, 10)
+ ->assertSee($ruleKey);
});
}
}