start to add new features

This commit is contained in:
Mark Gregory
2026-06-23 11:24:17 +01:00
parent 4ee0b8e604
commit aec6989053
11 changed files with 201 additions and 28 deletions
+99
View File
@@ -201,3 +201,102 @@ Issue resolved and verified.
The requested work has been completed and no further action is currently required. This ticket is now closed.
```
## Ticket Workflow Stabilisation Checklist
This module treats the following workflows as mandatory production Helpdesk behaviour. Future changes must preserve these flows and update tests when behaviour changes.
### Create ticket
1. Open **Helpdesk > Tickets > Create**.
2. Enter a subject and details.
3. Use the browser spell checker in the subject and details editor before submitting.
4. Attach supporting files where useful.
5. Click **Create Ticket** once.
Validation must run only after **Create Ticket** is clicked. Rich-text editor synchronisation must not trigger premature validation or require a second click.
### Public reply
1. Open a ticket.
2. Choose **Public reply**.
3. Enter the response.
4. Submit the response.
A public reply is visible in the conversation and counts toward the first-response SLA. The first public response should set the first response timestamp once and must not be overwritten by later replies.
### Internal note
1. Open a ticket.
2. Choose **Internal note**.
3. Enter staff-only information.
4. Submit the note.
Internal notes are not customer-visible and must not satisfy the public first-response SLA.
### Close ticket
1. Open the ticket controls.
2. Select a closed status.
3. Enter a mandatory closure comment when prompted.
4. Choose whether the closure comment is public or internal.
5. Confirm **Close ticket**.
The closure comment is required for auditability. The resolution SLA should be recorded after closure.
### Reopen ticket
1. Open a closed ticket.
2. Select an open or in-progress status.
3. Add a reopen reason where the UI requires one.
4. Continue with public replies, internal notes, timers and attachments.
The original closure history must remain intact.
### SLA
The SLA panel must show clear operational wording:
- Awaiting public reply.
- Due soon.
- SLA met.
- Responded late.
- Breached.
It should show who first responded, when they responded, and how long it took. SLA audit events should be retained for review.
### Timer
1. Start a timer only when work begins.
2. The live elapsed timer appears only while the timer is running.
3. End the timer when work stops.
4. Confirm the saved time in the ticket log.
Timer UI must not leave hidden or unused JavaScript files behind.
### Attachments
Attachments may be added to tickets and responses. Uploaded files must remain validated by type and size, stored safely, and associated with the correct ticket/comment.
### Ticket reference sequence
Ticket references must be generated from `helpdesk_ticket_sequences` when the migration has run. The sequence row is locked during ticket creation, and the initial value is calculated from all existing ticket references, including soft-deleted tickets. This avoids duplicate references such as reusing `HD-2026-00002`.
### Spell checking
Browser-native spell checking is enabled on ticket subjects, details, public replies, threaded replies, timer notes and closure comments using `spellcheck="true"` and `lang="en-GB"`.
### Regression coverage expected
The module should keep tests around:
- Create ticket submit-only validation.
- Body editor synchronisation.
- Public and internal replies.
- Mandatory closure comments.
- Reopen behaviour where implemented.
- SLA state and audit trail updates.
- Timer start/end logging.
- Attachment validation and association.
- Reference sequence collision prevention.
- Browser spellcheck attributes on writable fields.
@@ -65,7 +65,7 @@
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button>
</div>
<div x-ref="editor" contenteditable="true" x-on:input.debounce.150ms="sync" x-on:keyup="sync"
<div x-ref="editor" contenteditable="true" spellcheck="true" lang="en-GB" x-on:input.debounce.150ms="sync" x-on:keyup="sync"
x-on:paste="$nextTick(() => sync())" x-on:blur="sync"
class="prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
data-placeholder="{{ $placeholder }}"></div>
@@ -23,7 +23,7 @@
Subject <span class="helpdesk-required-star">*</span>
</label>
<input id="subject" type="text" maxlength="180" wire:model.defer="subject"
<input id="subject" type="text" spellcheck="true" lang="en-GB" maxlength="180" wire:model.defer="subject"
class="mt-2 block h-14 w-full max-w-4xl rounded-xl border border-zinc-700 bg-zinc-950 px-4 text-base font-medium text-zinc-100 shadow-sm outline-none transition-colors duration-150 placeholder:text-zinc-500 focus:border-blue-500 focus:bg-zinc-950 focus:ring-2 focus:ring-blue-500/40"
data-dusk="ticket-subject" dusk="ticket-subject" autocomplete="off" />
@@ -269,12 +269,12 @@
<div id="body-editor"
class="min-h-[18rem] w-full px-4 py-3 text-sm leading-6 text-zinc-100 outline-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)]"
contenteditable="true" data-helpdesk-details-editor
contenteditable="true" spellcheck="true" lang="en-GB" data-helpdesk-details-editor
data-placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..."
data-dusk="ticket-body-editor" dusk="ticket-body-editor">{!! $body ?? '' !!}</div>
</div>
<textarea id="body" wire:model="body" class="sr-only" tabindex="-1" aria-hidden="true"></textarea>
<textarea id="body" spellcheck="true" lang="en-GB" wire:model="body" class="sr-only" tabindex="-1" aria-hidden="true"></textarea>
@error('body')
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
@@ -428,13 +428,13 @@
</button>
</div>
<div id="ticket-comment-body-editor" dusk="ticket-comment-editor" contenteditable="true"
<div id="ticket-comment-body-editor" dusk="ticket-comment-editor" contenteditable="true" spellcheck="true" lang="en-GB"
role="textbox" aria-multiline="true" data-helpdesk-rich-editor
data-helpdesk-sync-target="ticket-comment-body"
class="prose block min-h-[12rem] w-full max-w-none rounded-xl border border-zinc-300 bg-white p-4 text-sm leading-6 text-zinc-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:prose-invert">
{!! $oldCommentBody !!}</div>
<textarea dusk="comment-body" data-dusk="comment-body" id="ticket-comment-body"
<textarea dusk="comment-body" spellcheck="true" lang="en-GB" data-dusk="comment-body" id="ticket-comment-body"
name="commentBody" required
class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
</div>
@@ -214,7 +214,7 @@
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>"
class="rounded-lg border px-2 py-1 text-xs">Code</button>
</div>
<textarea id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4"
<textarea spellcheck="true" lang="en-GB" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4"
required
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
placeholder="Write a threaded Lotus Notes-style response..."></textarea>
@@ -56,7 +56,7 @@
</button>
</div>
<textarea name="description" rows="2" placeholder="Optional work note"
<textarea spellcheck="true" lang="en-GB" name="description" rows="2" placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description"></textarea>
@@ -146,7 +146,7 @@
Closure comment <span class="text-red-600">*</span>
</label>
<textarea id="closure_comment" name="closure_comment" rows="5" minlength="10" maxlength="5000"
<textarea id="closure_comment" spellcheck="true" lang="en-GB" name="closure_comment" rows="5" minlength="10" maxlength="5000"
placeholder="Explain what was completed, verified, or agreed before closing this ticket."
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-helpdesk-closure-comment>{{ old('closure_comment') }}</textarea>
@@ -18,7 +18,7 @@
</button>
</div>
<textarea name="description" rows="2" placeholder="Optional work note"
<textarea spellcheck="true" lang="en-GB" name="description" rows="2" placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description">{{ old('description') }}</textarea>
@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Ticket;
final class TicketReferenceService
@@ -18,48 +19,109 @@ final class TicketReferenceService
/**
* Build the next human-readable ticket reference for the current year.
*
* The lookup uses the highest numeric suffix, not the latest database id,
* because imported, restored, or manually adjusted tickets may not be in
* reference order. On PostgreSQL an advisory transaction lock is also used
* so two users cannot generate the same reference at the same time.
* The preferred strategy uses a dedicated sequence row locked inside the
* ticket creation transaction. The initial sequence value is backfilled from
* all existing ticket references, including soft-deleted tickets, so a
* previously used reference can never be reused while the unique index still
* protects it.
*/
public function next(): string
{
$year = now()->format('Y');
$prefix = 'HD-'.$year.'-';
$year = (int) now()->format('Y');
$prefix = 'HD';
$seriesPrefix = $prefix.'-'.$year.'-';
$this->lockReferenceSeries($prefix);
$this->lockReferenceSeries($seriesPrefix);
if (Schema::hasTable('helpdesk_ticket_sequences')) {
return $this->nextFromSequenceTable($prefix, $year, $seriesPrefix);
}
return $this->nextFromExistingReferences($seriesPrefix);
}
/**
* Return the next reference from the durable sequence table.
*/
private function nextFromSequenceTable(string $prefix, int $year, string $seriesPrefix): string
{
$sequence = DB::table('helpdesk_ticket_sequences')
->where('prefix', $prefix)
->where('year', $year)
->lockForUpdate()
->first();
if (! $sequence) {
DB::table('helpdesk_ticket_sequences')->insert([
'prefix' => $prefix,
'year' => $year,
'next_number' => $this->highestExistingSuffix($seriesPrefix) + 1,
'created_at' => now(),
'updated_at' => now(),
]);
$sequence = DB::table('helpdesk_ticket_sequences')
->where('prefix', $prefix)
->where('year', $year)
->lockForUpdate()
->first();
}
$nextNumber = max((int) $sequence->next_number, $this->highestExistingSuffix($seriesPrefix) + 1);
DB::table('helpdesk_ticket_sequences')
->where('id', $sequence->id)
->update([
'next_number' => $nextNumber + 1,
'updated_at' => now(),
]);
return $seriesPrefix.str_pad((string) $nextNumber, 5, '0', STR_PAD_LEFT);
}
/**
* Fallback for installations that have not run the sequence migration yet.
*/
private function nextFromExistingReferences(string $seriesPrefix): string
{
return $seriesPrefix.str_pad((string) ($this->highestExistingSuffix($seriesPrefix) + 1), 5, '0', STR_PAD_LEFT);
}
/**
* Find the highest used suffix across active and soft-deleted tickets.
*/
private function highestExistingSuffix(string $seriesPrefix): int
{
/** @var list<string> $existingReferences */
$existingReferences = Ticket::query()
->withoutGlobalScopes()
->withTrashed()
->where('reference', 'like', $prefix.'%')
->where('reference', 'like', $seriesPrefix.'%')
->pluck('reference')
->filter(static fn (mixed $reference): bool => is_string($reference) && preg_match('/^'.preg_quote($prefix, '/').'\d+$/', $reference) === 1)
->filter(static fn (mixed $reference): bool => is_string($reference) && preg_match('/^'.preg_quote($seriesPrefix, '/').'\d+$/', $reference) === 1)
->values()
->all();
$highestSuffix = 0;
foreach ($existingReferences as $existingReference) {
$highestSuffix = max($highestSuffix, (int) substr($existingReference, strlen($prefix)));
$highestSuffix = max($highestSuffix, (int) substr($existingReference, strlen($seriesPrefix)));
}
return $prefix.str_pad((string) ($highestSuffix + 1), 5, '0', STR_PAD_LEFT);
return $highestSuffix;
}
/**
* Lock the current ticket reference series for the active transaction.
*/
private function lockReferenceSeries(string $prefix): void
private function lockReferenceSeries(string $seriesPrefix): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::select('select pg_advisory_xact_lock(hashtext(?))', [
'helpdesk-ticket-reference:'.$prefix,
'helpdesk-ticket-reference:'.$seriesPrefix,
]);
}
}
@@ -13,12 +13,12 @@ class TicketTimerSecondsFilesTest extends TestCase
{
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
$model = (string) file_get_contents(base_path('Modules/Helpdesk/Models/TicketTimeLog.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringContainsString("'seconds'", $component);
$this->assertStringContainsString("'seconds'", $model);
$this->assertStringContainsString('diffInSeconds', $component);
$this->assertStringContainsString('sum('seconds')', $component);
$this->assertStringContainsString("sum('seconds')", $component);
$this->assertStringContainsString('formatElapsed', $view);
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
$this->assertStringNotContainsString('localStorage', $component);
@@ -30,10 +30,10 @@ class TicketTimerSecondsFilesTest extends TestCase
*/
public function test_history_query_reads_ticket_time_logs_directly(): void
{
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-log-history-query.blade.php'));
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/Resources/views/partials/ticket-time-log-history-query.blade.php'));
$this->assertStringContainsString('TicketTimeLog::query()', $partial);
$this->assertStringContainsString('whereNotNull('ended_at')', $partial);
$this->assertStringContainsString("whereNotNull('ended_at')", $partial);
$this->assertStringContainsString('seconds', $partial);
}
}
+12
View File
@@ -139,3 +139,15 @@
- Added an isSaving guard and save-target loading state so duplicate clicks cannot create duplicate submit attempts.
- Added regression coverage proving draft edits do not fire validation before submit.
- Preserved append-only changelog history.
- version: 1.9.43
date: 2026-06-23
type: feature
summary: Add Helpdesk stabilisation pack for spellcheck, durable ticket references, workflow documentation and regression coverage.
changes:
- Added a dedicated helpdesk_ticket_sequences migration so ticket references use a durable yearly sequence instead of relying only on scanning existing tickets.
- Updated TicketReferenceService to lock and increment the yearly sequence row inside the ticket creation transaction while still seeding the initial value from active and soft-deleted ticket references.
- Enabled browser-native spell checking on ticket creation, public replies, threaded replies, timer notes and closure comments.
- Added README workflow instructions for create, public reply, internal note, close, reopen, SLA, timers, attachments, spell checking and ticket reference sequencing.
- Added regression coverage for the sequence implementation, spellcheck wiring and documented stabilised workflows.
- Preserved append-only changelog history.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.42"
"version": "1.9.43"
}