start to add new features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.9.42"
|
||||
"version": "1.9.43"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user