diff --git a/TASKS.md b/TASKS.md index c9c361d..fb07620 100644 --- a/TASKS.md +++ b/TASKS.md @@ -61,7 +61,16 @@ - [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact) - [x] Create navigation configs (account.ts, admin.ts, marketing.ts) - [x] Set purple primary color (#7367F0) matching Vuexy demo -- [x] All 53 tests passing, build clean +- [x] All 114 tests passing, build clean (Phase 1-5 + Frontend + Notifications) + +## Notifications System ✅ +- [x] Create notification classes (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated) +- [x] Configure mail and database notification channels +- [x] Wire notifications to relevant events (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated) +- [x] Build NotificationBell component in Account and Admin layouts +- [x] Implement NotificationController (index, markAsRead, markAllAsRead) +- [x] FlashMessages supports info alerts +- [x] Inertia shared data includes impersonation state ## Phase 3: Provisioning Automation - [x] Create `ProvisioningServiceInterface` abstraction @@ -91,6 +100,22 @@ - [ ] Send credentials email on successful provisioning - [x] Log all provisioning actions to `provisioning_logs` table +## Support Ticket System (Standalone) ✅ +- [x] TicketReply model with relationships +- [x] Updated SupportTicket model with replies() relationship and department field +- [x] Migration: ticket_replies table + department column on support_tickets +- [x] Customer TicketController (index, create, store, show, reply, close) +- [x] Admin TicketController (index with filters, show, reply, updateStatus) +- [x] SupportTicketFactory with open/closed/urgent states +- [x] TicketReplyFactory with staffReply state +- [x] Customer Vue pages: Tickets/Index, Tickets/Create, Tickets/Show +- [x] Admin Vue pages: Admin/Tickets/Index, Admin/Tickets/Show +- [x] Ticket status/priority color resolvers +- [x] TypeScript interfaces for SupportTicket and TicketReply +- [x] Navigation items for both account and admin sidebars +- [x] Routes for both account and admin subdomains +- [x] 30 Pest tests (144 total, 775 assertions) + ## Phase 4: Customer Dashboard (account.ezscale.cloud) - [x] Build service overview dashboard: - [x] Active services list with status indicators @@ -109,7 +134,7 @@ - [ ] Payment history - [ ] Manage payment methods (add/remove cards, set default) - [ ] Upcoming renewals -- [ ] Plan upgrade/downgrade flow (self-service with proration) +- [x] Plan upgrade/downgrade flow (self-service with proration) - [ ] Subscription cancellation flow (with optional survey) - [x] Profile and account settings: - [x] Contact information @@ -136,7 +161,7 @@ - [x] Customer list (searchable, filterable) - [x] Customer detail view (profile, services, billing history, notes) - [ ] Edit customer information - - [ ] Impersonate customer (with audit logging) + - [x] Impersonate customer (with audit logging) - [ ] Add admin notes to customer account - [ ] View customer audit log - [x] Service management: @@ -146,10 +171,10 @@ - [x] Terminate service - [ ] Modify service (change plan, extend expiry) - [x] View provisioning logs -- [ ] Order management: - - [ ] Pending orders list - - [ ] Approve/reject orders (for semi-automated provisioning) - - [ ] View order details +- [x] Order management: + - [x] Pending orders list + - [x] Approve/reject orders (for semi-automated provisioning) + - [x] View order details - [x] Invoice management: - [x] All invoices list (filter by status, date, customer) - [ ] Create manual invoice @@ -241,7 +266,7 @@ - [ ] Coupon code application - [ ] Add to cart / checkout flow - [x] About page -- [x] Contact page +- [x] Contact page with form submission backend - [ ] Blog/news section (optional, or use WordPress?) - [ ] Knowledge base / FAQ: - [ ] Getting started guides @@ -253,6 +278,7 @@ - [x] Privacy Policy - [x] Acceptable Use Policy - [x] SLA (Service Level Agreement) + - [x] Footer links to legal pages - [ ] Signup flow: - [ ] Plan selection - [ ] Account creation diff --git a/website/app/Http/Controllers/Account/DashboardController.php b/website/app/Http/Controllers/Account/DashboardController.php index f6b36f3..a4c6090 100644 --- a/website/app/Http/Controllers/Account/DashboardController.php +++ b/website/app/Http/Controllers/Account/DashboardController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; +use App\Models\SupportTicket; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -42,6 +43,11 @@ class DashboardController extends Controller ->orderBy('current_period_end') ->value('current_period_end'); + $openTicketsCount = SupportTicket::query() + ->where('user_id', $user->id) + ->whereIn('status', ['open', 'in_progress', 'waiting']) + ->count(); + return Inertia::render('Dashboard', [ 'activeServicesCount' => $activeServicesCount, 'activeSubscriptionsCount' => $activeSubscriptionsCount, @@ -49,6 +55,7 @@ class DashboardController extends Controller 'latestInvoices' => $latestInvoices, 'pendingInvoicesAmount' => number_format((float) $pendingInvoicesAmount, 2, '.', ''), 'nextRenewalDate' => $nextRenewalDate, + 'openTicketsCount' => $openTicketsCount, ]); } } diff --git a/website/app/Http/Controllers/Account/TicketController.php b/website/app/Http/Controllers/Account/TicketController.php new file mode 100644 index 0000000..1791896 --- /dev/null +++ b/website/app/Http/Controllers/Account/TicketController.php @@ -0,0 +1,104 @@ +where('user_id', $request->user()->id) + ->latest('updated_at') + ->paginate(15) + ->withQueryString(); + + return Inertia::render('Tickets/Index', [ + 'tickets' => $tickets, + ]); + } + + public function create(): Response + { + return Inertia::render('Tickets/Create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'subject' => ['required', 'string', 'max:255'], + 'message' => ['required', 'string', 'min:10', 'max:5000'], + 'priority' => ['required', 'in:low,medium,high,urgent'], + 'department' => ['required', 'in:billing,technical,sales,general'], + ]); + + $ticket = SupportTicket::query()->create([ + 'user_id' => $request->user()->id, + 'subject' => $validated['subject'], + 'status' => 'open', + 'priority' => $validated['priority'], + 'department' => $validated['department'], + 'last_reply_at' => now(), + ]); + + TicketReply::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $request->user()->id, + 'body' => $validated['message'], + 'is_staff_reply' => false, + ]); + + return redirect()->route('account.tickets.show', $ticket) + ->with('success', 'Support ticket created successfully.'); + } + + public function show(Request $request, SupportTicket $ticket): Response + { + abort_unless($ticket->user_id === $request->user()->id, 403); + + $ticket->load(['replies.user:id,name,email', 'user:id,name,email']); + + return Inertia::render('Tickets/Show', [ + 'ticket' => $ticket, + ]); + } + + public function reply(Request $request, SupportTicket $ticket): RedirectResponse + { + abort_unless($ticket->user_id === $request->user()->id, 403); + abort_if($ticket->status === 'closed', 403, 'Cannot reply to a closed ticket.'); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + ]); + + TicketReply::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $request->user()->id, + 'body' => $validated['body'], + 'is_staff_reply' => false, + ]); + + $ticket->update(['last_reply_at' => now()]); + + return redirect()->back()->with('success', 'Reply added successfully.'); + } + + public function close(Request $request, SupportTicket $ticket): RedirectResponse + { + abort_unless($ticket->user_id === $request->user()->id, 403); + + $ticket->update(['status' => 'closed']); + + return redirect()->back()->with('success', 'Ticket has been closed.'); + } +} diff --git a/website/app/Http/Controllers/Admin/TicketController.php b/website/app/Http/Controllers/Admin/TicketController.php new file mode 100644 index 0000000..6e2168a --- /dev/null +++ b/website/app/Http/Controllers/Admin/TicketController.php @@ -0,0 +1,128 @@ +with('user:id,name,email'); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('subject', 'like', "%{$search}%") + ->orWhereHas('user', function ($uq) use ($search): void { + $uq->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + if ($priority = $request->input('priority')) { + $query->where('priority', $priority); + } + + if ($department = $request->input('department')) { + $query->where('department', $department); + } + + $tickets = $query->latest('updated_at') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('Admin/Tickets/Index', [ + 'tickets' => $tickets, + 'filters' => [ + 'search' => $request->input('search', ''), + 'status' => $request->input('status', ''), + 'priority' => $request->input('priority', ''), + 'department' => $request->input('department', ''), + ], + ]); + } + + public function show(SupportTicket $ticket): Response + { + $ticket->load([ + 'replies.user:id,name,email', + 'user:id,name,email,status,company', + ]); + + return Inertia::render('Admin/Tickets/Show', [ + 'ticket' => $ticket, + ]); + } + + public function reply(Request $request, SupportTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + 'status' => ['nullable', 'in:open,in_progress,waiting,closed'], + ]); + + TicketReply::query()->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $request->user()->id, + 'body' => $validated['body'], + 'is_staff_reply' => true, + ]); + + $updateData = ['last_reply_at' => now()]; + + if (! empty($validated['status'])) { + $updateData['status'] = $validated['status']; + } + + $ticket->update($updateData); + + AuditLog::query()->create([ + 'user_id' => $ticket->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'reply_ticket', + 'resource_type' => 'support_ticket', + 'resource_id' => $ticket->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return redirect()->back()->with('success', 'Reply sent successfully.'); + } + + public function updateStatus(Request $request, SupportTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'status' => ['required', 'in:open,in_progress,waiting,closed'], + ]); + + $ticket->update(['status' => $validated['status']]); + + AuditLog::query()->create([ + 'user_id' => $ticket->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'update_ticket_status', + 'resource_type' => 'support_ticket', + 'resource_id' => $ticket->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'details' => json_encode(['status' => $validated['status']]), + ]); + + return redirect()->back()->with('success', 'Ticket status updated.'); + } +} diff --git a/website/app/Models/SupportTicket.php b/website/app/Models/SupportTicket.php index 6173990..e4992d8 100644 --- a/website/app/Models/SupportTicket.php +++ b/website/app/Models/SupportTicket.php @@ -7,6 +7,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class SupportTicket extends Model { @@ -18,9 +19,11 @@ class SupportTicket extends Model 'subject', 'status', 'priority', + 'department', 'last_reply_at', ]; + /** @return array */ protected function casts(): array { return [ @@ -33,4 +36,9 @@ class SupportTicket extends Model { return $this->belongsTo(User::class); } + + public function replies(): HasMany + { + return $this->hasMany(TicketReply::class, 'ticket_id'); + } } diff --git a/website/app/Models/TicketReply.php b/website/app/Models/TicketReply.php new file mode 100644 index 0000000..399e386 --- /dev/null +++ b/website/app/Models/TicketReply.php @@ -0,0 +1,39 @@ + */ + protected function casts(): array + { + return [ + 'is_staff_reply' => 'boolean', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(SupportTicket::class, 'ticket_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/website/database/factories/SupportTicketFactory.php b/website/database/factories/SupportTicketFactory.php new file mode 100644 index 0000000..11d9479 --- /dev/null +++ b/website/database/factories/SupportTicketFactory.php @@ -0,0 +1,51 @@ + + */ +class SupportTicketFactory extends Factory +{ + protected $model = SupportTicket::class; + + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'subject' => fake()->sentence(), + 'status' => fake()->randomElement(['open', 'in_progress', 'waiting', 'closed']), + 'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']), + 'department' => fake()->randomElement(['billing', 'technical', 'sales', 'general']), + 'last_reply_at' => fake()->optional(0.7)->dateTimeBetween('-30 days', 'now'), + ]; + } + + public function open(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'open', + ]); + } + + public function closed(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'closed', + ]); + } + + public function urgent(): static + { + return $this->state(fn (array $attributes): array => [ + 'priority' => 'urgent', + ]); + } +} diff --git a/website/database/factories/TicketReplyFactory.php b/website/database/factories/TicketReplyFactory.php new file mode 100644 index 0000000..43508ea --- /dev/null +++ b/website/database/factories/TicketReplyFactory.php @@ -0,0 +1,36 @@ + + */ +class TicketReplyFactory extends Factory +{ + protected $model = TicketReply::class; + + /** @return array */ + public function definition(): array + { + return [ + 'ticket_id' => SupportTicket::factory(), + 'user_id' => User::factory(), + 'body' => fake()->paragraphs(2, true), + 'is_staff_reply' => false, + ]; + } + + public function staffReply(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_staff_reply' => true, + ]); + } +} diff --git a/website/database/migrations/2026_02_09_200000_create_ticket_replies_table.php b/website/database/migrations/2026_02_09_200000_create_ticket_replies_table.php new file mode 100644 index 0000000..98df50b --- /dev/null +++ b/website/database/migrations/2026_02_09_200000_create_ticket_replies_table.php @@ -0,0 +1,42 @@ +string('department')->default('general')->after('priority'); + }); + } + + Schema::create('ticket_replies', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('support_tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->boolean('is_staff_reply')->default(false); + $table->timestamps(); + + $table->index('ticket_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_replies'); + + if (Schema::hasColumn('support_tickets', 'department')) { + Schema::table('support_tickets', function (Blueprint $table): void { + $table->dropColumn('department'); + }); + } + } +}; diff --git a/website/resources/ts/Pages/Admin/Tickets/Index.vue b/website/resources/ts/Pages/Admin/Tickets/Index.vue new file mode 100644 index 0000000..d23c70a --- /dev/null +++ b/website/resources/ts/Pages/Admin/Tickets/Index.vue @@ -0,0 +1,233 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Tickets/Show.vue b/website/resources/ts/Pages/Admin/Tickets/Show.vue new file mode 100644 index 0000000..fd3a0af --- /dev/null +++ b/website/resources/ts/Pages/Admin/Tickets/Show.vue @@ -0,0 +1,397 @@ + + + diff --git a/website/resources/ts/Pages/Dashboard.vue b/website/resources/ts/Pages/Dashboard.vue index 040f2ab..c5b380f 100644 --- a/website/resources/ts/Pages/Dashboard.vue +++ b/website/resources/ts/Pages/Dashboard.vue @@ -13,6 +13,7 @@ interface Props { latestInvoices: Invoice[] pendingInvoicesAmount: string nextRenewalDate: string | null + openTicketsCount: number } defineOptions({ layout: AccountLayout }) @@ -378,10 +379,8 @@ const unpaidInvoices = computed(() => { - (() => { icon="tabler-headset" start /> - Get Support - - + diff --git a/website/resources/ts/Pages/Subscriptions/Show.vue b/website/resources/ts/Pages/Subscriptions/Show.vue index 8f55ed6..6562158 100644 --- a/website/resources/ts/Pages/Subscriptions/Show.vue +++ b/website/resources/ts/Pages/Subscriptions/Show.vue @@ -1,8 +1,10 @@ diff --git a/website/resources/ts/Pages/Tickets/Create.vue b/website/resources/ts/Pages/Tickets/Create.vue new file mode 100644 index 0000000..a4021d0 --- /dev/null +++ b/website/resources/ts/Pages/Tickets/Create.vue @@ -0,0 +1,114 @@ + + + diff --git a/website/resources/ts/Pages/Tickets/Index.vue b/website/resources/ts/Pages/Tickets/Index.vue new file mode 100644 index 0000000..e7a0508 --- /dev/null +++ b/website/resources/ts/Pages/Tickets/Index.vue @@ -0,0 +1,127 @@ + + + diff --git a/website/resources/ts/Pages/Tickets/Show.vue b/website/resources/ts/Pages/Tickets/Show.vue new file mode 100644 index 0000000..2539334 --- /dev/null +++ b/website/resources/ts/Pages/Tickets/Show.vue @@ -0,0 +1,225 @@ + + + diff --git a/website/resources/ts/navigation/account.ts b/website/resources/ts/navigation/account.ts index 4c951be..515a3fc 100644 --- a/website/resources/ts/navigation/account.ts +++ b/website/resources/ts/navigation/account.ts @@ -11,5 +11,6 @@ export const accountNavItems: NavItem[] = [ { title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' }, { title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' }, { title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' }, + { title: 'Support', href: '/tickets', icon: 'tabler-headset', matchPrefix: '/tickets' }, { title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' }, ] diff --git a/website/resources/ts/navigation/admin.ts b/website/resources/ts/navigation/admin.ts index ae5c32a..fa4c488 100644 --- a/website/resources/ts/navigation/admin.ts +++ b/website/resources/ts/navigation/admin.ts @@ -8,6 +8,7 @@ export const adminNavItems: NavItem[] = [ { title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' }, { title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' }, { title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' }, + { title: 'Tickets', href: '/tickets', icon: 'tabler-message-circle', matchPrefix: '/tickets' }, { title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' }, { title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' }, ] diff --git a/website/resources/ts/types/index.ts b/website/resources/ts/types/index.ts index 21c7164..16eeb78 100644 --- a/website/resources/ts/types/index.ts +++ b/website/resources/ts/types/index.ts @@ -163,4 +163,39 @@ export interface CouponRedemption { } } +export interface SupportTicket { + id: number + user_id: number + subject: string + status: 'open' | 'in_progress' | 'waiting' | 'closed' + priority: 'low' | 'medium' | 'high' | 'urgent' + department: 'billing' | 'technical' | 'sales' | 'general' + last_reply_at: string | null + created_at: string + updated_at: string + user?: { + id: number + name: string + email: string + status?: string + company?: string | null + } + replies?: TicketReply[] +} + +export interface TicketReply { + id: number + ticket_id: number + user_id: number + body: string + is_staff_reply: boolean + created_at: string + updated_at: string + user?: { + id: number + name: string + email: string + } +} + export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary' diff --git a/website/resources/ts/utils/resolvers.ts b/website/resources/ts/utils/resolvers.ts index f42799e..c1700ab 100644 --- a/website/resources/ts/utils/resolvers.ts +++ b/website/resources/ts/utils/resolvers.ts @@ -74,6 +74,26 @@ export function resolvePlatformUrl(platform: string, platformServiceId: string | return urls[platform] ?? null } +export function resolveTicketStatusColor(status: string): StatusColor { + const map: Record = { + open: 'info', + in_progress: 'success', + waiting: 'warning', + closed: 'secondary', + } + return map[status] ?? 'secondary' +} + +export function resolveTicketPriorityColor(priority: string): StatusColor { + const map: Record = { + low: 'success', + medium: 'info', + high: 'warning', + urgent: 'error', + } + return map[priority] ?? 'secondary' +} + export function formatPrice(price: string | number, cycle?: string): string { const amount = parseFloat(String(price)).toFixed(2) return cycle ? `$${amount}/${cycle}` : `$${amount}` diff --git a/website/routes/account.php b/website/routes/account.php index 292e643..02baf34 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController; use App\Http\Controllers\Account\ProfileController; use App\Http\Controllers\Account\ServiceController; use App\Http\Controllers\Account\SubscriptionController; +use App\Http\Controllers\Account\TicketController; use App\Http\Controllers\Account\UpgradeController; use Illuminate\Support\Facades\Route; @@ -53,6 +54,11 @@ Route::get('/billing/invoices/{invoice}/download', [BillingController::class, 'd Route::get('/billing/transactions', [BillingController::class, 'transactions'])->name('account.billing.transactions'); Route::post('/billing/setup-intent', [BillingController::class, 'setupIntent'])->name('account.billing.setup-intent'); +// Support Tickets +Route::resource('tickets', TicketController::class)->only(['index', 'create', 'store', 'show'])->names('account.tickets'); +Route::post('/tickets/{ticket}/reply', [TicketController::class, 'reply'])->name('account.tickets.reply'); +Route::post('/tickets/{ticket}/close', [TicketController::class, 'close'])->name('account.tickets.close'); + // Notifications Route::get('/notifications', [NotificationController::class, 'index'])->name('account.notifications.index'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('account.notifications.read'); diff --git a/website/routes/admin.php b/website/routes/admin.php index d6e529b..8f3a4b1 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\OrderController; use App\Http\Controllers\Admin\PlanController; use App\Http\Controllers\Admin\ServiceController; use App\Http\Controllers\Admin\SettingsController; +use App\Http\Controllers\Admin\TicketController as AdminTicketController; use Illuminate\Support\Facades\Route; Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard'); @@ -57,6 +58,14 @@ Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index'); Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update'); +// Support Tickets +Route::resource('tickets', AdminTicketController::class)->only(['index', 'show'])->names([ + 'index' => 'admin.tickets.index', + 'show' => 'admin.tickets.show', +]); +Route::post('tickets/{ticket}/reply', [AdminTicketController::class, 'reply'])->name('admin.tickets.reply'); +Route::put('tickets/{ticket}/status', [AdminTicketController::class, 'updateStatus'])->name('admin.tickets.status'); + // Impersonation Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start'); Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop'); diff --git a/website/tests/Feature/SupportTicketTest.php b/website/tests/Feature/SupportTicketTest.php new file mode 100644 index 0000000..77ee0d4 --- /dev/null +++ b/website/tests/Feature/SupportTicketTest.php @@ -0,0 +1,451 @@ +seed(RoleAndPermissionSeeder::class); + $this->accountUrl = 'http://'.config('app.domains.account'); + $this->adminUrl = 'http://'.config('app.domains.admin'); +}); + +// --------------------------------------------------------------------------- +// Customer Ticket Management +// --------------------------------------------------------------------------- +describe('Customer Ticket List', function (): void { + it('allows a customer to view their ticket list', function (): void { + $customer = User::factory()->customer()->create(); + SupportTicket::factory()->count(3)->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/tickets') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Tickets/Index') + ->has('tickets.data', 3) + ); + }); + + it('customer cannot see other users tickets', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + + SupportTicket::factory()->count(2)->create(['user_id' => $customer->id]); + SupportTicket::factory()->count(3)->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/tickets') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Tickets/Index') + ->has('tickets.data', 2) + ); + }); + + it('redirects guests to login when accessing ticket list', function (): void { + $this->get($this->accountUrl.'/tickets') + ->assertRedirect(); + }); +}); + +describe('Customer Ticket Creation', function (): void { + it('displays the create ticket form', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->accountUrl.'/tickets/create') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Tickets/Create') + ); + }); + + it('allows a customer to create a ticket', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets', [ + 'subject' => 'Unable to access VPS', + 'department' => 'technical', + 'priority' => 'high', + 'message' => 'I cannot SSH into my VPS server. Please help.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('support_tickets', [ + 'user_id' => $customer->id, + 'subject' => 'Unable to access VPS', + 'department' => 'technical', + 'priority' => 'high', + 'status' => 'open', + ]); + + $ticket = SupportTicket::where('user_id', $customer->id)->first(); + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $customer->id, + 'body' => 'I cannot SSH into my VPS server. Please help.', + 'is_staff_reply' => false, + ]); + }); + + it('validates required fields when creating a ticket', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets', []) + ->assertSessionHasErrors(['subject', 'department', 'priority', 'message']); + }); + + it('requires authentication to create a ticket', function (): void { + $this->get($this->accountUrl.'/tickets/create') + ->assertRedirect(); + }); +}); + +describe('Customer Ticket Detail', function (): void { + it('allows a customer to view their own ticket detail', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->create(['user_id' => $customer->id]); + TicketReply::factory()->count(2)->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $customer->id, + ]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/tickets/'.$ticket->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Tickets/Show') + ->has('ticket') + ->has('ticket.replies', 2) + ->where('ticket.id', $ticket->id) + ); + }); + + it('forbids a customer from viewing another users ticket', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->get($this->accountUrl.'/tickets/'.$ticket->id) + ->assertForbidden(); + }); +}); + +describe('Customer Ticket Reply', function (): void { + it('allows a customer to reply to their open ticket', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'Here is additional information about my issue.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $customer->id, + 'body' => 'Here is additional information about my issue.', + 'is_staff_reply' => false, + ]); + + expect($ticket->fresh()->last_reply_at)->not->toBeNull(); + }); + + it('customer cannot reply to a closed ticket', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'Trying to reply to closed ticket.', + ]) + ->assertForbidden(); + }); + + it('customer cannot reply to another users ticket', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'Unauthorized reply attempt.', + ]) + ->assertForbidden(); + }); + + it('validates reply body is required', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', []) + ->assertSessionHasErrors(['body']); + }); +}); + +describe('Customer Ticket Close', function (): void { + it('allows a customer to close their ticket', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/close') + ->assertRedirect(); + + expect($ticket->fresh()->status)->toBe('closed'); + }); + + it('customer cannot close another users ticket', function (): void { + $customer = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $otherUser->id]); + + $this->actingAs($customer) + ->post($this->accountUrl.'/tickets/'.$ticket->id.'/close') + ->assertForbidden(); + }); +}); + +// --------------------------------------------------------------------------- +// Admin Ticket Management +// --------------------------------------------------------------------------- +describe('Admin Ticket List', function (): void { + it('allows admin to view all tickets', function (): void { + $admin = User::factory()->admin()->create(); + $customer1 = User::factory()->customer()->create(); + $customer2 = User::factory()->customer()->create(); + + SupportTicket::factory()->count(2)->create(['user_id' => $customer1->id]); + SupportTicket::factory()->count(3)->create(['user_id' => $customer2->id]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/tickets') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Tickets/Index') + ->has('tickets.data', 5) + ->has('filters') + ); + }); + + it('allows admin to filter tickets by status', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + + SupportTicket::factory()->count(2)->create([ + 'user_id' => $customer->id, + 'status' => 'open', + ]); + SupportTicket::factory()->count(3)->create([ + 'user_id' => $customer->id, + 'status' => 'closed', + ]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/tickets?status=open') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Tickets/Index') + ->has('tickets.data', 2) + ); + }); + + it('allows admin to filter tickets by priority', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + + SupportTicket::factory()->count(1)->urgent()->create(['user_id' => $customer->id]); + SupportTicket::factory()->count(2)->create([ + 'user_id' => $customer->id, + 'priority' => 'low', + ]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/tickets?priority=urgent') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Tickets/Index') + ->has('tickets.data', 1) + ); + }); + + it('allows admin to filter tickets by department', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + + SupportTicket::factory()->count(2)->create([ + 'user_id' => $customer->id, + 'department' => 'billing', + ]); + SupportTicket::factory()->count(3)->create([ + 'user_id' => $customer->id, + 'department' => 'technical', + ]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/tickets?department=billing') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Tickets/Index') + ->has('tickets.data', 2) + ); + }); + + it('denies customer access to admin ticket list', function (): void { + $customer = User::factory()->customer()->create(); + + $this->actingAs($customer) + ->get($this->adminUrl.'/tickets') + ->assertForbidden(); + }); + + it('redirects guest to login when accessing admin ticket list', function (): void { + $this->get($this->adminUrl.'/tickets') + ->assertRedirect(); + }); +}); + +describe('Admin Ticket Detail', function (): void { + it('allows admin to view any ticket', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->create(['user_id' => $customer->id]); + TicketReply::factory()->count(3)->create(['ticket_id' => $ticket->id]); + + $this->actingAs($admin) + ->get($this->adminUrl.'/tickets/'.$ticket->id) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Tickets/Show') + ->has('ticket') + ->has('ticket.replies', 3) + ->where('ticket.id', $ticket->id) + ); + }); +}); + +describe('Admin Ticket Reply', function (): void { + it('allows admin to reply to a ticket as staff', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'Thank you for your inquiry. We are looking into this.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $admin->id, + 'body' => 'Thank you for your inquiry. We are looking into this.', + 'is_staff_reply' => true, + ]); + + expect($ticket->fresh()->last_reply_at)->not->toBeNull(); + }); + + it('admin can reply to a closed ticket', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', [ + 'body' => 'Additional information regarding this closed ticket.', + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $admin->id, + 'body' => 'Additional information regarding this closed ticket.', + 'is_staff_reply' => true, + ]); + }); + + it('validates reply body is required for admin', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->post($this->adminUrl.'/tickets/'.$ticket->id.'/reply', []) + ->assertSessionHasErrors(['body']); + }); +}); + +describe('Admin Ticket Status Update', function (): void { + it('allows admin to update ticket status', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [ + 'status' => 'in_progress', + ]) + ->assertRedirect(); + + expect($ticket->fresh()->status)->toBe('in_progress'); + }); + + it('allows admin to change ticket to closed status', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [ + 'status' => 'closed', + ]) + ->assertRedirect(); + + expect($ticket->fresh()->status)->toBe('closed'); + }); + + it('allows admin to reopen a closed ticket', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->closed()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [ + 'status' => 'open', + ]) + ->assertRedirect(); + + expect($ticket->fresh()->status)->toBe('open'); + }); + + it('validates status is required', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($admin) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', []) + ->assertSessionHasErrors(['status']); + }); + + it('denies customer access to status update endpoint', function (): void { + $customer = User::factory()->customer()->create(); + $ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->put($this->adminUrl.'/tickets/'.$ticket->id.'/status', [ + 'status' => 'closed', + ]) + ->assertForbidden(); + }); +});