feat(16-automated-tasks): moduł zadań automatycznych — CRUD + watcher/executor
Reguły automatyzacji oparte na zdarzeniach (receipt.created) z warunkami (integracja/kanał sprzedaży, AND logic) i akcjami (wyślij e-mail z 3 trybami odbiorcy: klient / firma / klient+firma). Trigger w ReceiptController po utworzeniu paragonu — błąd automatyzacji nie blokuje sukcesu paragonu. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
346
src/Modules/Automation/AutomationController.php
Normal file
346
src/Modules/Automation/AutomationController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Automation;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class AutomationController
|
||||
{
|
||||
private const ALLOWED_EVENTS = ['receipt.created'];
|
||||
private const ALLOWED_CONDITION_TYPES = ['integration'];
|
||||
private const ALLOWED_ACTION_TYPES = ['send_email'];
|
||||
private const ALLOWED_RECIPIENTS = ['client', 'client_and_company', 'company'];
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly AutomationRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$rules = $this->repository->findAll();
|
||||
|
||||
$html = $this->template->render('automation/index', [
|
||||
'title' => 'Zadania automatyczne',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'automation',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'rules' => $rules,
|
||||
'successMessage' => Flash::get('settings.automation.success', ''),
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return $this->renderForm(null);
|
||||
}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$id = (int) $request->input('id', '0');
|
||||
$rule = $id > 0 ? $this->repository->findById($id) : null;
|
||||
|
||||
if ($rule === null) {
|
||||
Flash::set('settings.automation.error', 'Nie znaleziono reguly');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
return $this->renderForm($rule);
|
||||
}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$validationError = $this->validateInput($request);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings.automation.error', $validationError);
|
||||
return Response::redirect('/settings/automation/create');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->create(
|
||||
$this->extractRuleData($request),
|
||||
$this->extractConditions($request),
|
||||
$this->extractActions($request)
|
||||
);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo utworzone');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad zapisu zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
$validationError = $this->validateInput($request);
|
||||
if ($validationError !== null) {
|
||||
Flash::set('settings.automation.error', $validationError);
|
||||
return Response::redirect('/settings/automation/edit?id=' . $id);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->update(
|
||||
$id,
|
||||
$this->extractRuleData($request),
|
||||
$this->extractConditions($request),
|
||||
$this->extractActions($request)
|
||||
);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo zaktualizowane');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad aktualizacji zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function destroy(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->delete($id);
|
||||
Flash::set('settings.automation.success', 'Zadanie automatyczne zostalo usuniete');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad usuwania zadania automatycznego');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
public function toggleStatus(Request $request): Response
|
||||
{
|
||||
$error = $this->validateCsrf($request);
|
||||
if ($error !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy identyfikator');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->toggleActive($id);
|
||||
Flash::set('settings.automation.success', 'Status zadania zostal zmieniony');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.automation.error', 'Blad zmiany statusu');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
private function renderForm(?array $rule): Response
|
||||
{
|
||||
$html = $this->template->render('automation/form', [
|
||||
'title' => $rule !== null ? 'Edytuj zadanie automatyczne' : 'Nowe zadanie automatyczne',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'automation',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'rule' => $rule,
|
||||
'integrations' => $this->repository->listOrderIntegrations(),
|
||||
'emailTemplates' => $this->repository->listEmailTemplates(),
|
||||
'eventTypes' => self::ALLOWED_EVENTS,
|
||||
'conditionTypes' => self::ALLOWED_CONDITION_TYPES,
|
||||
'actionTypes' => self::ALLOWED_ACTION_TYPES,
|
||||
'recipientOptions' => self::ALLOWED_RECIPIENTS,
|
||||
'errorMessage' => Flash::get('settings.automation.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
private function validateCsrf(Request $request): ?Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings.automation.error', 'Nieprawidlowy token CSRF');
|
||||
return Response::redirect('/settings/automation');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateInput(Request $request): ?string
|
||||
{
|
||||
$name = trim((string) $request->input('name', ''));
|
||||
if ($name === '' || mb_strlen($name) > 128) {
|
||||
return 'Nazwa jest wymagana (maks. 128 znakow)';
|
||||
}
|
||||
|
||||
$eventType = (string) $request->input('event_type', '');
|
||||
if (!in_array($eventType, self::ALLOWED_EVENTS, true)) {
|
||||
return 'Nieprawidlowy typ zdarzenia';
|
||||
}
|
||||
|
||||
$conditions = $this->extractConditions($request);
|
||||
if (count($conditions) === 0) {
|
||||
return 'Wymagany jest co najmniej jeden warunek';
|
||||
}
|
||||
|
||||
$actions = $this->extractActions($request);
|
||||
if (count($actions) === 0) {
|
||||
return 'Wymagana jest co najmniej jedna akcja';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractRuleData(Request $request): array
|
||||
{
|
||||
return [
|
||||
'name' => trim((string) $request->input('name', '')),
|
||||
'event_type' => (string) $request->input('event_type', ''),
|
||||
'is_active' => $request->input('is_active', null) !== null ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{type: string, value: array<string, mixed>}>
|
||||
*/
|
||||
private function extractConditions(Request $request): array
|
||||
{
|
||||
$raw = $request->input('conditions', []);
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($raw as $condition) {
|
||||
if (!is_array($condition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($condition['type'] ?? '');
|
||||
if (!in_array($type, self::ALLOWED_CONDITION_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->parseConditionValue($type, $condition);
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = ['type' => $type, 'value' => $value];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $condition
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function parseConditionValue(string $type, array $condition): ?array
|
||||
{
|
||||
if ($type === 'integration') {
|
||||
$ids = $condition['integration_ids'] ?? [];
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$integrationIds = array_values(array_filter(
|
||||
array_map('intval', $ids),
|
||||
static fn (int $id): bool => $id > 0
|
||||
));
|
||||
|
||||
return count($integrationIds) > 0 ? ['integration_ids' => $integrationIds] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{type: string, config: array<string, mixed>}>
|
||||
*/
|
||||
private function extractActions(Request $request): array
|
||||
{
|
||||
$raw = $request->input('actions', []);
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($raw as $action) {
|
||||
if (!is_array($action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($action['type'] ?? '');
|
||||
if (!in_array($type, self::ALLOWED_ACTION_TYPES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = $this->parseActionConfig($type, $action);
|
||||
if ($config === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = ['type' => $type, 'config' => $config];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $action
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function parseActionConfig(string $type, array $action): ?array
|
||||
{
|
||||
if ($type === 'send_email') {
|
||||
$templateId = (int) ($action['template_id'] ?? 0);
|
||||
$recipient = (string) ($action['recipient'] ?? '');
|
||||
|
||||
if ($templateId <= 0 || !in_array($recipient, self::ALLOWED_RECIPIENTS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['template_id' => $templateId, 'recipient' => $recipient];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user