refactor: Fixes #106 create ticket automatically from brevo webhook manager

This commit is contained in:
Jeremy Munsch 2026-06-24 16:25:58 +02:00
parent 6854af708b
commit 0ec3be521d
No known key found for this signature in database
GPG key ID: 7A502D8B9839C78C
47 changed files with 6173 additions and 210 deletions

4633
_ide_helper.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Dto;
final readonly class BrevoWebHookDto
{
public function __construct(
public readonly string $plainTextContent,
public readonly ?string $brand
) {}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Events;
use Domain\Models\ValueObjects\Email;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
readonly class BrevoWebhookCallReceived
{
use Dispatchable;
public function __construct(
public Email $from,
public Email $to,
public string $htmlRawContent
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('default'),
];
}
}

View file

@ -1,77 +0,0 @@
<?php
namespace App\Http\Controllers\Bo;
use Carbon\Carbon;
use App\Models\Site;
use App\Models\Ticket;
use App\Mail\NewTicket;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Mail;
use App\Enums\Tickets\TicketTypeEnum;
use App\Enums\Tickets\TicketStatusEnum;
use App\Http\Requests\Bo\BrevoWehbookRequest;
class BrevoController extends Controller
{
public function createTicket(BrevoWehbookRequest $request)
{
$data = [];
$site = Site::where('name', 'Mailing en masse')->firstOrFail();
$formContent = $request->items[0]["ExtractedMarkdownMessage"];
$tblRegexPatterns = [
'user' => '/Matricule et Nom du demandeur\s*\|\s*([^|]+?)\s*\|/i',
'email' => '/Email du demandeur\s*\|\s*([^|]+?)\s*\|/i',
'brand' => '/Marque\s*\/\s*Filiale\s*\|\s*([^|]+?)\s*\|/i',
'subject' => '/Pour quels besoins avez-vous besoin d\'envoyer des emails en masse\s*\?\s*\|\s*([^|]+?)\s*\|/is',
'date_mep' => '/Date de première envoi souhaité\s*\|\s*([^|]+?)\s*\|/i',
'comment' => '/Commentaire\s*\|\s*([^|]+?)(?=\s*\|)/i',
];
foreach ($tblRegexPatterns as $field => $regex) {
if (preg_match($regex, $formContent, $matches)) {
$data[$field] = trim($matches[1]);
} else {
return response()->json(['reason' => "Champ manquant $field"], 422);
}
}
if ($data['date_mep']) {
$dateMep = Carbon::createFromFormat('d/m/Y', $data['date_mep'])->format('Y-m-d');
}
$ticket = new Ticket([
'site_id' => $site->id,
'type' => TicketTypeEnum::mailing->value,
'status' => TicketStatusEnum::new->value,
'date_mep' => $dateMep ?? null,
'text' => "
Bonjour,\n
Nom du demandeur : {$data['user']},
Adresse mail : {$data['email']},
Marque / Filiale concernée : {$data['brand']},
Sujet : {$data['subject']},
Date d'envoi souhaitée : {$data['date_mep']},
Commentaire : {$data['comment']},\n
Cordialement.
",
'title' => "Demande de mail en masse pour {$data['brand']}",
'is_internal' => false
]);
$ticket->user()->associate($site->projectManager);
$ticket->site()->associate($site);
$ticket->assignedUser()->associate($site->projectManager);
$ticket->saveOrFail();
if (config('mail.MAIL_ADMIN')) {
Mail::to(config('mail.MAIL_ADMIN'))->send(new NewTicket($ticket));
} else {
throw new \ErrorException('Missing MAIL_ADMIN in .env');
}
return response()->json(['ok' => true]);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Events\BrevoWebhookCallReceived;
use App\Http\Requests\Bo\BrevoWebhookRequest;
use Domain\Models\ValueObjects\Email;
use Psr\Log\LoggerInterface;
use Illuminate\Routing\Controller as BaseController;
use \Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class BrevoWebhookController extends BaseController
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly Dispatcher $dispatcher
) {}
public function webhookCallReceived(BrevoWebhookRequest $request): JsonResponse
{
$fromEmail = $request->getFromAddress();
$toEmail = $request->getToAddress();
$formContent = $request->getRawHtmlBody();
if (!$this->isFromLdc($fromEmail)) {
$this->logger->warning(
'Received a brevo webhook call from a non ldc email.',
[
'from' => $fromEmail,
'to' => $toEmail,
'content' => base64_encode($formContent)
]
);
return response()->json(
['ok' => false],
Response::HTTP_BAD_REQUEST
);
}
$this->dispatcher->dispatch(
new BrevoWebhookCallReceived(
from: $fromEmail,
to: $toEmail,
htmlRawContent: $formContent,
)
);
return response()->json(['ok' => true]);
}
private function isFromLdc(Email $email): bool
{
return substr(strrchr($email->value, "@"), 1) === 'ldc.fr';
}
}

View file

