Add email integration for ticket system and IMAP inbound support
Email integration: IMAP polling via webklex/php-imap (M365/Zoho/MIAB
compatible), TicketEmailProcessor with 3-strategy ticket matching
(In-Reply-To, References, subject line [EZSCALE-{id}]), signature and
quote stripping, scheduled command every 2min.
Outbound: 4 notification classes (TicketCreated, CustomerReply,
StaffReply, StatusChanged) with email threading headers via
withSymfonyMessage(). Staff replies set Reply-To: support@ezscale.cloud.
Frontend: ticket reference chips on all pages, "Via Email" badges on
email-originated replies. 12 new tests (163 total, 816 assertions).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,4 +86,16 @@ ENHANCE_API_KEY=
|
|||||||
SCREENSHOT_AUTH_ENABLED=false
|
SCREENSHOT_AUTH_ENABLED=false
|
||||||
SCREENSHOT_TOKEN=
|
SCREENSHOT_TOKEN=
|
||||||
|
|
||||||
|
# Support Ticket Email Integration
|
||||||
|
SUPPORT_EMAIL=support@ezscale.cloud
|
||||||
|
TICKET_EMAIL_POLLING=false
|
||||||
|
IMAP_HOST=outlook.office365.com
|
||||||
|
IMAP_PORT=993
|
||||||
|
IMAP_ENCRYPTION=ssl
|
||||||
|
IMAP_USERNAME=
|
||||||
|
IMAP_PASSWORD=
|
||||||
|
IMAP_VALIDATE_CERT=true
|
||||||
|
IMAP_PROTOCOL=imap
|
||||||
|
IMAP_FOLDER=INBOX
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
48
website/app/Console/Commands/ProcessTicketEmails.php
Normal file
48
website/app/Console/Commands/ProcessTicketEmails.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Tickets\EmailServiceInterface;
|
||||||
|
use App\Services\Tickets\TicketEmailProcessor;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProcessTicketEmails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tickets:process-emails';
|
||||||
|
|
||||||
|
protected $description = 'Fetch and process incoming support ticket emails via IMAP';
|
||||||
|
|
||||||
|
public function handle(EmailServiceInterface $emailService, TicketEmailProcessor $processor): int
|
||||||
|
{
|
||||||
|
if (! config('tickets.polling_enabled')) {
|
||||||
|
$this->warn('Ticket email polling is disabled. Set TICKET_EMAIL_POLLING=true to enable.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emails = $emailService->fetchNewEmails();
|
||||||
|
$count = count($emails);
|
||||||
|
|
||||||
|
foreach ($emails as $email) {
|
||||||
|
$processor->processEmail($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Processed {$count} email(s).");
|
||||||
|
Log::info('Ticket email processing complete', ['count' => $count]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Failed to process ticket emails: '.$e->getMessage());
|
||||||
|
Log::error('Ticket email processing failed', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,12 @@ namespace App\Http\Controllers\Account;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\SupportTicket;
|
use App\Models\SupportTicket;
|
||||||
use App\Models\TicketReply;
|
use App\Models\TicketReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\TicketCreatedNotification;
|
||||||
|
use App\Notifications\TicketCustomerReplyNotification;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -57,6 +61,10 @@ class TicketController extends Controller
|
|||||||
'is_staff_reply' => false,
|
'is_staff_reply' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Notify all admins about the new ticket
|
||||||
|
$admins = User::role('admin')->get();
|
||||||
|
Notification::send($admins, new TicketCreatedNotification($ticket));
|
||||||
|
|
||||||
return redirect()->route('account.tickets.show', $ticket)
|
return redirect()->route('account.tickets.show', $ticket)
|
||||||
->with('success', 'Support ticket created successfully.');
|
->with('success', 'Support ticket created successfully.');
|
||||||
}
|
}
|
||||||
@@ -81,7 +89,7 @@ class TicketController extends Controller
|
|||||||
'body' => ['required', 'string', 'max:5000'],
|
'body' => ['required', 'string', 'max:5000'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TicketReply::query()->create([
|
$reply = TicketReply::query()->create([
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'body' => $validated['body'],
|
'body' => $validated['body'],
|
||||||
@@ -90,6 +98,10 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
$ticket->update(['last_reply_at' => now()]);
|
$ticket->update(['last_reply_at' => now()]);
|
||||||
|
|
||||||
|
// Notify all admins about the customer reply
|
||||||
|
$admins = User::role('admin')->get();
|
||||||
|
Notification::send($admins, new TicketCustomerReplyNotification($ticket, $reply));
|
||||||
|
|
||||||
return redirect()->back()->with('success', 'Reply added successfully.');
|
return redirect()->back()->with('success', 'Reply added successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\SupportTicket;
|
use App\Models\SupportTicket;
|
||||||
use App\Models\TicketReply;
|
use App\Models\TicketReply;
|
||||||
|
use App\Notifications\TicketStaffReplyNotification;
|
||||||
|
use App\Notifications\TicketStatusChangedNotification;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -76,7 +78,7 @@ class TicketController extends Controller
|
|||||||
'status' => ['nullable', 'in:open,in_progress,waiting,closed'],
|
'status' => ['nullable', 'in:open,in_progress,waiting,closed'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TicketReply::query()->create([
|
$reply = TicketReply::query()->create([
|
||||||
'ticket_id' => $ticket->id,
|
'ticket_id' => $ticket->id,
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'body' => $validated['body'],
|
'body' => $validated['body'],
|
||||||
@@ -91,6 +93,9 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
$ticket->update($updateData);
|
$ticket->update($updateData);
|
||||||
|
|
||||||
|
// Notify the customer about the staff reply
|
||||||
|
$ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply));
|
||||||
|
|
||||||
AuditLog::query()->create([
|
AuditLog::query()->create([
|
||||||
'user_id' => $ticket->user_id,
|
'user_id' => $ticket->user_id,
|
||||||
'admin_id' => $request->user()->id,
|
'admin_id' => $request->user()->id,
|
||||||
@@ -110,8 +115,13 @@ class TicketController extends Controller
|
|||||||
'status' => ['required', 'in:open,in_progress,waiting,closed'],
|
'status' => ['required', 'in:open,in_progress,waiting,closed'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$oldStatus = $ticket->status;
|
||||||
|
|
||||||
$ticket->update(['status' => $validated['status']]);
|
$ticket->update(['status' => $validated['status']]);
|
||||||
|
|
||||||
|
// Notify the customer about the status change
|
||||||
|
$ticket->user?->notify(new TicketStatusChangedNotification($ticket, $oldStatus, $validated['status']));
|
||||||
|
|
||||||
AuditLog::query()->create([
|
AuditLog::query()->create([
|
||||||
'user_id' => $ticket->user_id,
|
'user_id' => $ticket->user_id,
|
||||||
'admin_id' => $request->user()->id,
|
'admin_id' => $request->user()->id,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -15,6 +16,8 @@ class SupportTicket extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'ticket_reference',
|
||||||
|
'customer_email',
|
||||||
'subject',
|
'subject',
|
||||||
'status',
|
'status',
|
||||||
'priority',
|
'priority',
|
||||||
@@ -30,6 +33,25 @@ class SupportTicket extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::created(function (SupportTicket $ticket): void {
|
||||||
|
if (empty($ticket->ticket_reference)) {
|
||||||
|
$ticket->updateQuietly([
|
||||||
|
'ticket_reference' => sprintf(
|
||||||
|
config('tickets.ticket_reference_format'),
|
||||||
|
$ticket->id
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeByReference(Builder $query, string $reference): Builder
|
||||||
|
{
|
||||||
|
return $query->where('ticket_reference', $reference);
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class TicketReply extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'body',
|
'body',
|
||||||
'is_staff_reply',
|
'is_staff_reply',
|
||||||
|
'message_id',
|
||||||
|
'from_email',
|
||||||
|
'via_email',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
@@ -24,6 +27,7 @@ class TicketReply extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'is_staff_reply' => 'boolean',
|
'is_staff_reply' => 'boolean',
|
||||||
|
'via_email' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
website/app/Notifications/TicketCreatedNotification.php
Normal file
56
website/app/Notifications/TicketCreatedNotification.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class TicketCreatedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SupportTicket $ticket,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']';
|
||||||
|
$adminUrl = 'https://'.config('app.domains.admin').'/tickets/'.$this->ticket->id;
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('New Ticket: '.$this->ticket->subject.' '.$reference)
|
||||||
|
->greeting("Hello {$notifiable->name}!")
|
||||||
|
->line('A new support ticket has been created.')
|
||||||
|
->line('**Subject:** '.$this->ticket->subject)
|
||||||
|
->line('**Priority:** '.ucfirst($this->ticket->priority))
|
||||||
|
->line('**Department:** '.ucfirst($this->ticket->department))
|
||||||
|
->line('**Customer:** '.($this->ticket->user?->name ?? $this->ticket->customer_email ?? 'Unknown'))
|
||||||
|
->action('View Ticket', $adminUrl)
|
||||||
|
->line('Please review and respond at your earliest convenience.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'ticket_created',
|
||||||
|
'ticket_id' => $this->ticket->id,
|
||||||
|
'subject' => $this->ticket->subject,
|
||||||
|
'priority' => $this->ticket->priority,
|
||||||
|
'department' => $this->ticket->department,
|
||||||
|
'message' => 'New support ticket: '.$this->ticket->subject,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class TicketCustomerReplyNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SupportTicket $ticket,
|
||||||
|
public TicketReply $reply,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']';
|
||||||
|
$adminUrl = 'https://'.config('app.domains.admin').'/tickets/'.$this->ticket->id;
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Re: '.$this->ticket->subject.' '.$reference)
|
||||||
|
->greeting("Hello {$notifiable->name}!")
|
||||||
|
->line('A customer has replied to a support ticket.')
|
||||||
|
->line('**Ticket:** '.$this->ticket->subject)
|
||||||
|
->line('**Customer:** '.($this->reply->user?->name ?? $this->reply->from_email ?? 'Unknown'))
|
||||||
|
->line($this->reply->body)
|
||||||
|
->action('View Ticket', $adminUrl)
|
||||||
|
->line('Please review and respond at your earliest convenience.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'ticket_customer_reply',
|
||||||
|
'ticket_id' => $this->ticket->id,
|
||||||
|
'reply_id' => $this->reply->id,
|
||||||
|
'subject' => $this->ticket->subject,
|
||||||
|
'message' => 'Customer replied to ticket: '.$this->ticket->subject,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
91
website/app/Notifications/TicketStaffReplyNotification.php
Normal file
91
website/app/Notifications/TicketStaffReplyNotification.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class TicketStaffReplyNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SupportTicket $ticket,
|
||||||
|
public TicketReply $reply,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']';
|
||||||
|
$host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'ezscale.cloud';
|
||||||
|
$rawMessageId = 'ticket-'.$this->reply->id.'@'.$host;
|
||||||
|
$storedMessageId = '<'.$rawMessageId.'>';
|
||||||
|
|
||||||
|
// Store message_id on the reply for future threading
|
||||||
|
$this->reply->updateQuietly(['message_id' => $storedMessageId]);
|
||||||
|
|
||||||
|
$accountUrl = 'https://'.config('app.domains.account').'/tickets/'.$this->ticket->id;
|
||||||
|
|
||||||
|
$mail = (new MailMessage)
|
||||||
|
->subject('Re: '.$this->ticket->subject.' '.$reference)
|
||||||
|
->replyTo((string) config('tickets.support_email'))
|
||||||
|
->greeting('Hello '.$notifiable->name.'!')
|
||||||
|
->line($this->reply->body)
|
||||||
|
->action('View Ticket', $accountUrl)
|
||||||
|
->line('Reply to this email or click the button above to respond.');
|
||||||
|
|
||||||
|
$mail->withSymfonyMessage(function (\Symfony\Component\Mime\Email $message) use ($rawMessageId): void {
|
||||||
|
// Remove existing Message-ID header and replace with ours
|
||||||
|
$headers = $message->getHeaders();
|
||||||
|
if ($headers->has('Message-ID')) {
|
||||||
|
$headers->remove('Message-ID');
|
||||||
|
}
|
||||||
|
$headers->addIdHeader('Message-ID', $rawMessageId);
|
||||||
|
|
||||||
|
$lastReply = $this->ticket->replies()
|
||||||
|
->whereNotNull('message_id')
|
||||||
|
->where('id', '!=', $this->reply->id)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastReply) {
|
||||||
|
$lastId = trim((string) $lastReply->message_id, '<>');
|
||||||
|
$headers->addIdHeader('In-Reply-To', $lastId);
|
||||||
|
|
||||||
|
$referenceIds = $this->ticket->replies()
|
||||||
|
->whereNotNull('message_id')
|
||||||
|
->where('id', '!=', $this->reply->id)
|
||||||
|
->pluck('message_id')
|
||||||
|
->map(fn (string $id): string => trim($id, '<>'))
|
||||||
|
->toArray();
|
||||||
|
$headers->addIdHeader('References', $referenceIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $mail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'ticket_staff_reply',
|
||||||
|
'ticket_id' => $this->ticket->id,
|
||||||
|
'reply_id' => $this->reply->id,
|
||||||
|
'subject' => $this->ticket->subject,
|
||||||
|
'message' => 'Staff replied to your ticket: '.$this->ticket->subject,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class TicketStatusChangedNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public SupportTicket $ticket,
|
||||||
|
public string $oldStatus,
|
||||||
|
public string $newStatus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail', 'database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$reference = $this->ticket->ticket_reference ?? '[EZSCALE-'.$this->ticket->id.']';
|
||||||
|
$accountUrl = 'https://'.config('app.domains.account').'/tickets/'.$this->ticket->id;
|
||||||
|
$formattedStatus = ucwords(str_replace('_', ' ', $this->newStatus));
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject($this->ticket->subject.' '.$reference.' - Status: '.$formattedStatus)
|
||||||
|
->greeting('Hello '.$notifiable->name.'!')
|
||||||
|
->line('The status of your support ticket has been updated.')
|
||||||
|
->line('**Ticket:** '.$this->ticket->subject)
|
||||||
|
->line('**New Status:** '.$formattedStatus)
|
||||||
|
->action('View Ticket', $accountUrl)
|
||||||
|
->line('Thank you for using EZSCALE support!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'ticket_status_changed',
|
||||||
|
'ticket_id' => $this->ticket->id,
|
||||||
|
'subject' => $this->ticket->subject,
|
||||||
|
'old_status' => $this->oldStatus,
|
||||||
|
'new_status' => $this->newStatus,
|
||||||
|
'message' => 'Ticket "'.$this->ticket->subject.'" status changed to '.ucwords(str_replace('_', ' ', $this->newStatus)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
|
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
|
||||||
$this->app->singleton(BillingServiceFactory::class);
|
$this->app->singleton(BillingServiceFactory::class);
|
||||||
$this->app->singleton(ProvisioningFactory::class);
|
$this->app->singleton(ProvisioningFactory::class);
|
||||||
|
$this->app->bind(
|
||||||
|
\App\Services\Tickets\EmailServiceInterface::class,
|
||||||
|
\App\Services\Tickets\ImapEmailService::class
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
13
website/app/Services/Tickets/EmailServiceInterface.php
Normal file
13
website/app/Services/Tickets/EmailServiceInterface.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tickets;
|
||||||
|
|
||||||
|
interface EmailServiceInterface
|
||||||
|
{
|
||||||
|
/** @return array<int, ParsedEmail> */
|
||||||
|
public function fetchNewEmails(): array;
|
||||||
|
|
||||||
|
public function markAsProcessed(mixed $message): void;
|
||||||
|
}
|
||||||
150
website/app/Services/Tickets/ImapEmailService.php
Normal file
150
website/app/Services/Tickets/ImapEmailService.php
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tickets;
|
||||||
|
|
||||||
|
use Webklex\PHPIMAP\ClientManager;
|
||||||
|
use Webklex\PHPIMAP\Message;
|
||||||
|
|
||||||
|
class ImapEmailService implements EmailServiceInterface
|
||||||
|
{
|
||||||
|
private ClientManager $clientManager;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->clientManager = new ClientManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, ParsedEmail> */
|
||||||
|
public function fetchNewEmails(): array
|
||||||
|
{
|
||||||
|
$client = $this->clientManager->make([
|
||||||
|
'host' => config('tickets.imap.host'),
|
||||||
|
'port' => config('tickets.imap.port'),
|
||||||
|
'encryption' => config('tickets.imap.encryption'),
|
||||||
|
'validate_cert' => config('tickets.imap.validate_cert'),
|
||||||
|
'username' => config('tickets.imap.username'),
|
||||||
|
'password' => config('tickets.imap.password'),
|
||||||
|
'protocol' => config('tickets.imap.protocol'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->connect();
|
||||||
|
|
||||||
|
$folder = $client->getFolder((string) config('tickets.imap_folder'));
|
||||||
|
|
||||||
|
if ($folder === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $folder->messages()->unseen()->get();
|
||||||
|
|
||||||
|
$emails = [];
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
/** @var Message $message */
|
||||||
|
$from = $this->extractFromAddress($message);
|
||||||
|
$subject = (string) $message->getSubject();
|
||||||
|
$body = $this->cleanBody($message);
|
||||||
|
$messageId = (string) $message->getMessageId();
|
||||||
|
$inReplyTo = $this->extractHeader($message, 'in_reply_to');
|
||||||
|
$references = $this->extractHeader($message, 'references');
|
||||||
|
|
||||||
|
$emails[] = new ParsedEmail(
|
||||||
|
messageId: $messageId,
|
||||||
|
from: $from,
|
||||||
|
subject: $subject,
|
||||||
|
body: $body,
|
||||||
|
inReplyTo: $inReplyTo,
|
||||||
|
references: $references,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->markAsProcessed($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsProcessed(mixed $message): void
|
||||||
|
{
|
||||||
|
if ($message instanceof Message) {
|
||||||
|
$message->setFlag('Seen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractFromAddress(Message $message): string
|
||||||
|
{
|
||||||
|
$from = $message->getFrom();
|
||||||
|
if ($from && count($from) > 0) {
|
||||||
|
return (string) $from->first()->mail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractHeader(Message $message, string $header): ?string
|
||||||
|
{
|
||||||
|
$value = $message->get($header);
|
||||||
|
if ($value !== null && (string) $value !== '') {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanBody(Message $message): string
|
||||||
|
{
|
||||||
|
$body = $message->hasTextBody()
|
||||||
|
? (string) $message->getTextBody()
|
||||||
|
: strip_tags((string) $message->getHTMLBody());
|
||||||
|
|
||||||
|
return $this->stripSignaturesAndQuotes($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function stripSignaturesAndQuotes(string $body): string
|
||||||
|
{
|
||||||
|
// Strip email signatures
|
||||||
|
$signaturePatterns = [
|
||||||
|
'/^--\s*$/m',
|
||||||
|
'/^_{3,}$/m',
|
||||||
|
'/^Sent from my .+$/mi',
|
||||||
|
'/^Get Outlook for .+$/mi',
|
||||||
|
'/^Sent from .+$/mi',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($signaturePatterns as $pattern) {
|
||||||
|
if (preg_match($pattern, $body, $matches, \PREG_OFFSET_CAPTURE)) {
|
||||||
|
$body = substr($body, 0, (int) $matches[0][1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip quoted text (lines starting with >)
|
||||||
|
$lines = explode("\n", $body);
|
||||||
|
$cleanLines = [];
|
||||||
|
$hitQuote = false;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
// Check for "On <date> <person> wrote:" pattern
|
||||||
|
if (preg_match('/^On .+ wrote:\s*$/i', $line)) {
|
||||||
|
$hitQuote = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lines starting with >
|
||||||
|
if (str_starts_with(trim($line), '>')) {
|
||||||
|
$hitQuote = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once we hit quoted content, skip remaining lines
|
||||||
|
if ($hitQuote) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanLines[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(implode("\n", $cleanLines));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
website/app/Services/Tickets/ParsedEmail.php
Normal file
17
website/app/Services/Tickets/ParsedEmail.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tickets;
|
||||||
|
|
||||||
|
class ParsedEmail
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $messageId,
|
||||||
|
public readonly string $from,
|
||||||
|
public readonly string $subject,
|
||||||
|
public readonly string $body,
|
||||||
|
public readonly ?string $inReplyTo = null,
|
||||||
|
public readonly ?string $references = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
144
website/app/Services/Tickets/TicketEmailProcessor.php
Normal file
144
website/app/Services/Tickets/TicketEmailProcessor.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tickets;
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\TicketCreatedNotification;
|
||||||
|
use App\Notifications\TicketCustomerReplyNotification;
|
||||||
|
use App\Notifications\TicketStaffReplyNotification;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
class TicketEmailProcessor
|
||||||
|
{
|
||||||
|
public function processEmail(ParsedEmail $email): void
|
||||||
|
{
|
||||||
|
$ticket = $this->findTicketFromEmail($email);
|
||||||
|
|
||||||
|
if ($ticket) {
|
||||||
|
$this->addReplyToTicket($ticket, $email);
|
||||||
|
} else {
|
||||||
|
$this->createNewTicket($email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findTicketFromEmail(ParsedEmail $email): ?SupportTicket
|
||||||
|
{
|
||||||
|
// Strategy 1: Match by In-Reply-To header against ticket_replies.message_id
|
||||||
|
if ($email->inReplyTo) {
|
||||||
|
$reply = TicketReply::query()
|
||||||
|
->where('message_id', $email->inReplyTo)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($reply) {
|
||||||
|
return $reply->ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Match by References header against ticket_replies.message_id
|
||||||
|
if ($email->references) {
|
||||||
|
$referenceIds = preg_split('/\s+/', $email->references);
|
||||||
|
if ($referenceIds) {
|
||||||
|
$reply = TicketReply::query()
|
||||||
|
->whereIn('message_id', $referenceIds)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($reply) {
|
||||||
|
return $reply->ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Match by subject line ticket reference [EZSCALE-{id}]
|
||||||
|
$regex = config('tickets.ticket_reference_regex');
|
||||||
|
if (preg_match($regex, $email->subject, $matches)) {
|
||||||
|
$ticketId = (int) $matches[1];
|
||||||
|
|
||||||
|
return SupportTicket::query()->find($ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addReplyToTicket(SupportTicket $ticket, ParsedEmail $email): void
|
||||||
|
{
|
||||||
|
$isStaff = $this->isStaffEmail($email->from);
|
||||||
|
$user = User::query()->where('email', $email->from)->first();
|
||||||
|
|
||||||
|
$reply = TicketReply::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'body' => $email->body,
|
||||||
|
'is_staff_reply' => $isStaff,
|
||||||
|
'message_id' => $email->messageId,
|
||||||
|
'from_email' => $email->from,
|
||||||
|
'via_email' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket->update([
|
||||||
|
'last_reply_at' => now(),
|
||||||
|
'status' => $ticket->status === 'closed' ? 'open' : $ticket->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send appropriate notifications
|
||||||
|
if ($isStaff) {
|
||||||
|
$ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply));
|
||||||
|
} else {
|
||||||
|
$admins = User::role('admin')->get();
|
||||||
|
Notification::send($admins, new TicketCustomerReplyNotification($ticket, $reply));
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Email reply added to ticket', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'from' => $email->from,
|
||||||
|
'is_staff' => $isStaff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewTicket(ParsedEmail $email): void
|
||||||
|
{
|
||||||
|
$user = User::query()->where('email', $email->from)->first();
|
||||||
|
|
||||||
|
$ticket = SupportTicket::query()->create([
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'customer_email' => $email->from,
|
||||||
|
'subject' => $email->subject,
|
||||||
|
'status' => 'open',
|
||||||
|
'priority' => 'medium',
|
||||||
|
'department' => config('tickets.default_department'),
|
||||||
|
'last_reply_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
TicketReply::query()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $user?->id,
|
||||||
|
'body' => $email->body,
|
||||||
|
'is_staff_reply' => false,
|
||||||
|
'message_id' => $email->messageId,
|
||||||
|
'from_email' => $email->from,
|
||||||
|
'via_email' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Notify admins about the new ticket
|
||||||
|
$admins = User::role('admin')->get();
|
||||||
|
Notification::send($admins, new TicketCreatedNotification($ticket));
|
||||||
|
|
||||||
|
Log::info('New ticket created from email', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'from' => $email->from,
|
||||||
|
'subject' => $email->subject,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStaffEmail(string $email): bool
|
||||||
|
{
|
||||||
|
$user = User::query()->where('email', $email)->first();
|
||||||
|
|
||||||
|
return $user?->hasRole('admin') ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"laravel/passport": "^13.4",
|
"laravel/passport": "^13.4",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"spatie/laravel-permission": "^6.24",
|
"spatie/laravel-permission": "^6.24",
|
||||||
"srmklive/paypal": "^3.0"
|
"srmklive/paypal": "^3.0",
|
||||||
|
"webklex/php-imap": "^6.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
83
website/composer.lock
generated
83
website/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7166e8eccf92defcc15b9e2bdb8d8108",
|
"content-hash": "7ed919792a4edfaad8af686d9fdf0522",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -7773,6 +7773,87 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-11-21T01:49:47+00:00"
|
"time": "2024-11-21T01:49:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "webklex/php-imap",
|
||||||
|
"version": "6.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Webklex/php-imap.git",
|
||||||
|
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||||
|
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"illuminate/pagination": ">=5.0.0",
|
||||||
|
"nesbot/carbon": "^2.62.1|^3.2.4",
|
||||||
|
"php": "^8.0.2",
|
||||||
|
"symfony/http-foundation": ">=2.8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.5.10"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/mime": "Recomended for better extension support",
|
||||||
|
"symfony/var-dumper": "Usefull tool for debugging"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "6.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Webklex\\PHPIMAP\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Malte Goldenbaum",
|
||||||
|
"email": "github@webklex.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP IMAP client",
|
||||||
|
"homepage": "https://github.com/webklex/php-imap",
|
||||||
|
"keywords": [
|
||||||
|
"imap",
|
||||||
|
"mail",
|
||||||
|
"php-imap",
|
||||||
|
"pop3",
|
||||||
|
"webklex"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Webklex/php-imap/issues",
|
||||||
|
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.buymeacoffee.com/webklex",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://ko-fi.com/webklex",
|
||||||
|
"type": "ko_fi"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-04-25T06:02:37+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
|
|||||||
26
website/config/tickets.php
Normal file
26
website/config/tickets.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'support_email' => env('SUPPORT_EMAIL', 'support@ezscale.cloud'),
|
||||||
|
|
||||||
|
'imap' => [
|
||||||
|
'host' => env('IMAP_HOST', 'outlook.office365.com'),
|
||||||
|
'port' => (int) env('IMAP_PORT', 993),
|
||||||
|
'encryption' => env('IMAP_ENCRYPTION', 'ssl'),
|
||||||
|
'username' => env('IMAP_USERNAME'),
|
||||||
|
'password' => env('IMAP_PASSWORD'),
|
||||||
|
'validate_cert' => (bool) env('IMAP_VALIDATE_CERT', true),
|
||||||
|
'protocol' => env('IMAP_PROTOCOL', 'imap'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'polling_enabled' => (bool) env('TICKET_EMAIL_POLLING', false),
|
||||||
|
'imap_folder' => env('IMAP_FOLDER', 'INBOX'),
|
||||||
|
|
||||||
|
'ticket_reference_format' => '[EZSCALE-%d]',
|
||||||
|
'ticket_reference_regex' => '/\[EZSCALE-(\d+)\]/i',
|
||||||
|
|
||||||
|
'departments' => ['billing', 'technical', 'sales', 'general'],
|
||||||
|
'default_department' => 'general',
|
||||||
|
];
|
||||||
@@ -20,6 +20,8 @@ class SupportTicketFactory extends Factory
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
|
'ticket_reference' => null,
|
||||||
|
'customer_email' => null,
|
||||||
'subject' => fake()->sentence(),
|
'subject' => fake()->sentence(),
|
||||||
'status' => fake()->randomElement(['open', 'in_progress', 'waiting', 'closed']),
|
'status' => fake()->randomElement(['open', 'in_progress', 'waiting', 'closed']),
|
||||||
'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']),
|
'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']),
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class TicketReplyFactory extends Factory
|
|||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'body' => fake()->paragraphs(2, true),
|
'body' => fake()->paragraphs(2, true),
|
||||||
'is_staff_reply' => false,
|
'is_staff_reply' => false,
|
||||||
|
'message_id' => null,
|
||||||
|
'from_email' => null,
|
||||||
|
'via_email' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,4 +36,13 @@ class TicketReplyFactory extends Factory
|
|||||||
'is_staff_reply' => true,
|
'is_staff_reply' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function viaEmail(?string $fromEmail = null): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'via_email' => true,
|
||||||
|
'from_email' => $fromEmail ?? fake()->safeEmail(),
|
||||||
|
'message_id' => '<'.fake()->uuid().'@example.com>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->string('ticket_reference')->nullable()->unique()->after('id');
|
||||||
|
$table->string('customer_email')->nullable()->index()->after('user_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make user_id nullable to support tickets created via email from unknown senders
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('ticket_replies', function (Blueprint $table): void {
|
||||||
|
$table->string('message_id')->nullable()->index()->after('is_staff_reply');
|
||||||
|
$table->string('from_email')->nullable()->after('message_id');
|
||||||
|
$table->boolean('via_email')->default(false)->after('from_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make user_id nullable to support replies from unknown email senders
|
||||||
|
Schema::table('ticket_replies', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['ticket_reference', 'customer_email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('support_tickets', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('ticket_replies', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['message_id', 'from_email', 'via_email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('ticket_replies', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -173,6 +173,15 @@ function formatDate(dateStr: string): string {
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-body-2 font-weight-medium">
|
<td class="text-body-2 font-weight-medium">
|
||||||
{{ ticket.subject }}
|
{{ ticket.subject }}
|
||||||
|
<VChip
|
||||||
|
v-if="ticket.ticket_reference"
|
||||||
|
size="x-small"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_reference }}
|
||||||
|
</VChip>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ function getUserInitial(name: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-center gap-2">
|
<div class="d-flex align-center gap-2">
|
||||||
<span class="text-h4 font-weight-bold">Ticket #{{ ticket.id }}</span>
|
<span class="text-h4 font-weight-bold">Ticket #{{ ticket.id }}</span>
|
||||||
|
<VChip
|
||||||
|
v-if="ticket.ticket_reference"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_reference }}
|
||||||
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
:color="resolveTicketStatusColor(ticket.status)"
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -162,7 +170,7 @@ function getUserInitial(name: string): string {
|
|||||||
</VAvatar>
|
</VAvatar>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-body-1 font-weight-medium">
|
<div class="text-body-1 font-weight-medium">
|
||||||
{{ reply.user?.name ?? 'Unknown' }}
|
{{ reply.user?.name ?? reply.from_email ?? 'Unknown' }}
|
||||||
<VChip
|
<VChip
|
||||||
v-if="reply.is_staff_reply"
|
v-if="reply.is_staff_reply"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
@@ -171,6 +179,15 @@ function getUserInitial(name: string): string {
|
|||||||
>
|
>
|
||||||
Staff
|
Staff
|
||||||
</VChip>
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="reply.via_email"
|
||||||
|
size="x-small"
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
Via Email
|
||||||
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-medium-emphasis">
|
<div class="text-caption text-medium-emphasis">
|
||||||
{{ formatDateTime(reply.created_at) }}
|
{{ formatDateTime(reply.created_at) }}
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ function formatStatus(status: string): string {
|
|||||||
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
<tr v-for="ticket in tickets.data" :key="ticket.id">
|
||||||
<td class="text-body-2 font-weight-medium">
|
<td class="text-body-2 font-weight-medium">
|
||||||
{{ ticket.subject }}
|
{{ ticket.subject }}
|
||||||
|
<VChip
|
||||||
|
v-if="ticket.ticket_reference"
|
||||||
|
size="x-small"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_reference }}
|
||||||
|
</VChip>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<VChip
|
<VChip
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ function getUserInitial(name: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-center ga-3 mb-2">
|
<div class="d-flex align-center ga-3 mb-2">
|
||||||
<span class="text-h5 font-weight-bold">{{ ticket.subject }}</span>
|
<span class="text-h5 font-weight-bold">{{ ticket.subject }}</span>
|
||||||
|
<VChip
|
||||||
|
v-if="ticket.ticket_reference"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ ticket.ticket_reference }}
|
||||||
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
:color="resolveTicketStatusColor(ticket.status)"
|
:color="resolveTicketStatusColor(ticket.status)"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -134,7 +142,7 @@ function getUserInitial(name: string): string {
|
|||||||
</VAvatar>
|
</VAvatar>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-body-1 font-weight-medium">
|
<div class="text-body-1 font-weight-medium">
|
||||||
{{ reply.user?.name ?? 'Unknown' }}
|
{{ reply.user?.name ?? reply.from_email ?? 'Unknown' }}
|
||||||
<VChip
|
<VChip
|
||||||
v-if="reply.is_staff_reply"
|
v-if="reply.is_staff_reply"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
@@ -143,6 +151,15 @@ function getUserInitial(name: string): string {
|
|||||||
>
|
>
|
||||||
Staff
|
Staff
|
||||||
</VChip>
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
v-if="reply.via_email"
|
||||||
|
size="x-small"
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
Via Email
|
||||||
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-medium-emphasis">
|
<div class="text-caption text-medium-emphasis">
|
||||||
{{ formatDateTime(reply.created_at) }}
|
{{ formatDateTime(reply.created_at) }}
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export interface CouponRedemption {
|
|||||||
export interface SupportTicket {
|
export interface SupportTicket {
|
||||||
id: number
|
id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
|
ticket_reference: string | null
|
||||||
|
customer_email: string | null
|
||||||
subject: string
|
subject: string
|
||||||
status: 'open' | 'in_progress' | 'waiting' | 'closed'
|
status: 'open' | 'in_progress' | 'waiting' | 'closed'
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent'
|
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
@@ -189,6 +191,9 @@ export interface TicketReply {
|
|||||||
user_id: number
|
user_id: number
|
||||||
body: string
|
body: string
|
||||||
is_staff_reply: boolean
|
is_staff_reply: boolean
|
||||||
|
message_id: string | null
|
||||||
|
from_email: string | null
|
||||||
|
via_email: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
user?: {
|
user?: {
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
|
|||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::command('billing:process-dunning')->daily()->at('06:00');
|
Schedule::command('billing:process-dunning')->daily()->at('06:00');
|
||||||
|
Schedule::command('tickets:process-emails')->everyTwoMinutes()->withoutOverlapping();
|
||||||
|
|||||||
285
website/tests/Feature/TicketEmailProcessingTest.php
Normal file
285
website/tests/Feature/TicketEmailProcessingTest.php
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\SupportTicket;
|
||||||
|
use App\Models\TicketReply;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\TicketCreatedNotification;
|
||||||
|
use App\Notifications\TicketCustomerReplyNotification;
|
||||||
|
use App\Notifications\TicketStaffReplyNotification;
|
||||||
|
use App\Notifications\TicketStatusChangedNotification;
|
||||||
|
use App\Services\Tickets\ImapEmailService;
|
||||||
|
use App\Services\Tickets\ParsedEmail;
|
||||||
|
use App\Services\Tickets\TicketEmailProcessor;
|
||||||
|
use Database\Seeders\RoleAndPermissionSeeder;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(RoleAndPermissionSeeder::class);
|
||||||
|
$this->accountUrl = 'http://'.config('app.domains.account');
|
||||||
|
$this->adminUrl = 'http://'.config('app.domains.admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ticket Reference Auto-Generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Ticket Reference', function (): void {
|
||||||
|
it('auto-generates ticket reference on create', function (): void {
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
|
||||||
|
$ticket = SupportTicket::factory()->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticket->refresh();
|
||||||
|
|
||||||
|
expect($ticket->ticket_reference)->toBe('[EZSCALE-'.$ticket->id.']');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Email Processing - New Tickets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('TicketEmailProcessor - New Tickets', function (): void {
|
||||||
|
it('creates new ticket from email for known user', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->customer()->create(['email' => 'customer@example.com']);
|
||||||
|
User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$processor = app(TicketEmailProcessor::class);
|
||||||
|
|
||||||
|
$email = new ParsedEmail(
|
||||||
|
messageId: '<msg-001@example.com>',
|
||||||
|
from: 'customer@example.com',
|
||||||
|
subject: 'Need help with VPS',
|
||||||
|
body: 'I cannot connect to my VPS server.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->processEmail($email);
|
||||||
|
|
||||||
|
$ticket = SupportTicket::where('customer_email', 'customer@example.com')->first();
|
||||||
|
|
||||||
|
expect($ticket)->not->toBeNull()
|
||||||
|
->and($ticket->user_id)->toBe($customer->id)
|
||||||
|
->and($ticket->subject)->toBe('Need help with VPS')
|
||||||
|
->and($ticket->status)->toBe('open')
|
||||||
|
->and($ticket->department)->toBe('general');
|
||||||
|
|
||||||
|
$reply = TicketReply::where('ticket_id', $ticket->id)->first();
|
||||||
|
expect($reply->body)->toBe('I cannot connect to my VPS server.')
|
||||||
|
->and($reply->via_email)->toBeTrue()
|
||||||
|
->and($reply->from_email)->toBe('customer@example.com')
|
||||||
|
->and($reply->message_id)->toBe('<msg-001@example.com>');
|
||||||
|
|
||||||
|
Notification::assertSentTo(
|
||||||
|
User::role('admin')->first(),
|
||||||
|
TicketCreatedNotification::class
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new ticket from email for unknown sender', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$processor = app(TicketEmailProcessor::class);
|
||||||
|
|
||||||
|
$email = new ParsedEmail(
|
||||||
|
messageId: '<msg-002@example.com>',
|
||||||
|
from: 'stranger@example.com',
|
||||||
|
subject: 'Pricing question',
|
||||||
|
body: 'How much does a VPS cost?',
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->processEmail($email);
|
||||||
|
|
||||||
|
$ticket = SupportTicket::where('customer_email', 'stranger@example.com')->first();
|
||||||
|
|
||||||
|
expect($ticket)->not->toBeNull()
|
||||||
|
->and($ticket->user_id)->toBeNull()
|
||||||
|
->and($ticket->subject)->toBe('Pricing question');
|
||||||
|
|
||||||
|
$reply = TicketReply::where('ticket_id', $ticket->id)->first();
|
||||||
|
expect($reply->user_id)->toBeNull()
|
||||||
|
->and($reply->from_email)->toBe('stranger@example.com')
|
||||||
|
->and($reply->via_email)->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Email Processing - Reply Matching
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('TicketEmailProcessor - Reply Matching', function (): void {
|
||||||
|
it('matches reply by subject reference [EZSCALE-{id}]', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->customer()->create(['email' => 'customer@example.com']);
|
||||||
|
User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$ticket = SupportTicket::factory()->open()->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'subject' => 'Server issue',
|
||||||
|
]);
|
||||||
|
$ticket->refresh(); // ensure ticket_reference is populated
|
||||||
|
|
||||||
|
$processor = app(TicketEmailProcessor::class);
|
||||||
|
|
||||||
|
$email = new ParsedEmail(
|
||||||
|
messageId: '<msg-reply-001@example.com>',
|
||||||
|
from: 'customer@example.com',
|
||||||
|
subject: 'Re: Server issue '.$ticket->ticket_reference,
|
||||||
|
body: 'Here is additional info.',
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->processEmail($email);
|
||||||
|
|
||||||
|
$replies = TicketReply::where('ticket_id', $ticket->id)->get();
|
||||||
|
|
||||||
|
expect($replies)->toHaveCount(1)
|
||||||
|
->and($replies->first()->body)->toBe('Here is additional info.')
|
||||||
|
->and($replies->first()->via_email)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches reply by In-Reply-To header', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->customer()->create(['email' => 'customer@example.com']);
|
||||||
|
User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$ticket = SupportTicket::factory()->open()->create([
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingReply = TicketReply::factory()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'user_id' => $customer->id,
|
||||||
|
'message_id' => '<original-msg@example.com>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$processor = app(TicketEmailProcessor::class);
|
||||||
|
|
||||||
|
$email = new ParsedEmail(
|
||||||
|
messageId: '<msg-reply-002@example.com>',
|
||||||
|
from: 'customer@example.com',
|
||||||
|
subject: 'Re: Something totally different',
|
||||||
|
body: 'Follow-up information.',
|
||||||
|
inReplyTo: '<original-msg@example.com>',
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->processEmail($email);
|
||||||
|
|
||||||
|
$newReplies = TicketReply::where('ticket_id', $ticket->id)
|
||||||
|
->where('id', '!=', $existingReply->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($newReplies)->toHaveCount(1)
|
||||||
|
->and($newReplies->first()->body)->toBe('Follow-up information.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body Cleaning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Email Body Cleaning', function (): void {
|
||||||
|
it('strips email signatures', function (): void {
|
||||||
|
$body = "Hello, I need help with my VPS.\n\n-- \nJohn Doe\nSenior Developer";
|
||||||
|
|
||||||
|
$cleaned = ImapEmailService::stripSignaturesAndQuotes($body);
|
||||||
|
|
||||||
|
expect($cleaned)->toBe('Hello, I need help with my VPS.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips quoted content', function (): void {
|
||||||
|
$body = "Thank you for the update.\n\nOn Mon, Feb 9, 2026 John wrote:\n> Original message here\n> More quoted text";
|
||||||
|
|
||||||
|
$cleaned = ImapEmailService::stripSignaturesAndQuotes($body);
|
||||||
|
|
||||||
|
expect($cleaned)->toBe('Thank you for the update.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Artisan Command
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('ProcessTicketEmails Command', function (): void {
|
||||||
|
it('exits with warning when polling is disabled', function (): void {
|
||||||
|
config(['tickets.polling_enabled' => false]);
|
||||||
|
|
||||||
|
$this->artisan('tickets:process-emails')
|
||||||
|
->expectsOutput('Ticket email polling is disabled. Set TICKET_EMAIL_POLLING=true to enable.')
|
||||||
|
->assertExitCode(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Notification Integration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Ticket Notifications', function (): void {
|
||||||
|
it('sends admin notification when customer creates ticket via web', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets', [
|
||||||
|
'subject' => 'Help with billing',
|
||||||
|
'department' => 'billing',
|
||||||
|
'priority' => 'medium',
|
||||||
|
'message' => 'I need help understanding my invoice details.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
Notification::assertSentTo($admin, TicketCreatedNotification::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends customer notification when staff replies', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$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' => 'We are looking into this right now.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
Notification::assertSentTo($customer, TicketStaffReplyNotification::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends customer notification when status changes', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
Notification::assertSentTo($customer, TicketStatusChangedNotification::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends admin notification when customer replies', function (): void {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->customer()->create();
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$ticket = SupportTicket::factory()->open()->create(['user_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->post($this->accountUrl.'/tickets/'.$ticket->id.'/reply', [
|
||||||
|
'body' => 'I have more details about my issue.',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
Notification::assertSentTo($admin, TicketCustomerReplyNotification::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user