diff --git a/composer.json b/composer.json index c0e2a9a..5219d2a 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,9 @@ { "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.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" } + ], + "seeders": [ + "Dashboard\\Ticketing\\Database\\Seeders\\EmailTemplatesSeeder" ] } } diff --git a/database/migrations/2026_04_14_000001_make_priorities_global.php b/database/migrations/2026_04_14_000001_make_priorities_global.php new file mode 100644 index 0000000..7ef1376 --- /dev/null +++ b/database/migrations/2026_04_14_000001_make_priorities_global.php @@ -0,0 +1,52 @@ +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(); + }); + } +}; diff --git a/database/migrations/2026_04_14_000002_create_ticket_views_table.php b/database/migrations/2026_04_14_000002_create_ticket_views_table.php new file mode 100644 index 0000000..6e6db04 --- /dev/null +++ b/database/migrations/2026_04_14_000002_create_ticket_views_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_17_000001_add_participants_and_merge_to_tickets.php b/database/migrations/2026_04_17_000001_add_participants_and_merge_to_tickets.php new file mode 100644 index 0000000..5de3543 --- /dev/null +++ b/database/migrations/2026_04_17_000001_add_participants_and_merge_to_tickets.php @@ -0,0 +1,46 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_17_000002_create_ticketing_statuses_table.php b/database/migrations/2026_04_17_000002_create_ticketing_statuses_table.php new file mode 100644 index 0000000..e613e5c --- /dev/null +++ b/database/migrations/2026_04_17_000002_create_ticketing_statuses_table.php @@ -0,0 +1,46 @@ +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'); + } +}; diff --git a/src/Console/Commands/AutoCloseTickets.php b/src/Console/Commands/AutoCloseTickets.php new file mode 100644 index 0000000..4f48099 --- /dev/null +++ b/src/Console/Commands/AutoCloseTickets.php @@ -0,0 +1,33 @@ +info('Auto-close is disabled (ticketing.auto_close_days = 0).'); + return 0; + } + + $cutoff = now()->subDays($days); + + $count = Ticket::where('status', 'resolved') + ->where('updated_at', '<=', $cutoff) + ->update(['status' => 'closed']); + + $this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago."); + + return 0; + } +} diff --git a/src/Database/Seeders/EmailTemplatesSeeder.php b/src/Database/Seeders/EmailTemplatesSeeder.php new file mode 100644 index 0000000..32cfbd5 --- /dev/null +++ b/src/Database/Seeders/EmailTemplatesSeeder.php @@ -0,0 +1,372 @@ +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, + ], + ]; + } +} diff --git a/src/Database/Seeders/TicketingDemoSeeder.php b/src/Database/Seeders/TicketingDemoSeeder.php index 60b4b66..6c21ec7 100644 --- a/src/Database/Seeders/TicketingDemoSeeder.php +++ b/src/Database/Seeders/TicketingDemoSeeder.php @@ -17,15 +17,19 @@ class TicketingDemoSeeder extends Seeder { public function run(): void { - $admin = DB::table('users')->where('email', 'admin@vancouverchristian.org')->first(); - $micah = DB::table('users')->where('email', 'micah@qa.test')->first(); - $nahum = DB::table('users')->where('email', 'nahum@qa.test')->first(); + // Use the first super admin, or fall back to any existing user + $admin = DB::table('users')->where('is_super_admin', 1)->first() + ?? DB::table('users')->first(); if (! $admin) { - $this->command?->warn('TicketingDemoSeeder skipped: admin user not found.'); + $this->command?->warn('TicketingDemoSeeder skipped: no users found in the database.'); 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(); // 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(); $groups = [ - ['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'], - ['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'], - ['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'], + ['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'], + ['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'], + ['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'], ]; $groupModels = collect(); @@ -52,94 +56,95 @@ class TicketingDemoSeeder extends Seeder foreach ($users as $index => $user) { TicketingAgentAccess::firstOrCreate([ - 'user_id' => $user->id, + 'user_id' => $user->id, 'group_id' => $group->id, ], [ 'role' => $index === 0 ? 'manager' : 'agent', ]); } - foreach ([ - ['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1], - ['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2], - ['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3], - ['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4], - ] as $priorityData) { - PriorityLevel::firstOrCreate([ - 'group_id' => $group->id, - 'name' => $priorityData['name'], - ], [ - 'color' => $priorityData['color'], - 'description' => null, - 'sort_order' => $priorityData['sort_order'], - ]); + // Global priorities — only seed once (first group iteration) + if (PriorityLevel::count() === 0) { + foreach ([ + ['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1], + ['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2], + ['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3], + ['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4], + ] as $priorityData) { + PriorityLevel::firstOrCreate( + ['name' => $priorityData['name']], + ['color' => $priorityData['color'], 'description' => null, 'sort_order' => $priorityData['sort_order']] + ); + } } $projects = match ($group->prefix) { 'IT' => [ - ['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'], - ['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'], - ['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'], + ['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'], + ['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'], + ['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'], + ['name' => 'Network & Wi-Fi', 'description' => 'Connectivity and access point issues'], ], 'FAC' => [ - ['name' => 'Work Orders', 'description' => 'General campus maintenance requests'], - ['name' => 'HVAC', 'description' => 'Heating and cooling issues'], - ['name' => 'Events Setup', 'description' => 'Room setup and teardown support'], + ['name' => 'Work Orders', 'description' => 'General campus maintenance requests'], + ['name' => 'HVAC', 'description' => 'Heating and cooling issues'], + ['name' => 'Events Setup', 'description' => 'Room setup and teardown support'], + ['name' => 'Grounds', 'description' => 'Exterior and landscaping requests'], ], default => [ - ['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'], - ['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'], + ['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'], + ['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'], ['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'], + ['name' => 'Payroll', 'description' => 'Pay queries and corrections'], ], }; foreach ($projects as $projectData) { TicketingProject::firstOrCreate([ 'group_id' => $group->id, - 'name' => $projectData['name'], + 'name' => $projectData['name'], ], [ 'description' => $projectData['description'], - 'status' => 'active', - 'created_by' => $admin->id, + 'status' => 'active', + 'created_by' => $admin->id, ]); } $groupModels->push($group->fresh(['priorityLevels', 'projects', 'agentAccess'])); } - $submitterPool = $this->ensureSubmitterPool(); - $statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed']; - $ticketBlueprints = $this->ticketBlueprints(); + $submitterPool = $this->ensureSubmitterPool(); + $statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed']; + $blueprints = $this->ticketBlueprints(); + $blueprintCount = count($blueprints); + $ticketCount = 45; - foreach (range(1, 50) as $i) { - $group = $groupModels[($i - 1) % $groupModels->count()]; - $priority = $group->priorityLevels->random(); - $project = $group->projects->random(); + foreach (range(1, $ticketCount) as $i) { + $group = $groupModels[($i - 1) % $groupModels->count()]; + $priority = $group->priorityLevels->random(); + $project = $group->projects->random(); $submitter = $submitterPool[($i - 1) % count($submitterPool)]; $assigneeId = $group->agentAccess->random()->user_id; - $blueprint = $ticketBlueprints[($i - 1) % count($ticketBlueprints)]; - $createdAt = Carbon::now()->subDays(rand(0, 35))->subHours(rand(0, 23))->subMinutes(rand(0, 59)); - $status = $statuses[array_rand($statuses)]; - - $title = $blueprint['title']; - if ($i > count($ticketBlueprints)) { - $title .= ' #' . $i; - } + $blueprint = $blueprints[($i - 1) % $blueprintCount]; + $createdAt = Carbon::now()->subDays(rand(0, 45))->subHours(rand(0, 23))->subMinutes(rand(0, 59)); + $status = $statuses[array_rand($statuses)]; $ticket = Ticket::updateOrCreate( ['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)], [ - 'group_id' => $group->id, + 'group_id' => $group->id, 'submitter_id' => $submitter['id'], - 'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0 ? null : $assigneeId, - 'project_id' => $project->id, - 'title' => $title, + 'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0 + ? null + : $assigneeId, + 'project_id' => $project->id, + 'title' => $blueprint['title'], 'description' => $blueprint['description'], - 'status' => $status, + 'status' => $status, 'priority_id' => $priority->id, - 'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null, - 'created_at' => $createdAt, - 'updated_at' => $createdAt, + 'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, ] ); @@ -154,14 +159,14 @@ class TicketingDemoSeeder extends Seeder private function ensureSubmitterPool(): array { $defaults = [ - ['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local', 'role' => 'staff'], - ['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local', 'role' => 'staff'], - ['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local', 'role' => 'staff'], - ['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local', 'role' => 'staff'], - ['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local', 'role' => 'admin'], - ['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local', 'role' => 'staff'], - ['name' => 'Grace Library', 'email' => 'grace.library@vcs.local', 'role' => 'staff'], - ['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local', 'role' => 'staff'], + ['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local'], + ['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local'], + ['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local'], + ['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local'], + ['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local'], + ['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local'], + ['name' => 'Grace Library', 'email' => 'grace.library@vcs.local'], + ['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local'], ]; $pool = []; @@ -171,14 +176,14 @@ class TicketingDemoSeeder extends Seeder if (! $userId) { $userId = DB::table('users')->insertGetId([ - 'name' => $person['name'], - 'email' => $person['email'], - 'google_id' => 'demo-' . Str::slug($person['email']), - 'role' => $person['role'], + 'name' => $person['name'], + 'email' => $person['email'], + 'google_id' => 'demo-' . Str::slug($person['email']), + 'is_super_admin' => 0, 'email_verified_at' => now(), - 'password' => bcrypt(Str::random(32)), - 'created_at' => now(), - 'updated_at' => now(), + 'password' => bcrypt(Str::random(32)), + 'created_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 { - $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); $messages = [ [ - 'user_id' => $submitter['id'], + 'user_id' => $submitter['id'], 'author_email' => $submitter['email'], - 'body' => $ticket->description, - 'is_internal' => false, - 'source' => rand(0, 3) === 0 ? 'email' : 'web', - 'created_at' => $createdAt, + 'body' => $ticket->description, + 'is_internal' => false, + 'source' => rand(0, 3) === 0 ? 'email' : 'web', + 'created_at' => $createdAt, ], ]; if ($agentId) { $messages[] = [ - 'user_id' => $agentId, + 'user_id' => $agentId, 'author_email' => null, - 'body' => $this->agentReplyForStatus($status), - 'is_internal' => false, - 'source' => 'web', - 'created_at' => $createdAt->copy()->addHours(rand(1, 8)), + 'body' => $this->agentReplyForStatus($status), + 'is_internal' => false, + 'source' => 'web', + 'created_at' => $createdAt->copy()->addHours(rand(1, 8)), ]; } if (in_array($status, ['in_progress', 'pending', 'resolved', 'closed']) && $agentId) { $messages[] = [ - 'user_id' => $agentId, + 'user_id' => $agentId, 'author_email' => null, - 'body' => $this->internalNoteForGroup($group->prefix), - 'is_internal' => true, - 'source' => 'web', - 'created_at' => $createdAt->copy()->addHours(rand(2, 16)), + 'body' => $this->internalNoteForGroup($group->prefix), + 'is_internal' => true, + 'source' => 'web', + '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'])) { $messages[] = [ - 'user_id' => $submitter['id'], + 'user_id' => $submitter['id'], 'author_email' => $submitter['email'], - 'body' => 'Thanks — that fixed it. Appreciate the quick help.', - 'is_internal' => false, - 'source' => 'web', - 'created_at' => $createdAt->copy()->addHours(rand(10, 36)), + 'body' => 'Thanks — that fixed it. Appreciate the quick help.', + 'is_internal' => false, + 'source' => 'web', + 'created_at' => $createdAt->copy()->addHours(rand(10, 36)), ]; } foreach ($messages as $message) { TicketMessage::create([ - 'ticket_id' => $ticket->id, - 'user_id' => $message['user_id'], - 'author_email' => $message['author_email'], - 'body' => $message['body'], - 'is_internal' => $message['is_internal'], - 'source' => $message['source'], + 'ticket_id' => $ticket->id, + 'user_id' => $message['user_id'], + 'author_email' => $message['author_email'], + 'body' => $message['body'], + 'is_internal' => $message['is_internal'], + 'source' => $message['source'], 'email_message_id' => $message['source'] === 'email' ? '<' . Str::uuid() . '@vcs.local>' : null, - 'created_at' => $message['created_at'], - 'updated_at' => $message['created_at'], + 'created_at' => $message['created_at'], + 'updated_at' => $message['created_at'], ]); } } @@ -255,48 +271,139 @@ class TicketingDemoSeeder extends Seeder private function agentReplyForStatus(string $status): string { 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.', - '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.', - 'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.', - default => 'Thanks, we are on it.', + '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.', + 'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.', + default => 'Thanks, we are on it.', }; } private function internalNoteForGroup(string $prefix): string { - return 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.', - 'FAC' => 'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.', - 'HR' => 'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.', - default => 'Internal: triage complete, waiting on next action.', + $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.', + 'Internal: checked MDM — device is enrolled. May need remote wipe if issue persists.', + '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 { 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.'], - ['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' => 'Chromebook cart missing three chargers', 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'], - ['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' => '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.'], - ['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' => 'Classroom speakers crackling during assemblies', 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'], - ['title' => 'Request for additional key fob', 'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'], - ['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.'], - ['title' => 'Benefits question about dependent coverage', 'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'], - ['title' => 'Broken desk in portable 3', 'description' => 'One of the student desks has a cracked support and is wobbling badly.'], - ['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' => 'Request setup for parent info night', 'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'], - ['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' => 'Door closer slamming in west hallway', 'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'], - ['title' => 'Need substitute teacher account reactivated', 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'], - ['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.'], - ['title' => 'Request ergonomic chair assessment', 'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'], - ['title' => 'Water fountain on second floor leaking', 'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'], - ['title' => 'Need permission to edit school calendar', 'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'], + // ── IT ──────────────────────────────────────────────────────────── + ['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.'], + ['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' => 'Chromebook cart missing three chargers', + 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'], + ['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.'], + ['title' => 'Classroom speakers crackling during assemblies', + 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'], + ['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.'], + ['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' => 'Need substitute teacher account reactivated', + 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'], + ['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.'], + ['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.'], ]; } } diff --git a/src/Http/Controllers/TicketController.php b/src/Http/Controllers/TicketController.php index 8c8d9e5..b50ba95 100644 --- a/src/Http/Controllers/TicketController.php +++ b/src/Http/Controllers/TicketController.php @@ -2,11 +2,17 @@ namespace Dashboard\Ticketing\Http\Controllers; +use App\Models\Setting; use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\Ticket; +use Dashboard\Ticketing\Models\TicketAttachment; use Dashboard\Ticketing\Models\TicketingAgentAccess; use Dashboard\Ticketing\Models\TicketingGroup; use Dashboard\Ticketing\Models\TicketingProject; +use Dashboard\Ticketing\Models\TicketMessage; +use Dashboard\Ticketing\Models\TicketParticipant; +use Dashboard\Ticketing\Models\TicketStatus; +use Dashboard\Ticketing\Models\TicketView; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; @@ -51,29 +57,46 @@ class TicketController extends Controller return $messages->filter(fn($m) => !$m->is_internal)->values(); } + private function loadUserPrefs(): array + { + $raw = Setting::get('ticketing.pref.' . Auth::id(), '{}'); + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + public function index(Request $request): Response { $user = Auth::user(); $agentGroupIds = $this->agentGroupIds(); $isAgent = count($agentGroupIds) > 0; + // Merge saved prefs as defaults — URL params always win + $pref = $this->loadUserPrefs(); + if (! $request->filled('filter') && ! empty($pref['filter'])) { + $request->merge(['filter' => $pref['filter']]); + } + // If no groups exist at all, render a first-run / bootstrap state $totalGroups = TicketingGroup::count(); if ($totalGroups === 0) { return Inertia::render('Ticketing/Index', [ - 'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1], - 'groups' => [], - 'priorities' => [], - 'projects' => [], - 'isAgent' => $isAgent, - 'filters' => [], - 'ticketDetail' => null, - 'detailAgents' => [], - 'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0], - 'isBootstrap' => true, + 'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1], + 'groups' => [], + 'priorities' => [], + 'projects' => [], + 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), + 'isAgent' => $isAgent, + 'filters' => [], + 'ticketDetail' => null, + 'detailAgents' => [], + 'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0], + 'isBootstrap' => true, + 'userPrefs' => ['view_mode' => 'list', 'filter' => 'all'], ]); } + $closedSlugs = TicketStatus::where('is_closed', true)->pluck('slug')->toArray(); + $baseQuery = Ticket::query(); if ($isAgent) { @@ -84,18 +107,18 @@ class TicketController extends Controller $viewCounts = $isAgent ? [ - 'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(), - 'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(), - 'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(), + 'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(), + 'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(), + 'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(), 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), - 'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), + 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(), ] : [ 'all' => (clone $baseQuery)->count(), 'mine' => 0, 'unassigned' => 0, 'pending' => (clone $baseQuery)->where('status', 'pending')->count(), - 'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), + 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(), ]; $query = Ticket::with(['group', 'priority', 'project']); @@ -122,9 +145,9 @@ class TicketController extends Controller } elseif ($request->filter === 'pending') { $query->where('status', 'pending'); } elseif ($request->filter === 'resolved') { - $query->whereIn('status', ['resolved', 'closed']); + $query->whereIn('status', $closedSlugs); } elseif (!$request->filled('status')) { - $query->whereNotIn('status', ['resolved', 'closed']); + $query->whereNotIn('status', $closedSlugs); } } else { $query->where('submitter_id', $user->id); @@ -134,7 +157,7 @@ class TicketController extends Controller if ($request->filter === 'pending') { $query->where('status', 'pending'); } elseif ($request->filter === 'resolved') { - $query->whereIn('status', ['resolved', 'closed']); + $query->whereIn('status', $closedSlugs); } } @@ -150,10 +173,7 @@ class TicketController extends Controller }); $groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get(); - $priorities = PriorityLevel::whereNull('group_id') - ->orWhereIn('group_id', $agentGroupIds ?: [0]) - ->orderBy('sort_order') - ->get(); + $priorities = PriorityLevel::orderBy('sort_order')->get(); $projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds)) ->where('status', 'active') ->get(); @@ -178,6 +198,7 @@ class TicketController extends Controller 'project', 'messages.attachments', 'attachments', + 'views', ])->whereKey($request->detail); if ($isAgent) { @@ -189,42 +210,56 @@ class TicketController extends Controller $dt = $detailQuery->first(); if ($dt) { + $viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect(); $detailUserIds = collect([$dt->submitter_id, $dt->assigned_to]) ->merge($dt->messages->pluck('user_id')) + ->merge($viewUserIds) ->filter() ->unique(); $detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id'); $dt->submitter = $detailUsers[$dt->submitter_id] ?? null; - $dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null; + $dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null; - // Bug #2: Filter internal notes for non-agents + // Filter internal notes for non-agents $visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent); $visibleMessages->each(function ($msg) use ($detailUsers) { $msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null; }); $dt->setRelation('messages', $visibleMessages); + // Augment views with user names (agents only — don't expose to submitters) + if ($isAgent) { + $dt->views->each(fn($v) => $v->user = $detailUsers[$v->user_id] ?? null); + } else { + $dt->setRelation('views', collect()); + } + $ticketDetail = $dt; if ($isAgent) { - $agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id'); + $agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id'); $detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); } } } return Inertia::render('Ticketing/Index', [ - 'tickets' => $tickets, - 'groups' => $groups, - 'priorities' => $priorities, - 'projects' => $projects, - 'isAgent' => $isAgent, - 'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']), - 'ticketDetail' => $ticketDetail, - 'detailAgents' => $detailAgents, - 'viewCounts' => $viewCounts, - 'isBootstrap' => false, + 'tickets' => $tickets, + 'groups' => $groups, + 'priorities' => $priorities, + 'projects' => $projects, + 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), + 'isAgent' => $isAgent, + 'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']), + 'ticketDetail' => $ticketDetail, + 'detailAgents' => $detailAgents, + 'viewCounts' => $viewCounts, + 'isBootstrap' => false, + 'userPrefs' => [ + 'view_mode' => $pref['view_mode'] ?? 'list', + 'filter' => $pref['filter'] ?? 'all', + ], ]); } @@ -246,11 +281,7 @@ class TicketController extends Controller ]); } - // Only pass global priorities + priorities scoped to accessible groups - $groupIds = $groups->pluck('id')->toArray(); - $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $groupIds)) - ->orderBy('sort_order') - ->get(); + $priorities = PriorityLevel::orderBy('sort_order')->get(); return Inertia::render('Ticketing/Create', [ 'groups' => $groups, @@ -319,12 +350,17 @@ class TicketController extends Controller abort(403); } - $ticket->load(['group', 'priority', 'project', 'messages', 'attachments']); + $ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']); // Bug #2: Filter internal notes for submitters $visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent); - $userIds = $visibleMessages->pluck('user_id')->filter()->unique(); + $participantUserIds = $ticket->participants->pluck('user_id'); + $userIds = $visibleMessages->pluck('user_id') + ->merge($participantUserIds) + ->push($ticket->submitter_id) + ->push($ticket->assigned_to) + ->filter()->unique(); $users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id'); $visibleMessages->each(function ($msg) use ($users) { @@ -333,21 +369,30 @@ class TicketController extends Controller $ticket->setRelation('messages', $visibleMessages); + // Attach submitter and assignee info + $ticket->submitter = $users[$ticket->submitter_id] ?? null; + $ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null; + + // Augment participants with user info + $ticket->participants->each(function ($p) use ($users) { + $p->user = $users[$p->user_id] ?? null; + }); + $agents = []; if ($isAgent) { $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); $agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); } - $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) - ->orderBy('sort_order')->get(); + $priorities = PriorityLevel::orderBy('sort_order')->get(); return Inertia::render('Ticketing/Show', [ - 'ticket' => $ticket, - 'isAgent' => $isAgent, - 'isManager' => $isAgent && $this->isManager($ticket->group_id), - 'agents' => $agents, - 'priorities' => $priorities, + 'ticket' => $ticket, + 'isAgent' => $isAgent, + 'isManager' => $isAgent && $this->isManager($ticket->group_id), + 'agents' => $agents, + 'priorities' => $priorities, + 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), ]); } @@ -358,8 +403,7 @@ class TicketController extends Controller } $ticket->load(['group', 'priority', 'project']); - $priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) - ->orderBy('sort_order')->get(); + $priorities = PriorityLevel::orderBy('sort_order')->get(); $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); $agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); $projects = TicketingProject::where('group_id', $ticket->group_id)->get(); @@ -378,35 +422,48 @@ class TicketController extends Controller abort(403); } - // Bug #3: Valid assignees are users with agent access to this group - $validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id) + // Determine effective group (may be changing via a transfer) + $targetGroupId = $request->filled('group_id') + ? (int) $request->input('group_id') + : $ticket->group_id; + + // Valid assignees are agents of the target group + $validAssigneeIds = TicketingAgentAccess::where('group_id', $targetGroupId) ->pluck('user_id') ->toArray(); + // Build valid status list from DB (exclude 'merged' — set only by the merge operation) + $validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(','); + $rules = [ - 'title' => 'sometimes|required|string|max:255', + 'title' => 'sometimes|required|string|max:255', 'description' => 'sometimes|required|string', - 'status' => 'sometimes|in:open,in_progress,pending,resolved,closed', + 'status' => "sometimes|in:{$validStatusSlugs}", 'priority_id' => 'nullable|exists:ticketing_priority_levels,id', - 'due_date' => 'nullable|date', - 'project_id' => 'nullable|exists:ticketing_projects,id', + 'due_date' => 'nullable|date', + 'project_id' => 'nullable|exists:ticketing_projects,id', + 'group_id' => 'nullable|exists:ticketing_groups,id', 'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]), ]; $validated = $request->validate($rules); - // Bug #4: priority_id must belong to this group or be global - if (!empty($validated['priority_id'])) { - $priority = PriorityLevel::find($validated['priority_id']); - if ($priority && $priority->group_id !== null && $priority->group_id !== $ticket->group_id) { - abort(422, 'Priority does not belong to this ticket\'s group.'); + // Group transfer: requester must be a manager of the target group + if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) { + if (! $this->isManager((int) $validated['group_id'])) { + abort(403, 'You must be a manager of the destination group to transfer tickets.'); } + // Clear assignee and project if they don't belong to the new group + $validated['assigned_to'] = null; + $validated['project_id'] = null; + } else { + unset($validated['group_id']); // no-op if unchanged } - // Bug #4: project_id must belong to this group + // project_id must belong to the effective group if (!empty($validated['project_id'])) { $project = TicketingProject::find($validated['project_id']); - if ($project && $project->group_id !== $ticket->group_id) { + if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) { abort(422, 'Project does not belong to this ticket\'s group.'); } } @@ -416,6 +473,50 @@ class TicketController extends Controller return back()->with('success', 'Ticket updated.'); } + /** + * Record that an agent viewed this ticket (called after 10s of the pane being open). + * Throttled: at most one record per user per 2 hours per ticket. + */ + public function recordView(Request $request, Ticket $ticket) + { + if (! $this->isAgent($ticket->group_id)) { + return response()->json(['ok' => false], 403); + } + + $alreadySeen = TicketView::where('ticket_id', $ticket->id) + ->where('user_id', Auth::id()) + ->where('viewed_at', '>=', now()->subHours(2)) + ->exists(); + + if (! $alreadySeen) { + TicketView::create([ + 'ticket_id' => $ticket->id, + 'user_id' => Auth::id(), + 'viewed_at' => now(), + ]); + } + + return response()->json(['ok' => true]); + } + + public function savePrefs(Request $request) + { + $validated = $request->validate([ + 'view_mode' => 'sometimes|in:list,kanban', + 'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved', + ]); + + if (empty($validated)) { + return response()->json(['ok' => false], 422); + } + + $key = 'ticketing.pref.' . Auth::id(); + $current = json_decode(Setting::get($key, '{}'), true) ?: []; + Setting::set($key, json_encode(array_merge($current, $validated))); + + return response()->json(['ok' => true]); + } + public function destroy(Ticket $ticket) { if (!$this->isManager($ticket->group_id)) { @@ -426,6 +527,204 @@ class TicketController extends Controller return redirect()->route('ticketing.index'); } + /** + * Merge another ticket INTO this one. + * The source ticket's messages and attachments move here; source is marked merged. + */ + public function merge(Request $request, Ticket $ticket) + { + if (! $this->isAgent($ticket->group_id)) { + abort(403); + } + + $validated = $request->validate([ + 'source_number' => 'required|string', + ]); + + $source = Ticket::where('number', $validated['source_number'])->first(); + + if (! $source) { + return back()->withErrors(['source_number' => 'Ticket not found.']); + } + if ($source->id === $ticket->id) { + return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']); + } + if ($source->status === 'merged') { + return back()->withErrors(['source_number' => 'That ticket has already been merged.']); + } + if (! $this->isAgent($source->group_id)) { + return back()->withErrors(['source_number' => 'You do not have access to that ticket.']); + } + + DB::transaction(function () use ($ticket, $source) { + // Move messages to this ticket + TicketMessage::where('ticket_id', $source->id) + ->update(['ticket_id' => $ticket->id]); + + // Move attachments to this ticket + TicketAttachment::where('ticket_id', $source->id) + ->update(['ticket_id' => $ticket->id]); + + // Add a system note marking the merge + TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => Auth::id(), + 'body' => "Merged from ticket {$source->number}.", + 'is_internal' => true, + 'source' => 'system', + ]); + + // Add source's submitter as a participant (if not already the submitter of target) + if ($source->submitter_id !== $ticket->submitter_id) { + TicketParticipant::firstOrCreate( + ['ticket_id' => $ticket->id, 'user_id' => $source->submitter_id], + ['added_by' => Auth::id()] + ); + } + + // Mark source as merged + $source->update([ + 'status' => 'merged', + 'merged_into_id' => $ticket->id, + ]); + }); + + return redirect()->route('ticketing.show', $ticket) + ->with('success', "Ticket {$source->number} merged in."); + } + + /** + * Split this ticket — create a copy with a new number, same metadata, duplicated messages. + */ + public function split(Request $request, Ticket $ticket) + { + if (! $this->isAgent($ticket->group_id)) { + abort(403); + } + + $newTicket = DB::transaction(function () use ($ticket) { + $group = TicketingGroup::findOrFail($ticket->group_id); + $number = $group->nextTicketNumber(); + + $newTicket = Ticket::create([ + 'number' => $number, + 'group_id' => $ticket->group_id, + 'submitter_id' => $ticket->submitter_id, + 'assigned_to' => $ticket->assigned_to, + 'project_id' => $ticket->project_id, + 'title' => $ticket->title . ' (split)', + 'description' => $ticket->description, + 'status' => 'open', + 'priority_id' => $ticket->priority_id, + 'due_date' => $ticket->due_date, + ]); + + // Duplicate messages (exclude system notes) + $ticket->load('messages'); + foreach ($ticket->messages as $msg) { + TicketMessage::create([ + 'ticket_id' => $newTicket->id, + 'user_id' => $msg->user_id, + 'author_email' => $msg->author_email, + 'body' => $msg->body, + 'is_internal' => $msg->is_internal, + 'source' => $msg->source, + ]); + } + + // Cross-reference notes on both tickets + TicketMessage::create([ + 'ticket_id' => $ticket->id, + 'user_id' => Auth::id(), + 'body' => "Split: new ticket {$newTicket->number} created from this ticket.", + 'is_internal' => true, + 'source' => 'system', + ]); + TicketMessage::create([ + 'ticket_id' => $newTicket->id, + 'user_id' => Auth::id(), + 'body' => "Split from ticket {$ticket->number}.", + 'is_internal' => true, + 'source' => 'system', + ]); + + return $newTicket; + }); + + return redirect()->route('ticketing.show', $newTicket) + ->with('success', "Ticket {$newTicket->number} created from split."); + } + + /** + * Add a participant to a ticket. + */ + public function addParticipant(Request $request, Ticket $ticket) + { + if (! $this->isAgent($ticket->group_id)) { + abort(403); + } + + $validated = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + ]); + + TicketParticipant::firstOrCreate( + ['ticket_id' => $ticket->id, 'user_id' => $validated['user_id']], + ['added_by' => Auth::id()] + ); + + return back()->with('success', 'Participant added.'); + } + + /** + * Remove a participant from a ticket. + */ + public function removeParticipant(Ticket $ticket, int $userId) + { + if (! $this->isAgent($ticket->group_id)) { + abort(403); + } + + TicketParticipant::where('ticket_id', $ticket->id) + ->where('user_id', $userId) + ->delete(); + + return back()->with('success', 'Participant removed.'); + } + + /** + * Search users by name/email — for the participant picker. + * Returns up to 10 matches, excluding existing participants and the submitter. + */ + public function searchUsers(Request $request, Ticket $ticket) + { + if (! $this->isAgent($ticket->group_id)) { + abort(403); + } + + $q = $request->input('q', ''); + if (strlen($q) < 2) { + return response()->json([]); + } + + $excludeIds = $ticket->participants->pluck('user_id') + ->push($ticket->submitter_id) + ->filter() + ->unique() + ->values(); + + $users = DB::table('users') + ->where(function ($query) use ($q) { + $query->where('name', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }) + ->whereNotIn('id', $excludeIds) + ->limit(10) + ->get(['id', 'name', 'email']); + + return response()->json($users); + } + public function myTickets(): Response { $tickets = Ticket::where('submitter_id', Auth::id()) diff --git a/src/Http/Controllers/TicketingSettingsController.php b/src/Http/Controllers/TicketingSettingsController.php index 5f50036..1ca9951 100644 --- a/src/Http/Controllers/TicketingSettingsController.php +++ b/src/Http/Controllers/TicketingSettingsController.php @@ -2,12 +2,13 @@ namespace Dashboard\Ticketing\Http\Controllers; +use App\Models\Setting; use Dashboard\Ticketing\Models\EmailConnection; use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\TicketingAgentAccess; use Dashboard\Ticketing\Models\TicketingGroup; use Dashboard\Ticketing\Models\TicketingProject; -use Illuminate\Validation\Rule; +use Dashboard\Ticketing\Models\TicketStatus; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; @@ -77,7 +78,7 @@ class TicketingSettingsController extends Controller $agents = $isBootstrap ? collect() - : TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get(); + : TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get(); if ($agents->isNotEmpty()) { $agentUserIds = $agents->pluck('user_id')->unique(); @@ -87,8 +88,7 @@ class TicketingSettingsController extends Controller $priorities = $isBootstrap ? collect() - : PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds)) - ->orderBy('sort_order')->get(); + : PriorityLevel::orderBy('sort_order')->get(); $projects = $isBootstrap ? collect() @@ -101,14 +101,16 @@ class TicketingSettingsController extends Controller : EmailConnection::whereIn('group_id', $myGroupIds)->get(); return Inertia::render('Ticketing/Settings', [ - 'groups' => $groups, - 'agents' => $agents, - 'priorities' => $priorities, - 'projects' => $projects, + 'groups' => $groups, + 'agents' => $agents, + 'priorities' => $priorities, + 'projects' => $projects, + 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(), 'emailConnections' => $emailConnections, - 'myGroupIds' => $myGroupIds, - 'isBootstrap' => $isBootstrap, - 'isSiteAdmin' => $this->isSiteAdmin(), + 'myGroupIds' => $myGroupIds, + 'isBootstrap' => $isBootstrap, + 'isSiteAdmin' => $this->isSiteAdmin(), + 'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0), ]); } @@ -150,11 +152,10 @@ class TicketingSettingsController extends Controller ]; foreach ($defaults as $d) { PriorityLevel::create([ - 'group_id' => $group->id, - 'name' => $d['name'], - 'color' => $d['color'], + 'name' => $d['name'], + 'color' => $d['color'], 'description' => null, - 'sort_order' => $d['sort_order'], + 'sort_order' => $d['sort_order'], ]); } } @@ -209,21 +210,17 @@ class TicketingSettingsController extends Controller public function storePriority(Request $request) { - $this->requireAgentAccess(); + if (!$this->isSiteAdmin()) { + abort(403, 'Only site admins can manage global priorities.'); + } $validated = $request->validate([ - 'name' => 'required|string|max:100', - 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'name' => 'required|string|max:100', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', 'description' => 'nullable|string', - 'sort_order' => 'integer|min:0', - 'group_id' => 'nullable|exists:ticketing_groups,id', + 'sort_order' => 'integer|min:0', ]); - // If group_id given, caller must be manager of that group - if (!empty($validated['group_id'])) { - $this->requireManagerAccess($validated['group_id']); - } - PriorityLevel::create($validated); return back()->with('success', 'Priority level created.'); @@ -231,33 +228,17 @@ class TicketingSettingsController extends Controller public function updatePriority(Request $request, PriorityLevel $priority) { - $this->requireAgentAccess(); - - if ($priority->group_id) { - $this->requireManagerAccess($priority->group_id); - } else { - // Global priorities require site admin - if (!$this->isSiteAdmin()) { - abort(403, 'Only site admins can manage global priorities.'); - } + if (!$this->isSiteAdmin()) { + abort(403, 'Only site admins can manage global priorities.'); } $validated = $request->validate([ - 'name' => 'required|string|max:100', - 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'name' => 'required|string|max:100', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', 'description' => 'nullable|string', - 'sort_order' => 'required|integer|min:0', - 'group_id' => [ - 'nullable', - 'exists:ticketing_groups,id', - Rule::in([$priority->group_id, null]), - ], + 'sort_order' => 'required|integer|min:0', ]); - if (!empty($validated['group_id'])) { - $this->requireManagerAccess($validated['group_id']); - } - $priority->update($validated); return back()->with('success', 'Priority level updated.'); @@ -265,15 +246,8 @@ class TicketingSettingsController extends Controller public function destroyPriority(PriorityLevel $priority) { - $this->requireAgentAccess(); - - if ($priority->group_id) { - $this->requireManagerAccess($priority->group_id); - } else { - // Global priorities require site admin - if (!$this->isSiteAdmin()) { - abort(403, 'Only site admins can manage global priorities.'); - } + if (!$this->isSiteAdmin()) { + abort(403, 'Only site admins can manage global priorities.'); } if ($priority->tickets()->exists()) { @@ -400,4 +374,97 @@ class TicketingSettingsController extends Controller $connection->delete(); return back()->with('success', 'Email connection removed.'); } + + public function storeStatus(Request $request) + { + if (! $this->isSiteAdmin()) { + abort(403, 'Only site admins can manage ticket statuses.'); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'sort_order' => 'required|integer|min:0', + 'is_closed' => 'boolean', + ]); + + // Derive a slug from the name (lowercase, underscores) — must be unique + $slug = \Str::slug($validated['name'], '_'); + if (TicketStatus::where('slug', $slug)->exists()) { + $slug = $slug . '_' . time(); + } + + TicketStatus::create([ + 'slug' => $slug, + 'name' => $validated['name'], + 'color' => $validated['color'], + 'sort_order' => $validated['sort_order'], + 'is_closed' => $request->boolean('is_closed', false), + 'is_system' => false, + ]); + + return back()->with('success', 'Status created.'); + } + + public function updateStatus(Request $request, TicketStatus $status) + { + if (! $this->isSiteAdmin()) { + abort(403, 'Only site admins can manage ticket statuses.'); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', + 'sort_order' => 'required|integer|min:0', + 'is_closed' => 'boolean', + ]); + + // System statuses: only allow name, color, sort_order to be changed — not is_closed + $update = [ + 'name' => $validated['name'], + 'color' => $validated['color'], + 'sort_order' => $validated['sort_order'], + ]; + if (! $status->is_system) { + $update['is_closed'] = $request->boolean('is_closed', false); + } + + $status->update($update); + + return back()->with('success', 'Status updated.'); + } + + public function destroyStatus(TicketStatus $status) + { + if (! $this->isSiteAdmin()) { + abort(403, 'Only site admins can manage ticket statuses.'); + } + + if ($status->is_system) { + return back()->withErrors(['status' => 'System statuses cannot be deleted.']); + } + + if ($status->tickets()->exists()) { + return back()->withErrors(['status' => 'Cannot delete a status that is in use by tickets.']); + } + + $status->delete(); + + return back()->with('success', 'Status removed.'); + } + + public function updateAutomation(Request $request) + { + if (! $this->isSiteAdmin()) { + abort(403, 'Only site admins can manage automation settings.'); + } + + $validated = $request->validate([ + 'auto_close_days' => 'required|integer|min:0|max:365', + ]); + + Setting::set('ticketing.auto_close_days', $validated['auto_close_days']); + + return back()->with('success', 'Automation settings saved.'); + } } diff --git a/src/Models/PriorityLevel.php b/src/Models/PriorityLevel.php index cc7475d..6e9f287 100644 --- a/src/Models/PriorityLevel.php +++ b/src/Models/PriorityLevel.php @@ -3,19 +3,13 @@ namespace Dashboard\Ticketing\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class PriorityLevel extends Model { protected $table = 'ticketing_priority_levels'; - protected $fillable = ['group_id', 'name', 'color', 'description', 'sort_order']; - - public function group(): BelongsTo - { - return $this->belongsTo(TicketingGroup::class, 'group_id'); - } + protected $fillable = ['name', 'color', 'description', 'sort_order']; public function tickets(): HasMany { diff --git a/src/Models/Ticket.php b/src/Models/Ticket.php index 8140811..3426604 100644 --- a/src/Models/Ticket.php +++ b/src/Models/Ticket.php @@ -10,7 +10,7 @@ class Ticket extends Model { protected $fillable = [ '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', ]; protected $casts = [ @@ -41,4 +41,19 @@ class Ticket extends Model { 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'); + } } diff --git a/src/Models/TicketParticipant.php b/src/Models/TicketParticipant.php new file mode 100644 index 0000000..3a9e175 --- /dev/null +++ b/src/Models/TicketParticipant.php @@ -0,0 +1,20 @@ +belongsTo(Ticket::class, 'ticket_id'); + } +} diff --git a/src/Models/TicketStatus.php b/src/Models/TicketStatus.php new file mode 100644 index 0000000..ce512af --- /dev/null +++ b/src/Models/TicketStatus.php @@ -0,0 +1,23 @@ + 'boolean', + 'is_system' => 'boolean', + ]; + + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'status', 'slug'); + } +} diff --git a/src/Models/TicketView.php b/src/Models/TicketView.php new file mode 100644 index 0000000..4139f94 --- /dev/null +++ b/src/Models/TicketView.php @@ -0,0 +1,20 @@ + 'datetime']; + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/src/TicketingServiceProvider.php b/src/TicketingServiceProvider.php index 519b4b5..97d68a8 100644 --- a/src/TicketingServiceProvider.php +++ b/src/TicketingServiceProvider.php @@ -12,5 +12,11 @@ class TicketingServiceProvider extends ServiceProvider { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadRoutesFrom(__DIR__.'/routes/ticketing.php'); + + if ($this->app->runningInConsole()) { + $this->commands([ + \Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class, + ]); + } } } diff --git a/src/routes/ticketing.php b/src/routes/ticketing.php index 8012774..1e051be 100644 --- a/src/routes/ticketing.php +++ b/src/routes/ticketing.php @@ -14,6 +14,7 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) // ── Fixed paths first (before any /{ticket} wildcards) ──────────────── Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets'); + Route::post('/prefs', [TicketController::class, 'savePrefs']) ->name('prefs.save'); Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show'); Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () { @@ -41,6 +42,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store'); 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::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) ────────── @@ -49,9 +54,15 @@ Route::middleware(['web', 'auth', 'app.access:ticketing']) }); Route::middleware('permission:ticketing.manage')->group(function () { - Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit'); - Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update'); - Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy'); + Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit'); + Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update'); + 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'); + 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 () {