@ -28,7 +28,7 @@ class RedirectIfAuthenticated
switch ($guard) {
case 'backend':
if (!$request->routeIs('bo.*') && !$request->routeIs('login')) {
continue;
continue 2;
}
return \redirect()->route('bo.dashboard');
default:

View file

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Bo;
use Domain\Models\ValueObjects\Email;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Config;
class BrevoWebhookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
public function getFromAddress(): Email
{
return new Email($this->string('items.0.From.Address'));
}
public function getToAddress(): Email
{
return new Email((string) $this->input('items.0.To.0.Address'));
}
public function getRawHtmlBody(): string
{
return $this->string('items.0.RawHtmlBody');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$expectedFormAddress = Config::string('mail.brevo_webook.expected_from_address');
return [
'items' => ['required', 'array', 'min:1'],
'items.0.From.Address' => ['required', 'email:rfc,spoof'],
'items.0.To.*.Address' => ['required', 'email:rfc,spoof', "in:{$expectedFormAddress}"],
'items.0.RawHtmlBody' => ['required', 'string', 'min:10'],
];
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\Bo;
use Illuminate\Foundation\Http\FormRequest;
class BrevoWehbookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.0.From.Address' => 'required|email',
'items.0.To.*.Address' => 'required|email|in:mailing@reply.uwil.fr',
'items.0.ExtractedMarkdownMessage' => 'required|string|min:10',
];
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Listeners;
use App\Events\BrevoWebhookCallReceived;
use App\Mail\NewTicket;
use App\Repositories\SiteRepository;
use App\Services\AutomaticTicketCreator;
use Domain\Models\ValueObjects\Email;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Psr\Log\LoggerInterface;
// NOTE: this should be queued in order to be eventually retried or at least keep info data.
class CreateTicketAutomatically implements ShouldQueue
{
private const string SITE_NAME_WHERE_TO_CREATE_TICKET = 'Mailing en masse';
private readonly Email $mailAdmin;
public function __construct(
private readonly LoggerInterface $logger,
private readonly AutomaticTicketCreator $automaticTicketCreator,
private readonly SiteRepository $siteRepository,
) {
$this->mailAdmin = new Email(
Config::string('mail.MAIL_ADMIN')
);
}
public function handle(BrevoWebhookCallReceived $event): void
{
$fromEmail = $event->from;
$toEmail = $event->to;
$htmlRawContent = $event->htmlRawContent;
$this->logger->info('Received a ticket creation event from brevo');
$this->logger->debug(
'Payload for ticket creation form brevo',
[
'from_email' => $fromEmail->value,
'to_email' => $toEmail->value,
'html_raw_content' => base64_encode($htmlRawContent)
]
);
$siteReceivingTickets = $this->siteRepository->findByName(self::SITE_NAME_WHERE_TO_CREATE_TICKET);
if ($siteReceivingTickets === null) {
throw new \UnexpectedValueException(
'No site found with name \'' .
self::SITE_NAME_WHERE_TO_CREATE_TICKET . '\''
);
}
$projectManager = $siteReceivingTickets->projectManager()->first();
if ($projectManager === null) {
throw new \UnexpectedValueException(
'No project manager found for site \'' .
self::SITE_NAME_WHERE_TO_CREATE_TICKET . '\''
);
}
$ticket = $this->automaticTicketCreator->createTicket(
$siteReceivingTickets,
$projectManager,
$htmlRawContent,
);
Mail::to($this->mailAdmin->value)
->send(new NewTicket(
ticketId: $ticket->id,
siteName: $siteReceivingTickets->name,
ticketTitle: $ticket->title,
ticketType: $ticket->type,
userName: $fromEmail->value,
ticketDescription: $ticket->text,
));
}
}

View file

@ -2,29 +2,23 @@
namespace App\Mail;
use App\Models\Ticket;
use App\Enums\Tickets\TicketTypeEnum;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class NewTicket extends Mailable
class NewTicket extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
/** @var \App\Models\Ticket */
private $ticket;
/**
* Create a new message instance.
*
* @param \App\Models\Ticket $ticket
* @return void
*/
public function __construct(Ticket $ticket)
{
$this->ticket = $ticket;
}
public function __construct(
private readonly int $ticketId,
private readonly string $siteName,
private readonly string $ticketTitle,
private readonly TicketTypeEnum $ticketType,
private readonly string $userName,
private readonly string $ticketDescription,
) {}
/**
* Build the message.
@ -33,8 +27,14 @@ class NewTicket extends Mailable
*/
public function build()
{
$ticket = $this->ticket;
return $this->subject("[Support] Nouveau ticket n° $ticket->id - $ticket->title")
->view('emails.new-ticket', compact('ticket'));
return $this->subject("[Support] Nouveau ticket n° {$this->ticketId} - {$this->ticketTitle}")
->view('emails.new-ticket', [
'ticketId' => $this->ticketId,
'siteName' => $this->siteName,
'ticketTitle' => $this->ticketTitle,
'ticketType' => $this->ticketType,
'userName' => $this->userName,
'ticketDescription' => $this->ticketDescription,
]);
}
}

View file

