Compare commits
40 Commits
9527147c32
...
card/6217-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0cec54f9b | ||
|
|
7e675e7beb | ||
|
|
e4d0622361 | ||
|
|
abd21f73f2 | ||
|
|
51073fba1e | ||
|
|
8b4f788e43 | ||
|
|
a977aedc95 | ||
| 10ef42d78c | |||
|
|
9cc2e59b45 | ||
|
|
94fb6ac53c | ||
|
|
adcb02863d | ||
|
|
428a9e42c6 | ||
|
|
eb198ab05a | ||
|
|
dbe3677093 | ||
|
|
0dbc81684b | ||
|
|
7522d48ab9 | ||
|
|
b80e200f83 | ||
|
|
332551e2e3 | ||
|
|
c492becbdb | ||
|
|
2b6d9b1651 | ||
|
|
5eb2ec7bcf | ||
|
|
420ef78603 | ||
|
|
5315afc239 | ||
|
|
c0225d7691 | ||
| ef4d0c4f93 | |||
| 0e48949114 | |||
| a4a365da3b | |||
| e24124b6d4 | |||
| 82892deafe | |||
| afbbd3fcde | |||
| 5a83263f7a | |||
| 4719cbab8b | |||
| 1175c20a57 | |||
| 9ce5b45b79 | |||
| 0be06694b2 | |||
|
|
a54c01453d | ||
| dcfbf20ab0 | |||
| e49a1e2ac9 | |||
|
|
dd0a458250 | ||
|
|
ffb64078d8 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/ticketing",
|
"name": "dashboard/ticketing",
|
||||||
"description": "Help desk ticketing snap-in for the Dashboard platform",
|
"description": "Help desk ticketing snap-in for the Dashboard platform",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
|
"providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
"tier": "optional",
|
||||||
|
"stability": "beta",
|
||||||
"nav_folder": {
|
"nav_folder": {
|
||||||
"label": "Help Desk",
|
"label": "Help Desk",
|
||||||
"icon": "ticket",
|
"icon": "ticket",
|
||||||
@@ -34,6 +36,9 @@
|
|||||||
{ "key": "ticketing.create", "label": "Create Tickets", "description": "Create tickets on behalf of other users" },
|
{ "key": "ticketing.create", "label": "Create Tickets", "description": "Create tickets on behalf of other users" },
|
||||||
{ "key": "ticketing.manage", "label": "Manage Tickets", "description": "View, assign, and resolve all tickets" },
|
{ "key": "ticketing.manage", "label": "Manage Tickets", "description": "View, assign, and resolve all tickets" },
|
||||||
{ "key": "ticketing.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" }
|
{ "key": "ticketing.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" }
|
||||||
|
],
|
||||||
|
"seeders": [
|
||||||
|
"Dashboard\\Ticketing\\Database\\Seeders\\EmailTemplatesSeeder"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
config/ticketing.php
Normal file
15
config/ticketing.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The filesystem disk ticket attachments are stored on. Read at runtime via
|
||||||
|
* config('ticketing.storage_disk') — NOT env() — so it survives config:cache
|
||||||
|
* in production. Before this file existed the controllers read
|
||||||
|
* env('TICKETING_STORAGE_DISK') directly, which returns null once the config
|
||||||
|
* is cached, silently forcing every attachment onto the 'local' disk and
|
||||||
|
* orphaning anything an operator had pointed elsewhere (secrets spec S3).
|
||||||
|
*/
|
||||||
|
'storage_disk' => env('TICKETING_STORAGE_DISK', 'local'),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Deduplicate before removing group_id:
|
||||||
|
// If the same priority name exists multiple times (once per group), keep the first
|
||||||
|
// and delete duplicates, then reassign any tickets that referenced the deleted ones.
|
||||||
|
$names = DB::table('ticketing_priority_levels')
|
||||||
|
->select('name')
|
||||||
|
->groupBy('name')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->pluck('name');
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$rows = DB::table('ticketing_priority_levels')
|
||||||
|
->where('name', $name)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$keepId = $rows->first()->id;
|
||||||
|
|
||||||
|
foreach ($rows->skip(1) as $row) {
|
||||||
|
// Reassign tickets pointing at the duplicate
|
||||||
|
DB::table('tickets')
|
||||||
|
->where('priority_id', $row->id)
|
||||||
|
->update(['priority_id' => $keepId]);
|
||||||
|
|
||||||
|
DB::table('ticketing_priority_levels')->where('id', $row->id)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
|
||||||
|
// Drop the foreign key and column
|
||||||
|
$table->dropForeign(['group_id']);
|
||||||
|
$table->dropColumn('group_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
|
||||||
|
$table->foreignId('group_id')->nullable()->constrained('ticketing_groups')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ticket_views', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->timestamp('viewed_at');
|
||||||
|
$table->index(['ticket_id', 'user_id']);
|
||||||
|
$table->index('viewed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ticket_views');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. Create ticket_participants table
|
||||||
|
Schema::create('ticket_participants', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->unsignedBigInteger('added_by')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->unique(['ticket_id', 'user_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Add merged_into_id to tickets
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('merged_into_id')->nullable()->after('due_date');
|
||||||
|
$table->foreign('merged_into_id')->references('id')->on('tickets')->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Expand status ENUM to include 'merged'
|
||||||
|
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Reverse ENUM change (remove merged)
|
||||||
|
DB::statement("UPDATE tickets SET status = 'closed' WHERE status = 'merged'");
|
||||||
|
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed') DEFAULT 'open'");
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['merged_into_id']);
|
||||||
|
$table->dropColumn('merged_into_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('ticket_participants');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. Create ticketing_statuses table
|
||||||
|
Schema::create('ticketing_statuses', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('slug', 50)->unique(); // machine key — immutable after creation
|
||||||
|
$table->string('name', 100); // user-facing display name
|
||||||
|
$table->string('color', 7)->default('#6b7280'); // hex color
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||||
|
$table->boolean('is_closed')->default(false); // counts as "resolved/closed" in filters
|
||||||
|
$table->boolean('is_system')->default(false); // cannot be deleted
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Seed default statuses
|
||||||
|
$now = now();
|
||||||
|
DB::table('ticketing_statuses')->insert([
|
||||||
|
['slug' => 'open', 'name' => 'Open', 'color' => '#3b82f6', 'sort_order' => 1, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['slug' => 'in_progress', 'name' => 'In Progress', 'color' => '#7c3aed', 'sort_order' => 2, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['slug' => 'pending', 'name' => 'Pending', 'color' => '#d97706', 'sort_order' => 3, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['slug' => 'resolved', 'name' => 'Resolved', 'color' => '#16a34a', 'sort_order' => 4, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['slug' => 'closed', 'name' => 'Closed', 'color' => '#6b7280', 'sort_order' => 5, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['slug' => 'merged', 'name' => 'Merged', 'color' => '#9ca3af', 'sort_order' => 99, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Change tickets.status from ENUM to VARCHAR(50) — existing slug values are kept as-is
|
||||||
|
DB::statement("ALTER TABLE tickets MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'open'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Restore ENUM (data already uses these slugs so no data loss)
|
||||||
|
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
|
||||||
|
|
||||||
|
Schema::dropIfExists('ticketing_statuses');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups can be retired without destroying ticket history.
|
||||||
|
*
|
||||||
|
* A group may own tickets, and tickets are the audit record of real
|
||||||
|
* correspondence — so "delete this group" can only ever hard-delete when the
|
||||||
|
* group is empty. When it owns tickets we archive instead, and archiving needs
|
||||||
|
* somewhere to record itself.
|
||||||
|
*
|
||||||
|
* Expand-only: one nullable timestamp added to an existing table. Nothing is
|
||||||
|
* dropped, renamed or backfilled, and every existing row keeps its meaning
|
||||||
|
* (NULL = active), so the old code path reads this table unchanged. That is
|
||||||
|
* what keeps the trunk deployable — see `php artisan dashboard:doctor`.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_groups', function (Blueprint $table) {
|
||||||
|
$table->timestamp('archived_at')->nullable()->after('prefix');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Deliberate NO-OP, matching the warnings-column precedent in
|
||||||
|
// dashboard-myedpuller-sync: DashboardDoctor greps the WHOLE migration file
|
||||||
|
// for drop/rename patterns, so a dropColumn even in down() raises the
|
||||||
|
// "pending destructive migration" advisory on every host where this is
|
||||||
|
// still pending — a confusing warning for a purely additive change. A
|
||||||
|
// nullable column costs nothing to leave behind.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ticket reference must never be issued twice.
|
||||||
|
*
|
||||||
|
* `TicketingGroup::nextTicketNumber()` derived the next reference from the MAX
|
||||||
|
* suffix over the group's **live** tickets. Tickets are hard-deleted (no
|
||||||
|
* SoftDeletes on the model), so deleting the newest ticket in a group put the
|
||||||
|
* number back in the pool and the next ticket was issued the same reference —
|
||||||
|
* two different tickets both carrying `QAX-0004`.
|
||||||
|
*
|
||||||
|
* That matters well outside Ticketing, because the reference is the correlation
|
||||||
|
* key that leaves the app: it is the `[QAX-0004]` token in outbound mail subject
|
||||||
|
* lines, so a reply can thread onto the wrong ticket; the shell Notification Log
|
||||||
|
* is an audit trail keyed on that subject; and `TicketController::merge()` looks
|
||||||
|
* a ticket up by `number` and takes `->first()`, so a duplicate makes it merge
|
||||||
|
* into an arbitrary one of the two.
|
||||||
|
*
|
||||||
|
* This column is that pool: a per-group high-water mark that only ever goes up,
|
||||||
|
* independent of whether the ticket it was issued to still exists.
|
||||||
|
*
|
||||||
|
* **The backfill is the load-bearing half of this migration.** Left at 0, the
|
||||||
|
* first ticket created after deploy would be issued `-0001` on a group that has
|
||||||
|
* been running for months — turning a bug that reused one number into one that
|
||||||
|
* reuses every number. So each group is seeded with the same MAX the old code
|
||||||
|
* would have read, scoped to its CURRENT prefix exactly as the old query was.
|
||||||
|
* The next reference issued after this migration is therefore identical to the
|
||||||
|
* one the old code would have issued; all that changes is that it can no longer
|
||||||
|
* go backwards afterwards.
|
||||||
|
*
|
||||||
|
* Expand-only: one column added with a default, nothing dropped or renamed, and
|
||||||
|
* existing rows keep their meaning. Note there is deliberately NO unique index
|
||||||
|
* on `tickets.number` — production already contains duplicates created by this
|
||||||
|
* bug (the report evidences at least `QAX-0001`–`QAX-0004`), so adding one here
|
||||||
|
* would fail the deploy. De-duplicating the existing rows is its own decision,
|
||||||
|
* with its own migration, and is not made silently here.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('ticketing_groups', 'last_ticket_number')) {
|
||||||
|
Schema::table('ticketing_groups', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('last_ticket_number')->default(0)->after('prefix');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the high-water mark from the numbers already issued. Same shape as
|
||||||
|
// the query this replaces: the group's own tickets, restricted to its
|
||||||
|
// current prefix, suffix cast to an integer.
|
||||||
|
foreach (DB::table('ticketing_groups')->select('id', 'prefix')->get() as $group) {
|
||||||
|
$prefix = $group->prefix.'-';
|
||||||
|
|
||||||
|
$max = (int) DB::table('tickets')
|
||||||
|
->where('group_id', $group->id)
|
||||||
|
->where('number', 'like', $prefix.'%')
|
||||||
|
->max(DB::raw('CAST(SUBSTRING(number, '.(strlen($prefix) + 1).') AS UNSIGNED)'));
|
||||||
|
|
||||||
|
// Never lower a counter that already holds a larger value — this
|
||||||
|
// migration must be safe to re-run and must never hand back a number.
|
||||||
|
DB::table('ticketing_groups')
|
||||||
|
->where('id', $group->id)
|
||||||
|
->where('last_ticket_number', '<', $max)
|
||||||
|
->update(['last_ticket_number' => $max]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Deliberate NO-OP, matching the archived_at precedent above it:
|
||||||
|
// DashboardDoctor greps the whole migration file for drop/rename patterns,
|
||||||
|
// so a dropColumn even in down() raises "pending destructive migration" on
|
||||||
|
// every host where this is still pending. An integer column with a default
|
||||||
|
// costs nothing to leave behind — and dropping it would silently restore
|
||||||
|
// the reference-reuse bug.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MERGE AND SPLIT WROTE A `source` THE COLUMN DID NOT ACCEPT. [central-ticketing] #415
|
||||||
|
*
|
||||||
|
* Both actions write an audit note into `ticket_messages` with `source = 'system'`,
|
||||||
|
* and the column was created as `enum('web','email')`. Under strict mode the insert
|
||||||
|
* throws `Data truncated for column 'source'` INSIDE the transaction, so nothing
|
||||||
|
* commits: no merge, no split, no partial state, and a generic 500 page. Every
|
||||||
|
* guard around them worked — a missing reference, a self-merge and an absent
|
||||||
|
* `source_number` were all handled correctly — so only the happy path was broken,
|
||||||
|
* which is why it survived.
|
||||||
|
*
|
||||||
|
* ── THE WHOLE DOMAIN, NOT THE ONE IN THE TRACE ──────────────────────────────
|
||||||
|
*
|
||||||
|
* The trace named `'system'`. Widening to accept only that would be a guess about
|
||||||
|
* how many more there are, so every value written to this column anywhere in the
|
||||||
|
* platform was enumerated first:
|
||||||
|
*
|
||||||
|
* 'web' TicketMessageController::store(), the demo seeder
|
||||||
|
* 'email' the demo seeder (no inbound-mail ingester exists yet)
|
||||||
|
* 'system' TicketController::merge() and ::split(), the three notes
|
||||||
|
*
|
||||||
|
* `split()` also copies `$msg->source` verbatim when duplicating a thread, which
|
||||||
|
* introduces no fourth value. So the set is closed at three and this runs once.
|
||||||
|
*
|
||||||
|
* ── WHY AN ENUM AND NOT A VARCHAR ───────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `tickets.status` went the other way — `2026_04_17_000002` deliberately widened it
|
||||||
|
* from an enum to `varchar(50)` — and the difference is the test worth writing down:
|
||||||
|
* statuses became OPERATOR-DEFINABLE rows in `ticketing_statuses`, so no enum could
|
||||||
|
* ever hold them. `source` is the opposite: all three values are set by code, an
|
||||||
|
* operator has no way to add a fourth, and the enum is what makes a typo'd source a
|
||||||
|
* loud failure rather than a silently unfilterable row.
|
||||||
|
*
|
||||||
|
* The write side is bounded too — `TicketMessage::system()` is now the only place a
|
||||||
|
* system note is created, so the three scattered `'source' => 'system'` literals that
|
||||||
|
* caused this cannot reappear one at a time.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement(
|
||||||
|
"ALTER TABLE ticket_messages
|
||||||
|
MODIFY COLUMN source ENUM('web','email','system') NOT NULL DEFAULT 'web'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Rewrite the rows first: narrowing an enum that still holds 'system' turns
|
||||||
|
// those rows into the empty string rather than failing, which would leave the
|
||||||
|
// merge and split notes present but unattributable. 'web' is the honest
|
||||||
|
// fallback — they were written by a person pressing a button in the app.
|
||||||
|
DB::table('ticket_messages')->where('source', 'system')->update(['source' => 'web']);
|
||||||
|
|
||||||
|
DB::statement(
|
||||||
|
"ALTER TABLE ticket_messages
|
||||||
|
MODIFY COLUMN source ENUM('web','email') NOT NULL DEFAULT 'web'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPTION A OF [central-ticketing] #938 — the repair, behind a setting that is OFF.
|
||||||
|
*
|
||||||
|
* The rows the merge bug minted (`merged_into_id` set, `status` back to `open`)
|
||||||
|
* are data, not code, and #920 deliberately shipped no data change. This
|
||||||
|
* migration is the one that would: it re-marks every such row `merged`, which
|
||||||
|
* makes it read-only under #920's guard, and it leaves every message,
|
||||||
|
* participant and field edit made after the merge exactly where it is.
|
||||||
|
*
|
||||||
|
* IT DOES NOT RUN UNLESS THE OPERATOR HAS ASKED. `MergedTicketRepair::SETTING`
|
||||||
|
* — "auto-repair inconsistent merged tickets on deploy" — defaults off, and a
|
||||||
|
* box that has never heard of it therefore deploys this migration and changes
|
||||||
|
* nothing at all. That is what lets the code ship ahead of the decision: the
|
||||||
|
* office gets the badge and the one-click fix now (option B), and turning this
|
||||||
|
* on is the operator's separate, later, reversible act (option C).
|
||||||
|
*
|
||||||
|
* WHAT IT ALWAYS DOES, EITHER WAY, IS COUNT AND SAY SO. The dry-run count goes
|
||||||
|
* to the log on every deploy whether the repair is armed or not, because the
|
||||||
|
* number is the thing #938 was blocked on and a deploy is the one moment
|
||||||
|
* something is definitely looking at prod's database.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// The dry run, first and unconditionally.
|
||||||
|
$count = MergedTicketRepair::count();
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 dry run: %d ticket(s) have merged_into_id set with a status other than "merged".',
|
||||||
|
$count,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! MergedTicketRepair::enabledOnDeploy()) {
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938: leaving those %d row(s) editable — "%s" is off. '
|
||||||
|
.'Turn it on in Ticketing → Settings → Automation, or fix them one at a '
|
||||||
|
.'time with the "Mark merged" button on each ticket.',
|
||||||
|
$count,
|
||||||
|
MergedTicketRepair::SETTING,
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MergedTicketRepair::repairAll('migration 2026_09_04_000001');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no down migration, and that is a decision rather than an omission.
|
||||||
|
*
|
||||||
|
* Reverting would mean putting each row's status back to what it was before
|
||||||
|
* the bad save, and nothing anywhere records what that was — the bug
|
||||||
|
* overwrote it. Setting them all back to `open` would not be a rollback; it
|
||||||
|
* would be re-creating the corrupt shape on rows that may have been merged
|
||||||
|
* legitimately years ago. A row repaired in error is corrected the same way
|
||||||
|
* any other status is: an agent edits the ticket.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Intentionally empty — see the note above.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO WROTE THIS ROW AS A RESCUE — [central-ticketing] #5002.
|
||||||
|
*
|
||||||
|
* #4806 let a site admin appoint somebody to a help desk group that has no
|
||||||
|
* manager left who could still act. That write is the only way back into such
|
||||||
|
* a group, and it ENDS the condition that allowed it: appoint a `manager` and
|
||||||
|
* the group has a manager again, so the recovery arm shuts behind the press.
|
||||||
|
* With `destroyAgent()` gated on plain manager rights, a rescuer who named the
|
||||||
|
* wrong person could not take the row back — the only person who could was the
|
||||||
|
* person wrongly appointed.
|
||||||
|
*
|
||||||
|
* `orphanedGroupIds()` cannot tell that case from a healthy group with one
|
||||||
|
* working manager: both are "orphaned if you disregard this row", and allowing
|
||||||
|
* the second would let any site admin strip a manager from a group that is
|
||||||
|
* none of their business (#4710). So the row has to say where it came from.
|
||||||
|
*
|
||||||
|
* Nullable, and null on every row written the ordinary way: this is not
|
||||||
|
* "created_by", it is "this row exists because <user> rescued the group, and
|
||||||
|
* it is theirs to undo". No foreign key — `user_id` on this table has none
|
||||||
|
* either, and a deleted rescuer should leave the row alone, not take it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('recovered_by')->nullable()->after('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('recovered_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="mb-6">
|
|
||||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mt-2">Submit a Ticket</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bootstrap / No groups state -->
|
|
||||||
<div v-if="isBootstrap" class="bg-amber-50 border border-amber-300 rounded-xl px-5 py-6 text-center">
|
|
||||||
<p class="text-amber-800 font-semibold text-base mb-2">📦 Ticketing isn't set up yet</p>
|
|
||||||
<p class="text-sm text-amber-700 mb-4">An admin needs to create at least one group before tickets can be submitted.</p>
|
|
||||||
<Link v-if="isSiteAdmin" :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700">Go to Settings</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form v-else @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
|
||||||
<!-- Group -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Group <span class="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
v-model="form.group_id"
|
|
||||||
required
|
|
||||||
class="w-full border-gray-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Select a group</option>
|
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="form.errors.group_id" class="text-xs text-red-600 mt-1">{{ form.errors.group_id }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
|
|
||||||
<input
|
|
||||||
v-model="form.title"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Brief summary of the issue"
|
|
||||||
class="w-full border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
|
||||||
required
|
|
||||||
rows="5"
|
|
||||||
placeholder="Describe the issue in detail..."
|
|
||||||
class="w-full border-gray-300 rounded-lg text-sm"
|
|
||||||
></textarea>
|
|
||||||
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
|
||||||
<select
|
|
||||||
v-model="form.priority_id"
|
|
||||||
class="w-full border-gray-300 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option :value="null">No priority</option>
|
|
||||||
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Due Date -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
|
||||||
<input
|
|
||||||
v-model="form.due_date"
|
|
||||||
type="date"
|
|
||||||
class="w-full border-gray-300 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<div class="flex justify-end pt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="inline-flex items-center gap-2 bg-gray-900 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-60 transition"
|
|
||||||
>
|
|
||||||
<span v-if="form.processing">Submitting…</span>
|
|
||||||
<span v-else>Submit Ticket</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, watch } from 'vue'
|
|
||||||
import { Link, useForm } from '@inertiajs/vue3'
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
groups: Array,
|
|
||||||
priorities: Array,
|
|
||||||
isBootstrap: Boolean,
|
|
||||||
isSiteAdmin: Boolean,
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
group_id: '',
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
priority_id: null,
|
|
||||||
due_date: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredPriorities = computed(() => {
|
|
||||||
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
|
|
||||||
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset priority if it's no longer valid for the newly selected group
|
|
||||||
watch(() => form.group_id, () => {
|
|
||||||
const valid = filteredPriorities.value.some(p => p.id === form.priority_id)
|
|
||||||
if (!valid) {
|
|
||||||
form.priority_id = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
form.post(route('ticketing.store'))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="mb-6">
|
|
||||||
<Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline">← Back to ticket</Link>
|
|
||||||
<h1 class="text-xl font-bold text-gray-900 mt-2">
|
|
||||||
Edit <span class="font-mono text-base">{{ ticket.number }}</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
|
||||||
<!-- Title -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
|
||||||
<input v-model="form.title" type="text" required class="w-full border-gray-300 rounded-lg text-sm" />
|
|
||||||
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
||||||
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 rounded-lg text-sm"></textarea>
|
|
||||||
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
||||||
<select v-model="form.status" class="w-full border-gray-300 rounded-lg text-sm">
|
|
||||||
<option value="open">Open</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="resolved">Resolved</option>
|
|
||||||
<option value="closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
|
||||||
<select v-model="form.priority_id" class="w-full border-gray-300 rounded-lg text-sm">
|
|
||||||
<option :value="null">No priority</option>
|
|
||||||
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assignee -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
|
|
||||||
<select v-model="form.assigned_to" class="w-full border-gray-300 rounded-lg text-sm">
|
|
||||||
<option :value="null">Unassigned</option>
|
|
||||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Project</label>
|
|
||||||
<select v-model="form.project_id" class="w-full border-gray-300 rounded-lg text-sm">
|
|
||||||
<option :value="null">No project</option>
|
|
||||||
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Due Date -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
|
|
||||||
<input v-model="form.due_date" type="date" class="w-full border-gray-300 rounded-lg text-sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end pt-2">
|
|
||||||
<Link
|
|
||||||
:href="route('ticketing.show', { ticket: ticket.id })"
|
|
||||||
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>Cancel</Link>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="form.processing"
|
|
||||||
class="px-5 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-60 transition"
|
|
||||||
>
|
|
||||||
{{ form.processing ? 'Saving…' : 'Save Changes' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
import { Link, useForm } from '@inertiajs/vue3'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
ticket: Object,
|
|
||||||
priorities: Array,
|
|
||||||
agents: Array,
|
|
||||||
projects: Array,
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
title: props.ticket.title,
|
|
||||||
description: props.ticket.description,
|
|
||||||
status: props.ticket.status,
|
|
||||||
priority_id: props.ticket.priority_id,
|
|
||||||
assigned_to: props.ticket.assigned_to,
|
|
||||||
project_id: props.ticket.project_id,
|
|
||||||
due_date: props.ticket.due_date,
|
|
||||||
})
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
form.put(route('ticketing.update', { ticket: props.ticket.id }), {
|
|
||||||
onSuccess: () => {
|
|
||||||
// redirect happens server side
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-screen overflow-hidden bg-gray-100 text-gray-900">
|
|
||||||
|
|
||||||
<!-- Bootstrap / first-run state -->
|
|
||||||
<div v-if="isBootstrap" class="flex flex-col items-center justify-center w-full h-full text-center px-6">
|
|
||||||
<div class="max-w-md">
|
|
||||||
<div class="text-5xl mb-4">🎫</div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-2">Ticketing isn’t set up yet</h2>
|
|
||||||
<p class="text-gray-500 mb-6">Create your first group in Settings to get started. Default priorities will be seeded automatically.</p>
|
|
||||||
<Link :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-700 transition">Go to Settings</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<aside
|
|
||||||
class="w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col"
|
|
||||||
:class="showSidebar ? 'flex flex-col' : 'hidden'"
|
|
||||||
>
|
|
||||||
<div class="border-b border-gray-200 p-5">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400">Shared inbox</p>
|
|
||||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Ticketing</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
|
|
||||||
@click="showSidebar = false"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
:href="route('ticketing.create')"
|
|
||||||
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700:bg-indigo-400"
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 space-y-6 overflow-y-auto p-5">
|
|
||||||
<section class="space-y-2">
|
|
||||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Group switcher</label>
|
|
||||||
<select
|
|
||||||
v-model="activeGroupId"
|
|
||||||
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
|
||||||
@change="applyFilters({ group_id: normalizeNullable(activeGroupId) })"
|
|
||||||
>
|
|
||||||
<option :value="null">All groups</option>
|
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
|
||||||
</select>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Views</h2>
|
|
||||||
<span class="text-xs text-gray-400">{{ filteredTickets.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-for="view in visibleViews"
|
|
||||||
:key="view.key"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-sm transition"
|
|
||||||
:class="currentFilter === view.key
|
|
||||||
? 'bg-gray-900 text-white shadow-sm'
|
|
||||||
: 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
|
||||||
@click="applyFilters({ filter: view.key === 'all' ? 'all' : view.key })"
|
|
||||||
>
|
|
||||||
<span>{{ view.label }}</span>
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2 py-0.5 text-xs"
|
|
||||||
:class="currentFilter === view.key ? 'bg-white/15 text-white' : 'bg-gray-200 text-gray-600'"
|
|
||||||
>
|
|
||||||
{{ viewCount(view.key) }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Priority</h2>
|
|
||||||
<button
|
|
||||||
v-if="filters.priority_id"
|
|
||||||
type="button"
|
|
||||||
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
|
|
||||||
@click="applyFilters({ priority_id: undefined })"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
v-for="priority in priorities"
|
|
||||||
:key="priority.id"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition"
|
|
||||||
:class="Number(filters.priority_id) === priority.id ? 'shadow-sm' : 'border-gray-200 text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
|
||||||
:style="priorityChipStyle(priority, Number(filters.priority_id) === priority.id)"
|
|
||||||
@click="togglePriority(priority.id)"
|
|
||||||
>
|
|
||||||
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: priority.color || '#94a3b8' }"></span>
|
|
||||||
{{ priority.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Projects</h2>
|
|
||||||
<button
|
|
||||||
v-if="filters.project_id"
|
|
||||||
type="button"
|
|
||||||
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
|
|
||||||
@click="applyFilters({ project_id: undefined })"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
|
|
||||||
:class="!filters.project_id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
|
||||||
@click="applyFilters({ project_id: undefined })"
|
|
||||||
>
|
|
||||||
<span>All projects</span>
|
|
||||||
<span class="text-xs text-gray-400">{{ totalProjectCount }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-for="project in projects"
|
|
||||||
:key="project.id"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
|
|
||||||
:class="Number(filters.project_id) === project.id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
|
||||||
@click="applyFilters({ project_id: Number(filters.project_id) === project.id ? undefined : project.id })"
|
|
||||||
>
|
|
||||||
<span class="truncate">{{ project.name }}</span>
|
|
||||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">{{ project.ticket_count ?? 0 }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main
|
|
||||||
class="min-w-0 flex-1 border-r border-gray-200 bg-white xl:max-w-[34rem]"
|
|
||||||
:class="showList ? 'flex' : 'hidden lg:flex'"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
<div class="border-b border-gray-200 p-5">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Queue</p>
|
|
||||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">{{ currentViewTitle }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
|
|
||||||
@click="showSidebar = true"
|
|
||||||
>
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
:href="route('ticketing.create')"
|
|
||||||
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700 lg:hidden"
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</Link>
|
|
||||||
<div class="hidden rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 sm:block">
|
|
||||||
{{ tickets.total }} total
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 sm:hidden">
|
|
||||||
{{ tickets.total }} total
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search tickets, people, or numbers"
|
|
||||||
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
v-model="sortBy"
|
|
||||||
class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
|
||||||
>
|
|
||||||
<option value="newest">Newest first</option>
|
|
||||||
<option value="oldest">Oldest first</option>
|
|
||||||
<option value="priority">Priority</option>
|
|
||||||
<option value="status">Status</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
<div v-if="filteredTickets.length === 0" class="flex h-full flex-col items-center justify-center px-6 text-center">
|
|
||||||
<div class="rounded-3xl border border-dashed border-gray-300 px-8 py-10">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Nothing matches the current filters.</p>
|
|
||||||
<p class="mt-2 text-sm text-gray-400">Try a different view, search, or priority chip.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-for="ticket in filteredTickets"
|
|
||||||
:key="ticket.id"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full gap-4 border-b border-gray-100 px-4 py-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/60 sm:px-5"
|
|
||||||
:class="selectedTicketId === ticket.id ? 'bg-indigo-50/80' : ''"
|
|
||||||
@click="selectTicket(ticket.id)"
|
|
||||||
>
|
|
||||||
<div class="w-1 self-stretch rounded-full" :style="{ backgroundColor: ticket.priority?.color || ticket.group?.color || '#cbd5e1' }"></div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-gray-400">{{ ticket.number }}</span>
|
|
||||||
<span :class="badgeClass(ticket.status)" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold">{{ statusLabel(ticket.status) }}</span>
|
|
||||||
<span v-if="hasUnread(ticket)" class="h-2.5 w-2.5 rounded-full bg-sky-500"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 truncate text-sm font-semibold text-gray-900">{{ ticket.title }}</p>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center gap-3 text-xs text-gray-500">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="avatar-ring bg-gray-200 text-gray-700">{{ initials(ticket.submitter?.name || ticket.submitter?.email || '?') }}</span>
|
|
||||||
<span class="truncate">{{ ticket.submitter?.name || ticket.submitter?.email || 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{{ ageLabel(ticket.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-col items-end justify-between gap-3">
|
|
||||||
<div class="text-xs font-medium text-gray-500">{{ ticket.priority?.name || 'No priority' }}</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-[11px] uppercase tracking-[0.16em] text-gray-400">Assignee</span>
|
|
||||||
<span
|
|
||||||
class="avatar-ring"
|
|
||||||
:class="ticket.assignee ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-200 text-gray-500'"
|
|
||||||
>
|
|
||||||
{{ ticket.assignee ? initials(ticket.assignee.name || ticket.assignee.email) : '—' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tickets.last_page > 1" class="flex items-center justify-between border-t border-gray-200 px-5 py-3 text-sm">
|
|
||||||
<span class="text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
v-if="tickets.prev_page_url"
|
|
||||||
:href="tickets.prev_page_url"
|
|
||||||
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
|
|
||||||
>Prev</Link>
|
|
||||||
<Link
|
|
||||||
v-if="tickets.next_page_url"
|
|
||||||
:href="tickets.next_page_url"
|
|
||||||
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
|
|
||||||
>Next</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class="min-w-0 flex-1 flex-col bg-gray-50"
|
|
||||||
:class="showDetail ? 'flex' : 'hidden lg:flex'"
|
|
||||||
>
|
|
||||||
<div v-if="!ticketDetail" class="flex h-full items-center justify-center p-8">
|
|
||||||
<div class="max-w-md rounded-3xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Conversation</p>
|
|
||||||
<h3 class="mt-3 text-2xl font-semibold tracking-tight">Pick a ticket</h3>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-gray-500">
|
|
||||||
The right panel becomes the full thread view: metadata, internal notes, and reply composer included. Miracles do happen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="border-b border-gray-200 bg-white px-4 py-5 sm:px-6">
|
|
||||||
<div class="mb-4 lg:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100"
|
|
||||||
@click="backToList"
|
|
||||||
>
|
|
||||||
← Back to list
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold text-white" :style="{ backgroundColor: ticketDetail.group?.color || '#6366f1' }">{{ ticketDetail.number }}</span>
|
|
||||||
<span :class="badgeClass(ticketDetail.status)" class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold">{{ statusLabel(ticketDetail.status) }}</span>
|
|
||||||
<span v-if="ticketDetail.priority" class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600">
|
|
||||||
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: ticketDetail.priority.color || '#94a3b8' }"></span>
|
|
||||||
{{ ticketDetail.priority.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-start gap-3">
|
|
||||||
<h3 class="min-w-0 flex-1 text-2xl font-semibold tracking-tight text-gray-900">{{ ticketDetail.title }}</h3>
|
|
||||||
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="text-sm font-medium text-indigo-600 transition hover:text-indigo-500">Open full page</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-3 md:grid-cols-5">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Assignee</div>
|
|
||||||
<div class="mt-2 flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span class="avatar-ring bg-indigo-100 text-indigo-700">{{ ticketDetail.assignee ? initials(ticketDetail.assignee.name || ticketDetail.assignee.email) : '—' }}</span>
|
|
||||||
<span class="truncate">{{ ticketDetail.assignee?.name || ticketDetail.assignee?.email || 'Unassigned' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Due date</div>
|
|
||||||
<div class="mt-2 text-sm font-medium">{{ formatDate(ticketDetail.due_date, 'No due date') }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Project</div>
|
|
||||||
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.project?.name || 'No project' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Submitter</div>
|
|
||||||
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.submitter?.name || ticketDetail.submitter?.email || 'Unknown' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Opened</div>
|
|
||||||
<div class="mt-2 text-sm font-medium">{{ ageLabel(ticketDetail.created_at) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
|
||||||
<span>Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{{ ticketDetail.attachments?.length || 0 }} attachments</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isAgent" class="mt-5 grid gap-3 lg:grid-cols-4">
|
|
||||||
<select v-model="metaForm.status" class="meta-select" @change="saveMeta">
|
|
||||||
<option v-for="status in statuses" :key="status.value" :value="status.value">{{ status.label }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select v-model="metaForm.priority_id" class="meta-select" @change="saveMeta">
|
|
||||||
<option :value="null">No priority</option>
|
|
||||||
<option v-for="priority in priorities" :key="priority.id" :value="priority.id">{{ priority.name }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select v-model="metaForm.assigned_to" class="meta-select" @change="saveMeta">
|
|
||||||
<option :value="null">Unassigned</option>
|
|
||||||
<option v-for="agent in detailAgents" :key="agent.id" :value="agent.id">{{ agent.name || agent.email }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input v-model="metaForm.due_date" type="date" class="meta-select" @change="saveMeta" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
|
||||||
<div v-if="ticketDetail.description" class="mb-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Description</div>
|
|
||||||
<p class="whitespace-pre-wrap text-sm leading-6 text-gray-700">{{ ticketDetail.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!ticketDetail.messages?.length" class="rounded-3xl border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500">
|
|
||||||
No messages yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="message in ticketDetail.messages"
|
|
||||||
:key="message.id"
|
|
||||||
class="flex"
|
|
||||||
:class="messageAlignment(message)"
|
|
||||||
>
|
|
||||||
<div class="max-w-3xl rounded-3xl px-5 py-4 shadow-sm" :class="messageCardClass(message)">
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium opacity-80">
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="avatar-ring bg-white/70 text-gray-700">{{ initials(messageAuthor(message)) }}</span>
|
|
||||||
{{ messageRoleLabel(message) }}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{{ message.author?.name || message.author_email || 'Unknown' }}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{{ preciseDate(message.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6">{{ message.body }}</p>
|
|
||||||
|
|
||||||
<div v-if="message.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
|
||||||
<a
|
|
||||||
v-for="attachment in message.attachments"
|
|
||||||
:key="attachment.id"
|
|
||||||
:href="route('ticketing.attachments.show', { attachment: attachment.id })"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-current/20 px-3 py-1.5 text-xs font-medium transition hover:bg-white/30:bg-gray-900/30"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
📎 {{ attachment.filename }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 bg-white px-4 py-5 sm:px-6">
|
|
||||||
<div v-if="isAgent" class="mb-4 inline-flex rounded-2xl bg-gray-100 p-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
|
|
||||||
:class="replyMode === 'reply' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
|
||||||
@click="replyMode = 'reply'"
|
|
||||||
>Reply</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
|
|
||||||
:class="replyMode === 'internal' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-500'"
|
|
||||||
@click="replyMode = 'internal'"
|
|
||||||
>Internal note</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="sendMessage" class="space-y-3">
|
|
||||||
<textarea
|
|
||||||
v-model="messageForm.body"
|
|
||||||
rows="4"
|
|
||||||
required
|
|
||||||
class="w-full rounded-3xl border px-4 py-3 text-sm outline-none transition focus:ring-2"
|
|
||||||
:class="replyMode === 'internal'
|
|
||||||
? 'border-amber-300 bg-amber-50 text-amber-950 focus:border-amber-400 focus:ring-amber-100:ring-amber-500/20'
|
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-900 focus:border-indigo-400 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20'"
|
|
||||||
:placeholder="replyMode === 'internal' ? 'Internal note for agents only…' : 'Reply to the thread…'"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<div class="text-xs text-gray-400">
|
|
||||||
{{ replyMode === 'internal' ? 'Only agents can see internal notes.' : 'Replies are visible in the ticket thread.' }}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="messageForm.processing"
|
|
||||||
class="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-white transition disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
:class="replyMode === 'internal' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-gray-900 hover:bg-gray-700:bg-indigo-400'"
|
|
||||||
>
|
|
||||||
{{ messageForm.processing ? 'Sending…' : replyMode === 'internal' ? 'Save note' : 'Send reply' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
tickets: Object,
|
|
||||||
groups: Array,
|
|
||||||
priorities: Array,
|
|
||||||
projects: Array,
|
|
||||||
isAgent: Boolean,
|
|
||||||
filters: Object,
|
|
||||||
ticketDetail: Object,
|
|
||||||
detailAgents: Array,
|
|
||||||
viewCounts: Object,
|
|
||||||
isBootstrap: Boolean,
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = ref('')
|
|
||||||
const sortBy = ref('newest')
|
|
||||||
const activeGroupId = ref(props.filters?.group_id ? Number(props.filters.group_id) : null)
|
|
||||||
const replyMode = ref('reply')
|
|
||||||
const showSidebar = ref(!props.ticketDetail)
|
|
||||||
|
|
||||||
const visibleViews = computed(() => {
|
|
||||||
const base = [
|
|
||||||
{ key: 'all', label: 'All Open' },
|
|
||||||
{ key: 'mine', label: 'Mine' },
|
|
||||||
{ key: 'unassigned', label: 'Unassigned' },
|
|
||||||
{ key: 'pending', label: 'Pending' },
|
|
||||||
{ key: 'resolved', label: 'Resolved' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return props.isAgent ? base : base.filter((view) => ['all', 'pending', 'resolved'].includes(view.key))
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentFilter = computed(() => props.filters?.filter || 'all')
|
|
||||||
const selectedTicketId = computed(() => props.ticketDetail?.id ?? (props.filters?.detail ? Number(props.filters.detail) : null))
|
|
||||||
const currentViewTitle = computed(() => visibleViews.value.find((view) => view.key === currentFilter.value)?.label || 'Tickets')
|
|
||||||
const totalProjectCount = computed(() => props.projects.reduce((sum, project) => sum + Number(project.ticket_count || 0), 0))
|
|
||||||
const showList = computed(() => !props.ticketDetail)
|
|
||||||
const showDetail = computed(() => !!props.ticketDetail)
|
|
||||||
|
|
||||||
const filteredTickets = computed(() => {
|
|
||||||
const term = search.value.trim().toLowerCase()
|
|
||||||
const priorityWeight = Object.fromEntries(
|
|
||||||
[...props.priorities].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((priority, index, arr) => [priority.id, arr.length - index])
|
|
||||||
)
|
|
||||||
|
|
||||||
let items = [...(props.tickets?.data || [])]
|
|
||||||
|
|
||||||
if (term) {
|
|
||||||
items = items.filter((ticket) => {
|
|
||||||
const haystack = [
|
|
||||||
ticket.number,
|
|
||||||
ticket.title,
|
|
||||||
ticket.status,
|
|
||||||
ticket.submitter?.name,
|
|
||||||
ticket.submitter?.email,
|
|
||||||
ticket.assignee?.name,
|
|
||||||
ticket.assignee?.email,
|
|
||||||
ticket.priority?.name,
|
|
||||||
].filter(Boolean).join(' ').toLowerCase()
|
|
||||||
|
|
||||||
return haystack.includes(term)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
items.sort((a, b) => {
|
|
||||||
if (sortBy.value === 'oldest') {
|
|
||||||
return new Date(a.created_at) - new Date(b.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy.value === 'priority') {
|
|
||||||
return (priorityWeight[b.priority_id] || 0) - (priorityWeight[a.priority_id] || 0) || new Date(b.created_at) - new Date(a.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy.value === 'status') {
|
|
||||||
return statusSortValue(a.status) - statusSortValue(b.status) || new Date(b.created_at) - new Date(a.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(b.created_at) - new Date(a.created_at)
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
})
|
|
||||||
|
|
||||||
const metaForm = useForm({
|
|
||||||
status: props.ticketDetail?.status ?? 'open',
|
|
||||||
priority_id: props.ticketDetail?.priority_id ?? null,
|
|
||||||
assigned_to: props.ticketDetail?.assigned_to ?? null,
|
|
||||||
due_date: props.ticketDetail?.due_date ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const messageForm = useForm({
|
|
||||||
body: '',
|
|
||||||
is_internal: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.ticketDetail,
|
|
||||||
(detail) => {
|
|
||||||
replyMode.value = 'reply'
|
|
||||||
showSidebar.value = !detail
|
|
||||||
metaForm.defaults({
|
|
||||||
status: detail?.status ?? 'open',
|
|
||||||
priority_id: detail?.priority_id ?? null,
|
|
||||||
assigned_to: detail?.assigned_to ?? null,
|
|
||||||
due_date: detail?.due_date ?? null,
|
|
||||||
})
|
|
||||||
metaForm.reset()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ value: 'open', label: 'Open' },
|
|
||||||
{ value: 'in_progress', label: 'In Progress' },
|
|
||||||
{ value: 'pending', label: 'Pending' },
|
|
||||||
{ value: 'resolved', label: 'Resolved' },
|
|
||||||
{ value: 'closed', label: 'Closed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function normalizeNullable(value) {
|
|
||||||
return value === '' || value === null || value === undefined ? undefined : Number(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParams(overrides = {}) {
|
|
||||||
const params = {
|
|
||||||
...props.filters,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(params).forEach((key) => {
|
|
||||||
if (params[key] === undefined || params[key] === null || params[key] === '') {
|
|
||||||
delete params[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters(overrides = {}) {
|
|
||||||
const params = buildParams({ ...overrides, detail: overrides.detail === undefined ? props.filters?.detail : overrides.detail })
|
|
||||||
|
|
||||||
router.get(route('ticketing.index'), params, {
|
|
||||||
preserveState: true,
|
|
||||||
preserveScroll: true,
|
|
||||||
replace: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePriority(priorityId) {
|
|
||||||
applyFilters({ priority_id: Number(props.filters.priority_id) === priorityId ? undefined : priorityId })
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTicket(ticketId) {
|
|
||||||
router.get(route('ticketing.index'), buildParams({ detail: ticketId }), {
|
|
||||||
preserveState: true,
|
|
||||||
preserveScroll: true,
|
|
||||||
replace: true,
|
|
||||||
only: ['ticketDetail', 'detailAgents', 'filters'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function backToList() {
|
|
||||||
router.get(route('ticketing.index'), buildParams({ detail: undefined }), {
|
|
||||||
preserveState: true,
|
|
||||||
preserveScroll: true,
|
|
||||||
replace: true,
|
|
||||||
only: ['ticketDetail', 'detailAgents', 'filters'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveMeta() {
|
|
||||||
if (!props.ticketDetail) return
|
|
||||||
|
|
||||||
metaForm.transform((data) => ({
|
|
||||||
...data,
|
|
||||||
priority_id: normalizeNullable(data.priority_id),
|
|
||||||
assigned_to: normalizeNullable(data.assigned_to),
|
|
||||||
due_date: data.due_date || null,
|
|
||||||
})).put(route('ticketing.update', { ticket: props.ticketDetail.id }), {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage() {
|
|
||||||
if (!props.ticketDetail) return
|
|
||||||
|
|
||||||
messageForm.transform((data) => ({
|
|
||||||
...data,
|
|
||||||
is_internal: props.isAgent && replyMode.value === 'internal',
|
|
||||||
})).post(route('ticketing.messages.store', { ticket: props.ticketDetail.id }), {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
messageForm.reset()
|
|
||||||
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewCount(key) {
|
|
||||||
return props.viewCounts?.[key] ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(value) {
|
|
||||||
return String(value || '?')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function badgeClass(status) {
|
|
||||||
return {
|
|
||||||
open: 'bg-sky-100 text-sky-700',
|
|
||||||
in_progress: 'bg-violet-100 text-violet-700',
|
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
|
||||||
resolved: 'bg-emerald-100 text-emerald-700',
|
|
||||||
closed: 'bg-gray-200 text-gray-700',
|
|
||||||
}[status] || 'bg-gray-200 text-gray-700'
|
|
||||||
}
|
|
||||||
|
|
||||||
function priorityChipStyle(priority, active) {
|
|
||||||
const color = priority.color || '#94a3b8'
|
|
||||||
return active
|
|
||||||
? { borderColor: color, backgroundColor: `${color}18`, color }
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status) {
|
|
||||||
return {
|
|
||||||
open: 'Open',
|
|
||||||
in_progress: 'In Progress',
|
|
||||||
pending: 'Pending',
|
|
||||||
resolved: 'Resolved',
|
|
||||||
closed: 'Closed',
|
|
||||||
}[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusSortValue(status) {
|
|
||||||
return {
|
|
||||||
open: 1,
|
|
||||||
in_progress: 2,
|
|
||||||
pending: 3,
|
|
||||||
resolved: 4,
|
|
||||||
closed: 5,
|
|
||||||
}[status] || 99
|
|
||||||
}
|
|
||||||
|
|
||||||
function ageLabel(value) {
|
|
||||||
return relativeTime(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function preciseDate(value) {
|
|
||||||
if (!value) return 'Unknown time'
|
|
||||||
return new Date(value).toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value, fallback = '—') {
|
|
||||||
if (!value) return fallback
|
|
||||||
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeTime(value) {
|
|
||||||
if (!value) return '—'
|
|
||||||
const date = new Date(value)
|
|
||||||
const diff = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000))
|
|
||||||
if (diff < 60) return `${diff}s ago`
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
||||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
|
||||||
return `${Math.floor(diff / 604800)}w ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUnread(ticket) {
|
|
||||||
if (!ticket || !props.ticketDetail) return false
|
|
||||||
return ticket.id !== props.ticketDetail.id && ['open', 'pending'].includes(ticket.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageAlignment(message) {
|
|
||||||
if (message.is_internal) return 'justify-center'
|
|
||||||
return isSubmitterMessage(message) ? 'justify-start' : 'justify-end'
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageCardClass(message) {
|
|
||||||
if (message.is_internal) {
|
|
||||||
return 'border border-amber-200 bg-amber-50 text-amber-950'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSubmitterMessage(message)) {
|
|
||||||
return 'border border-gray-200 bg-white text-gray-800'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-900 text-white'
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSubmitterMessage(message) {
|
|
||||||
return Number(message.user_id) === Number(props.ticketDetail?.submitter_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageRoleLabel(message) {
|
|
||||||
if (message.is_internal) return 'Internal note'
|
|
||||||
return isSubmitterMessage(message) ? 'Submitter' : 'Agent reply'
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageAuthor(message) {
|
|
||||||
return message.author?.name || message.author?.email || message.author_email || 'Unknown'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.avatar-ring {
|
|
||||||
@apply inline-flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-semibold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-select {
|
|
||||||
@apply w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 focus:ring-indigo-500/20;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div class="max-w-3xl mx-auto">
|
|
||||||
<div class="mb-6 flex justify-between items-center">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
|
|
||||||
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-gray-900 text-white text-sm px-4 py-2 rounded-lg hover:bg-gray-700 transition">
|
|
||||||
+ Submit Ticket
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 divide-y divide-gray-100">
|
|
||||||
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
|
|
||||||
You haven't submitted any tickets yet.
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
v-for="ticket in tickets.data"
|
|
||||||
:key="ticket.id"
|
|
||||||
:href="route('ticketing.show', { ticket: ticket.id })"
|
|
||||||
class="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0 mt-0.5"
|
|
||||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
|
||||||
>{{ ticket.number }}</span>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-gray-800 truncate">{{ ticket.title }}</p>
|
|
||||||
<p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0">
|
|
||||||
{{ statusLabel(ticket.status) }}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="tickets.last_page > 1" class="mt-4 flex justify-center gap-4 text-sm">
|
|
||||||
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-indigo-600 hover:underline">← Previous</Link>
|
|
||||||
<span class="text-gray-500">{{ tickets.current_page }} / {{ tickets.last_page }}</span>
|
|
||||||
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next →</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
import { Link } from '@inertiajs/vue3'
|
|
||||||
|
|
||||||
defineProps({ tickets: Object })
|
|
||||||
|
|
||||||
function statusLabel(status) {
|
|
||||||
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
|
|
||||||
return map[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status) {
|
|
||||||
const map = {
|
|
||||||
open: 'bg-blue-100 text-blue-700',
|
|
||||||
in_progress: 'bg-purple-100 text-purple-700',
|
|
||||||
pending: 'bg-yellow-100 text-yellow-700',
|
|
||||||
resolved: 'bg-green-100 text-green-700',
|
|
||||||
closed: 'bg-gray-100 text-gray-500',
|
|
||||||
}
|
|
||||||
return map[status] || 'bg-gray-100 text-gray-600'
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = Math.floor((now - date) / 1000)
|
|
||||||
if (diff < 60) return `${diff}s ago`
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
||||||
return `${Math.floor(diff / 86400)}d ago`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-5xl mx-auto py-8 px-4">
|
|
||||||
<div class="mb-6">
|
|
||||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mt-2">Ticketing Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 border border-amber-300 rounded-xl">
|
|
||||||
<h2 class="text-base font-semibold text-amber-800 mb-1">🚀 First-Run Setup</h2>
|
|
||||||
<p class="text-sm text-amber-700">
|
|
||||||
No groups exist yet. Create your first group below to get started.
|
|
||||||
Default priorities (Low, Medium, High, Urgent) will be seeded automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
|
|
||||||
{{ $page.props.flash.success }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$page.props.errors?.priority" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
|
||||||
{{ $page.props.errors.priority }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$page.props.errors?.project" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
|
||||||
{{ $page.props.errors.project }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-1 border-b border-gray-200 mb-6">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="activeTab = tab.key"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition',
|
|
||||||
activeTab === tab.key
|
|
||||||
? 'border-indigo-600 text-indigo-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
]"
|
|
||||||
>{{ tab.label }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'groups'">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Groups</h2>
|
|
||||||
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
|
|
||||||
{{ showAddGroup ? 'Cancel' : '+ Add Group' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showAddGroup" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">New Group</h3>
|
|
||||||
<form @submit.prevent="submitGroup" class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="groupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Prefix (e.g. IT)</label>
|
|
||||||
<input v-model="groupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
|
|
||||||
<input v-model="groupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
|
||||||
<input v-model="groupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end">
|
|
||||||
<button type="submit" :disabled="groupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
|
||||||
Create Group
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div v-if="groups.length === 0" class="text-sm text-gray-400 italic">No groups yet.</div>
|
|
||||||
<div
|
|
||||||
v-for="group in groups"
|
|
||||||
:key="group.id"
|
|
||||||
class="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-xl"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-sm text-gray-800">{{ group.name }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="startEditGroup(group)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="editingGroup" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Edit: {{ editingGroup.name }}</h3>
|
|
||||||
<form @submit.prevent="submitEditGroup" class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="editGroupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Prefix</label>
|
|
||||||
<input v-model="editGroupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
|
|
||||||
<input v-model="editGroupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
|
||||||
<input v-model="editGroupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="editingGroup = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="submit" :disabled="editGroupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'agents'">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Agents</h2>
|
|
||||||
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
|
|
||||||
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showAddAgent" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
|
||||||
<form @submit.prevent="submitAgent" class="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">User ID</label>
|
|
||||||
<input v-model="agentForm.user_id" required type="number" placeholder="User ID" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
|
||||||
<select v-model="agentForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option value="">Select group</option>
|
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Role</label>
|
|
||||||
<select v-model="agentForm.role" class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option value="agent">Agent</option>
|
|
||||||
<option value="manager">Manager</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-3 flex justify-end">
|
|
||||||
<button type="submit" :disabled="agentForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
|
||||||
Add Agent
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div v-if="agents.length === 0" class="text-sm text-gray-400 italic">No agents configured yet.</div>
|
|
||||||
<div
|
|
||||||
v-for="access in agents"
|
|
||||||
:key="access.id"
|
|
||||||
class="flex items-center justify-between px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-800">{{ access.user?.name || 'User #' + access.user_id }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p>
|
|
||||||
</div>
|
|
||||||
<button @click="openRemoveAgentModal(access)" class="text-xs text-red-500 hover:underline">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'priorities'">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Priority Levels</h2>
|
|
||||||
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
|
|
||||||
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showAddPriority" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
|
||||||
<form @submit.prevent="submitPriority" class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="priorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
|
||||||
<input v-model="priorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
|
||||||
<input v-model="priorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
|
|
||||||
<input v-model="priorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Group (blank = global)</label>
|
|
||||||
<select v-model="priorityForm.group_id" class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option :value="null">Global</option>
|
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end">
|
|
||||||
<button type="submit" :disabled="priorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
|
||||||
Create Priority
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div v-if="priorities.length === 0" class="text-sm text-gray-400 italic">No priorities defined yet.</div>
|
|
||||||
<div
|
|
||||||
v-for="p in priorities"
|
|
||||||
:key="p.id"
|
|
||||||
class="flex items-center gap-3 px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
|
|
||||||
>
|
|
||||||
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-800">{{ p.name }}</p>
|
|
||||||
<p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button @click="startEditPriority(p)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
|
||||||
<button @click="openDeletePriorityModal(p)" class="text-xs text-red-500 hover:underline">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="editingPriority" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Edit Priority: {{ editingPriority.name }}</h3>
|
|
||||||
<form @submit.prevent="submitEditPriority" class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="editPriorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
|
||||||
<input v-model="editPriorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
|
||||||
<input v-model="editPriorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
|
|
||||||
<input v-model="editPriorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Scope</label>
|
|
||||||
<input :value="editingPriority.group_id ? 'Group-specific' : 'Global'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="editingPriority = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="submit" :disabled="editPriorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeTab === 'projects'">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Projects</h2>
|
|
||||||
<button @click="showAddProject = !showAddProject" class="text-sm text-indigo-600 hover:underline">
|
|
||||||
{{ showAddProject ? 'Cancel' : '+ Add Project' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showAddProject" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
|
||||||
<form @submit.prevent="submitProject" class="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
|
||||||
<select v-model="projectForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option value="">Select group</option>
|
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Status</label>
|
|
||||||
<select v-model="projectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="projectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
|
||||||
<textarea v-model="projectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end">
|
|
||||||
<button type="submit" :disabled="projectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
|
||||||
Create Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div v-if="projects.length === 0" class="text-sm text-gray-400 italic">No projects defined yet.</div>
|
|
||||||
<div
|
|
||||||
v-for="project in projects"
|
|
||||||
:key="project.id"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl"
|
|
||||||
>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-800">{{ project.name }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ groups.find(g => g.id === project.group_id)?.name || 'Unknown Group' }} · {{ project.status }}</p>
|
|
||||||
<p v-if="project.description" class="text-xs text-gray-500 mt-1">{{ project.description }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button @click="startEditProject(project)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
|
||||||
<button @click="openDeleteProjectModal(project)" class="text-xs text-red-500 hover:underline">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="editingProject" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700">Edit Project: {{ editingProject.name }}</h3>
|
|
||||||
<form @submit.prevent="submitEditProject" class="grid grid-cols-2 gap-3">
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
|
||||||
<input v-model="editProjectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Status</label>
|
|
||||||
<select v-model="editProjectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
|
||||||
<input :value="groups.find(g => g.id === editingProject.group_id)?.name || 'Unknown Group'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
|
||||||
<textarea v-model="editProjectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="editingProject = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="submit" :disabled="editProjectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="removeAgentModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
|
||||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Remove agent?</h3>
|
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
|
||||||
Remove <strong>{{ pendingAgentRemoval?.user?.name || ('User #' + pendingAgentRemoval?.user_id) }}</strong>
|
|
||||||
from <strong>{{ pendingAgentRemoval?.group?.name }}</strong>?
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="closeRemoveAgentModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="button" @click="confirmRemoveAgent" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="deletePriorityModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
|
||||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Delete priority?</h3>
|
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
|
||||||
Delete <strong>{{ pendingPriorityDelete?.name }}</strong>? This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="closeDeletePriorityModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="button" @click="confirmDeletePriority" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="deleteProjectModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
|
||||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Delete project?</h3>
|
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
|
||||||
Delete <strong>{{ pendingProjectDelete?.name }}</strong>? This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-2">
|
|
||||||
<button type="button" @click="closeDeleteProjectModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="button" @click="confirmDeleteProject" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
groups: Array,
|
|
||||||
agents: Array,
|
|
||||||
priorities: Array,
|
|
||||||
projects: Array,
|
|
||||||
myGroupIds: Array,
|
|
||||||
isBootstrap: Boolean,
|
|
||||||
isSiteAdmin: Boolean,
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeTab = ref('groups')
|
|
||||||
const tabs = [
|
|
||||||
{ key: 'groups', label: 'Groups' },
|
|
||||||
{ key: 'agents', label: 'Agents' },
|
|
||||||
{ key: 'priorities', label: 'Priorities' },
|
|
||||||
{ key: 'projects', label: 'Projects' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const showAddGroup = ref(false)
|
|
||||||
const showAddAgent = ref(false)
|
|
||||||
const showAddPriority = ref(false)
|
|
||||||
const showAddProject = ref(false)
|
|
||||||
const editingGroup = ref(null)
|
|
||||||
const editingPriority = ref(null)
|
|
||||||
const editingProject = ref(null)
|
|
||||||
const removeAgentModalOpen = ref(false)
|
|
||||||
const pendingAgentRemoval = ref(null)
|
|
||||||
const deletePriorityModalOpen = ref(false)
|
|
||||||
const pendingPriorityDelete = ref(null)
|
|
||||||
const deleteProjectModalOpen = ref(false)
|
|
||||||
const pendingProjectDelete = ref(null)
|
|
||||||
|
|
||||||
const groupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
|
|
||||||
const editGroupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
|
|
||||||
const agentForm = useForm({ user_id: '', group_id: '', role: 'agent' })
|
|
||||||
const priorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
|
|
||||||
const editPriorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
|
|
||||||
const projectForm = useForm({ group_id: '', name: '', description: '', status: 'active' })
|
|
||||||
const editProjectForm = useForm({ name: '', description: '', status: 'active' })
|
|
||||||
|
|
||||||
function submitGroup() {
|
|
||||||
groupForm.post(route('ticketing.settings.groups.store'), {
|
|
||||||
onSuccess: () => { showAddGroup.value = false; groupForm.reset() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditGroup(group) {
|
|
||||||
editingGroup.value = group
|
|
||||||
editGroupForm.name = group.name
|
|
||||||
editGroupForm.email_address = group.email_address || ''
|
|
||||||
editGroupForm.color = group.color
|
|
||||||
editGroupForm.prefix = group.prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitEditGroup() {
|
|
||||||
editGroupForm.put(route('ticketing.settings.groups.update', { group: editingGroup.value.id }), {
|
|
||||||
onSuccess: () => { editingGroup.value = null }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitAgent() {
|
|
||||||
agentForm.post(route('ticketing.settings.agents.store'), {
|
|
||||||
onSuccess: () => { showAddAgent.value = false; agentForm.reset() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRemoveAgentModal(access) {
|
|
||||||
pendingAgentRemoval.value = access
|
|
||||||
removeAgentModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRemoveAgentModal() {
|
|
||||||
removeAgentModalOpen.value = false
|
|
||||||
pendingAgentRemoval.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmRemoveAgent() {
|
|
||||||
if (!pendingAgentRemoval.value) return
|
|
||||||
|
|
||||||
router.delete(route('ticketing.settings.agents.destroy', { access: pendingAgentRemoval.value.id }), {
|
|
||||||
onFinish: closeRemoveAgentModal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitPriority() {
|
|
||||||
priorityForm.post(route('ticketing.settings.priorities.store'), {
|
|
||||||
onSuccess: () => { showAddPriority.value = false; priorityForm.reset() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditPriority(priority) {
|
|
||||||
editingPriority.value = priority
|
|
||||||
editPriorityForm.name = priority.name
|
|
||||||
editPriorityForm.color = priority.color
|
|
||||||
editPriorityForm.description = priority.description || ''
|
|
||||||
editPriorityForm.sort_order = priority.sort_order
|
|
||||||
editPriorityForm.group_id = priority.group_id
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitEditPriority() {
|
|
||||||
editPriorityForm.put(route('ticketing.settings.priorities.update', { priority: editingPriority.value.id }), {
|
|
||||||
onSuccess: () => { editingPriority.value = null }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeletePriorityModal(priority) {
|
|
||||||
pendingPriorityDelete.value = priority
|
|
||||||
deletePriorityModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDeletePriorityModal() {
|
|
||||||
deletePriorityModalOpen.value = false
|
|
||||||
pendingPriorityDelete.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeletePriority() {
|
|
||||||
if (!pendingPriorityDelete.value) return
|
|
||||||
|
|
||||||
router.delete(route('ticketing.settings.priorities.destroy', { priority: pendingPriorityDelete.value.id }), {
|
|
||||||
onFinish: closeDeletePriorityModal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitProject() {
|
|
||||||
projectForm.post(route('ticketing.settings.projects.store'), {
|
|
||||||
onSuccess: () => { showAddProject.value = false; projectForm.reset() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditProject(project) {
|
|
||||||
editingProject.value = project
|
|
||||||
editProjectForm.name = project.name
|
|
||||||
editProjectForm.description = project.description || ''
|
|
||||||
editProjectForm.status = project.status
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitEditProject() {
|
|
||||||
editProjectForm.put(route('ticketing.settings.projects.update', { project: editingProject.value.id }), {
|
|
||||||
onSuccess: () => { editingProject.value = null }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeleteProjectModal(project) {
|
|
||||||
pendingProjectDelete.value = project
|
|
||||||
deleteProjectModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDeleteProjectModal() {
|
|
||||||
deleteProjectModalOpen.value = false
|
|
||||||
pendingProjectDelete.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteProject() {
|
|
||||||
if (!pendingProjectDelete.value) return
|
|
||||||
|
|
||||||
router.delete(route('ticketing.settings.projects.destroy', { project: pendingProjectDelete.value.id }), {
|
|
||||||
onFinish: closeDeleteProjectModal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<!-- Ticket Header -->
|
|
||||||
<div class="p-5 border-b border-gray-200">
|
|
||||||
<div class="flex items-start gap-3 flex-wrap">
|
|
||||||
<span
|
|
||||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
|
|
||||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
|
||||||
>
|
|
||||||
{{ ticket.number }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Title (editable inline for agents) -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="titleEdit"
|
|
||||||
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
|
|
||||||
@keyup.enter="saveTitle"
|
|
||||||
@keyup.esc="editingTitle = false"
|
|
||||||
/>
|
|
||||||
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
|
|
||||||
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
v-else
|
|
||||||
class="text-xl font-semibold text-gray-900"
|
|
||||||
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
|
|
||||||
@click="isAgent && startEditTitle()"
|
|
||||||
>
|
|
||||||
{{ ticket.title }}
|
|
||||||
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meta row -->
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mt-3">
|
|
||||||
<!-- Status -->
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
v-if="isAgent"
|
|
||||||
v-model="metaForm.status"
|
|
||||||
@change="saveMeta"
|
|
||||||
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
|
|
||||||
:class="statusClass(metaForm.status)"
|
|
||||||
>
|
|
||||||
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
|
||||||
</select>
|
|
||||||
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
|
|
||||||
{{ statusLabel(ticket.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority -->
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
v-if="isAgent"
|
|
||||||
v-model="metaForm.priority_id"
|
|
||||||
@change="saveMeta"
|
|
||||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
|
||||||
>
|
|
||||||
<option :value="null">No priority</option>
|
|
||||||
<option v-for="p in priorities" :key="p.id" :value="p.id">
|
|
||||||
{{ p.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600">
|
|
||||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
|
|
||||||
{{ ticket.priority.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assignee -->
|
|
||||||
<div v-if="isAgent">
|
|
||||||
<select
|
|
||||||
v-model="metaForm.assigned_to"
|
|
||||||
@change="saveMeta"
|
|
||||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
|
||||||
>
|
|
||||||
<option :value="null">Unassigned</option>
|
|
||||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Due date -->
|
|
||||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
|
||||||
<span>📅</span>
|
|
||||||
<input
|
|
||||||
v-if="isAgent"
|
|
||||||
v-model="metaForm.due_date"
|
|
||||||
type="date"
|
|
||||||
@change="saveMeta"
|
|
||||||
class="text-xs border-0 bg-transparent cursor-pointer p-0"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit / Delete actions -->
|
|
||||||
<div class="ml-auto flex gap-2">
|
|
||||||
<Link
|
|
||||||
v-if="isAgent"
|
|
||||||
:href="route('ticketing.edit', { ticket: ticket.id })"
|
|
||||||
class="text-xs text-indigo-600 hover:underline"
|
|
||||||
>Edit</Link>
|
|
||||||
<button
|
|
||||||
v-if="isManager"
|
|
||||||
@click="confirmDelete"
|
|
||||||
class="text-xs text-red-500 hover:underline"
|
|
||||||
>Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message Thread -->
|
|
||||||
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
|
||||||
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
|
|
||||||
No messages yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="msg in ticket.messages"
|
|
||||||
:key="msg.id"
|
|
||||||
:class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
|
|
||||||
msg.is_internal
|
|
||||||
? 'bg-amber-50 border border-amber-200 text-amber-900'
|
|
||||||
: isOwnMessage(msg)
|
|
||||||
? 'bg-indigo-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
|
|
||||||
<span v-if="msg.is_internal">🔒 Internal Note · </span>
|
|
||||||
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
|
|
||||||
<span>· {{ timeAgo(msg.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reply Area -->
|
|
||||||
<div class="border-t border-gray-200 p-5">
|
|
||||||
<!-- Tab switcher (agents only) -->
|
|
||||||
<div v-if="isAgent" class="flex gap-2 mb-3">
|
|
||||||
<button
|
|
||||||
@click="replyTab = 'reply'"
|
|
||||||
:class="[
|
|
||||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
|
||||||
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
]"
|
|
||||||
>Reply to submitter</button>
|
|
||||||
<button
|
|
||||||
@click="replyTab = 'internal'"
|
|
||||||
:class="[
|
|
||||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
|
||||||
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
]"
|
|
||||||
>🔒 Internal Note</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="sendMessage">
|
|
||||||
<textarea
|
|
||||||
v-model="messageForm.body"
|
|
||||||
required
|
|
||||||
rows="3"
|
|
||||||
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
|
|
||||||
:class="[
|
|
||||||
'w-full rounded-lg text-sm border',
|
|
||||||
replyTab === 'internal'
|
|
||||||
? 'border-amber-300 bg-amber-50'
|
|
||||||
: 'border-gray-300'
|
|
||||||
]"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-3">
|
|
||||||
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
|
||||||
<input type="file" class="hidden" @change="attachFile" />
|
|
||||||
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="messageForm.processing"
|
|
||||||
:class="[
|
|
||||||
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
|
|
||||||
replyTab === 'internal'
|
|
||||||
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
|
|
||||||
: 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ messageForm.processing ? 'Sending…' : 'Send' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
ticket: Object,
|
|
||||||
isAgent: Boolean,
|
|
||||||
isManager: Boolean,
|
|
||||||
agents: Array,
|
|
||||||
priorities: Array,
|
|
||||||
})
|
|
||||||
|
|
||||||
const replyTab = ref('reply')
|
|
||||||
const editingTitle = ref(false)
|
|
||||||
const titleEdit = ref(props.ticket.title)
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ value: 'open', label: 'Open' },
|
|
||||||
{ value: 'in_progress', label: 'In Progress' },
|
|
||||||
{ value: 'pending', label: 'Pending' },
|
|
||||||
{ value: 'resolved', label: 'Resolved' },
|
|
||||||
{ value: 'closed', label: 'Closed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const metaForm = useForm({
|
|
||||||
status: props.ticket.status,
|
|
||||||
priority_id: props.ticket.priority_id,
|
|
||||||
assigned_to: props.ticket.assigned_to,
|
|
||||||
due_date: props.ticket.due_date,
|
|
||||||
})
|
|
||||||
|
|
||||||
const messageForm = useForm({
|
|
||||||
body: '',
|
|
||||||
is_internal: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
function isOwnMessage(msg) {
|
|
||||||
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditTitle() {
|
|
||||||
titleEdit.value = props.ticket.title
|
|
||||||
editingTitle.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTitle() {
|
|
||||||
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
|
|
||||||
onSuccess: () => { editingTitle.value = false }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveMeta() {
|
|
||||||
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage() {
|
|
||||||
messageForm.is_internal = replyTab.value === 'internal'
|
|
||||||
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
|
|
||||||
onSuccess: () => { messageForm.body = '' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachFile(event) {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
if (!file) return
|
|
||||||
const data = new FormData()
|
|
||||||
data.append('file', file)
|
|
||||||
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete() {
|
|
||||||
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
|
|
||||||
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status) {
|
|
||||||
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
|
|
||||||
return map[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(status) {
|
|
||||||
const map = {
|
|
||||||
open: 'bg-blue-100 text-blue-700',
|
|
||||||
in_progress: 'bg-purple-100 text-purple-700',
|
|
||||||
pending: 'bg-yellow-100 text-yellow-700',
|
|
||||||
resolved: 'bg-green-100 text-green-700',
|
|
||||||
closed: 'bg-gray-100 text-gray-500',
|
|
||||||
}
|
|
||||||
return map[status] || 'bg-gray-100 text-gray-600'
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = Math.floor((now - date) / 1000)
|
|
||||||
if (diff < 60) return `${diff}s ago`
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
||||||
return `${Math.floor(diff / 86400)}d ago`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
63
src/Console/Commands/AutoCloseTickets.php
Normal file
63
src/Console/Commands/AutoCloseTickets.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class AutoCloseTickets extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ticketing:auto-close';
|
||||||
|
protected $description = 'Close resolved tickets that have not been updated within the configured grace period.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) Setting::get('ticketing.auto_close_days', 0);
|
||||||
|
|
||||||
|
if ($days <= 0) {
|
||||||
|
$this->info('Auto-close is disabled (ticketing.auto_close_days = 0).');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CLOSED ONE AT A TIME, THROUGH THE MODEL — [central-ticketing] #4781.
|
||||||
|
*
|
||||||
|
* This was a single `->update(['status' => 'closed'])`, which is one
|
||||||
|
* statement and no model events. Since #4781 a ticket's status change is
|
||||||
|
* an audited fact, and a bulk update would have left exactly one way of
|
||||||
|
* closing a ticket invisible: the Changes log would show a ticket going
|
||||||
|
* open → resolved and then nothing, while the row itself said `closed`.
|
||||||
|
* "Who closed this" is the question the audit trail exists to answer,
|
||||||
|
* and "the nightly sweep did, on this date" is a real answer — but only
|
||||||
|
* if the sweep writes it.
|
||||||
|
*
|
||||||
|
* Per-ticket rather than one summary event because the answer has to be
|
||||||
|
* on THE TICKET: the per-record history panel reads rows whose subject
|
||||||
|
* is that ticket, and an aggregate "closed 14 tickets" row would not
|
||||||
|
* appear in any of their histories.
|
||||||
|
*
|
||||||
|
* `chunkById`, so a first sweep over a long backlog does not load every
|
||||||
|
* resolved ticket into memory at once. Paging by id is what makes this
|
||||||
|
* safe while the filtered column is being written: each page continues
|
||||||
|
* from the last id seen rather than from an offset, so rows dropping out
|
||||||
|
* of `status = 'resolved'` cannot cause a page to be skipped.
|
||||||
|
*/
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
Ticket::where('status', 'resolved')
|
||||||
|
->where('updated_at', '<=', $cutoff)
|
||||||
|
->chunkById(200, function ($tickets) use (&$count) {
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$ticket->forceFill(['status' => 'closed'])->save();
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago.");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Console/Commands/RepairMergedTickets.php
Normal file
67
src/Console/Commands/RepairMergedTickets.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Console\Commands;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DRY-RUN COUNT, AND THE REPAIR — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* The first question #938 asks is a number: how many rows on prod carry
|
||||||
|
* `merged_into_id` with a status other than `merged`. That number decides
|
||||||
|
* whether the office corrects them one at a time from the badge or whether a
|
||||||
|
* deploy re-marks them all, and there was no way to ask for it that did not
|
||||||
|
* involve typing a raw UPDATE-shaped statement into tinker on the production
|
||||||
|
* box. So `--dry-run` is the default and is the point of this command:
|
||||||
|
*
|
||||||
|
* php artisan ticketing:repair-merged --dry-run # count + list, writes NOTHING
|
||||||
|
* php artisan ticketing:repair-merged --force # re-mark them all
|
||||||
|
*
|
||||||
|
* `--force` is required to write, rather than `--dry-run` being required not
|
||||||
|
* to. A repair command whose bare form repairs is one fat-finger away from
|
||||||
|
* being the incident it was written to clean up after.
|
||||||
|
*/
|
||||||
|
class RepairMergedTickets extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ticketing:repair-merged
|
||||||
|
{--dry-run : Count and list the inconsistent rows; write nothing (the default).}
|
||||||
|
{--force : Actually re-mark every inconsistent row as merged.}';
|
||||||
|
|
||||||
|
protected $description = 'Report — or, with --force, repair — tickets whose merged_into_id is set while their status is not "merged" (#938).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$rows = MergedTicketRepair::rows();
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'SELECT COUNT(*) FROM tickets WHERE merged_into_id IS NOT NULL AND status <> \'merged\' => %d',
|
||||||
|
$rows->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
$this->info('Nothing is inconsistent. No repair to make.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['id', 'number', 'status', 'merged_into_id', 'updated_at'],
|
||||||
|
$rows->map(fn ($t) => [
|
||||||
|
$t->id, $t->number, $t->status, $t->merged_into_id, (string) $t->updated_at,
|
||||||
|
])->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->option('force')) {
|
||||||
|
$this->comment('Dry run — nothing was written. Re-run with --force to re-mark these as merged.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = MergedTicketRepair::repairAll('artisan ticketing:repair-merged');
|
||||||
|
|
||||||
|
$this->info("Re-marked {$changed} ticket(s) as merged. Their messages and participants are untouched.");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
372
src/Database/Seeders/EmailTemplatesSeeder.php
Normal file
372
src/Database/Seeders/EmailTemplatesSeeder.php
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds all standard ticketing email templates into the shell's email_templates table.
|
||||||
|
* Uses updateOrCreate on slug so re-running on reinstall is safe — existing customisations
|
||||||
|
* to subject/body are NOT overwritten (only missing templates are inserted).
|
||||||
|
*/
|
||||||
|
class EmailTemplatesSeeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
foreach ($this->templates() as $template) {
|
||||||
|
// Only insert if the slug doesn't exist yet — don't overwrite user edits
|
||||||
|
$exists = DB::table('email_templates')->where('slug', $template['slug'])->exists();
|
||||||
|
if (! $exists) {
|
||||||
|
DB::table('email_templates')->insert(array_merge($template, [
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templates(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ── Submitter-facing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_received',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Received — Confirmation',
|
||||||
|
'subject' => '[{{ticket_number}}] We received your request: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
Thank you for reaching out. We've received your request and created a ticket for you.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Team: {{group_name}}
|
||||||
|
|
||||||
|
You can view your ticket and any updates here:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
We'll be in touch as soon as possible.
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_closed',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Closed — Resolution Notice',
|
||||||
|
'subject' => '[{{ticket_number}}] Your ticket has been resolved',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
Your ticket has been marked as resolved.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Team: {{group_name}}
|
||||||
|
|
||||||
|
{{resolution_note}}
|
||||||
|
|
||||||
|
If you have further questions or the issue has not been fully resolved, you can reply to this email or reopen your ticket here:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.status_changed',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Status Changed — Submitter Notice',
|
||||||
|
'subject' => '[{{ticket_number}}] Your ticket status changed to {{new_status}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
The status of your ticket has been updated.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Previous status: {{old_status}}
|
||||||
|
New status: {{new_status}}
|
||||||
|
|
||||||
|
View your ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_reopened_submitter',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Reopened — Submitter Notice',
|
||||||
|
'subject' => '[{{ticket_number}}] Your ticket has been reopened',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
Your ticket has been reopened and is being looked into again.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
|
||||||
|
View your ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.agent_reply',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Agent Reply to Submitter',
|
||||||
|
'subject' => '[{{ticket_number}}] Update on your ticket: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
{{agent_name}} has replied to your ticket:
|
||||||
|
|
||||||
|
---
|
||||||
|
{{reply_body}}
|
||||||
|
---
|
||||||
|
|
||||||
|
View and reply to your ticket here:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.pending_waiting',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Pending — Waiting on Submitter',
|
||||||
|
'subject' => '[{{ticket_number}}] We need more information from you',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
Your ticket is currently on hold while we wait for additional information from you.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
|
||||||
|
Please reply with the requested information so we can continue helping you:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
If we don't hear back within a few days, this ticket may be closed automatically.
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.priority_changed_submitter',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Priority Changed — Submitter Notice',
|
||||||
|
'subject' => '[{{ticket_number}}] Priority updated on your ticket',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
The priority of your ticket has been updated.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Previous priority: {{old_priority}}
|
||||||
|
New priority: {{new_priority}}
|
||||||
|
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_escalated_submitter',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Escalated — Submitter Notice',
|
||||||
|
'subject' => '[{{ticket_number}}] Your ticket has been escalated',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{submitter_name}},
|
||||||
|
|
||||||
|
Your ticket has been escalated to ensure a faster resolution.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
|
||||||
|
{{escalation_reason}}
|
||||||
|
|
||||||
|
We're treating this as a priority. You can track progress here:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Agent / internal notifications ───────────────────────────────────
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_assigned_agent',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Assigned to Agent',
|
||||||
|
'subject' => '[{{ticket_number}}] New ticket assigned to you: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{agent_name}},
|
||||||
|
|
||||||
|
A ticket has been assigned to you.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Submitted by: {{submitter_name}} ({{submitter_email}})
|
||||||
|
Team: {{group_name}}
|
||||||
|
Priority: {{priority_name}}
|
||||||
|
|
||||||
|
View ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.submitter_reply_agent',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Submitter Replied — Agent Notification',
|
||||||
|
'subject' => '[{{ticket_number}}] {{submitter_name}} replied: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{agent_name}},
|
||||||
|
|
||||||
|
{{submitter_name}} has replied to their ticket.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
|
||||||
|
---
|
||||||
|
{{reply_body}}
|
||||||
|
---
|
||||||
|
|
||||||
|
View and respond:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.internal_note_agents',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Internal Note Posted — Agent Notification',
|
||||||
|
'subject' => '[{{ticket_number}}] New internal note by {{note_author}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi,
|
||||||
|
|
||||||
|
{{note_author}} posted an internal note on ticket {{ticket_number}}.
|
||||||
|
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
|
||||||
|
---
|
||||||
|
{{note_body}}
|
||||||
|
---
|
||||||
|
|
||||||
|
View ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_reopened_agent',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Reopened — Agent Notification',
|
||||||
|
'subject' => '[{{ticket_number}}] Ticket reopened: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{agent_name}},
|
||||||
|
|
||||||
|
A ticket you worked on has been reopened.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Submitted by: {{submitter_name}}
|
||||||
|
|
||||||
|
View ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.unassigned_alert',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Unassigned Ticket Alert — Manager Notification',
|
||||||
|
'subject' => '[{{ticket_number}}] Unassigned ticket needs attention: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi,
|
||||||
|
|
||||||
|
A ticket in {{group_name}} has not been assigned to an agent.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Submitted: {{submitted_at}}
|
||||||
|
|
||||||
|
Please assign this ticket:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.ticket_escalated_agents',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Ticket Escalated — Agent Notification',
|
||||||
|
'subject' => '[ESCALATED] [{{ticket_number}}] {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{agent_name}},
|
||||||
|
|
||||||
|
Ticket {{ticket_number}} has been escalated.
|
||||||
|
|
||||||
|
Subject: {{ticket_subject}}
|
||||||
|
Submitted by: {{submitter_name}} ({{submitter_email}})
|
||||||
|
Priority: {{priority_name}}
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
{{escalation_reason}}
|
||||||
|
|
||||||
|
This ticket requires immediate attention:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Email-to-ticket auto-reply ────────────────────────────────────────
|
||||||
|
|
||||||
|
[
|
||||||
|
'slug' => 'ticketing.email_autoreply',
|
||||||
|
'snap_in_slug' => 'ticketing',
|
||||||
|
'name' => 'Email Inbound — Auto Reply',
|
||||||
|
'subject' => '[{{ticket_number}}] We received your message: {{ticket_subject}}',
|
||||||
|
'body' => <<<'BODY'
|
||||||
|
Hi {{sender_name}},
|
||||||
|
|
||||||
|
Thank you for your email. We've created a support ticket on your behalf.
|
||||||
|
|
||||||
|
Ticket: {{ticket_number}}
|
||||||
|
Team: {{group_name}}
|
||||||
|
|
||||||
|
You can track your request online here:
|
||||||
|
{{ticket_url}}
|
||||||
|
|
||||||
|
Please do not reply to this message — use the link above to add updates to your ticket.
|
||||||
|
|
||||||
|
{{app_name}} Support
|
||||||
|
BODY,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,15 +17,19 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$admin = DB::table('users')->where('email', 'admin@vancouverchristian.org')->first();
|
// Use the first super admin, or fall back to any existing user
|
||||||
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
|
$admin = DB::table('users')->where('is_super_admin', 1)->first()
|
||||||
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
|
?? DB::table('users')->first();
|
||||||
|
|
||||||
if (! $admin) {
|
if (! $admin) {
|
||||||
$this->command?->warn('TicketingDemoSeeder skipped: admin user not found.');
|
$this->command?->warn('TicketingDemoSeeder skipped: no users found in the database.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional agents (QA test accounts if they exist)
|
||||||
|
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
|
||||||
|
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
|
||||||
|
|
||||||
$users = collect([$admin, $micah, $nahum])->filter()->values();
|
$users = collect([$admin, $micah, $nahum])->filter()->values();
|
||||||
|
|
||||||
// Clear prior demo/QA ticketing data so the demo state stays coherent instead of
|
// Clear prior demo/QA ticketing data so the demo state stays coherent instead of
|
||||||
@@ -38,9 +42,9 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
TicketingGroup::query()->delete();
|
TicketingGroup::query()->delete();
|
||||||
|
|
||||||
$groups = [
|
$groups = [
|
||||||
['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'],
|
['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'],
|
||||||
['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'],
|
['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'],
|
||||||
['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'],
|
['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$groupModels = collect();
|
$groupModels = collect();
|
||||||
@@ -52,94 +56,95 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
|
|
||||||
foreach ($users as $index => $user) {
|
foreach ($users as $index => $user) {
|
||||||
TicketingAgentAccess::firstOrCreate([
|
TicketingAgentAccess::firstOrCreate([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'group_id' => $group->id,
|
'group_id' => $group->id,
|
||||||
], [
|
], [
|
||||||
'role' => $index === 0 ? 'manager' : 'agent',
|
'role' => $index === 0 ? 'manager' : 'agent',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ([
|
// Global priorities — only seed once (first group iteration)
|
||||||
['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1],
|
if (PriorityLevel::count() === 0) {
|
||||||
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
|
foreach ([
|
||||||
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
|
['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1],
|
||||||
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
|
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
|
||||||
] as $priorityData) {
|
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
|
||||||
PriorityLevel::firstOrCreate([
|
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
|
||||||
'group_id' => $group->id,
|
] as $priorityData) {
|
||||||
'name' => $priorityData['name'],
|
PriorityLevel::firstOrCreate(
|
||||||
], [
|
['name' => $priorityData['name']],
|
||||||
'color' => $priorityData['color'],
|
['color' => $priorityData['color'], 'description' => null, 'sort_order' => $priorityData['sort_order']]
|
||||||
'description' => null,
|
);
|
||||||
'sort_order' => $priorityData['sort_order'],
|
}
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$projects = match ($group->prefix) {
|
$projects = match ($group->prefix) {
|
||||||
'IT' => [
|
'IT' => [
|
||||||
['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'],
|
['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'],
|
||||||
['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'],
|
['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'],
|
||||||
['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'],
|
['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'],
|
||||||
|
['name' => 'Network & Wi-Fi', 'description' => 'Connectivity and access point issues'],
|
||||||
],
|
],
|
||||||
'FAC' => [
|
'FAC' => [
|
||||||
['name' => 'Work Orders', 'description' => 'General campus maintenance requests'],
|
['name' => 'Work Orders', 'description' => 'General campus maintenance requests'],
|
||||||
['name' => 'HVAC', 'description' => 'Heating and cooling issues'],
|
['name' => 'HVAC', 'description' => 'Heating and cooling issues'],
|
||||||
['name' => 'Events Setup', 'description' => 'Room setup and teardown support'],
|
['name' => 'Events Setup', 'description' => 'Room setup and teardown support'],
|
||||||
|
['name' => 'Grounds', 'description' => 'Exterior and landscaping requests'],
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'],
|
['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'],
|
||||||
['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'],
|
['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'],
|
||||||
['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'],
|
['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'],
|
||||||
|
['name' => 'Payroll', 'description' => 'Pay queries and corrections'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach ($projects as $projectData) {
|
foreach ($projects as $projectData) {
|
||||||
TicketingProject::firstOrCreate([
|
TicketingProject::firstOrCreate([
|
||||||
'group_id' => $group->id,
|
'group_id' => $group->id,
|
||||||
'name' => $projectData['name'],
|
'name' => $projectData['name'],
|
||||||
], [
|
], [
|
||||||
'description' => $projectData['description'],
|
'description' => $projectData['description'],
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'created_by' => $admin->id,
|
'created_by' => $admin->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupModels->push($group->fresh(['priorityLevels', 'projects', 'agentAccess']));
|
$groupModels->push($group->fresh(['priorityLevels', 'projects', 'agentAccess']));
|
||||||
}
|
}
|
||||||
|
|
||||||
$submitterPool = $this->ensureSubmitterPool();
|
$submitterPool = $this->ensureSubmitterPool();
|
||||||
$statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed'];
|
$statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed'];
|
||||||
$ticketBlueprints = $this->ticketBlueprints();
|
$blueprints = $this->ticketBlueprints();
|
||||||
|
$blueprintCount = count($blueprints);
|
||||||
|
$ticketCount = 45;
|
||||||
|
|
||||||
foreach (range(1, 50) as $i) {
|
foreach (range(1, $ticketCount) as $i) {
|
||||||
$group = $groupModels[($i - 1) % $groupModels->count()];
|
$group = $groupModels[($i - 1) % $groupModels->count()];
|
||||||
$priority = $group->priorityLevels->random();
|
$priority = $group->priorityLevels->random();
|
||||||
$project = $group->projects->random();
|
$project = $group->projects->random();
|
||||||
$submitter = $submitterPool[($i - 1) % count($submitterPool)];
|
$submitter = $submitterPool[($i - 1) % count($submitterPool)];
|
||||||
$assigneeId = $group->agentAccess->random()->user_id;
|
$assigneeId = $group->agentAccess->random()->user_id;
|
||||||
$blueprint = $ticketBlueprints[($i - 1) % count($ticketBlueprints)];
|
$blueprint = $blueprints[($i - 1) % $blueprintCount];
|
||||||
$createdAt = Carbon::now()->subDays(rand(0, 35))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
|
$createdAt = Carbon::now()->subDays(rand(0, 45))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
|
||||||
$status = $statuses[array_rand($statuses)];
|
$status = $statuses[array_rand($statuses)];
|
||||||
|
|
||||||
$title = $blueprint['title'];
|
|
||||||
if ($i > count($ticketBlueprints)) {
|
|
||||||
$title .= ' #' . $i;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticket = Ticket::updateOrCreate(
|
$ticket = Ticket::updateOrCreate(
|
||||||
['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)],
|
['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)],
|
||||||
[
|
[
|
||||||
'group_id' => $group->id,
|
'group_id' => $group->id,
|
||||||
'submitter_id' => $submitter['id'],
|
'submitter_id' => $submitter['id'],
|
||||||
'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0 ? null : $assigneeId,
|
'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0
|
||||||
'project_id' => $project->id,
|
? null
|
||||||
'title' => $title,
|
: $assigneeId,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'title' => $blueprint['title'],
|
||||||
'description' => $blueprint['description'],
|
'description' => $blueprint['description'],
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'priority_id' => $priority->id,
|
'priority_id' => $priority->id,
|
||||||
'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null,
|
'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null,
|
||||||
'created_at' => $createdAt,
|
'created_at' => $createdAt,
|
||||||
'updated_at' => $createdAt,
|
'updated_at' => $createdAt,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -154,14 +159,14 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
private function ensureSubmitterPool(): array
|
private function ensureSubmitterPool(): array
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local', 'role' => 'staff'],
|
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local'],
|
||||||
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local', 'role' => 'staff'],
|
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local'],
|
||||||
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local', 'role' => 'staff'],
|
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local'],
|
||||||
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local', 'role' => 'staff'],
|
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local'],
|
||||||
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local', 'role' => 'admin'],
|
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local'],
|
||||||
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local', 'role' => 'staff'],
|
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local'],
|
||||||
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local', 'role' => 'staff'],
|
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local'],
|
||||||
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local', 'role' => 'staff'],
|
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$pool = [];
|
$pool = [];
|
||||||
@@ -171,14 +176,14 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
|
|
||||||
if (! $userId) {
|
if (! $userId) {
|
||||||
$userId = DB::table('users')->insertGetId([
|
$userId = DB::table('users')->insertGetId([
|
||||||
'name' => $person['name'],
|
'name' => $person['name'],
|
||||||
'email' => $person['email'],
|
'email' => $person['email'],
|
||||||
'google_id' => 'demo-' . Str::slug($person['email']),
|
'google_id' => 'demo-' . Str::slug($person['email']),
|
||||||
'role' => $person['role'],
|
'is_super_admin' => 0,
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => bcrypt(Str::random(32)),
|
'password' => bcrypt(Str::random(32)),
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,64 +195,75 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
|
|
||||||
private function seedThread(Ticket $ticket, array $submitter, TicketingGroup $group, string $status, Carbon $createdAt): void
|
private function seedThread(Ticket $ticket, array $submitter, TicketingGroup $group, string $status, Carbon $createdAt): void
|
||||||
{
|
{
|
||||||
$agents = TicketingAgentAccess::where('group_id', $group->id)->pluck('user_id')->all();
|
$agents = TicketingAgentAccess::where('group_id', $group->id)->pluck('user_id')->all();
|
||||||
$agentId = $ticket->assigned_to ?: ($agents[0] ?? null);
|
$agentId = $ticket->assigned_to ?: ($agents[0] ?? null);
|
||||||
|
|
||||||
$messages = [
|
$messages = [
|
||||||
[
|
[
|
||||||
'user_id' => $submitter['id'],
|
'user_id' => $submitter['id'],
|
||||||
'author_email' => $submitter['email'],
|
'author_email' => $submitter['email'],
|
||||||
'body' => $ticket->description,
|
'body' => $ticket->description,
|
||||||
'is_internal' => false,
|
'is_internal' => false,
|
||||||
'source' => rand(0, 3) === 0 ? 'email' : 'web',
|
'source' => rand(0, 3) === 0 ? 'email' : 'web',
|
||||||
'created_at' => $createdAt,
|
'created_at' => $createdAt,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($agentId) {
|
if ($agentId) {
|
||||||
$messages[] = [
|
$messages[] = [
|
||||||
'user_id' => $agentId,
|
'user_id' => $agentId,
|
||||||
'author_email' => null,
|
'author_email' => null,
|
||||||
'body' => $this->agentReplyForStatus($status),
|
'body' => $this->agentReplyForStatus($status),
|
||||||
'is_internal' => false,
|
'is_internal' => false,
|
||||||
'source' => 'web',
|
'source' => 'web',
|
||||||
'created_at' => $createdAt->copy()->addHours(rand(1, 8)),
|
'created_at' => $createdAt->copy()->addHours(rand(1, 8)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($status, ['in_progress', 'pending', 'resolved', 'closed']) && $agentId) {
|
if (in_array($status, ['in_progress', 'pending', 'resolved', 'closed']) && $agentId) {
|
||||||
$messages[] = [
|
$messages[] = [
|
||||||
'user_id' => $agentId,
|
'user_id' => $agentId,
|
||||||
'author_email' => null,
|
'author_email' => null,
|
||||||
'body' => $this->internalNoteForGroup($group->prefix),
|
'body' => $this->internalNoteForGroup($group->prefix),
|
||||||
'is_internal' => true,
|
'is_internal' => true,
|
||||||
'source' => 'web',
|
'source' => 'web',
|
||||||
'created_at' => $createdAt->copy()->addHours(rand(2, 16)),
|
'created_at' => $createdAt->copy()->addHours(rand(2, 16)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($status, ['pending']) && $agentId) {
|
||||||
|
$messages[] = [
|
||||||
|
'user_id' => $submitter['id'],
|
||||||
|
'author_email' => $submitter['email'],
|
||||||
|
'body' => 'Thanks for looking into this. To answer your question — it started about a week ago and it does happen consistently.',
|
||||||
|
'is_internal' => false,
|
||||||
|
'source' => 'web',
|
||||||
|
'created_at' => $createdAt->copy()->addHours(rand(3, 20)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($status, ['resolved', 'closed'])) {
|
if (in_array($status, ['resolved', 'closed'])) {
|
||||||
$messages[] = [
|
$messages[] = [
|
||||||
'user_id' => $submitter['id'],
|
'user_id' => $submitter['id'],
|
||||||
'author_email' => $submitter['email'],
|
'author_email' => $submitter['email'],
|
||||||
'body' => 'Thanks — that fixed it. Appreciate the quick help.',
|
'body' => 'Thanks — that fixed it. Appreciate the quick help.',
|
||||||
'is_internal' => false,
|
'is_internal' => false,
|
||||||
'source' => 'web',
|
'source' => 'web',
|
||||||
'created_at' => $createdAt->copy()->addHours(rand(10, 36)),
|
'created_at' => $createdAt->copy()->addHours(rand(10, 36)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($messages as $message) {
|
foreach ($messages as $message) {
|
||||||
TicketMessage::create([
|
TicketMessage::create([
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'user_id' => $message['user_id'],
|
'user_id' => $message['user_id'],
|
||||||
'author_email' => $message['author_email'],
|
'author_email' => $message['author_email'],
|
||||||
'body' => $message['body'],
|
'body' => $message['body'],
|
||||||
'is_internal' => $message['is_internal'],
|
'is_internal' => $message['is_internal'],
|
||||||
'source' => $message['source'],
|
'source' => $message['source'],
|
||||||
'email_message_id' => $message['source'] === 'email' ? '<' . Str::uuid() . '@vcs.local>' : null,
|
'email_message_id' => $message['source'] === 'email' ? '<' . Str::uuid() . '@vcs.local>' : null,
|
||||||
'created_at' => $message['created_at'],
|
'created_at' => $message['created_at'],
|
||||||
'updated_at' => $message['created_at'],
|
'updated_at' => $message['created_at'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,48 +271,139 @@ class TicketingDemoSeeder extends Seeder
|
|||||||
private function agentReplyForStatus(string $status): string
|
private function agentReplyForStatus(string $status): string
|
||||||
{
|
{
|
||||||
return match ($status) {
|
return match ($status) {
|
||||||
'open' => 'Got it. We have this in the queue and will take a look shortly.',
|
'open' => 'Got it. We have this in the queue and will take a look shortly.',
|
||||||
'in_progress' => 'We are actively working on this now. I will update you once I have confirmed the fix.',
|
'in_progress' => 'We are actively working on this now. I will update you once I have confirmed the fix.',
|
||||||
'pending' => 'We need one more detail before we can continue. When did you first notice the issue?',
|
'pending' => 'We need one more detail before we can continue. When did you first notice the issue?',
|
||||||
'resolved' => 'This should now be fixed on our side. Please test when you have a minute.',
|
'resolved' => 'This should now be fixed on our side. Please test when you have a minute.',
|
||||||
'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.',
|
'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.',
|
||||||
default => 'Thanks, we are on it.',
|
default => 'Thanks, we are on it.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function internalNoteForGroup(string $prefix): string
|
private function internalNoteForGroup(string $prefix): string
|
||||||
{
|
{
|
||||||
return match ($prefix) {
|
$notes = match ($prefix) {
|
||||||
'IT' => 'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.',
|
'IT' => [
|
||||||
'FAC' => 'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
|
'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.',
|
||||||
'HR' => 'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
|
'Internal: checked MDM — device is enrolled. May need remote wipe if issue persists.',
|
||||||
default => 'Internal: triage complete, waiting on next action.',
|
'Internal: similar issue came up last month, possibly related to the Chrome policy push from 2 weeks ago.',
|
||||||
|
'Internal: this looks like a profile sync error. Tried clearing cache remotely, should be resolved.',
|
||||||
|
],
|
||||||
|
'FAC' => [
|
||||||
|
'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
|
||||||
|
'Internal: parts are on order, ETA 3 business days. Keep submitter updated.',
|
||||||
|
'Internal: assigned to weekend crew, no disruption to classes expected.',
|
||||||
|
'Internal: this is the third report from this room — worth doing a full inspection.',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
|
||||||
|
'Internal: escalated to payroll for confirmation. Waiting on their response before replying to the submitter.',
|
||||||
|
'Internal: reviewed file, looks like a data entry error on our end. Will correct and follow up.',
|
||||||
|
'Internal: HR director has been looped in. Response expected by end of week.',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return $notes[array_rand($notes)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ticketBlueprints(): array
|
private function ticketBlueprints(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['title' => 'Projector not turning on in Room 204', 'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'],
|
// ── IT ────────────────────────────────────────────────────────────
|
||||||
['title' => 'Need access to new staff shared drive', 'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'],
|
['title' => 'Projector not turning on in Room 204',
|
||||||
['title' => 'Chromebook cart missing three chargers', 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
|
'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'],
|
||||||
['title' => 'Gym thermostat is stuck at 27°C', 'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'],
|
['title' => 'Need access to new staff shared drive',
|
||||||
['title' => 'Can’t print to office copier', 'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'],
|
'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'],
|
||||||
['title' => 'New employee onboarding checklist incomplete', 'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'],
|
['title' => 'Chromebook cart missing three chargers',
|
||||||
['title' => 'Classroom speakers crackling during assemblies', 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
|
'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
|
||||||
['title' => 'Request for additional key fob', 'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
|
['title' => 'Can\'t print to office copier',
|
||||||
['title' => 'Google account keeps asking for password again', 'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'],
|
'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'],
|
||||||
['title' => 'Benefits question about dependent coverage', 'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
|
['title' => 'Classroom speakers crackling during assemblies',
|
||||||
['title' => 'Broken desk in portable 3', 'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
|
'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
|
||||||
['title' => 'Staff laptop camera not detected in Meet', 'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'],
|
['title' => 'Google account keeps asking for password again',
|
||||||
['title' => 'Request setup for parent info night', 'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
|
'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'],
|
||||||
['title' => 'Payroll question on missed stipend', 'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'],
|
['title' => 'Staff laptop camera not detected in Meet',
|
||||||
['title' => 'Door closer slamming in west hallway', 'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
|
'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'],
|
||||||
['title' => 'Need substitute teacher account reactivated', 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
|
['title' => 'Need substitute teacher account reactivated',
|
||||||
['title' => 'Student device filter blocking approved site', 'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'],
|
'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
|
||||||
['title' => 'Request ergonomic chair assessment', 'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
|
['title' => 'Student device filter blocking approved site',
|
||||||
['title' => 'Water fountain on second floor leaking', 'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
|
'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'],
|
||||||
['title' => 'Need permission to edit school calendar', 'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
|
['title' => 'Need permission to edit school calendar',
|
||||||
|
'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
|
||||||
|
['title' => 'Smartboard touch screen not responding',
|
||||||
|
'description' => 'The SMART Board in room 115 powers on and displays correctly but touch input stopped working this morning.'],
|
||||||
|
['title' => 'Wi-Fi dropping in the library wing',
|
||||||
|
'description' => 'Multiple staff have reported losing Wi-Fi connection in the library every afternoon around 1:30 PM.'],
|
||||||
|
['title' => 'Password reset needed for new EA',
|
||||||
|
'description' => 'Our new educational assistant started today and cannot log in. It looks like the account was created but the password was never sent.'],
|
||||||
|
['title' => 'Laptop won\'t connect to school network',
|
||||||
|
'description' => 'My personal MacBook was connecting to the staff network last week but now shows "cannot join" whenever I try.'],
|
||||||
|
['title' => 'Grade 8 iPads not syncing with MDM',
|
||||||
|
'description' => 'The Grade 8 iPad cart shows 12 devices as out of compliance in our MDM. Apps are not installing.'],
|
||||||
|
['title' => 'Classroom Apple TV not showing up',
|
||||||
|
'description' => 'The Apple TV in Room 309 disappeared from AirPlay device list on all our Macs. Tried a reboot — still not showing.'],
|
||||||
|
|
||||||
|
// ── Facilities ────────────────────────────────────────────────────
|
||||||
|
['title' => 'Gym thermostat is stuck at 27°C',
|
||||||
|
'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'],
|
||||||
|
['title' => 'Request for additional key fob',
|
||||||
|
'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
|
||||||
|
['title' => 'Broken desk in portable 3',
|
||||||
|
'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
|
||||||
|
['title' => 'Request setup for parent info night',
|
||||||
|
'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
|
||||||
|
['title' => 'Door closer slamming in west hallway',
|
||||||
|
'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
|
||||||
|
['title' => 'Water fountain on second floor leaking',
|
||||||
|
'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
|
||||||
|
['title' => 'Bathroom stall door hinge broken — girls\' washroom',
|
||||||
|
'description' => 'The middle stall door in the main floor girls\' washroom has a broken hinge and cannot close properly.'],
|
||||||
|
['title' => 'Light bulbs out in staff parking lot',
|
||||||
|
'description' => 'Three of the five overhead lights in the north staff parking lot are burnt out. Safety concern after dark.'],
|
||||||
|
['title' => 'Ceiling tile collapsed in room 112',
|
||||||
|
'description' => 'A ceiling tile in the corner of Room 112 fell overnight. Debris on the floor but no one was hurt.'],
|
||||||
|
['title' => 'Exterior gate latch not catching',
|
||||||
|
'description' => 'The latch on the playground gate by the K-3 yard is not catching properly. Gate swings open on its own.'],
|
||||||
|
['title' => 'Plumbing noise in staff washroom',
|
||||||
|
'description' => 'There is a loud knocking/banging noise coming from the pipes in the main floor staff washroom when the toilet is flushed.'],
|
||||||
|
['title' => 'Cafeteria exhaust fan broken',
|
||||||
|
'description' => 'The exhaust fan in the cafeteria kitchen has stopped working. It is getting smoky during lunch prep.'],
|
||||||
|
['title' => 'Window blinds broken in Grade 4 room',
|
||||||
|
'description' => 'Two of the three window blinds in Grade 4 classroom cannot be lowered. Afternoon sun is making it impossible to see the board.'],
|
||||||
|
['title' => 'Graffiti on east exterior wall',
|
||||||
|
'description' => 'There is graffiti on the east-facing exterior wall near the secondary entrance that needs to be cleaned before the parent open house.'],
|
||||||
|
|
||||||
|
// ── HR ────────────────────────────────────────────────────────────
|
||||||
|
['title' => 'New employee onboarding checklist incomplete',
|
||||||
|
'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'],
|
||||||
|
['title' => 'Benefits question about dependent coverage',
|
||||||
|
'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
|
||||||
|
['title' => 'Payroll question on missed stipend',
|
||||||
|
'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'],
|
||||||
|
['title' => 'Request ergonomic chair assessment',
|
||||||
|
'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
|
||||||
|
['title' => 'Update emergency contact information',
|
||||||
|
'description' => 'I recently moved and need to update my emergency contact and home address on file.'],
|
||||||
|
['title' => 'Parental leave documentation request',
|
||||||
|
'description' => 'I am planning parental leave starting in June and need to know what forms to submit and by when.'],
|
||||||
|
['title' => 'Professional development funding approval',
|
||||||
|
'description' => 'I would like to attend a literacy conference in May and need to know if PD funding is still available for this year.'],
|
||||||
|
['title' => 'Request for letter of employment',
|
||||||
|
'description' => 'My bank is requesting a letter of employment for a mortgage application. Can HR provide this?'],
|
||||||
|
['title' => 'Sick leave balance inquiry',
|
||||||
|
'description' => 'I am not sure how many sick days I have remaining this year. The employee portal is not showing my balance.'],
|
||||||
|
['title' => 'Contract renewal question for part-time staff',
|
||||||
|
'description' => 'My contract ends June 30 and I have not received any communication about renewal. Can someone confirm the status?'],
|
||||||
|
['title' => 'Request to change payroll deposit account',
|
||||||
|
'description' => 'I recently switched banks and need to update my direct deposit information before the next pay cycle.'],
|
||||||
|
['title' => 'Reference letter request for former employee',
|
||||||
|
'description' => 'A former staff member has asked us to provide a reference letter. Who should handle this and what is the process?'],
|
||||||
|
['title' => 'Vacation carryover from previous school year',
|
||||||
|
'description' => 'I had unused vacation days from last year that were supposed to carry over. They are not showing up in my current balance.'],
|
||||||
|
['title' => 'Volunteer supervision policy clarification',
|
||||||
|
'description' => 'We have a parent volunteer joining our classroom regularly. Do they need a criminal record check and what is our sign-in process?'],
|
||||||
|
['title' => 'Request for resignation acknowledgement letter',
|
||||||
|
'description' => 'I submitted my resignation two weeks ago and have not received any written acknowledgement from the school.'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class TicketAttachmentController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same read-only rule as a reply — see TicketMessageController::store(). #920
|
||||||
|
if ($ticket->isMerged()) {
|
||||||
|
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
||||||
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
||||||
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
||||||
@@ -49,7 +54,7 @@ class TicketAttachmentController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$file = $request->file('file');
|
$file = $request->file('file');
|
||||||
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
|
$disk = config('ticketing.storage_disk', 'local');
|
||||||
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
||||||
|
|
||||||
TicketAttachment::create([
|
TicketAttachment::create([
|
||||||
@@ -71,7 +76,7 @@ class TicketAttachmentController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
|
$disk = config('ticketing.storage_disk', 'local');
|
||||||
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
|
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,19 @@ class TicketMessageController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A merged ticket takes no new messages — [central-ticketing] #920.
|
||||||
|
*
|
||||||
|
* Show.vue replaces the composer with "This ticket has been merged and is
|
||||||
|
* read-only.", and that was the whole of the guard: a POST straight to this
|
||||||
|
* route (or from a page that was already open when the merge happened) still
|
||||||
|
* wrote a message onto a ticket whose thread has moved to the merge target,
|
||||||
|
* where nobody working the ticket would ever see it.
|
||||||
|
*/
|
||||||
|
if ($ticket->isMerged()) {
|
||||||
|
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'body' => 'required|string',
|
'body' => 'required|string',
|
||||||
'is_internal' => 'boolean',
|
'is_internal' => 'boolean',
|
||||||
@@ -62,9 +75,37 @@ class TicketMessageController extends Controller
|
|||||||
return back()->with('success', 'Message sent.');
|
return back()->with('success', 'Message sent.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #8: this was an empty placeholder, so nobody has ever been told their
|
||||||
|
* ticket was answered. Goes through the Notifications platform so it lands in the
|
||||||
|
* shared Notification Log and honours the person's preferences, rather than
|
||||||
|
* reaching for Mail:: directly as the old comment suggested.
|
||||||
|
*
|
||||||
|
* Only ever called for a non-internal agent reply — see the caller.
|
||||||
|
*/
|
||||||
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
||||||
{
|
{
|
||||||
// Placeholder for email notification
|
$submitter = \App\Models\User::find($ticket->submitter_id);
|
||||||
// In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message));
|
|
||||||
|
if (! $submitter) {
|
||||||
|
return; // a ticket raised on behalf of somebody with no account
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = route('ticketing.show', $ticket->id);
|
||||||
|
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
|
||||||
|
|
||||||
|
// The submitter, not their address. Handing Notify a string dropped
|
||||||
|
// to_user_id and to_name (so the Log printed the message type where the
|
||||||
|
// name goes) and skipped the preference check entirely, so the opt-out for
|
||||||
|
// this category did nothing. Notify resolves the address itself.
|
||||||
|
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
||||||
|
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
|
||||||
|
$submitter,
|
||||||
|
"[{$ticket->number}] {$ticket->title}",
|
||||||
|
'<p>'.e($author).' replied to your ticket:</p>'
|
||||||
|
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
|
||||||
|
.'<p><a href="'.e($url).'">View the ticket</a></p>',
|
||||||
|
$ticket,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,13 @@
|
|||||||
namespace Dashboard\Ticketing\Models;
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class PriorityLevel extends Model
|
class PriorityLevel extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'ticketing_priority_levels';
|
protected $table = 'ticketing_priority_levels';
|
||||||
|
|
||||||
protected $fillable = ['group_id', 'name', 'color', 'description', 'sort_order'];
|
protected $fillable = ['name', 'color', 'description', 'sort_order'];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TicketingGroup::class, 'group_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tickets(): HasMany
|
public function tickets(): HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,19 +2,77 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing\Models;
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
|
/** Set only by TicketController::merge(); never offered by any form. */
|
||||||
|
public const STATUS_MERGED = 'merged';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE AN UN-MERGED TICKET LANDS — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* A merge is a way of closing a ticket, so a ticket that is un-merged because
|
||||||
|
* its target was deleted goes to `closed` and not to `open`: it stays out of
|
||||||
|
* the All Open / Mine / Unassigned work queues, exactly as it was before, and
|
||||||
|
* the only thing that changes is that it is no longer read-only, so an agent
|
||||||
|
* can reopen it, reply to it or reassign it. Un-merging to `open` would push
|
||||||
|
* somebody's tidied-away ticket back into a live queue without being asked.
|
||||||
|
*
|
||||||
|
* It is one of the six values the `tickets.status` ENUM allows and it is
|
||||||
|
* `is_closed` in `ticketing_statuses`, so both the column and every count
|
||||||
|
* that reads `$closedSlugs` already understand it.
|
||||||
|
*/
|
||||||
|
public const STATUS_UNMERGED = 'closed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every surface says when a write to a merged ticket is refused. The Show
|
||||||
|
* page has displayed this sentence since merge shipped; #920 was that it was
|
||||||
|
* only ever a sentence, with no server-side guard behind it.
|
||||||
|
*/
|
||||||
|
public const READ_ONLY_REASON = 'This ticket has been merged and is read-only.';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
||||||
'title', 'description', 'status', 'priority_id', 'due_date',
|
'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// `date:Y-m-d`, not bare `date`. The column is a DATE and every UI that shows
|
||||||
|
// it is an <input type="date">, which accepts exactly `Y-m-d` and silently
|
||||||
|
// renders anything else as an EMPTY field. A bare `date` cast serializes to
|
||||||
|
// `2026-09-15T00:00:00.000000Z`, so a due date that had been stored perfectly
|
||||||
|
// well came back as a blank input on the edit form, the show page and the
|
||||||
|
// index pane — indistinguishable from "it was never saved", which is how this
|
||||||
|
// was reported (a QA sweep read `input.value === ''` as silent data loss).
|
||||||
|
//
|
||||||
|
// The write path was never at fault: the column, the `$fillable` entry and
|
||||||
|
// both `validate()` rules were correct throughout, and re-submitting the ISO
|
||||||
|
// string (which Show.vue's meta form does, because it seeds itself from this
|
||||||
|
// value) still stores the right day — measured in UTC and in
|
||||||
|
// America/New_York. So nothing was ever lost in the database; the value
|
||||||
|
// simply had no way of reaching the screen.
|
||||||
|
/**
|
||||||
|
* `merged_inconsistent` rides on every serialized ticket — #938.
|
||||||
|
*
|
||||||
|
* The badge and its "Mark merged" button are rendered per ROW, on the index
|
||||||
|
* list, in the detail pane and on the Show header, so the page needs the
|
||||||
|
* answer for each ticket it draws. Handing it the model's own predicate
|
||||||
|
* rather than letting three Vue files each write
|
||||||
|
* `t.merged_into_id && t.status !== 'merged'` is what keeps the client's idea
|
||||||
|
* of "inconsistent" from drifting away from the scope the repair uses.
|
||||||
|
* It costs no query — both columns are already loaded.
|
||||||
|
*
|
||||||
|
* #1567 added `merged_stranded` beside it, for the same reason: there are now
|
||||||
|
* TWO ways a merge can fail to add up and they need different offers, so the
|
||||||
|
* page is handed which one it is rather than working it out from the columns.
|
||||||
|
*/
|
||||||
|
protected $appends = ['merged_inconsistent', 'merged_stranded'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'due_date' => 'date',
|
'due_date' => 'date:Y-m-d',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
public function group(): BelongsTo
|
||||||
@@ -41,4 +99,153 @@ class Ticket extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(TicketAttachment::class, 'ticket_id');
|
return $this->hasMany(TicketAttachment::class, 'ticket_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function views(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketView::class, 'ticket_id')->orderBy('viewed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function participants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketParticipant::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mergedInto(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'merged_into_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A merged ticket is read-only — its messages and attachments now live on the
|
||||||
|
* ticket it was merged into, so it is a stub kept for its reference number and
|
||||||
|
* the audit trail, not a ticket anyone can still work.
|
||||||
|
*
|
||||||
|
* `status === 'merged'` and NOT `merged_into_id !== null`, deliberately. #920
|
||||||
|
* minted rows with `status = 'open', merged_into_id = 56` — the very rows this
|
||||||
|
* guard exists to prevent — and reading the id would make each of those
|
||||||
|
* permanently uneditable, i.e. a row the product will not let anyone correct.
|
||||||
|
* The status is the state; the id is the pointer that goes with it.
|
||||||
|
*/
|
||||||
|
public function isMerged(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_MERGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SHAPE THE MERGE BUG MINTED — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* `merged_into_id` points at a target while `status` is something other than
|
||||||
|
* `merged`: the row was merged, and then a save through the unguarded Edit
|
||||||
|
* form (#920) put the status back. #920 stopped new ones being made and
|
||||||
|
* deliberately left the existing ones EDITABLE — `isMerged()` reads the
|
||||||
|
* status, not the pointer — so that the office can correct one rather than
|
||||||
|
* needing SQL. This is the predicate that finds them.
|
||||||
|
*
|
||||||
|
* It is one definition and three callers read it: the badge that offers the
|
||||||
|
* fix, the `mark-merged` verb that applies it one row at a time, and the
|
||||||
|
* repair migration that applies it to all of them at once. Two spellings of
|
||||||
|
* "inconsistent" that drifted apart would mean a badge on a row the repair
|
||||||
|
* skips.
|
||||||
|
*/
|
||||||
|
public function scopeInconsistentlyMerged(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('merged_into_id')
|
||||||
|
->where('status', '!=', self::STATUS_MERGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row-level twin of {@see scopeInconsistentlyMerged()}. */
|
||||||
|
public function isInconsistentlyMerged(): bool
|
||||||
|
{
|
||||||
|
return $this->merged_into_id !== null && ! $this->isMerged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE OTHER DIRECTION — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* `status = 'merged'` with NOTHING to point at. `merged_into_id` carries
|
||||||
|
* `nullOnDelete()`, so deleting the ticket that others had been merged INTO
|
||||||
|
* used to blank their pointer and leave the status behind: a row that
|
||||||
|
* {@see isMerged()} still calls read-only, whose banner names a ticket that
|
||||||
|
* no longer exists, and which the Merged view — scoped by the pointer — could
|
||||||
|
* not list. Nothing in the product could act on it.
|
||||||
|
*
|
||||||
|
* `destroy()` no longer mints these ({@see \Dashboard\Ticketing\Support\StrandedMerge}),
|
||||||
|
* but the rows already made are still out there, so the shape needs a name:
|
||||||
|
* this is what the badge offers to un-merge and what the Merged view now
|
||||||
|
* includes.
|
||||||
|
*
|
||||||
|
* Deliberately NOT folded into {@see scopeInconsistentlyMerged()}: that scope
|
||||||
|
* is what #938's repair writes `status = 'merged'` over, and a stranded row
|
||||||
|
* already HAS that status — repairing it would match forever and write
|
||||||
|
* nothing, which is exactly the idempotency the repair guarantees.
|
||||||
|
*/
|
||||||
|
public function scopeStrandedMerge(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_MERGED)
|
||||||
|
->whereNull('merged_into_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row-level twin of {@see scopeStrandedMerge()}. */
|
||||||
|
public function isStrandedMerge(): bool
|
||||||
|
{
|
||||||
|
return $this->isMerged() && $this->merged_into_id === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EITHER shape of a merge that does not add up — #938's and #1567's.
|
||||||
|
*
|
||||||
|
* The two are mutually exclusive by construction (one has a pointer and the
|
||||||
|
* wrong status, the other has the right status and no pointer), and the
|
||||||
|
* badge, the sidebar's warning count and `merged_inconsistent` all want the
|
||||||
|
* union: "this row's status and its pointer disagree, whichever way round".
|
||||||
|
* The repair scopes stay separate because the fixes differ.
|
||||||
|
*/
|
||||||
|
public function scopeMergeInconsistent(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $q) {
|
||||||
|
$q->where(fn (Builder $i) => $i->inconsistentlyMerged())
|
||||||
|
->orWhere(fn (Builder $s) => $s->strandedMerge());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row that carries the merge shape AT ALL — what the Merged view lists.
|
||||||
|
*
|
||||||
|
* Pointer set (whatever the status: #938's rows included) or status `merged`
|
||||||
|
* (whether or not the pointer survived: #1567's stranded rows included).
|
||||||
|
* Scoping the view by the pointer alone dropped a stranded row out of the one
|
||||||
|
* view whose whole job is showing merges, leaving it visible only under
|
||||||
|
* Resolved — so a reader looking for merged tickets never found it.
|
||||||
|
*/
|
||||||
|
public function scopeEverMerged(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $q) {
|
||||||
|
$q->whereNotNull('merged_into_id')
|
||||||
|
->orWhere('status', self::STATUS_MERGED);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see $appends — the same answer, for the page.
|
||||||
|
*
|
||||||
|
* #1567 widened this to the union: a stranded row is inconsistent in the
|
||||||
|
* plainest sense (it says it is merged into nothing), and the flag existing
|
||||||
|
* while reading `false` on exactly that row was half of what the card
|
||||||
|
* reported.
|
||||||
|
*/
|
||||||
|
public function getMergedInconsistentAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->isInconsistentlyMerged() || $this->isStrandedMerge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see $appends — WHICH of the two shapes, for a page that must offer
|
||||||
|
* different fixes. `merged_inconsistent` says something is wrong;
|
||||||
|
* `merged_stranded` says the target is gone, so the offer is "un-merge"
|
||||||
|
* rather than #938's "mark merged".
|
||||||
|
*/
|
||||||
|
public function getMergedStrandedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->isStrandedMerge();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ namespace Dashboard\Ticketing\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class TicketMessage extends Model
|
class TicketMessage extends Model
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Every value `ticket_messages.source` accepts, written down once.
|
||||||
|
*
|
||||||
|
* The column is an enum and this is the same list, so the two can be compared
|
||||||
|
* rather than remembered. #415 was three `'source' => 'system'` literals against
|
||||||
|
* a column that only knew 'web' and 'email' — the value existed in the code for
|
||||||
|
* as long as merge and split have, and nowhere in the schema.
|
||||||
|
*/
|
||||||
|
public const SOURCES = ['web', 'email', 'system'];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'ticket_id', 'user_id', 'author_email', 'body',
|
'ticket_id', 'user_id', 'author_email', 'body',
|
||||||
'is_internal', 'source', 'email_message_id',
|
'is_internal', 'source', 'email_message_id',
|
||||||
@@ -17,6 +28,33 @@ class TicketMessage extends Model
|
|||||||
'is_internal' => 'boolean',
|
'is_internal' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ONLY WAY A SYSTEM NOTE IS WRITTEN.
|
||||||
|
*
|
||||||
|
* Merge writes one and split writes two, and before this they were three
|
||||||
|
* separate literals in one controller — which is how all three carried a
|
||||||
|
* `source` the column rejected, and how a fourth would have joined them.
|
||||||
|
*
|
||||||
|
* A system note is always internal: it is the audit trail of an agent action,
|
||||||
|
* addressed to whoever works the ticket next, and never to the submitter.
|
||||||
|
*/
|
||||||
|
public static function system(Ticket|int $ticket, string $body): self
|
||||||
|
{
|
||||||
|
return static::create([
|
||||||
|
'ticket_id' => $ticket instanceof Ticket ? $ticket->id : $ticket,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'body' => $body,
|
||||||
|
'is_internal' => true,
|
||||||
|
'source' => 'system',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note this class wrote, rather than a person or a mailbox. */
|
||||||
|
public function isSystem(): bool
|
||||||
|
{
|
||||||
|
return $this->source === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
public function ticket(): BelongsTo
|
public function ticket(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
|||||||
20
src/Models/TicketParticipant.php
Normal file
20
src/Models/TicketParticipant.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TicketParticipant extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
const CREATED_AT = 'created_at';
|
||||||
|
|
||||||
|
protected $fillable = ['ticket_id', 'user_id', 'added_by'];
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Models/TicketStatus.php
Normal file
23
src/Models/TicketStatus.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class TicketStatus extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'ticketing_statuses';
|
||||||
|
|
||||||
|
protected $fillable = ['slug', 'name', 'color', 'sort_order', 'is_closed', 'is_system'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_closed' => 'boolean',
|
||||||
|
'is_system' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'status', 'slug');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Models/TicketView.php
Normal file
20
src/Models/TicketView.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TicketView extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $table = 'ticket_views';
|
||||||
|
protected $fillable = ['ticket_id', 'user_id', 'viewed_at'];
|
||||||
|
protected $casts = ['viewed_at' => 'datetime'];
|
||||||
|
|
||||||
|
public function ticket(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Ticket::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,13 @@ class TicketingAgentAccess extends Model
|
|||||||
{
|
{
|
||||||
protected $table = 'ticketing_agent_access';
|
protected $table = 'ticketing_agent_access';
|
||||||
|
|
||||||
protected $fillable = ['user_id', 'group_id', 'role'];
|
/**
|
||||||
|
* `recovered_by` is #5002: the site admin who wrote this row as an orphan
|
||||||
|
* recovery (#4806), and may therefore take it back. Null on every row
|
||||||
|
* written by an ordinary manager — see the migration for why this is not
|
||||||
|
* a general-purpose `created_by`.
|
||||||
|
*/
|
||||||
|
protected $fillable = ['user_id', 'group_id', 'role', 'recovered_by'];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,45 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing\Models;
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class TicketingGroup extends Model
|
class TicketingGroup extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
|
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'archived_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = ['is_archived'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archived groups are deliberately NOT hidden by a global scope.
|
||||||
|
*
|
||||||
|
* The tickets in an archived group are still live correspondence: their
|
||||||
|
* agents must keep working them, `$ticket->group` must still resolve, and
|
||||||
|
* Settings must still list the group so a manager can restore it. Only the
|
||||||
|
* *pickers* — "which group should this new ticket go to", "filter by
|
||||||
|
* group" — exclude archived ones, via this scope, one call site at a time.
|
||||||
|
*/
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNull('archived_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeArchived(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('archived_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsArchivedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->archived_at !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public function tickets(): HasMany
|
public function tickets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'group_id');
|
return $this->hasMany(Ticket::class, 'group_id');
|
||||||
@@ -34,15 +66,59 @@ class TicketingGroup extends Model
|
|||||||
return $this->hasMany(EmailConnection::class, 'group_id');
|
return $this->hasMany(EmailConnection::class, 'group_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue the next ticket reference for this group.
|
||||||
|
*
|
||||||
|
* This used to be MAX(suffix) over the group's live tickets. Tickets are
|
||||||
|
* hard-deleted, so that made the reference a *reusable* handle: delete the
|
||||||
|
* newest ticket and its number went back in the pool, and the next ticket was
|
||||||
|
* issued the same one. Two tickets carrying `QAX-0004` is not a cosmetic
|
||||||
|
* problem — the reference is the `[QAX-0004]` token in outbound mail subjects
|
||||||
|
* (so a reply threads onto the wrong ticket), the key the shell Notification
|
||||||
|
* Log is audited by, and what `merge()` resolves a ticket by.
|
||||||
|
*
|
||||||
|
* The number now comes from `last_ticket_number`, a per-group high-water mark
|
||||||
|
* that is only ever raised. Deleting a ticket cannot lower it, because it does
|
||||||
|
* not consult the tickets table for the answer.
|
||||||
|
*
|
||||||
|
* Two details that are not decoration:
|
||||||
|
*
|
||||||
|
* - `lockForUpdate` inside a transaction. Read-then-write is a race: two people
|
||||||
|
* submitting at the same instant would otherwise both read N and both be
|
||||||
|
* issued N+1, reintroducing exactly the duplicate this method exists to
|
||||||
|
* prevent. The row lock serialises them.
|
||||||
|
* - The counter is raised to clear any live ticket that is already numbered
|
||||||
|
* past it before being incremented. Not every row gets its number from here —
|
||||||
|
* `TicketingDemoSeeder` writes them directly, and imports and manual repairs
|
||||||
|
* can too — and a counter that has fallen behind such a row would issue a
|
||||||
|
* number that is already in use. Taking the max of the two can only ever move
|
||||||
|
* the counter forwards, so this preserves the never-goes-backwards property
|
||||||
|
* rather than weakening it.
|
||||||
|
*/
|
||||||
public function nextTicketNumber(): string
|
public function nextTicketNumber(): string
|
||||||
{
|
{
|
||||||
$prefix = $this->prefix . '-';
|
$prefix = $this->prefix . '-';
|
||||||
$last = $this->tickets()
|
|
||||||
->where('number', 'like', $prefix . '%')
|
|
||||||
->orderByRaw('CAST(SUBSTRING(number, ?) AS UNSIGNED) DESC', [strlen($prefix) + 1])
|
|
||||||
->value('number');
|
|
||||||
|
|
||||||
$next = $last ? ((int) substr($last, strlen($prefix))) + 1 : 1;
|
$next = DB::transaction(function () use ($prefix) {
|
||||||
|
$counter = (int) static::query()
|
||||||
|
->whereKey($this->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->value('last_ticket_number');
|
||||||
|
|
||||||
|
$liveMax = (int) $this->tickets()
|
||||||
|
->where('number', 'like', $prefix . '%')
|
||||||
|
->max(DB::raw('CAST(SUBSTRING(number, ' . (strlen($prefix) + 1) . ') AS UNSIGNED)'));
|
||||||
|
|
||||||
|
$next = max($counter, $liveMax) + 1;
|
||||||
|
|
||||||
|
// Query-builder update, so `last_ticket_number` need not be fillable —
|
||||||
|
// it is a sequence, never something a form should be able to set.
|
||||||
|
static::query()->whereKey($this->getKey())->update(['last_ticket_number' => $next]);
|
||||||
|
$this->setAttribute('last_ticket_number', $next);
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
});
|
||||||
|
|
||||||
return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT);
|
return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/Support/AutomationSettingsAudit.php
Normal file
142
src/Support/AutomationSettingsAudit.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE AUTOMATION TAB, ON THE SHELL'S CHANGES LOG — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* Help Desk Settings → Automation decides two behaviour rules that run with
|
||||||
|
* nobody watching: how many days after a ticket is resolved the nightly sweep
|
||||||
|
* closes it ({@see \Dashboard\Ticketing\Console\Commands\AutoCloseTickets}),
|
||||||
|
* and whether a DEPLOY may rewrite inconsistently-merged rows
|
||||||
|
* ({@see MergedTicketRepair::SETTING}). Both were saved with no trace at all:
|
||||||
|
* `ticketing.settings.automation.update` is not in the census of every
|
||||||
|
* `app=ticketing` row ever written.
|
||||||
|
*
|
||||||
|
* ── WHY THIS IS NOT `registerAuditedModels()` ───────────────────────────────
|
||||||
|
*
|
||||||
|
* The other four tabs this card is about are fixed by declaring models to
|
||||||
|
* `AuditRegistry`. That cannot reach here: this tab writes `App\Models\Setting`
|
||||||
|
* rows, which are SHELL rows and not a ticketing model — declaring a ticketing
|
||||||
|
* model would not pick the path up, and declaring `Setting` itself would put
|
||||||
|
* every platform-wide toggle in Central on the `ticketing` filter and write a
|
||||||
|
* row per KEY. So this half is an explicit domain event, the shape
|
||||||
|
* central-admissions' `SettingsAudit` (#5117) and central-absent-sms' use for
|
||||||
|
* the same kind of panel.
|
||||||
|
*
|
||||||
|
* Three things it exists to get right, and they are that class's three:
|
||||||
|
*
|
||||||
|
* 1. ONE ROW PER SAVE, NOT PER KEY.
|
||||||
|
* 2. ONLY WHAT ACTUALLY MOVED. The tab re-posts both boxes on every save, so
|
||||||
|
* "somebody pressed Save" is not news.
|
||||||
|
* 3. THE ROW LANDS UNDER `ticketing`. `Audit::appFor()` answers `shell` for
|
||||||
|
* every non-model subject, and this subject is a string — so without the
|
||||||
|
* explicit override the row would exist and still be invisible to the
|
||||||
|
* `App = ticketing` filter, which is the same failure in a smaller package.
|
||||||
|
*
|
||||||
|
* Guarded on `class_exists` throughout: central-logs is optional on any given
|
||||||
|
* host.
|
||||||
|
*/
|
||||||
|
final class AutomationSettingsAudit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The ONE action an automation save is filed under.
|
||||||
|
*
|
||||||
|
* Not the bare `updated` the model-backed tabs use: those rows carry a
|
||||||
|
* subject an operator can click through to, and this one cannot — the
|
||||||
|
* subject is a screen, not a record — so the verb is where the row has to
|
||||||
|
* say what it is about.
|
||||||
|
*/
|
||||||
|
public const ACTION = 'ticketing.automation.changed';
|
||||||
|
|
||||||
|
/** The app the row is filed under — see rule 3 in the class docblock. */
|
||||||
|
public const APP = 'ticketing';
|
||||||
|
|
||||||
|
/** The frozen subject label: what an operator would call this screen. */
|
||||||
|
public const SUBJECT = 'Help desk automation settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings this tab writes, and what a box reads as when no row exists.
|
||||||
|
*
|
||||||
|
* ABSENT IS NOT BLANK: a school that has never opened this tab is living
|
||||||
|
* under these defaults, and they are what `index()` renders the boxes from
|
||||||
|
* (`Setting::get('ticketing.auto_close_days', 0)` and
|
||||||
|
* `MergedTicketRepair::enabledOnDeploy()`, which defaults `'0'`). Diffing
|
||||||
|
* against `null` instead would call the very first Save a change from
|
||||||
|
* nothing to 0 — the trail's first word about the nightly sweep being a
|
||||||
|
* change nobody made.
|
||||||
|
*
|
||||||
|
* @var array<string,string>
|
||||||
|
*/
|
||||||
|
private const DEFAULTS = [
|
||||||
|
'ticketing.auto_close_days' => '0',
|
||||||
|
MergedTicketRepair::SETTING => '0',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings as they stand right now. Call BEFORE the writes and hand the
|
||||||
|
* result to {@see record()}.
|
||||||
|
*
|
||||||
|
* ONE query and not one `Setting::get()` apiece, and every key is present in
|
||||||
|
* the answer, so `record()` can read the key list off the snapshot and the
|
||||||
|
* two halves cannot drift apart.
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
public static function snapshot(): array
|
||||||
|
{
|
||||||
|
$stored = Setting::query()
|
||||||
|
->whereIn('key', array_keys(self::DEFAULTS))
|
||||||
|
->pluck('value', 'key')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (self::DEFAULTS as $key => $default) {
|
||||||
|
$out[$key] = (string) ($stored[$key] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff the snapshot against what is stored now and record one row for what
|
||||||
|
* moved. Call AFTER the writes, with the array `snapshot()` handed back.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $before
|
||||||
|
*/
|
||||||
|
public static function record(array $before): void
|
||||||
|
{
|
||||||
|
if ($before === [] || ! class_exists(\Central\Logs\Support\Audit::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = static::snapshot();
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
foreach ($before as $key => $was) {
|
||||||
|
$now = $after[$key] ?? null;
|
||||||
|
|
||||||
|
// Settings are a TEXT column, so everything stored is a string and
|
||||||
|
// the only pair worth special-casing is "no row" against "a row
|
||||||
|
// holding nothing" — both read as the shipped default.
|
||||||
|
if ((string) ($was ?? '') === (string) ($now ?? '')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $was, 'to' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return; // rule 2
|
||||||
|
}
|
||||||
|
|
||||||
|
\Central\Logs\Support\Audit::record(
|
||||||
|
self::ACTION,
|
||||||
|
self::SUBJECT,
|
||||||
|
$changes,
|
||||||
|
['app' => self::APP],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/Support/EmailConnectionAudit.php
Normal file
225
src/Support/EmailConnectionAudit.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Models\EmailConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ONE WRITER OF THE EMAIL TAB'S AUDIT TRAIL — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* An email connection is a mailbox the help desk collects FROM: mail that
|
||||||
|
* arrives in it becomes a ticket on the group named beside it. So the row
|
||||||
|
* decides which mailbox the school's help-desk mail is pulled out of, with what
|
||||||
|
* credentials, and whose queue it lands in — and until this class, creating
|
||||||
|
* one, re-pointing one at a different mailbox or deleting one wrote NOTHING to
|
||||||
|
* Admin → Logs → Changes. The delete confirm even says the credentials go
|
||||||
|
* ("Tickets already created from it are kept; the saved credentials are not");
|
||||||
|
* nothing recorded that they had been there.
|
||||||
|
*
|
||||||
|
* ── WHY THIS IS NOT `registerAuditedModels()` ───────────────────────────────
|
||||||
|
*
|
||||||
|
* Its three siblings on the same Settings page — `PriorityLevel`,
|
||||||
|
* `TicketStatus`, `TicketingProject` — ARE declared to `AuditRegistry`, because
|
||||||
|
* every column worth recording on them is a column. This one is different in
|
||||||
|
* exactly one way, and it is the way that matters: the mailbox host, port,
|
||||||
|
* username and PASSWORD all live inside a single `config` JSON column
|
||||||
|
* (`EmailConnection::$casts = ['config' => 'array']`).
|
||||||
|
*
|
||||||
|
* - Declaring `config` copies the PASSWORD into `audit_events.changes`.
|
||||||
|
* `Audit::redact()` masks by TOP-LEVEL FIELD NAME, and `normalize()` walks
|
||||||
|
* into an array without redacting inside it, so a declared `config` is a
|
||||||
|
* credential written in clear to a table read under `logs.view` alone.
|
||||||
|
* - Declaring only `group_id` / `type` / `active` — the scalar columns — is
|
||||||
|
* silent on the write the card is actually about: the observer records
|
||||||
|
* nothing when no DECLARED field moved, so re-pointing a connection at a
|
||||||
|
* different host, or changing its password, would still leave no trace.
|
||||||
|
*
|
||||||
|
* So the config is FLATTENED here into `config.host`, `config.username`,
|
||||||
|
* `config.password`, … — which is the spelling `Audit::isSecretField()` masks,
|
||||||
|
* because it matches the substring `password`. {@see write()} masks it at the
|
||||||
|
* source as well, so the real credential is never handed to the logger at all
|
||||||
|
* and the central redaction is the second of two layers rather than the only
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* ── THE RULES IT KEEPS, WHICH ARE THE OBSERVER'S OWN ────────────────────────
|
||||||
|
*
|
||||||
|
* 1. ONE ROW PER SAVE. A save that moves the type AND the host is one edit and
|
||||||
|
* reads as one row, the way an observer-written row would.
|
||||||
|
* 2. NOTHING MOVED, NOTHING RECORDED. The Email tab re-posts every box it
|
||||||
|
* renders, so "somebody pressed Save" is not news. A blank password box
|
||||||
|
* means "keep the one you have" ({@see
|
||||||
|
* \Dashboard\Ticketing\Http\Controllers\TicketingSettingsController::mergedConnectionConfig()}),
|
||||||
|
* and keeping it is not a change.
|
||||||
|
* 3. THE VERBS ARE `created` / `updated` / `deleted`. Not a private
|
||||||
|
* `ticketing.email.*` spelling: the other six tabs on this page are
|
||||||
|
* observer-written under exactly these three, the Changes page's Action
|
||||||
|
* filter is a SELECT DISTINCT over them, and an operator asking "what was
|
||||||
|
* deleted in Help Desk today" should not have to know that one tab writes
|
||||||
|
* its rows by hand.
|
||||||
|
*
|
||||||
|
* The `app` is left to `Audit::appFor()`, which answers `ticketing` from the
|
||||||
|
* `Dashboard\Ticketing\…` namespace of the subject. The subject is the MODEL
|
||||||
|
* and not a string for the same reason: a string subject files under `shell`
|
||||||
|
* and leaves `EmailConnection` out of the Changes log's Subject-type facet,
|
||||||
|
* which is the census the card read the gap off.
|
||||||
|
*
|
||||||
|
* central-logs is optional on any given host — guarded once, in {@see write()},
|
||||||
|
* and not at the three call sites.
|
||||||
|
*/
|
||||||
|
final class EmailConnectionAudit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The config keys the Email tab writes, in the order the form asks for them.
|
||||||
|
*
|
||||||
|
* A fixed list and not `array_keys($config)`: the audit row should say the
|
||||||
|
* same thing about every connection, and an unknown key arriving in the blob
|
||||||
|
* is not something to copy into the log sight-unseen.
|
||||||
|
*/
|
||||||
|
private const CONFIG_KEYS = ['host', 'port', 'encryption', 'username', 'password', 'mailbox'];
|
||||||
|
|
||||||
|
public static function created(EmailConnection $connection): void
|
||||||
|
{
|
||||||
|
static::write('created', $connection, static::sided(static::state($connection), 'to'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $before {@see state()}, taken BEFORE the save
|
||||||
|
*/
|
||||||
|
public static function updated(EmailConnection $connection, array $before): void
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
foreach (static::state($connection) as $key => $now) {
|
||||||
|
$was = $before[$key] ?? null;
|
||||||
|
|
||||||
|
if (static::unchanged($was, $now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $was, 'to' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return; // rule 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static::write('updated', $connection, $changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleted(EmailConnection $connection): void
|
||||||
|
{
|
||||||
|
// What the row HELD when it was deleted is usually the whole question
|
||||||
|
// being asked — the same side the observer snapshots on a delete.
|
||||||
|
static::write('deleted', $connection, static::sided(static::state($connection), 'from'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection as the log describes it: the scalar columns, plus `config`
|
||||||
|
* flattened one level into dotted keys.
|
||||||
|
*
|
||||||
|
* Public because `updateEmailConnection()` has to take the BEFORE side
|
||||||
|
* itself, before the save — the two halves of an update's diff must be read
|
||||||
|
* the same way or they cannot be compared.
|
||||||
|
*
|
||||||
|
* `last_polled_at` is deliberately absent: it is written by the collector,
|
||||||
|
* not by a person, and a timestamp moving on its own is not a change
|
||||||
|
* anybody made.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function state(EmailConnection $connection): array
|
||||||
|
{
|
||||||
|
$config = is_array($connection->config) ? $connection->config : [];
|
||||||
|
|
||||||
|
$state = [
|
||||||
|
'group_id' => $connection->group_id === null ? null : (int) $connection->group_id,
|
||||||
|
'type' => $connection->type,
|
||||||
|
'active' => (bool) $connection->active,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (self::CONFIG_KEYS as $key) {
|
||||||
|
$value = $config[$key] ?? null;
|
||||||
|
|
||||||
|
$state['config.'.$key] = ($value === '' ? null : $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole state as one-sided {from,to} pairs, for a create (`to`) or a
|
||||||
|
* delete (`from`). Unset fields are dropped: an empty box is noise, not
|
||||||
|
* information — `AuditObserver::snapshot()` drops them for the same reason.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $state
|
||||||
|
* @return array<string, array{from:mixed,to:mixed}>
|
||||||
|
*/
|
||||||
|
private static function sided(array $state, string $side): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($state as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$key] = $side === 'from'
|
||||||
|
? ['from' => $value, 'to' => null]
|
||||||
|
: ['from' => null, 'to' => $value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Did this field move?
|
||||||
|
*
|
||||||
|
* Compared as strings because the two sides come from different places — the
|
||||||
|
* stored JSON on one and a freshly-posted form on the other — so a port that
|
||||||
|
* was saved as `993` and re-posted as `"993"` is the same port, and recording
|
||||||
|
* it as a change would make every Save look like an edit.
|
||||||
|
*/
|
||||||
|
private static function unchanged(mixed $was, mixed $now): bool
|
||||||
|
{
|
||||||
|
return static::scalar($was) === static::scalar($now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function scalar(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? json_encode($value) : (string) ($value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one row, with every secret masked before it leaves this class.
|
||||||
|
*
|
||||||
|
* The mask is `Audit::isSecretField()`'s own answer, applied to the same
|
||||||
|
* flattened key the row will carry, so this and `Audit::redact()` cannot
|
||||||
|
* disagree about what a secret is. NULL is preserved on either side: "was
|
||||||
|
* unset, now set" is information that gives nothing away, and it is the one
|
||||||
|
* thing the trail can honestly say about a credential.
|
||||||
|
*
|
||||||
|
* @param array<string, array{from:mixed,to:mixed}> $changes
|
||||||
|
*/
|
||||||
|
private static function write(string $action, EmailConnection $connection, array $changes): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Central\Logs\Support\Audit::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($changes as $key => $pair) {
|
||||||
|
if (! \Central\Logs\Support\Audit::isSecretField((string) $key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mask = fn ($v) => ($v === null || $v === '') ? null : \Central\Logs\Support\Audit::REDACTED;
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $mask($pair['from'] ?? null), 'to' => $mask($pair['to'] ?? null)];
|
||||||
|
}
|
||||||
|
|
||||||
|
\Central\Logs\Support\Audit::record($action, $connection, $changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/Support/EmailConnectionAuditLabels.php
Normal file
105
src/Support/EmailConnectionAuditLabels.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Central\Logs\Support\AuditRegistry;
|
||||||
|
use Closure;
|
||||||
|
use Dashboard\Ticketing\Models\EmailConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHAT THE CHANGES LOG CALLS AN EMAIL CONNECTION ROW — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* `Central\Logs\Support\Audit::subjectLabel()` asks
|
||||||
|
* `AuditRegistry::labelClosureFor()` for a label and falls back to
|
||||||
|
* `class_basename().' #'.$key` when there is none. For an email connection that
|
||||||
|
* fallback is `EmailConnection #2`, and the row an auditor most needs to read is
|
||||||
|
* the DELETE — at which point that id names nothing at all. The label is frozen
|
||||||
|
* into the `subject_label` COLUMN at write time, so it is also the only place
|
||||||
|
* the mailbox's name survives the row it described.
|
||||||
|
*
|
||||||
|
* ── WHY THIS DECLARES WITHOUT `AuditRegistry::audit()` ──────────────────────
|
||||||
|
*
|
||||||
|
* `audit()` ATTACHES `AuditObserver` as it declares, and the observer diffs
|
||||||
|
* DECLARED FIELDS by reading them off the model. `EmailConnection`'s mailbox
|
||||||
|
* host, port, username and PASSWORD all live inside one `config` JSON column
|
||||||
|
* (`$casts = ['config' => 'array']`), and `Audit::redact()` masks by TOP-LEVEL
|
||||||
|
* FIELD NAME only — `normalize()` walks into an array but never redacts inside
|
||||||
|
* one. So declaring `config` would copy the mailbox password verbatim into
|
||||||
|
* `audit_events.changes`, and declaring only the scalar columns would say
|
||||||
|
* nothing at all when somebody re-points the connection at a different mailbox,
|
||||||
|
* which is the write this card was filed about.
|
||||||
|
*
|
||||||
|
* {@see EmailConnectionAudit} is therefore the one writer of this trail — it
|
||||||
|
* flattens `config` into `config.host` / `config.password` / … keys, which is
|
||||||
|
* the spelling `Audit::isSecretField()` masks. That leaves only the label to
|
||||||
|
* declare, and it must be declared WITHOUT an observer or every row
|
||||||
|
* `EmailConnectionAudit` writes would be written a second time.
|
||||||
|
*
|
||||||
|
* Same shape and same reason as central-pulse's `PulseAuditLabels` (#5980), the
|
||||||
|
* shell's `App\Support\Groups\GroupAuditLabels` (#6046) and
|
||||||
|
* central-verification's `VerificationAuditLabels` (#5276): `AuditRegistry`
|
||||||
|
* holds its declarations in a `protected static` array and attaches the
|
||||||
|
* observer in the same call, so the only way to put a label closure in front of
|
||||||
|
* `Audit::subjectLabel()` without attaching anything is to subclass the registry
|
||||||
|
* and write the declaration directly — a subclass shares the parent's static
|
||||||
|
* storage, so this IS `AuditRegistry`'s own array and `labelClosureFor()` reads
|
||||||
|
* it back.
|
||||||
|
*/
|
||||||
|
final class EmailConnectionAuditLabels extends AuditRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Declare `EmailConnection`'s label to the Changes log.
|
||||||
|
*
|
||||||
|
* Called from `TicketingServiceProvider::registerAuditedModels()`, behind
|
||||||
|
* the `class_exists()` that method already keeps on the registry —
|
||||||
|
* central-logs is optional on any given host.
|
||||||
|
*/
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
self::declareLabel(EmailConnection::class, fn (EmailConnection $connection) => static::label($connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mailbox, then the host, then the group — the SAME order the Email tab
|
||||||
|
* itself names a row in (`emailConnectionLabel()` in `Settings.vue`), so the
|
||||||
|
* log calls a connection what the page the operator was looking at called it.
|
||||||
|
*
|
||||||
|
* `config['mailbox']` is deliberately NOT in that list: on this form it is
|
||||||
|
* the IMAP FOLDER and defaults to `INBOX`, so falling back to it would label
|
||||||
|
* every half-filled connection "INBOX".
|
||||||
|
*
|
||||||
|
* The group comes second because it is the other half of what the row
|
||||||
|
* decides — which desk's queue this mailbox's mail lands in.
|
||||||
|
*/
|
||||||
|
private static function label(EmailConnection $connection): string
|
||||||
|
{
|
||||||
|
$config = is_array($connection->config) ? $connection->config : [];
|
||||||
|
|
||||||
|
$mailbox = (string) ($config['username'] ?? $config['host'] ?? '');
|
||||||
|
|
||||||
|
return ($mailbox !== '' ? $mailbox : 'Mailbox')
|
||||||
|
.' → '.($connection->group?->name ?: 'a help desk group');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One label closure, and no observer.
|
||||||
|
*
|
||||||
|
* `fields: []` and `sensitive: false` are stated rather than left out so the
|
||||||
|
* shape of the declaration is the shape `AuditRegistry::audit()` writes —
|
||||||
|
* `fieldsFor()`, `isSensitive()` and `all()` all read this array.
|
||||||
|
*/
|
||||||
|
private static function declareLabel(string $modelClass, Closure $label): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
static::$models[$modelClass] = [
|
||||||
|
'fields' => [],
|
||||||
|
'label' => $label,
|
||||||
|
'sensitive' => false,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// The registry changed shape under us. Reported so it is visible,
|
||||||
|
// never rethrown: the cost is the id-label we have always had.
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/Support/MergedTicketRepair.php
Normal file
138
src/Support/MergedTicketRepair.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REPAIRING THE ROWS THE MERGE BUG MINTED — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* #920 stopped the bug making new ones: a ticket whose `status` is `merged` is
|
||||||
|
* read-only server-side, so the Edit form can no longer write `status = open`
|
||||||
|
* over a row that has already been folded into another ticket. It shipped NO
|
||||||
|
* data change, on purpose, and its guard reads the status rather than the
|
||||||
|
* pointer precisely so the rows already carrying the corrupt shape
|
||||||
|
* (`status = open, merged_into_id = 56`) stay editable and can be corrected
|
||||||
|
* through the UI instead of by hand in SQL.
|
||||||
|
*
|
||||||
|
* This class is the correction, and it is deliberately ONE implementation with
|
||||||
|
* three callers, because "which rows are broken" and "what fixing one means"
|
||||||
|
* are the two things that must not drift:
|
||||||
|
*
|
||||||
|
* - {@see \Dashboard\Ticketing\Http\Controllers\TicketController::markMerged()}
|
||||||
|
* — the office fixes one row, from the badge on the ticket header.
|
||||||
|
* - {@see \Dashboard\Ticketing\Console\Commands\RepairMergedTickets}
|
||||||
|
* — `--dry-run` is the COUNT, and it is the whole reason the class is not
|
||||||
|
* just a line inside the migration: the operator has to be able to see how
|
||||||
|
* many rows there are, and which, before anything writes.
|
||||||
|
* - the `2026_09_04_000001_repair_inconsistent_merged_tickets` migration
|
||||||
|
* — every row at once, on deploy, and ONLY when the operator has turned
|
||||||
|
* {@see SETTING} on.
|
||||||
|
*
|
||||||
|
* ── WHAT REPAIRING DOES *NOT* DO ────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* It writes `status = 'merged'` and nothing else. Messages, participants,
|
||||||
|
* assignee, priority and title are left exactly as they are, including edits
|
||||||
|
* made to the row AFTER the merge — those are somebody's work, they are the
|
||||||
|
* only copy, and there is no record of what the fields held before the bad
|
||||||
|
* save to revert them to. The ticket becoming read-only is the whole change.
|
||||||
|
*/
|
||||||
|
class MergedTicketRepair
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* "Auto-repair inconsistent merged tickets on deploy", and it DEFAULTS OFF.
|
||||||
|
*
|
||||||
|
* A deploy that silently rewrites rows nobody asked it to rewrite is the one
|
||||||
|
* outcome #938 rules out by name, so the migration reads this and no-ops
|
||||||
|
* when it is unset. Off is therefore the whole behaviour of a box that has
|
||||||
|
* never heard of this setting, which is what makes shipping the migration
|
||||||
|
* safe before the operator has decided anything.
|
||||||
|
*/
|
||||||
|
public const SETTING = 'ticketing.auto_repair_merged_on_deploy';
|
||||||
|
|
||||||
|
/** How many rows are inconsistent right now. The dry run, and it writes nothing. */
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return Ticket::query()->inconsistentlyMerged()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows themselves, for a human to look at before deciding.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
|
||||||
|
*/
|
||||||
|
public static function rows()
|
||||||
|
{
|
||||||
|
return Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Has the operator asked for the deploy-time repair? */
|
||||||
|
public static function enabledOnDeploy(): bool
|
||||||
|
{
|
||||||
|
return filter_var(Setting::get(self::SETTING, '0'), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mark every inconsistent row as merged. Returns how many rows changed.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT, and it is the scope that makes it so rather than a flag: the
|
||||||
|
* second run matches nothing, because the first run's rows no longer satisfy
|
||||||
|
* `status <> 'merged'`. So a migration that is re-run, a command run twice
|
||||||
|
* and a deploy that repeats all answer 0 and write nothing.
|
||||||
|
*/
|
||||||
|
public static function repairAll(string $by): int
|
||||||
|
{
|
||||||
|
$tickets = Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
|
||||||
|
|
||||||
|
if ($tickets->isEmpty()) {
|
||||||
|
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per model, not one builder update (#4781/#4807): `merged` makes a ticket
|
||||||
|
// permanently read-only, and a bulk statement would freeze it with nothing
|
||||||
|
// on the Changes log — while repair() below, the same change one ticket at
|
||||||
|
// a time, is recorded.
|
||||||
|
$tickets->each(fn (Ticket $ticket) => $ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save());
|
||||||
|
|
||||||
|
$numbers = $tickets->pluck('number')->all();
|
||||||
|
$changed = $tickets->count();
|
||||||
|
|
||||||
|
// The numbers, not just the count. These rows are being made read-only,
|
||||||
|
// and "which ones" is the question the office will ask afterwards.
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 merged-ticket repair (%s): re-marked %d ticket(s) as merged — %s',
|
||||||
|
$by,
|
||||||
|
$changed,
|
||||||
|
implode(', ', $numbers),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mark ONE row. Returns false when it was not inconsistent to begin with,
|
||||||
|
* so a double-clicked button and a stale page are both no-ops rather than a
|
||||||
|
* second write.
|
||||||
|
*/
|
||||||
|
public static function repair(Ticket $ticket, string $by): bool
|
||||||
|
{
|
||||||
|
if (! $ticket->isInconsistentlyMerged()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save();
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 merged-ticket repair (%s): re-marked %s as merged (merged into #%d).',
|
||||||
|
$by,
|
||||||
|
$ticket->number,
|
||||||
|
$ticket->merged_into_id,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/Support/StrandedMerge.php
Normal file
202
src/Support/StrandedMerge.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Dashboard\Ticketing\Models\TicketMessage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETING A MERGE TARGET MUST NOT STRAND WHAT WAS MERGED INTO IT — #1567.
|
||||||
|
*
|
||||||
|
* `tickets.merged_into_id` is declared `nullOnDelete()`, so deleting a ticket
|
||||||
|
* that other tickets had been merged INTO used to blank their pointer and walk
|
||||||
|
* away, leaving `status = 'merged'` behind with nothing on the other end:
|
||||||
|
*
|
||||||
|
* - {@see Ticket::isMerged()} still calls the row read-only, so the page drops
|
||||||
|
* the status / priority / assignee selects and the reply composer, and the
|
||||||
|
* only control left on it is Delete. Nothing can reopen it, reassign it or
|
||||||
|
* reply to it — ever.
|
||||||
|
* - the banner "This ticket has been merged and is read-only." names a merge
|
||||||
|
* target that no longer exists.
|
||||||
|
* - the Merged view was scoped by the pointer, so the row vanished from the one
|
||||||
|
* view whose job is showing merges and turned up only under Resolved.
|
||||||
|
*
|
||||||
|
* And `destroy()` gave no hint any of this was about to happen: the confirm was
|
||||||
|
* the generic "This cannot be undone."
|
||||||
|
*
|
||||||
|
* ── WHAT THIS DOES INSTEAD ──────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* {@see handleTargetDeletion()} runs inside `destroy()`'s transaction, BEFORE the
|
||||||
|
* row goes, and gives every ticket pointing at it somewhere real to be:
|
||||||
|
*
|
||||||
|
* RE-POINT, when the doomed ticket was itself merged into a third ticket. The
|
||||||
|
* messages genuinely did travel that far — merge moves them to the target, so
|
||||||
|
* merging the target onward moved them again — and the surviving ticket at the
|
||||||
|
* end of the chain really is where this ticket's content lives. This is the
|
||||||
|
* answer that keeps the merge true.
|
||||||
|
*
|
||||||
|
* UN-MERGE, otherwise. There is nowhere left to point, so the row stops
|
||||||
|
* claiming to be merged: {@see Ticket::STATUS_UNMERGED} (`closed`) and a NULL
|
||||||
|
* pointer, which is an ordinary closed ticket — out of the work queues, but
|
||||||
|
* editable, reopenable and answerable again.
|
||||||
|
*
|
||||||
|
* Either way the source gets a system note saying which ticket was deleted and
|
||||||
|
* what happened to it, because the row is being changed by somebody who was
|
||||||
|
* looking at a different ticket entirely and its own history is the only place
|
||||||
|
* that will ever explain it.
|
||||||
|
*
|
||||||
|
* ── THE ROWS ALREADY OUT THERE ──────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* This stops new ones. The stranded rows prod already holds are found by
|
||||||
|
* {@see Ticket::scopeStrandedMerge()}, now report `merged_inconsistent`, are
|
||||||
|
* listed by the Merged view, and are released one at a time by
|
||||||
|
* {@see \Dashboard\Ticketing\Http\Controllers\TicketController::unmerge()} —
|
||||||
|
* which calls {@see release()} here, so "un-merged" means one thing whichever
|
||||||
|
* door it came through.
|
||||||
|
*
|
||||||
|
* Deliberately NOT part of {@see MergedTicketRepair}: that class repairs #938's
|
||||||
|
* shape by writing `status = 'merged'` onto a row that has a target. This is the
|
||||||
|
* opposite operation on the opposite shape, and the two must not share a scope.
|
||||||
|
*/
|
||||||
|
class StrandedMerge
|
||||||
|
{
|
||||||
|
/** How many rows are stranded right now — status `merged`, pointer NULL. */
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return Ticket::query()->strandedMerge()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stranded rows themselves.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
|
||||||
|
*/
|
||||||
|
public static function rows()
|
||||||
|
{
|
||||||
|
return Ticket::query()->strandedMerge()->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every ticket merged into `$target` somewhere real to be, before
|
||||||
|
* `$target` is deleted. Call inside the deleting transaction.
|
||||||
|
*
|
||||||
|
* Group membership is NOT re-checked per source. A source can live in another
|
||||||
|
* group, and the alternative to touching it is leaving it permanently frozen
|
||||||
|
* — this is the delete's own clean-up, not a write the operator chose to make
|
||||||
|
* on that ticket.
|
||||||
|
*
|
||||||
|
* @return array{repointed: list<string>, unmerged: list<string>, cleared: list<string>}
|
||||||
|
* ticket numbers, by what happened to them
|
||||||
|
*/
|
||||||
|
public static function handleTargetDeletion(Ticket $target, string $by): array
|
||||||
|
{
|
||||||
|
$sources = Ticket::where('merged_into_id', $target->id)->get();
|
||||||
|
|
||||||
|
$result = ['repointed' => [], 'unmerged' => [], 'cleared' => []];
|
||||||
|
|
||||||
|
if ($sources->isEmpty()) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the content actually went. `merged_into_id` is nulled when ITS
|
||||||
|
// target is deleted, so this is either a live ticket or nothing.
|
||||||
|
$chain = $target->merged_into_id;
|
||||||
|
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
// #938's shape: the pointer is stale but the status is live, so the
|
||||||
|
// row is an ordinary working ticket that happens to carry a dangling
|
||||||
|
// id. Dropping the id is the whole fix — and it is what the FK was
|
||||||
|
// about to do anyway; doing it here means the row is consistent
|
||||||
|
// inside this transaction rather than as a side effect afterwards.
|
||||||
|
if (! $source->isMerged()) {
|
||||||
|
$source->forceFill(['merged_into_id' => null])->save();
|
||||||
|
$result['cleared'][] = $source->number;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `$chain === $source->id` cannot be produced by `merge()` (it
|
||||||
|
// refuses a merged source and a self-merge), but a cycle would make
|
||||||
|
// the row point at itself, which is worse than un-merging it.
|
||||||
|
if ($chain !== null && $chain !== $source->id) {
|
||||||
|
$chainTicket = Ticket::find($chain);
|
||||||
|
|
||||||
|
if ($chainTicket) {
|
||||||
|
$source->forceFill(['merged_into_id' => $chainTicket->id])->save();
|
||||||
|
|
||||||
|
TicketMessage::system($source, sprintf(
|
||||||
|
'Ticket %s was deleted. This ticket had been merged into it, and %s '
|
||||||
|
.'had itself been merged into %s — where its messages now live — so it '
|
||||||
|
.'is recorded as merged into %s.',
|
||||||
|
$target->number,
|
||||||
|
$target->number,
|
||||||
|
$chainTicket->number,
|
||||||
|
$chainTicket->number,
|
||||||
|
));
|
||||||
|
|
||||||
|
$result['repointed'][] = $source->number;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::release(
|
||||||
|
$source,
|
||||||
|
sprintf(
|
||||||
|
'Ticket %s, which this ticket had been merged into, was deleted. This '
|
||||||
|
.'ticket has been un-merged so that it is no longer read-only; its status '
|
||||||
|
.'is now %s.',
|
||||||
|
$target->number,
|
||||||
|
Ticket::STATUS_UNMERGED,
|
||||||
|
),
|
||||||
|
$by,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['unmerged'][] = $source->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #1567 deleting %s: re-pointed %d, un-merged %d, cleared %d dangling pointer(s) (%s).',
|
||||||
|
$target->number,
|
||||||
|
count($result['repointed']),
|
||||||
|
count($result['unmerged']),
|
||||||
|
count($result['cleared']),
|
||||||
|
$by,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-merge ONE ticket: status to {@see Ticket::STATUS_UNMERGED}, pointer to
|
||||||
|
* NULL, and a system note saying why.
|
||||||
|
*
|
||||||
|
* `forceFill`, not `update([...])`, for the same reason the rest of the merge
|
||||||
|
* code uses it: this is the machinery correcting its own state, and it must
|
||||||
|
* not be routed through anything that treats `merged` as read-only.
|
||||||
|
*
|
||||||
|
* Returns false when the row was not merged at all, so a double-clicked
|
||||||
|
* button and a stale page are no-ops rather than a second write.
|
||||||
|
*/
|
||||||
|
public static function release(Ticket $ticket, string $reason, string $by): bool
|
||||||
|
{
|
||||||
|
if (! $ticket->isMerged()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->forceFill([
|
||||||
|
'status' => Ticket::STATUS_UNMERGED,
|
||||||
|
'merged_into_id' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
TicketMessage::system($ticket, $reason);
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #1567 un-merged %s to %s (%s).',
|
||||||
|
$ticket->number,
|
||||||
|
Ticket::STATUS_UNMERGED,
|
||||||
|
$by,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/Support/TicketingNotifier.php
Normal file
161
src/Support/TicketingNotifier.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE place Ticketing reaches into the Notifications platform app (bridge
|
||||||
|
* shape: Dashboard\Tools\Support\ToolsNotifier).
|
||||||
|
*
|
||||||
|
* Issue #8: ticketing had NO notification integration at all — no `ticketing.*`
|
||||||
|
* category existed, so the shared Notification Log listed a category for every
|
||||||
|
* other app and nothing for this one. Note this was not a mail sweep like the
|
||||||
|
* other apps: there was no existing Mail:: call to redirect.
|
||||||
|
* TicketMessageController::notifySubmitter() was an empty stub with the comment
|
||||||
|
* "In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…))", so
|
||||||
|
* nobody has ever been told their ticket was answered.
|
||||||
|
*
|
||||||
|
* TWO CATEGORIES, matching the two moments a person is waiting on somebody else:
|
||||||
|
*
|
||||||
|
* - ticketing.replied → the submitter, when an agent answers. Internal notes are
|
||||||
|
* excluded by the caller: the whole point of an internal note is that the
|
||||||
|
* submitter does not see it.
|
||||||
|
* - ticketing.assigned → the agent a ticket has just been handed to. Skipped when
|
||||||
|
* somebody assigns a ticket to themselves, which needs no telling.
|
||||||
|
*
|
||||||
|
* Neither is mandatory. A reply notice is a convenience, not a security or
|
||||||
|
* financial record, so a person may turn it off in their notification preferences.
|
||||||
|
*
|
||||||
|
* SHIP-DARK SAFETY. available() is false when the platform is absent, and every
|
||||||
|
* caller treats a false return as "nothing was sent" rather than an error — there is
|
||||||
|
* no direct-mail fallback to run, because there was never any direct mail here.
|
||||||
|
* DELIBERATELY NOT a manifest dependency: requires.apps is a HARD requirement (a
|
||||||
|
* merely-disabled dep fails dashboard:doctor, which gates update.sh and promote.sh),
|
||||||
|
* and Notifications is a beta app that ships dark. Add it once Notifications is
|
||||||
|
* stability: stable.
|
||||||
|
*/
|
||||||
|
final class TicketingNotifier
|
||||||
|
{
|
||||||
|
public const REPLIED = 'ticketing.replied';
|
||||||
|
|
||||||
|
public const ASSIGNED = 'ticketing.assigned';
|
||||||
|
|
||||||
|
/** @var array<string, array{0:string,1:string}> key => [label, description] */
|
||||||
|
public const CATEGORIES = [
|
||||||
|
self::REPLIED => [
|
||||||
|
'Your help desk ticket was answered',
|
||||||
|
'Sent to the person who raised a ticket when an agent replies. Internal notes never trigger this.',
|
||||||
|
['staff', 'student'],
|
||||||
|
],
|
||||||
|
self::ASSIGNED => [
|
||||||
|
'A help desk ticket was assigned to you',
|
||||||
|
'Sent to an agent when a ticket is assigned to them by somebody else.',
|
||||||
|
['staff'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Test hook — behave as though Notifications isn't installed. */
|
||||||
|
private static bool $pretendMissing = false;
|
||||||
|
|
||||||
|
public static function available(): bool
|
||||||
|
{
|
||||||
|
return ! self::$pretendMissing
|
||||||
|
&& class_exists(\Central\Notifications\Support\Notify::class)
|
||||||
|
&& class_exists(\Central\Notifications\Support\Message::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pretendPlatformMissing(bool $missing = true): void
|
||||||
|
{
|
||||||
|
self::$pretendMissing = $missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declare the categories. Called from TicketingServiceProvider::boot(). */
|
||||||
|
public static function registerCategories(): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Central\Notifications\Support\NotificationRegistry::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach (self::CATEGORIES as $key => [$label, $description, $audience]) {
|
||||||
|
\Central\Notifications\Support\NotificationRegistry::register(
|
||||||
|
$key, $label, $description, ['mail'], mandatory: false, audience: $audience,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Ticketing: could not register notification categories: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue one email through the platform.
|
||||||
|
*
|
||||||
|
* PASS THE PERSON, NOT THE ADDRESS, whenever we have one.
|
||||||
|
*
|
||||||
|
* This took a bare string, and `Notify::sendAll()` derives everything
|
||||||
|
* user-shaped from the recipient it is handed: `$user = $recipient instanceof
|
||||||
|
* User ? $recipient : null`. With a string there is no `$user`, so three
|
||||||
|
* things silently went missing at once, all from the same line:
|
||||||
|
*
|
||||||
|
* - `to_user_id` was null, so the send never appeared in that person's
|
||||||
|
* notification history;
|
||||||
|
* - `to_name` was null, so the shared Notification Log fell through to its
|
||||||
|
* "we don't know who this went to" rendering and printed the message TYPE
|
||||||
|
* where the recipient's name belongs — for people we plainly do know, since
|
||||||
|
* both our notifications go to a Central user by definition (a ticket's
|
||||||
|
* submitter, or the agent it was just assigned to);
|
||||||
|
* - the preference branch is guarded by `if ($user && ! $mandatory && …)`, so
|
||||||
|
* it was skipped entirely and **the opt-out did nothing**. Neither of our
|
||||||
|
* categories is mandatory, so My Notifications offered a switch that was
|
||||||
|
* never read.
|
||||||
|
*
|
||||||
|
* A bare string is still accepted, for a future send to an address with no
|
||||||
|
* account behind it. It is simply no longer what our own callers hand over.
|
||||||
|
*
|
||||||
|
* Note we no longer resolve the address by hand either: `Notify` resolves a
|
||||||
|
* User school → personal → login, which is the platform's answer to "where do
|
||||||
|
* we email this person" and is what every other app gets.
|
||||||
|
*
|
||||||
|
* @param User|string $to the recipient — a User wherever one is known
|
||||||
|
* @return array{handled:bool,status:?string,error:?string,id:?int}
|
||||||
|
*/
|
||||||
|
public static function deliver(
|
||||||
|
string $category,
|
||||||
|
User|string $to,
|
||||||
|
string $subject,
|
||||||
|
string $bodyHtml,
|
||||||
|
?Model $context = null,
|
||||||
|
): array {
|
||||||
|
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
|
||||||
|
|
||||||
|
if (! self::available() || (is_string($to) && trim($to) === '')) {
|
||||||
|
return $unhandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$row = \Central\Notifications\Support\Notify::send(new \Central\Notifications\Support\Message(
|
||||||
|
category: $category,
|
||||||
|
to: $to,
|
||||||
|
subject: $subject,
|
||||||
|
body: $bodyHtml,
|
||||||
|
context: $context,
|
||||||
|
));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a notification failure break the action that caused it:
|
||||||
|
// sending a reply must succeed even if the email cannot be queued.
|
||||||
|
Log::warning("Ticketing: notification '{$category}' could not be queued: ".$e->getMessage());
|
||||||
|
|
||||||
|
return $unhandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'handled' => true,
|
||||||
|
'status' => $row->status,
|
||||||
|
'error' => $row->status === 'failed' ? ($row->error ?: 'The notification could not be queued.') : null,
|
||||||
|
'id' => $row->id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,310 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing;
|
namespace Dashboard\Ticketing;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class TicketingServiceProvider extends ServiceProvider
|
class TicketingServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void {}
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Merge so config('ticketing.*') resolves even when the host app has not
|
||||||
|
// published a copy — and, crucially, so the value is captured by
|
||||||
|
// config:cache instead of being read from env() at runtime (S3).
|
||||||
|
$this->mergeConfigFrom(__DIR__.'/../config/ticketing.php', 'ticketing');
|
||||||
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||||
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
|
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
|
||||||
|
|
||||||
|
// Declare ticketing.* notification categories (issue #8). Safe to call when
|
||||||
|
// Notifications is absent or dark — the notifier no-ops.
|
||||||
|
\Dashboard\Ticketing\Support\TicketingNotifier::registerCategories();
|
||||||
|
|
||||||
|
// Declare this app's models to the shell's audit trail (#4781), so a
|
||||||
|
// ticket, a group retirement and above all an AGENT GRANT are on the
|
||||||
|
// Changes log. Guarded inside — central-logs is optional on a host.
|
||||||
|
$this->registerAuditedModels();
|
||||||
|
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
||||||
|
\Dashboard\Ticketing\Console\Commands\RepairMergedTickets::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->scheduleAutoClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE NIGHTLY AUTO-CLOSE IS SCHEDULED HERE, BESIDE THE COMMAND IT NAMES
|
||||||
|
* ([central-ticketing] #2980, a spawn of [dashboard-shell] #2965).
|
||||||
|
*
|
||||||
|
* It used to be one line in the shell's routes/console.php — a file that
|
||||||
|
* names commands other repositories define. On 2026-09-26 01:10 PDT that
|
||||||
|
* arrangement cost production a php process a day whose entire output was
|
||||||
|
* *Command "admissions:remind-unconfirmed-links" is not defined*, exit 1: a
|
||||||
|
* schedule had reached a box nine and a half hours ahead of the code it
|
||||||
|
* scheduled, and nothing an operator could press would have fixed it,
|
||||||
|
* because the code was not there to fix.
|
||||||
|
*
|
||||||
|
* Registered from this provider, that is not expressible. The event exists
|
||||||
|
* on exactly the boxes where `AutoCloseTickets` is registered above,
|
||||||
|
* because the same `boot()` does both and both arrive in the same commit.
|
||||||
|
* A shell without central-ticketing simply has no such row in
|
||||||
|
* `schedule:list`, rather than a nightly failure about one.
|
||||||
|
*
|
||||||
|
* ── THE MINUTE IS STILL THE SHELL'S TO DECIDE ──────────────────────────
|
||||||
|
*
|
||||||
|
* `ScheduleStagger` is the one place a start time is chosen, and that is
|
||||||
|
* the whole point of it: a bare `->daily()` is 00:00:00, where every hourly
|
||||||
|
* and every quarter-hourly task on the platform already boots its own PHP
|
||||||
|
* inside the scheduler's one cgroup ([dashboard-shell] #1910). A time typed
|
||||||
|
* in this repository could not know that. Asking `dailyAt()` keeps this job
|
||||||
|
* at 00:12 — exactly where routes/console.php had it — and keeps the shell's
|
||||||
|
* map the single account of the night even for a job it no longer schedules.
|
||||||
|
*
|
||||||
|
* No `withoutOverlapping()`, and deliberately not added here: the shell's
|
||||||
|
* line carried none, and a lock is a behaviour change rather than a move.
|
||||||
|
*/
|
||||||
|
private function scheduleAutoClose(): void
|
||||||
|
{
|
||||||
|
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
|
||||||
|
$schedule->command('ticketing:auto-close')
|
||||||
|
// …AND THE CLOCK IS THE SCHOOL'S ([dashboard-shell] #3144).
|
||||||
|
// ScheduleStagger decides the MINUTE; `dailyAt()` on its own is
|
||||||
|
// read against config('app.timezone'), which is UTC on every box
|
||||||
|
// here — so 00:12 was landing at 17:12 the previous afternoon,
|
||||||
|
// i.e. the nightly sweep ran in the middle of the school day.
|
||||||
|
->timezone(\App\Support\Timezone::organisation())
|
||||||
|
->dailyAt(\App\Support\Ops\ScheduleStagger::dailyAt('ticketing:auto-close'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HELP DESK WRITES TO THE SHELL'S CHANGES LOG — [central-ticketing] #4781.
|
||||||
|
*
|
||||||
|
* Until this method existed, `ticketing` was not one of the apps
|
||||||
|
* `audit_events` had ever seen: 4,378 rows, fifteen distinct apps, not one
|
||||||
|
* of them this one. A QA sweep signed in as a super admin, created a ticket,
|
||||||
|
* replied to it, and found that the newest row in the table afterwards was
|
||||||
|
* the shell recording the sweep's own visit to the log page. So "who closed
|
||||||
|
* this", "who deleted that group" and — the one that actually matters —
|
||||||
|
* "who gave this person the keys to the HR desk's queue" had no answer
|
||||||
|
* anywhere in Central.
|
||||||
|
*
|
||||||
|
* Nothing in core was wrong. `AuditRegistry`'s own docblock is explicit that
|
||||||
|
* snap-ins register their own models from their own providers and that core
|
||||||
|
* never references a snap-in. Ticketing simply never declared. This is the
|
||||||
|
* declaration, in the shape `FormsServiceProvider::registerAuditedGrants()`
|
||||||
|
* uses for `FormCollaborator` and `FormSharedGroup` — the precedent is exact
|
||||||
|
* and so is the reason: granting somebody access to other people's
|
||||||
|
* correspondence is a permission change, and a permission change belongs on
|
||||||
|
* the record.
|
||||||
|
*
|
||||||
|
* Declaring a model is the whole job. The registry attaches `AuditObserver`
|
||||||
|
* itself, so every create / declared-field update / delete on these seven
|
||||||
|
* classes lands in the Changes tab under app `ticketing` (derived from the
|
||||||
|
* namespace by `Audit::appForClass()`), with the actor and the subject's
|
||||||
|
* label frozen into the row at write time.
|
||||||
|
*
|
||||||
|
* ── HALF A SETTINGS PAGE IS NOT A DECISION — #6217 ───────────────────────
|
||||||
|
*
|
||||||
|
* #4781 declared four models. Help Desk Settings has SEVEN tabs, and a
|
||||||
|
* second sweep a fortnight later found that Groups and Agents were fully
|
||||||
|
* audited while Priorities, Statuses, Projects, Email connections and
|
||||||
|
* Automation — same page, same buttons, same super admin — wrote nothing at
|
||||||
|
* all. The census was flat: zero rows for `settings.priorities.*`,
|
||||||
|
* `settings.statuses.*`, `settings.projects.*`, `settings.email.*` and
|
||||||
|
* `settings.automation.update` in the whole store, and none of those models
|
||||||
|
* in the Changes log's Subject-type facet. QA had driven the Priorities
|
||||||
|
* routes on the box inside 48 hours and the log held none of it.
|
||||||
|
*
|
||||||
|
* `PriorityLevel`, `TicketStatus` and `TicketingProject` join the list
|
||||||
|
* below. The other two could not, and are handled in their own classes for
|
||||||
|
* reasons that are theirs and not an omission — see the next section.
|
||||||
|
*
|
||||||
|
* ── THE FIELD LISTS ARE THE VOLUME CONTROL ───────────────────────────────
|
||||||
|
*
|
||||||
|
* Only declared fields are diffed and an update touching none of them
|
||||||
|
* records nothing at all, so these lists — not the tables' width — decide
|
||||||
|
* what the log costs. Each is the set of columns somebody would ask an
|
||||||
|
* auditor about: where a ticket lives, who is on the hook for it, what state
|
||||||
|
* it is in, and who may see it. `color` on a group and `description` on a
|
||||||
|
* ticket are left out deliberately: a palette tweak is not an audit event,
|
||||||
|
* and the body of a complaint is correspondence we are not copying into a
|
||||||
|
* second table to be kept for seven years.
|
||||||
|
*
|
||||||
|
* ── NOTHING HERE IS `sensitive`, AND THAT IS A DECISION ──────────────────
|
||||||
|
*
|
||||||
|
* `sensitive: true` buys exactly one thing: read-audit. It makes
|
||||||
|
* `RecordSensitiveViews` record a `viewed` row every time a route implicitly
|
||||||
|
* binds the model on a successful GET. For `Ticket` that is every agent
|
||||||
|
* opening every ticket all day — volume that would bury the writes this card
|
||||||
|
* is about, to answer a question nobody asked. It changes no retention
|
||||||
|
* window either (writes are kept on the operator's Changes window
|
||||||
|
* regardless; `sensitive` only lengthens what READ rows get). So: writes,
|
||||||
|
* declared; reads, not. If ticketing ever needs "who read this ticket", that
|
||||||
|
* is its own card and its own conversation about volume.
|
||||||
|
*
|
||||||
|
* ── WHAT IS AUDITED, BUT NOT FROM HERE ───────────────────────────────────
|
||||||
|
*
|
||||||
|
* `EmailConnection` and the Automation tab are both on the record; neither
|
||||||
|
* can be put there by a declaration.
|
||||||
|
*
|
||||||
|
* `EmailConnection` keeps its mailbox host, port, username and PASSWORD in
|
||||||
|
* one `config` JSON column. The registry diffs declared fields by reading
|
||||||
|
* them, and `Audit::redact()` masks by TOP-LEVEL FIELD NAME only — it never
|
||||||
|
* redacts inside an array — so declaring `config` would write the school's
|
||||||
|
* mailbox password in clear into `audit_events.changes`, a table readable
|
||||||
|
* under `logs.view` alone. Declaring only the scalar columns is the other
|
||||||
|
* trap: an update that moved no DECLARED field records nothing, so
|
||||||
|
* re-pointing a connection at a different mailbox would still be silent,
|
||||||
|
* and that is the exact write #6217 was filed about.
|
||||||
|
* {@see Support\EmailConnectionAudit} therefore writes the three verbs by
|
||||||
|
* hand with `config` flattened into `config.host` / `config.password` / …
|
||||||
|
* keys — the spelling the central redaction masks — and
|
||||||
|
* {@see Support\EmailConnectionAuditLabels} declares the LABEL here with no
|
||||||
|
* observer, so the hand-written rows are not doubled.
|
||||||
|
*
|
||||||
|
* The Automation tab writes `App\Models\Setting` rows, which are SHELL rows
|
||||||
|
* and not a ticketing model at all: no declaration here could reach them,
|
||||||
|
* and declaring `Setting` would put every platform-wide toggle in Central
|
||||||
|
* on the `ticketing` filter. {@see Support\AutomationSettingsAudit} records
|
||||||
|
* it as one explicit domain event per save.
|
||||||
|
*
|
||||||
|
* ── WHAT IS DELIBERATELY NOT DECLARED ────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `TicketMessage`. The reporter expected a row for the reply they posted,
|
||||||
|
* and it is the right instinct on the wrong model: a message can only ever
|
||||||
|
* be created (there is no edit route and no delete route — see
|
||||||
|
* `TicketMessageController`, which has one method), so auditing it could
|
||||||
|
* only ever mean one row per message. Every inbound email through an
|
||||||
|
* `EmailConnection` writes one, under `system`, and a merge rewrites whole
|
||||||
|
* runs of them. That is a stream of "a message exists" against the record of
|
||||||
|
* who CHANGED something, which is what the Changes tab is for — and the
|
||||||
|
* messages themselves are already the permanent, readable record of what was
|
||||||
|
* said. The ticket's own row says who opened it and who closed it; the
|
||||||
|
* thread says what was written in between.
|
||||||
|
*
|
||||||
|
* A group hard-delete cascades its agent-access rows in the DATABASE (see
|
||||||
|
* the migration's foreign key), so those grants end without a `deleted` row
|
||||||
|
* of their own. The group's deletion is recorded, which is the question
|
||||||
|
* being asked — but it is worth knowing that the two are one event here and
|
||||||
|
* not several.
|
||||||
|
*
|
||||||
|
* Guarded on the class and wrapped in a catch for the same reason Forms and
|
||||||
|
* Files are: central-logs is optional on any given host, and a declaration
|
||||||
|
* must never be the reason a box fails to boot.
|
||||||
|
*/
|
||||||
|
private function registerAuditedModels(): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Central\Logs\Support\AuditRegistry::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
* THE GRANT. The reason this card was filed and the first thing
|
||||||
|
* declared: `ticketing.settings.agents.store` / `.destroy` hand out
|
||||||
|
* and take back a person's access to a help desk group's entire
|
||||||
|
* queue, and did so leaving no trace at all.
|
||||||
|
*
|
||||||
|
* The label is resolved the long way round because this model has no
|
||||||
|
* `user()` relation and must not grow one — `TicketingSettingsController`
|
||||||
|
* HYDRATES `$access->user` as a plain attribute for the Settings page,
|
||||||
|
* and a relation of the same name would fight it.
|
||||||
|
*/
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingAgentAccess::class,
|
||||||
|
fields: ['user_id', 'group_id', 'role'],
|
||||||
|
label: fn ($access) => (\App\Models\User::find($access->user_id)?->name ?? 'Deleted user')
|
||||||
|
.' on '.($access->group?->name ?? 'a help desk group'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The desk itself: renamed, re-prefixed, pointed at a different
|
||||||
|
// inbox, archived, restored, or deleted outright. `archived_at` is
|
||||||
|
// what tells archive from delete — one button does both, chosen by
|
||||||
|
// whether the group still owns tickets.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingGroup::class,
|
||||||
|
fields: ['name', 'prefix', 'email_address', 'archived_at'],
|
||||||
|
label: fn ($group) => ($group->name ?: 'Help desk group')
|
||||||
|
.($group->prefix ? ' ('.$group->prefix.')' : ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ticket: created, deleted, moved between desks, re-assigned,
|
||||||
|
// re-prioritised, closed, merged. `merged_into_id` is declared
|
||||||
|
// beside `status` because a merge is a pair of columns moving
|
||||||
|
// together and reading either one alone misreports it.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\Ticket::class,
|
||||||
|
fields: [
|
||||||
|
'group_id', 'status', 'assigned_to', 'priority_id',
|
||||||
|
'project_id', 'due_date', 'merged_into_id', 'title',
|
||||||
|
],
|
||||||
|
label: fn ($ticket) => trim(($ticket->number ?: 'Ticket').' '.($ticket->title ?? '')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Participants are the OTHER way somebody comes to see a ticket's
|
||||||
|
// correspondence — narrower than an agent grant (one ticket, not a
|
||||||
|
// whole queue) but the same kind of fact.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketParticipant::class,
|
||||||
|
fields: ['ticket_id', 'user_id', 'added_by'],
|
||||||
|
label: fn ($participant) => (\App\Models\User::find($participant->user_id)?->name ?? 'Deleted user')
|
||||||
|
.' on ticket '.($participant->ticket?->number ?? '#'.$participant->ticket_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── THE SETTINGS CATALOGUES ([central-ticketing] #6217) ──────────
|
||||||
|
//
|
||||||
|
// The three tabs below are a shared vocabulary: every ticket in the
|
||||||
|
// school is filed under one of these rows, so renaming, reordering
|
||||||
|
// or removing one re-labels work that is already done. `color` is
|
||||||
|
// left out of all three for the reason the group's is — a palette
|
||||||
|
// tweak is not an audit event — and so is `description`, which is
|
||||||
|
// help text under a box rather than a fact about the desk.
|
||||||
|
|
||||||
|
// What a priority is CALLED and where it RANKS. `sort_order` is
|
||||||
|
// declared because it is the order the pickers and the board read:
|
||||||
|
// moving "Urgent" below "Low" changes what the office sees first.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\PriorityLevel::class,
|
||||||
|
fields: ['name', 'sort_order'],
|
||||||
|
label: fn ($priority) => 'Priority: '.($priority->name ?: '#'.$priority->getKey()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `slug` is declared beside `name` and never changes after creation
|
||||||
|
// — which is exactly why it is worth the row: `tickets.status`
|
||||||
|
// stores the SLUG, so the slug on a `deleted` entry is the only
|
||||||
|
// thing that says which tickets' status just stopped resolving to
|
||||||
|
// anything. `is_closed` is behaviour, not decoration: it decides
|
||||||
|
// whether a ticket in this status counts as finished, and the board
|
||||||
|
// reads it to decide which tickets are still open
|
||||||
|
// (`TicketController::index()`'s `$closedSlugs`).
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketStatus::class,
|
||||||
|
fields: ['slug', 'name', 'sort_order', 'is_closed'],
|
||||||
|
label: fn ($status) => 'Status: '.($status->name ?: $status->slug ?: '#'.$status->getKey()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A project is a folder of tickets on one desk. `group_id` is
|
||||||
|
// declared because it is which desk — the same fact `Ticket`
|
||||||
|
// declares it for.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingProject::class,
|
||||||
|
fields: ['group_id', 'name', 'status'],
|
||||||
|
label: fn ($project) => 'Project: '.($project->name ?: '#'.$project->getKey())
|
||||||
|
.' ('.($project->group?->name ?? 'a help desk group').')',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The LABEL only, and deliberately no observer — see the class, and
|
||||||
|
// the "WHAT IS AUDITED, BUT NOT FROM HERE" section above.
|
||||||
|
Support\EmailConnectionAuditLabels::register();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Never let a declaration break boot.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,20 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
|
|
||||||
// ── Fixed paths first (before any /{ticket} wildcards) ────────────────
|
// ── Fixed paths first (before any /{ticket} wildcards) ────────────────
|
||||||
|
|
||||||
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
|
// my-tickets and prefs carried only `app.access:ticketing`, which means "you hold
|
||||||
|
// ANY ticketing.* permission" — so ticketing.settings alone opened them. They are
|
||||||
|
// the submitter's own surface, and ticketing.view is literally described as
|
||||||
|
// "Submit tickets and view own ticket history", so it is the right key; managers
|
||||||
|
// hold it implicitly via the same either-or the rest of this file uses.
|
||||||
|
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
||||||
|
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
|
||||||
|
Route::post('/prefs', [TicketController::class, 'savePrefs']) ->name('prefs.save');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attachments stay ungated ON PURPOSE: TicketAttachmentController::show already
|
||||||
|
// authorises submitter-or-agent per attachment, which is finer than any
|
||||||
|
// permission could be. A key here would either duplicate that check or
|
||||||
|
// contradict it.
|
||||||
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
|
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
|
||||||
|
|
||||||
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
||||||
@@ -28,8 +41,25 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
|
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
|
||||||
Route::middleware('permission:ticketing.settings')->group(function () {
|
Route::middleware('permission:ticketing.settings')->group(function () {
|
||||||
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
|
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
|
||||||
|
// #6168 — `email` belongs in this list. It was the one tab segment
|
||||||
|
// the whitelist omitted while the page shipped an `emailConnections`
|
||||||
|
// prop on every render and the three write routes below were live, so
|
||||||
|
// the only deep link an admin could guess fell through to the POST-only
|
||||||
|
// `/settings/email` and answered 405. The Vue's TAB_KEYS and this list
|
||||||
|
// are now pinned to each other by
|
||||||
|
// tests/Unit/TicketingSettingsTabsAreOneVocabularyTest.php.
|
||||||
|
Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index'])
|
||||||
|
->where('tab', 'groups|agents|priorities|statuses|projects|email|automation')->name('settings.tab');
|
||||||
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
|
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
|
||||||
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
|
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
|
||||||
|
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
|
||||||
|
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
|
||||||
|
// #4926 — the add-an-agent form's people search. Before it the form's
|
||||||
|
// first field was a raw numeric user id with "Find IDs in Admin → Users"
|
||||||
|
// under it, on the one screen that is the only recovery for a help desk
|
||||||
|
// group with no manager left (#4806). Two segments, so it cannot collide
|
||||||
|
// with the single-segment `/settings/{tab}` above.
|
||||||
|
Route::get('/settings/agents/search', [TicketingSettingsController::class, 'searchUsers']) ->name('settings.agents.search');
|
||||||
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
|
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
|
||||||
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
|
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
|
||||||
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');
|
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');
|
||||||
@@ -41,6 +71,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store');
|
Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store');
|
||||||
Route::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update');
|
Route::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update');
|
||||||
Route::delete('/settings/email/{connection}', [TicketingSettingsController::class, 'destroyEmailConnection'])->name('settings.email.destroy');
|
Route::delete('/settings/email/{connection}', [TicketingSettingsController::class, 'destroyEmailConnection'])->name('settings.email.destroy');
|
||||||
|
Route::put('/settings/automation', [TicketingSettingsController::class, 'updateAutomation']) ->name('settings.automation.update');
|
||||||
|
Route::post('/settings/statuses', [TicketingSettingsController::class, 'storeStatus']) ->name('settings.statuses.store');
|
||||||
|
Route::put('/settings/statuses/{status}', [TicketingSettingsController::class, 'updateStatus']) ->name('settings.statuses.update');
|
||||||
|
Route::delete('/settings/statuses/{status}', [TicketingSettingsController::class, 'destroyStatus']) ->name('settings.statuses.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Wildcard /{ticket} routes (last, after all fixed paths) ──────────
|
// ── Wildcard /{ticket} routes (last, after all fixed paths) ──────────
|
||||||
@@ -49,9 +83,19 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('permission:ticketing.manage')->group(function () {
|
Route::middleware('permission:ticketing.manage')->group(function () {
|
||||||
Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit');
|
Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit');
|
||||||
Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update');
|
Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update');
|
||||||
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy');
|
Route::delete('/{ticket}', [TicketController::class, 'destroy']) ->name('destroy');
|
||||||
|
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
|
||||||
|
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
|
||||||
|
Route::post('/{ticket}/split', [TicketController::class, 'split']) ->name('split');
|
||||||
|
// #938 — the one-click fix for a row the merge bug left inconsistent.
|
||||||
|
Route::post('/{ticket}/mark-merged', [TicketController::class, 'markMerged']) ->name('mark-merged');
|
||||||
|
// #1567 — the one-click release for a row whose merge target was deleted.
|
||||||
|
Route::post('/{ticket}/unmerge', [TicketController::class, 'unmerge']) ->name('unmerge');
|
||||||
|
Route::post('/{ticket}/participants', [TicketController::class, 'addParticipant']) ->name('participants.store');
|
||||||
|
Route::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
|
||||||
|
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user