@ -7,7 +7,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $name
* @property int $id Doit correspondre aux ids marques de la CRM
* @property string $name Nom de la marque
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, Account> $accounts
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Site> $sites
* @mixin \Eloquent
*/
class Brand extends Model
{

View file

@ -7,7 +7,14 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* @property int $id
* @property string $name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Invoice> $invoices
* @property-read mixed $value
* @method static \Database\Factories\CompanyFactory factory($count = null, $state = [])
* @mixin \Eloquent
*/
class Company extends Model
{

View file

@ -7,10 +7,16 @@ use App\Models\Brand;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $label
* @property AccountTypeEnum $type
* @property integer $balance
* @property Brand $brand_id
* @property int $id
* @property AccountTypeEnum $type Enum Credit\AccountTypeEnum
* @property string $label Custom account label
* @property float $balance Account balance
* @property int $brand_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read Brand $brand
* @property-read \App\Models\Credit\CreditLog|null $creditlogs
* @mixin \Eloquent
*/
class Account extends Model
{

View file

@ -6,10 +6,15 @@ use App\Enums\Credit\BalanceOperationEnum;
use Illuminate\Database\Eloquent\Model;
/**
* @property BalanceOperationEnum $type
* @property double $amount
* @property Account $credit_account_id
* @property date $created_at
* @property int $id
* @property BalanceOperationEnum $type Enum Credit\BalanceOperationEnum
* @property float $amount Montant de l'opération
* @property int $credit_account_id
* @property string $created_at
* @property-read \App\Models\Credit\Account $account
* @property-read \App\Models\Credit\EstimationLog|null $pivot
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Credit\Estimation> $estimation
* @mixin \Eloquent
*/
class CreditLog extends Model
{

View file

@ -9,6 +9,22 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
/**
* @property int $id
* @property EstimationStatusEnum $status Enum Credit\EstimationStatusEnum
* @property numeric $amount Estimation amount
* @property int $ticket_id
* @property int $credit_account_id The account to debit.
* @property int $estimator_id The person who has done the estimate.
* @property int|null $validator_id The person who has validate the estimate.
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Credit\Account $account
* @property-read User|null $estimator
* @property-read Ticket|null $ticket
* @property-read User|null $validator
* @mixin \Eloquent
*/
class Estimation extends Model
{
/**

View file

@ -4,6 +4,14 @@ namespace App\Models\Credit;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property int $id
* @property int $credit_log_id
* @property int $credit_estimation_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @mixin \Eloquent
*/
class EstimationLog extends Pivot
{
/**

View file

@ -4,6 +4,26 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $title Gitlab issue title
* @property string $url Gitlab URL
* @property int $estimate_time Gitlab issue estimate time in seconds
* @property int $spent_time Gitlab issue spent time in seconds
* @property int $is_closed Gitlab issue status
* @property int $gitlab_id Gitlab issue id
* @property int $gitlab_iid Gitlab issue iid in project
* @property int|null $milestone_iid Gitlab issue linked milestone iid in project
* @property int $gitlab_project_id Gitlab project id
* @property string $g_created_at Gitlab issue created at
* @property string $g_updated_at Gitlab issue updated at
* @property string|null $g_closed_at Gitlab issue closed at
* @property string|null $g_due_date Gitlab issue due date
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\GitlabLabels> $gitlabLabels
* @mixin \Eloquent
*/
class GitlabIssues extends Model
{
/**

View file

@ -4,6 +4,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name Label name
* @property int $gitlab_id Label Gitlab id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\GitlabIssues> $issues
* @mixin \Eloquent
*/
class GitlabLabels extends Model
{
/**

View file

@ -4,6 +4,22 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $title Milestone title
* @property string $url Gitlab URL
* @property int $is_closed Gitlab milestone status
* @property int $gitlab_id Gitlab milestone id
* @property int $gitlab_iid Gitlab milestone iid in project
* @property int $gitlab_project_id Gitlab project id
* @property string $g_created_at Gitlab milestone created at
* @property string $g_updated_at Gitlab milestone updated at
* @property string|null $g_start_date Gitlab milestone start date
* @property string|null $g_due_date Gitlab milestone due date
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @mixin \Eloquent
*/
class GitlabMilestones extends Model
{
/**

View file

@ -4,6 +4,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name Gitlab project name
* @property string $url Gitlab URL
* @property int $is_archived Gitlab archive status
* @property int $gitlab_id Gitlab unique ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @mixin \Eloquent
*/
class GitlabProjects extends Model
{
/**

View file

@ -7,9 +7,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $title
* @property string $message
* @property InfoColorsEnum $type
* @property InfoColorsEnum $type Defined by the InfoColorsEnum enum.
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @mixin \Eloquent
*/
class Information extends Model
{

View file

@ -13,22 +13,34 @@ use Mccarlosen\LaravelMpdf\LaravelMpdf;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property string $ref
* @property string $firstname
* @property string $lastname
* @property string $object
* @property string $tasks
* @property double $total
* @property double $total_days
* @property string $comment
* @property boolean $show_days
* @property date $signed_date
* @property date $sent_client_date
* @property date $sent_accounting_date
* @property Site $site_id
* @property Company $company_id
* @property Company $invoiced_to_id
* @property InvoiceStatusEnum $status
* @property int $id
* @property string $ref The invoice reference built as YYYY_MM_INCREMENT_USER. INCREMENT is the N invoice of the month. USER is the first two letters of the user who last edited the invoice. This field has to generated only on create.
* @property string $firstname The person to which the invoice is addressed.
* @property string $lastname The person to which the invoice is addressed.
* @property string $object The invoice object.
* @property array<array-key, mixed> $tasks A JSON object, each line has to be contructed using these fields description, days, type Invoices\TaskTypeEnum, amount
* @property float $total The total calculated using all tasks
* @property float $total_days The total days calculated using all tasks
* @property string|null $comment Any comment joined to the invoice
* @property int $show_days Show days on the printed invoice
* @property Carbon|null $signed_date The date the invoice was signed
* @property Carbon|null $sent_client_date The date the invoice was send to the client
* @property Carbon|null $sent_accounting_date The date the invoice was send to the client
* @property int $site_id
* @property int $company_id
* @property int $invoiced_to_id
* @property InvoiceStatusEnum $status Status of the invoice.
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property-read \App\Models\Company $company
* @property-read \App\Models\Company $companyInvoiced
* @property-read \App\Models\Site|null $site
* @method static \Database\Factories\InvoiceFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Invoice onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Invoice withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Invoice withoutTrashed()
* @mixin \Eloquent
*/
class Invoice extends Model
{

View file

@ -7,6 +7,14 @@ use App\Lib\Helpers\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name Un ou deux mot désignant le label
* @property string $color Cast HTML color
* @property-read \App\Models\Site|null $site
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Ticket> $tickets
* @mixin \Eloquent
*/
class Label extends Model
{
use HasFactory;

View file

@ -9,12 +9,20 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
*
* @property string $message The message content.
* @property \App\Enums\MessageTypeEnum $type The message is not visible for clients.
* @property string $file The message joined file path.
*
* @package App\Models
* @property int $id
* @property int $ticket_id
* @property int|null $user_id
* @property string $message
* @property MessageTypeEnum $type Message Type defined by an enum.
* @property string|null $file
* @property string|null $file2 Pièce jointes n°2
* @property string|null $file3 Pièce jointes n°3
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Ticket|null $ticket
* @property-read \App\Models\User|null $user
* @mixin \Eloquent
*/
class Message extends Model
{

View file

@ -6,8 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
* @property double $amount
* @property float $amount The cost per day
* @property bool $published
* @method static \Database\Factories\PackageFactory factory($count = null, $state = [])
* @mixin \Eloquent
*/
class Package extends Model
{

View file

@ -10,11 +10,26 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property string $name
* @property string $url_re7
* @property string $url_prod
* @property integer $gitlab_project_id
* @property Brand $brand_id
* @property int $id
* @property string $name
* @property string|null $url_re7
* @property string|null $url_prod
* @property int|null $gitlab_project_id
* @property int|null $brand_id Relation is nullable, since not all site would be affiliate with a brand.
* @property int|null $project_manager_id Project manager relationship
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read \App\Models\Brand|null $brand
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Invoice> $invoices
* @property-read \App\Models\User|null $projectManager
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Ticket> $tickets
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $users
* @property-read mixed $value
* @method static \Illuminate\Database\Eloquent\Builder<static>|Site onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Site withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Site withoutTrashed()
* @mixin \Eloquent
*/
class Site extends Model
{

View file

@ -4,6 +4,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property int $site_id
* @property int $user_id
* @mixin \Eloquent
*/
class SiteUser extends Pivot
{
/**

View file

@ -22,6 +22,44 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\ModelNotFoundException;
/**
* @property int $id
* @property int $created_by
* @property int|null $assigned_to La personne a qui est assigné le ticket.
* @property int $site_id
* @property TicketTypeEnum $type
* @property string $title Titre du ticket
* @property string $text
* @property string|null $file
* @property string|null $file2
* @property string|null $file3 Pièce jointes n°3
* @property string|null $file4 Pièce jointes n°4
* @property string|null $file5 Pièce jointes n°5
* @property TicketStatusEnum $status
* @property int $order Ticket order.
* @property bool $is_internal
* @property string|null $gitlab_issue URL de l'issue Gitlab
* @property \Illuminate\Support\Carbon|null $date_mep Date MEP souhaitée
* @property int $last_activity Last activity on the ticket.
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read \App\Models\User|null $assignedUser
* @property-read Estimation|null $estimation
* @property-read string $full_name
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Label> $labels
* @property-read \Illuminate\Database\Eloquent\Collection<int, Ticket> $linkedTickets
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Message> $messages
* @property-read \App\Models\User|null $projectManager
* @property-read \App\Models\Site|null $site
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $subscriptions
* @property-read TicketType|null $ticketType
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder<static>|Ticket onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Ticket withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Ticket withoutTrashed()
* @mixin \Eloquent
*/
class Ticket extends Model
{
use HasFactory;

View file

@ -9,8 +9,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property TicketTypeEnum $type
* @property string $template
* @property int $id
* @property TicketTypeEnum $type TicketType Enum
* @property string $template Le template du type de ticket
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, Ticket> $tickets
* @mixin \Eloquent
*/
class TicketType extends Model
{

View file

@ -15,12 +15,27 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @property int $id
* @property string $name
* @property string $email
* @property string $password
* @property string $remember_token
* @property RoleEnum $role
* @property Collection $sites
* @property string|null $remember_token
* @property int|null $gitlab_id ID du user Gitlab
* @property int|null $favourite_site_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read Collection<int, \App\Models\Message> $messages
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read Collection<int, \App\Models\Site> $sites
* @property-read Collection<int, \App\Models\Ticket> $subscriptions
* @property-read Collection<int, \App\Models\Ticket> $tickets
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|User onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User withoutTrashed()
* @mixin \Eloquent
*/
class User extends Authenticatable
{

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Site;
use Override;
class EloquentSiteRepository implements SiteRepository
{
#[Override]
public function findByName(string $name): ?Site
{
return Site::query()
->where('name', $name)
->first();
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Site;
interface SiteRepository
{
public function findByName(string $name): ?Site;
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\Tickets\TicketStatusEnum;
use App\Enums\Tickets\TicketTypeEnum;
use App\Models\Site;
use App\Models\Ticket;
use App\Models\User;
class AutomaticTicketCreator
{
public function __construct(
private readonly BrevoWebookParser $parser
) {}
public function createTicket(
Site $forSite,
User $withProjectManager,
string $withRawHtmlContent,
): Ticket {
$parsedContent = $this->parser->parseRawHtml($withRawHtmlContent);
$textContent = "Bonjour,\n";
$textContent .= " Contenu :\n\n";
$textContent .= " {$parsedContent->plainTextContent},\n\n";
$textContent .= " Cordialement.";
$title = "Demande de mail en masse";
if (!empty($parsedContent->brand)) {
$title .= " pour {$parsedContent->brand}";
}
$ticket = new Ticket([
'site_id' => $forSite->id,
'type' => TicketTypeEnum::mailing->value,
'status' => TicketStatusEnum::estimateTodo->value,
'date_mep' => null,
'text' => $textContent,
'title' => $title,
'is_internal' => false
]);
$ticket->site()->associate($forSite);
$ticket->user()->associate($withProjectManager);
$ticket->assignedUser()->associate($withProjectManager);
$ticket->saveOrFail();
return $ticket;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Dto\BrevoWebHookDto;
use App\Exceptions\BrevoParseFailure;
class BrevoWebookParser
{
/** @throws BrevoParseFailure */
public function parseRawHtml(string $rawHtml): BrevoWebHookDto
{
$data = [];
$plainText = strip_tags($rawHtml);
$tblRegexPatterns = [
'user' => '/^Matricule et Nom du demandeur\s*\r?\n(.*)$/im',
'email' => '/^Email du demandeur\s*\r?\n(.*)$/im',
'brand' => '/^Marque\s*\/\s*Filiale\s*\r?\n(.*)$/im',
];
foreach ($tblRegexPatterns as $field => $regex) {
if (preg_match($regex, $plainText, $matches)) {
$data[$field] = trim($matches[1]);
}
}
return new BrevoWebHookDto(
plainTextContent: $plainText,
brand: $data['brand'] ?? null
);
}
}

View file

@ -28,6 +28,7 @@
"uwil/k8s-laravel": "^1.2"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.7",
"fakerphp/faker": "^1.9.1",
"laravel-lang/common": "^4.0",
"laravel/pint": "^1.0",
@ -43,7 +44,8 @@
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Database\\Seeders\\": "database/seeders/",
"Domain\\": "domain/"
}
},
"repositories": [
@ -67,7 +69,11 @@
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
"@php artisan lang:update",
"@php artisan ide-helper:generate",
"@php artisan ide-helper:models -RW"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""

294
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7416f0b20f3b4cbad37458002462d3d8",
"content-hash": "8d2ff09233a3a226f9f5779c2a66d3a6",
"packages": [
{
"name": "brick/math",
@ -9097,6 +9097,298 @@
},
"time": "2023-02-15T13:05:41+00:00"
},
{
"name": "barryvdh/laravel-ide-helper",
"version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-ide-helper.git",
"reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a",
"reference": "ad7e37676f1ff985d55ef1b6b96a0c0a40f2609a",
"shasum": ""
},
"require": {
"barryvdh/reflection-docblock": "^2.4",
"composer/class-map-generator": "^1.0",
"ext-json": "*",
"illuminate/console": "^11.15 || ^12 || ^13.0",
"illuminate/database": "^11.15 || ^12 || ^13.0",
"illuminate/filesystem": "^11.15 || ^12 || ^13.0",
"illuminate/support": "^11.15 || ^12 || ^13.0",
"php": "^8.2"
},
"require-dev": {
"ext-pdo_sqlite": "*",
"friendsofphp/php-cs-fixer": "^3",
"illuminate/config": "^11.15 || ^12 || ^13.0",
"illuminate/view": "^11.15 || ^12 || ^13.0",
"larastan/larastan": "^3.1",
"mockery/mockery": "^1.4",
"orchestra/testbench": "^9.2 || ^10 || ^11.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5 || ^11.5.3 || ^12.5.12",
"spatie/phpunit-snapshot-assertions": "^4 || ^5",
"vlucas/phpdotenv": "^5"
},
"suggest": {
"illuminate/events": "Required for automatic helper generation (^6|^7|^8|^9|^10|^11)."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\LaravelIdeHelper\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.",
"keywords": [
"autocomplete",
"codeintel",
"dev",
"helper",
"ide",
"laravel",
"netbeans",
"phpdoc",
"phpstorm",
"sublime"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-ide-helper/issues",
"source": "https://github.com/barryvdh/laravel-ide-helper/tree/v3.7.0"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-03-17T14:12:51+00:00"
},
{
"name": "barryvdh/reflection-docblock",
"version": "v2.4.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/ReflectionDocBlock.git",
"reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b",
"reference": "4f5ba70c30c81f2ce03a16a9965832cfcc31ed3b",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^8.5.14|^9"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.3.x-dev"
}
},
"autoload": {
"psr-0": {
"Barryvdh": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"support": {
"source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.4.1"
},
"time": "2026-03-05T20:09:01+00:00"
},
{
"name": "composer/class-map-generator",
"version": "1.7.3",
"source": {
"type": "git",
"url": "https://github.com/composer/class-map-generator.git",
"reference": "86d8208fc3c649a3a999daf1a63c25201be2990f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f",
"reference": "86d8208fc3c649a3a999daf1a63c25201be2990f",
"shasum": ""
},
"require": {
"composer/pcre": "^2.1 || ^3.1",
"php": "^7.2 || ^8.0",
"symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
"phpstan/phpstan-phpunit": "^1 || ^2",
"phpstan/phpstan-strict-rules": "^1.1 || ^2",
"phpunit/phpunit": "^8",
"symfony/filesystem": "^5.4 || ^6 || ^7 || ^8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\ClassMapGenerator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Utilities to scan PHP code and generate class maps.",
"keywords": [
"classmap"
],
"support": {
"issues": "https://github.com/composer/class-map-generator/issues",
"source": "https://github.com/composer/class-map-generator/tree/1.7.3"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-05-05T09:17:07+00:00"
},
{
"name": "composer/pcre",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-06-07T11:47:49+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",

379
config/ide-helper.php Normal file
View file

@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Session\Store;
return [
/*
|--------------------------------------------------------------------------
| Filename
|--------------------------------------------------------------------------
|
| The default filename.
|
*/
'filename' => '_ide_helper.php',
/*
|--------------------------------------------------------------------------
| Models filename
|--------------------------------------------------------------------------
|
| The default filename for the models helper file.
|
*/
'models_filename' => '_ide_helper_models.php',
/*
|--------------------------------------------------------------------------
| PhpStorm meta filename
|--------------------------------------------------------------------------
|
| PhpStorm also supports the directory `.phpstorm.meta.php/` with arbitrary
| files in it, should you need additional files for your project; e.g.
| `.phpstorm.meta.php/laravel_ide_Helper.php'.
|
*/
'meta_filename' => '.phpstorm.meta.php',
/*
|--------------------------------------------------------------------------
| Fluent helpers
|--------------------------------------------------------------------------
|
| Set to true to generate commonly used Fluent methods.
|
*/
'include_fluent' => false,
/*
|--------------------------------------------------------------------------
| Write model query methods
|--------------------------------------------------------------------------
|
| Set to false to disable generated docs for the 'query()', 'newQuery()' and 'newModelQuery()' methods.
|
*/
'write_query_methods' => false,
/*
|--------------------------------------------------------------------------
| Write model magic methods
|--------------------------------------------------------------------------
|
| Set to false to disable write magic methods of model.
|
*/
'write_model_magic_where' => false,
/*
|--------------------------------------------------------------------------
| Write model external Eloquent builder methods
|--------------------------------------------------------------------------
|
| Set to false to disable write external Eloquent builder methods.
|
*/
'write_model_external_builder_methods' => false,
/*
|--------------------------------------------------------------------------
| Write model relation count and exists properties
|--------------------------------------------------------------------------
|
| Set to false to disable writing of relation count and exists properties
| to model DocBlocks.
|
*/
'write_model_relation_count_properties' => false,
'write_model_relation_exists_properties' => false,
/*
|--------------------------------------------------------------------------
| Write Eloquent model mixins
|--------------------------------------------------------------------------
|
| This will add the necessary DocBlock mixins to the model class
| contained in the Laravel framework. This helps the IDE with
| auto-completion.
|
| Please be aware that this setting changes a file within the /vendor directory.
|
*/
'write_eloquent_model_mixins' => false,
/*
|--------------------------------------------------------------------------
| Helper files to include
|--------------------------------------------------------------------------
|
| Include helper files. By default not included, but can be toggled with the
| -- helpers (-H) option. Extra helper files can be included.
|
*/
'include_helpers' => false,
'helper_files' => [
base_path() . '/vendor/laravel/framework/src/Illuminate/Support/helpers.php',
base_path() . '/vendor/laravel/framework/src/Illuminate/Foundation/helpers.php',
],
/*
|--------------------------------------------------------------------------
| Model locations to include
|--------------------------------------------------------------------------
|
| Define in which directories the ide-helper:models command should look
| for models.
|
| glob patterns are supported to easier reach models in sub-directories,
| e.g. `app/Services/* /Models` (without the space).
|
*/
'model_locations' => [
'app',
],
/*
|--------------------------------------------------------------------------
| Models to ignore
|--------------------------------------------------------------------------
|
| Define which models should be ignored.
|
*/
'ignored_models' => [],
/*
|--------------------------------------------------------------------------
| Models hooks
|--------------------------------------------------------------------------
|
| Define which hook classes you want to run for models to add custom information.
|
| Hooks should implement Barryvdh\LaravelIdeHelper\Contracts\ModelHookInterface.
|
*/
'model_hooks' => [
// App\Support\IdeHelper\MyModelHook::class
],
/*
|--------------------------------------------------------------------------
| Extra classes
|--------------------------------------------------------------------------
|
| These implementations are not really extended, but called with magic functions.
|
*/
'extra' => [
'Eloquent' => [Builder::class, \Illuminate\Database\Query\Builder::class],
'Session' => [Store::class],
],
'magic' => [],
/*
|--------------------------------------------------------------------------
| Interface implementations
|--------------------------------------------------------------------------
|
| These interfaces will be replaced with the implementing class. Some interfaces
| are detected by the helpers, others can be listed below.
|
*/
'interfaces' => [
// App\MyInterface::class => App\MyImplementation::class,
],
/*
|--------------------------------------------------------------------------
| Support for camel cased models
|--------------------------------------------------------------------------
|
| There are some Laravel packages (such as Eloquence) that allow for accessing
| Eloquent model properties via camel case, instead of snake case.
|
| Enabling this option will support these packages by saving all model
| properties as camel case, instead of snake case.
|
| For example, normally you would see this:
|
| * @property \Illuminate\Support\Carbon $created_at
| * @property \Illuminate\Support\Carbon $updated_at
|
| With this enabled, the properties will be this:
|
| * @property \Illuminate\Support\Carbon $createdAt
| * @property \Illuminate\Support\Carbon $updatedAt
|
| Note, it is currently an all-or-nothing option.
|
*/
'model_camel_case_properties' => false,
/*
|--------------------------------------------------------------------------
| Property casts
|--------------------------------------------------------------------------
|
| Cast the given "real type" to the given "type".
|
*/
'type_overrides' => [
'integer' => 'int',
'boolean' => 'bool',
],
/*
|--------------------------------------------------------------------------
| Include DocBlocks from classes
|--------------------------------------------------------------------------
|
| Include DocBlocks from classes to allow additional code inspection for
| magic methods and properties.
|
*/
'include_class_docblocks' => false,
/*
|--------------------------------------------------------------------------
| Force FQN usage
|--------------------------------------------------------------------------
|
| Use the fully qualified (class) name in DocBlocks,
| even if the class exists in the same namespace
| or there is an import (use className) of the class.
|
*/
'force_fqn' => false,
/*
|--------------------------------------------------------------------------
| Use generics syntax
|--------------------------------------------------------------------------
|
| Use generics syntax within DocBlocks,
| e.g. `Collection<User>` instead of `Collection|User[]`.
|
*/
'use_generics_annotations' => true,
/*
|--------------------------------------------------------------------------
| Default return types for macros
|--------------------------------------------------------------------------
|
| Define default return types for macros without explicit return types.
| e.g. `\Illuminate\Database\Query\Builder::class => 'static'`,
| `\Illuminate\Support\Str::class => 'string'`
|
*/
'macro_default_return_types' => [
Factory::class => PendingRequest::class,
],
/*
|--------------------------------------------------------------------------
| Additional relation types
|--------------------------------------------------------------------------
|
| Sometimes it's needed to create custom relation types. The key of the array
| is the relationship method name. The value of the array is the fully-qualified
| class name of the relationship, e.g. `'relationName' => RelationShipClass::class`.
|
*/
'additional_relation_types' => [],
/*
|--------------------------------------------------------------------------
| Additional relation return types
|--------------------------------------------------------------------------
|
| When using custom relation types its possible for the class name to not contain
| the proper return type of the relation. The key of the array is the relationship
| method name. The value of the array is the return type of the relation ('many'
| or 'morphTo').
| e.g. `'relationName' => 'many'`.
|
*/
'additional_relation_return_types' => [],
/*
|--------------------------------------------------------------------------
| Enforce nullable Eloquent relationships on not null columns
|--------------------------------------------------------------------------
|
| When set to true (default), this option enforces nullable Eloquent relationships.
| However, in cases where the application logic ensures the presence of related
| records it may be desirable to set this option to false to avoid unwanted null warnings.
|
| Default: true
| A not null column with no foreign key constraint will have a "nullable" relationship.
| * @property int $not_null_column_with_no_foreign_key_constraint
| * @property-read BelongsToVariation|null $notNullColumnWithNoForeignKeyConstraint
|
| Option: false
| A not null column with no foreign key constraint will have a "not nullable" relationship.
| * @property int $not_null_column_with_no_foreign_key_constraint
| * @property-read BelongsToVariation $notNullColumnWithNoForeignKeyConstraint
|
*/
'enforce_nullable_relationships' => true,
/*
|--------------------------------------------------------------------------
| Make soft deletable relations nullable
|--------------------------------------------------------------------------
|
| When set to true (default), relationships to models using SoftDeletes trait
| will be marked as nullable. This is because soft-deleted records are excluded
| from queries by default, meaning even non-nullable foreign keys can return
| null when the related model is soft-deleted.
|
| Default: true
| A relationship to a soft-deletable model will include |null in the type:
| * @property-read Team|null $team
|
| Option: false
| A relationship to a soft-deletable model will NOT include |null (unless
| nullable for other reasons such as nullable foreign key column):
| * @property-read Team $team
|
*/
'soft_deletes_force_nullable' => true,
/*
|--------------------------------------------------------------------------
| Run artisan commands after migrations to generate model helpers
|--------------------------------------------------------------------------
|
| The specified commands should run after migrations are finished running.
|
*/
'post_migrate' => [
// 'ide-helper:models --nowrite',
],
];

View file

@ -18,6 +18,10 @@ return [
// * Mail admin
'MAIL_ADMIN' => env('MAIL_ADMIN', 'digital@ldc.fr'),
'brevo_webook' => [
'expected_from_address' => 'mailing@reply.uwil.fr'
],
/*
|--------------------------------------------------------------------------
| Mailer Configurations

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Site;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class SiteFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Site::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->company(),
'url_re7' => fake()->url(),
'url_prod' => fake()->url(),
'gitlab_project_id' => fake()->optional()->randomNumber(4),
'brand_id' => null,
'project_manager_id' => null,
];
}
/**
* Indique que le site a un chef de projet assigné.
*/
public function withProjectManager(): static
{
return $this->state(fn(array $attributes) => [
'project_manager_id' => User::factory(),
]);
}
}

View file

@ -1,10 +1,12 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use App\Enums\Users\RoleEnum;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
@ -18,17 +20,23 @@ class UserFactory extends Factory
/**
* Define the model's default state.
*
* @return array
* @return array<string, mixed>
*/
public function definition()
public function definition(): array
{
return [
'name' => $this->faker->name,
'email' => $this->faker->unique()->safeEmail,
'email_verified_at' => now(),
// * password
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
'remember_token' => Str::random(10),
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'role' => RoleEnum::developper,
'gitlab_id' => fake()->optional()->randomNumber(5),
'favourite_site_id' => null,
];
}
public function admin(): static
{
return $this->state(fn(array $attributes) => [
'role' => RoleEnum::admin,
]);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class Email
{
public function __construct(
public string $value
) {
$this->validate($value);
}
private function validate(string $email): void
{
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \DomainException('Invalid email address');
}
}
}

View file

@ -1,13 +1,13 @@
<p>Bonjour,</p>
<p>Un nouveau ticket (n°{{ $ticket->id }}) a été créé sur le support :</p>
<p>Un nouveau ticket (n°{{ $ticketId }}) a été créé sur le support :</p>
<ul>
<li>Projet : {{ $ticket->site->name }}</li>
<li>Titre : {{ $ticket->title }}</li>
<li>Type : {{ $ticket->type->label() }}</li>
<li>Créé par : {{ $ticket->user()->withTrashed()->firstOrFail()->name }}</li>
<li>Projet : {{ $siteName }}</li>
<li>Titre : {{ $ticketTitle }}</li>
<li>Type : {{ $ticketType->label() }}</li>
<li>Créé par : {{ $userName }}</li>
</ul>
<p>
Description :<br>
{!! clean(nl2br($ticket->text)) !!}
</p>
<p>Lien vers le ticket : <a href="{{ route('bo.ticket.show', $ticket->id) }}" traget="_blank">Ouvrir dans un nouvel onglet</a></p>
<p>Lien vers le ticket : <a href="{{ route('bo.ticket.show', $ticketId) }}" traget="_blank">Ouvrir dans un nouvel onglet</a></p>

View file

@ -12,13 +12,13 @@ use App\Http\Controllers\Bo\PackageController;
use App\Http\Controllers\Bo\SiteController;
use App\Http\Controllers\Bo\TicketController;
use App\Http\Controllers\Bo\TicketTypeController;
use App\Http\Controllers\Bo\BrevoController;
use App\Http\Controllers\Bo\BrevoWebhookController;
use App\Http\Controllers\Bo\UserController;
use App\Http\Middleware\SecureHeaders;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
Route::post('/brevo-in', [BrevoController::class, 'createTicket']);
Route::post('/brevo-in', [BrevoWebhookController::class, 'webhookCallReceived']);
Route::middleware('admin-secure')
->group(function () {
@ -130,7 +130,8 @@ Route::middleware('admin-secure')
->middleware('can:estimate,ticket')
->name('estimateSubmit');
Route::post('/{ticket}/estimate/answer', [
EstimationController::class, 'estimatePendingSubmit'
EstimationController::class,
'estimatePendingSubmit'
])
->middleware('can:validateEstimate,ticket')
->name('estimatePendingSubmit');

View file

@ -15,6 +15,6 @@ class ExampleTest extends TestCase
{
$response = $this->get('/');
$response->assertStatus(200);
$response->assertStatus(302);
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$this->assertTrue(true);
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Dto\BrevoWebHookDto;
use App\Enums\Tickets\TicketStatusEnum;
use App\Enums\Tickets\TicketTypeEnum;
use App\Models\Site;
use App\Models\Ticket;
use App\Models\User;
use App\Services\AutomaticTicketCreator;
use App\Services\BrevoWebookParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class AutomaticTicketCreatorTest extends TestCase
{
use RefreshDatabase;
private AutomaticTicketCreator $creator;
private BrevoWebookParser|MockInterface $parserMock;
protected function setUp(): void
{
parent::setUp();
$this->parserMock = Mockery::mock(BrevoWebookParser::class);
$this->creator = new AutomaticTicketCreator($this->parserMock);
}
public function testCanCreateTicketWithProjectManagerAndBrand(): void
{
// GIVEN
$site = Site::factory()->create();
$manager = User::factory()->create();
$rawHtml = 'Simili HTML dynamique';
$dto = new BrevoWebHookDto(
plainTextContent: 'Contenu parsé',
brand: 'LDC Service'
);
$this->parserMock
->shouldReceive('parseRawHtml')
->once()
->with($rawHtml)
->andReturn($dto);
// WHEN
$ticket = $this->creator->createTicket($site, $manager, $rawHtml);
// THEN
$this->assertInstanceOf(Ticket::class, $ticket);
$this->assertEquals($site->id, $ticket->site_id);
$this->assertEquals(TicketTypeEnum::mailing, $ticket->type);
$this->assertEquals(TicketStatusEnum::estimateTodo, $ticket->status);
$this->assertEquals('Demande de mail en masse pour LDC Service', $ticket->title);
$this->assertStringContainsString('Contenu :', $ticket->text);
$this->assertStringContainsString('Contenu parsé', $ticket->text);
$this->assertFalse($ticket->is_internal);
$this->assertTrue($ticket->user->is($manager));
$this->assertTrue($ticket->assignedUser->is($manager));
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'created_by' => $manager->id,
'assigned_to' => $manager->id,
'title' => 'Demande de mail en masse pour LDC Service'
]);
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Dto\BrevoWebHookDto;
use App\Services\BrevoWebookParser;
use PHPUnit\Framework\TestCase;
class BrevoWebookParserTest extends TestCase
{
private BrevoWebookParser $parser;
protected function setUp(): void
{
parent::setUp();
$this->parser = new BrevoWebookParser();
}
public function testCanExtractDataWithRegexUsingPipedFormat(): void
{
// GIVEN
$rawHtml = <<<TEXT
Détails de la demande :\r\n\r\nOutil envoi de mails en masse\r\n\r\n\r\nDescription\r\nReponse\r\n\r\n\r\nMatricule et Nom du demandeur\r\nK00112323 : toto jean\r\n\r\n\r\nEmail du demandeur\r\ntoto.jean@ldc.fr : Jean\r\n\r\n\r\nMarque / Filiale\r\nLDC Service\r\n\r\n\r\nPour quels besoins avez-vous besoin d'envoyer des emails en masse ?\r\nL'aigüe ambiguïté exige qu'on s'y penche. Œil, cœur, bœuf: la naïveté est un mythe. Test accents: àéèïôùç. Maman a acheté 15,99&euro; de pâte (soit 10%)! Symboles: & ~ # | \\ ^ @ % * $ £ ¥ ¤ + = - _ Parenthèses: (test) [test] {test} &lt;test&gt;. Cyrillique: Добрый день. Arabe: مرحبا. Emojis: 😀🚀🐛. Test XSS: &lt;script&gt;alert('XSS');&lt;/script&gt; Test SQL: SELECT * FROM users WHERE nom = 'O\\'Brian'; DROP TABLE test; -- Fin du texte 1. 1234567890. Remplissage pour atteindre un peu moins de 500 chars.\r\n\r\n\r\nDate de première envoi souhaité\r\n30/06/2026\r\n\r\n\r\nCommentaire\r\nTest de sauts de ligne pour le second champ.\r\n\r\n\tTabulation et espaces multiples.\r\nJSON :\r\n{'id': 999, 'name': 'L'Étranger', 'tags': ['&lt;img src=x onerror=alert(1)&gt;']}\r\n\r\nVérification Regex :\r\nEmail: jean.dupont+test@exemple.co.uk\r\nURL: https://www.exemple.com/api/v1/data?p=1#ancre\r\nTél: +33(0)123456789\r\nIPv4: 192.168.1.254 | IPv6: 2001:db8::8a2e:370:7334\r\n\r\nCe second bloc vérifie que les données structurées et les liens survivent au transfert. Remplissage pour atteindre la limite de sept cents caractères sans la dépasser. Chinois: 这是一个测试. Japonais: こんにちは世界. Test des retours chariots invisibles. Fin du second bloc de test.\r\n\r\n\r\n
TEXT;
// WHEN
$dto = $this->parser->parseRawHtml($rawHtml);
// THEN
$this->assertInstanceOf(BrevoWebHookDto::class, $dto);
$this->assertEquals('LDC Service', $dto->brand);
$this->assertEquals($rawHtml, $dto->plainTextContent);
}
public function testHandlesMissingFieldsGracefully(): void
{
// GIVEN
$rawHtml = "<p>Contenu classique sans le formalisme du tableau markdown</p>";
// WHEN
$dto = $this->parser->parseRawHtml($rawHtml);
// THEN
$this->assertNull($dto->brand);
$this->assertEquals("Contenu classique sans le formalisme du tableau markdown", $dto->plainTextContent);
}
}