diff --git a/.env.example b/.env.example index f44b3bc..382e811 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,15 @@ APP_FAKER_LOCALE=en_US LOG_STACK=daily +# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it) +LOG_DISCORD_WEBHOOK_URL= +LOG_DISCORD_LEVEL=error +LOG_DISCORD_MENTION= +LOG_DISCORD_TITLE="## kanbangit error" +LOG_DISCORD_TIMEOUT=3 +LOG_DISCORD_CONNECT_TIMEOUT=3 +LOG_DISCORD_MAX_TRACE_LINES=12 + DB_CONNECTION=mariadb DB_HOST=kanbangit-db DB_PORT=3306 diff --git a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php index 995a4f3..ef2a338 100644 --- a/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php +++ b/app/Actions/Notifications/BuildProjectNotificationSettingsPayload.php @@ -2,8 +2,11 @@ namespace App\Actions\Notifications; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Enums\NotificationFilterAttribute; +use App\Enums\NotificationFilterOperator; use App\Http\Resources\NotificationActionResource; use App\Http\Resources\ProjectResource; use App\Models\Project; @@ -22,6 +25,9 @@ class BuildProjectNotificationSettingsPayload return [ 'channelOptions' => NotificationChannel::options(), 'eventTypeOptions' => NotificationEventType::options(), + 'filterAttributeOptions' => NotificationFilterAttribute::options(), + 'filterOperatorOptions' => NotificationFilterOperator::options(), + 'discordAllowedMentionOptions' => DiscordAllowedMentionType::options(), 'notificationActions' => NotificationActionResource::collection( $this->listProjectNotificationActions->handle($project), )->resolve(), @@ -38,7 +44,7 @@ class BuildProjectNotificationSettingsPayload { return [ [ - 'description' => 'Absolute ticket URL for the status change event.', + 'description' => 'Absolute ticket URL for the notification event.', 'value' => '$url', ], [ @@ -46,7 +52,7 @@ class BuildProjectNotificationSettingsPayload 'value' => '$ticket_key', ], [ - 'description' => 'Ticket title from the changed ticket.', + 'description' => 'Ticket title for the notification event.', 'value' => '$ticket_title', ], [ @@ -54,17 +60,25 @@ class BuildProjectNotificationSettingsPayload 'value' => '$project_name', ], [ - 'description' => 'Origin status label before the move.', + 'description' => 'Origin status label for status moves; blank for ticket create and assignee changes.', 'value' => '$status_from', ], [ - 'description' => 'Destination status label after the move.', + 'description' => 'Destination or initial status label for the ticket event.', 'value' => '$status_to', ], [ - 'description' => 'Actor name when the move has a known user.', + 'description' => 'Actor name when the event has a known user.', 'value' => '$actor_name', ], + [ + 'description' => 'Previous assignee name when the event updates assignment; blank otherwise.', + 'value' => '$assignee_from', + ], + [ + 'description' => 'New assignee name when the event updates assignment; blank when unassigned.', + 'value' => '$assignee_to', + ], ]; } } diff --git a/app/Actions/Notifications/BuildTicketAssigneeChangedPayload.php b/app/Actions/Notifications/BuildTicketAssigneeChangedPayload.php new file mode 100644 index 0000000..75e8e3e --- /dev/null +++ b/app/Actions/Notifications/BuildTicketAssigneeChangedPayload.php @@ -0,0 +1,61 @@ +loadMissing('ticketStatus.boardColumn'); + + return new TicketAssigneeChangedPayload( + projectId: $project->id, + projectKey: $project->key, + projectName: $project->name, + ticketId: $ticket->id, + ticketKey: $ticket->ticket_key, + ticketTitle: $ticket->title, + ticketUrl: route('projects.tickets.show', [$project, $ticket]), + actorId: $actor?->id, + actorName: $actor?->name, + fromStatusId: null, + fromStatusLabel: null, + toStatusId: $ticket->ticket_status_id, + toStatusLabel: $this->statusLabel($ticket->ticketStatus), + occurredAt: $occurredAt, + fromAssigneeId: $fromAssigneeId, + fromAssigneeName: $fromAssigneeName, + toAssigneeId: $toAssigneeId, + toAssigneeName: $toAssigneeName, + ); + } + + private function statusLabel(?TicketStatus $ticketStatus): string + { + if ($ticketStatus === null) { + return 'Unknown status'; + } + + $ticketStatus->loadMissing('boardColumn'); + + if ($ticketStatus->boardColumn === null || $ticketStatus->boardColumn->name === $ticketStatus->name) { + return $ticketStatus->name; + } + + return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + } +} diff --git a/app/Actions/Notifications/BuildTicketCreatedPayload.php b/app/Actions/Notifications/BuildTicketCreatedPayload.php new file mode 100644 index 0000000..0bdb9fc --- /dev/null +++ b/app/Actions/Notifications/BuildTicketCreatedPayload.php @@ -0,0 +1,46 @@ +id, + projectKey: $project->key, + projectName: $project->name, + ticketId: $ticket->id, + ticketKey: $ticket->ticket_key, + ticketTitle: $ticket->title, + ticketUrl: route('projects.tickets.show', [$project, $ticket]), + actorId: $actor?->id, + actorName: $actor?->name, + toStatusId: $ticketStatus->id, + toStatusLabel: $this->statusLabel($ticketStatus), + occurredAt: $occurredAt, + ); + } + + private function statusLabel(TicketStatus $ticketStatus): string + { + $ticketStatus->loadMissing('boardColumn'); + + if ($ticketStatus->boardColumn === null || $ticketStatus->boardColumn->name === $ticketStatus->name) { + return $ticketStatus->name; + } + + return "{$ticketStatus->boardColumn->name} / {$ticketStatus->name}"; + } +} diff --git a/app/Actions/Notifications/BuildTicketStatusChangedPayload.php b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php index b923fed..96e2ebd 100644 --- a/app/Actions/Notifications/BuildTicketStatusChangedPayload.php +++ b/app/Actions/Notifications/BuildTicketStatusChangedPayload.php @@ -2,7 +2,6 @@ namespace App\Actions\Notifications; -use App\Enums\NotificationEventType; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketStatus; @@ -22,7 +21,6 @@ class BuildTicketStatusChangedPayload CarbonImmutable $occurredAt, ): TicketStatusChangedPayload { return new TicketStatusChangedPayload( - eventType: NotificationEventType::TicketStatusChanged, projectId: $project->id, projectKey: $project->key, projectName: $project->name, diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 8cdaef5..d614767 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -2,6 +2,7 @@ namespace App\Actions\Notifications; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationDeliveryStatus; use App\Models\NotificationAction; @@ -27,7 +28,7 @@ class DeliverNotificationAction implements ShouldQueue public function __construct( public int $notificationActionId, - public TicketStatusChangedPayload $payload, + public NotificationEvent $event, public ?int $notificationDeliveryLogId, ) {} @@ -59,7 +60,7 @@ class DeliverNotificationAction implements ShouldQueue if ($notificationAction->channel === NotificationChannel::Discord) { $discordMessage = app(RenderDiscordMessageTemplate::class)->handle( $notificationAction->message_template, - $this->payload, + $this->event, ); $validationError = $this->discordMessageValidationError($discordMessage); @@ -244,7 +245,7 @@ class DeliverNotificationAction implements ShouldQueue 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $this->payload->eventType, + 'event_type' => $this->event->eventType(), 'notification_action_id' => $notificationAction->id, 'payload_metadata' => $this->payloadMetadata(), ]); @@ -280,18 +281,60 @@ class DeliverNotificationAction implements ShouldQueue ->post($notificationAction->delivery_url, [ 'content' => $message, 'allowed_mentions' => [ - 'parse' => [], + 'parse' => $this->discordAllowedMentions($notificationAction), ], ]); } + /** + * @return list + */ + private function discordAllowedMentions(NotificationAction $notificationAction): array + { + $configuredAllowedMentions = data_get( + $notificationAction->channel_configuration, + 'allowed_mentions', + [], + ); + + if (! is_array($configuredAllowedMentions)) { + return []; + } + + $allowedMentions = []; + + foreach ($configuredAllowedMentions as $allowedMention) { + if (! is_string($allowedMention)) { + continue; + } + + $allowedMention = Str::lower(trim($allowedMention)); + + if ($allowedMention === '') { + continue; + } + + if (! in_array($allowedMention, DiscordAllowedMentionType::values(), true)) { + continue; + } + + if (in_array($allowedMention, $allowedMentions, true)) { + continue; + } + + $allowedMentions[] = $allowedMention; + } + + return $allowedMentions; + } + private function sendWebhookRequest(NotificationAction $notificationAction): Response { return Http::acceptJson() ->asJson() ->connectTimeout(5) ->timeout(10) - ->post($notificationAction->delivery_url, $this->payload->toArray()); + ->post($notificationAction->delivery_url, $this->event->payload()->toArray()); } private function hasAttemptsRemaining(int $attemptCount): bool @@ -355,14 +398,11 @@ class DeliverNotificationAction implements ShouldQueue } /** - * @return array{project_key: string, ticket_key: string} + * @return array */ private function payloadMetadata(): array { - return [ - 'project_key' => $this->payload->projectKey, - 'ticket_key' => $this->payload->ticketKey, - ]; + return $this->event->payload()->deliveryMetadata(); } private function excerpt(?string $value): ?string diff --git a/app/Actions/Notifications/MatchNotificationFilters.php b/app/Actions/Notifications/MatchNotificationFilters.php new file mode 100644 index 0000000..9553370 --- /dev/null +++ b/app/Actions/Notifications/MatchNotificationFilters.php @@ -0,0 +1,236 @@ + $filters + */ + public function handle(array $filters, NotificationEventPayload $payload): bool + { + if ($filters === []) { + return true; + } + + $payloadContext = $this->payloadContext($payload); + + foreach ($filters as $filter) { + if (! $this->matchesFilter($filter, $payloadContext)) { + return false; + } + } + + return true; + } + + /** + * @param array{attribute?: mixed, operator?: mixed, value?: mixed} $filter + * @param array $payloadContext + */ + private function matchesFilter(array $filter, array $payloadContext): bool + { + $attribute = trim((string) Arr::get($filter, 'attribute', '')); + + if ($attribute === '') { + return false; + } + + $operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', '')); + + if ($operator === null) { + return false; + } + + $actualValue = data_get($payloadContext, $attribute); + + return match ($operator) { + NotificationFilterOperator::Equals => $this->matchesEquals($actualValue, Arr::get($filter, 'value')), + NotificationFilterOperator::NotEquals => $this->matchesNotEquals($actualValue, Arr::get($filter, 'value')), + NotificationFilterOperator::Exists => $actualValue !== null, + NotificationFilterOperator::NotExists => $actualValue === null, + NotificationFilterOperator::In => $this->matchesIn($actualValue, Arr::get($filter, 'value')), + }; + } + + /** + * @param array|string|null $expectedValue + */ + private function matchesEquals(mixed $actualValue, array|string|null $expectedValue): bool + { + if ($actualValue === null) { + return false; + } + + $normalizedExpectedValue = $this->normalizeComparable($expectedValue); + + if ($normalizedExpectedValue === null) { + return false; + } + + return $this->normalizeComparable($actualValue) === $normalizedExpectedValue; + } + + /** + * @param array|string|null $expectedValue + */ + private function matchesNotEquals(mixed $actualValue, array|string|null $expectedValue): bool + { + $normalizedExpectedValue = $this->normalizeComparable($expectedValue); + + if ($normalizedExpectedValue === null) { + return false; + } + + if ($actualValue === null) { + return true; + } + + return $this->normalizeComparable($actualValue) !== $normalizedExpectedValue; + } + + /** + * @param array|string|null $expectedValues + */ + private function matchesIn(mixed $actualValue, array|string|null $expectedValues): bool + { + if ($actualValue === null) { + return false; + } + + $normalizedActualValue = $this->normalizeComparable($actualValue); + + if ($normalizedActualValue === null) { + return false; + } + + $normalizedExpectedValues = $this->normalizeComparableList($expectedValues); + + if ($normalizedExpectedValues === []) { + return false; + } + + return in_array($normalizedActualValue, $normalizedExpectedValues, true); + } + + /** + * @return array + */ + private function normalizeComparableList(array|string|null $value): array + { + if (is_array($value)) { + $normalized = []; + + foreach ($value as $candidate) { + $normalizedCandidate = $this->normalizeComparable($candidate); + + if ($normalizedCandidate !== null) { + $normalized[] = $normalizedCandidate; + } + } + + return array_values(array_unique($normalized)); + } + + if (! is_string($value)) { + return []; + } + + $chunks = preg_split('/[,\r\n]+/', $value) ?: []; + $normalized = []; + + foreach ($chunks as $chunk) { + $normalizedChunk = $this->normalizeComparable($chunk); + + if ($normalizedChunk !== null) { + $normalized[] = $normalizedChunk; + } + } + + return array_values(array_unique($normalized)); + } + + private function normalizeComparable(mixed $value): ?string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_int($value) || is_float($value)) { + return trim((string) $value); + } + + if (! is_string($value)) { + return null; + } + + $trimmedValue = trim($value); + + return $trimmedValue === '' ? null : $trimmedValue; + } + + /** + * @return array + */ + private function payloadContext(NotificationEventPayload $payload): array + { + $projectId = property_exists($payload, 'projectId') ? $payload->projectId : $payload->projectId(); + $projectKey = property_exists($payload, 'projectKey') ? $payload->projectKey : null; + $projectName = property_exists($payload, 'projectName') ? $payload->projectName : null; + $ticketId = property_exists($payload, 'ticketId') ? $payload->ticketId : null; + $ticketKey = property_exists($payload, 'ticketKey') ? $payload->ticketKey : null; + $ticketTitle = property_exists($payload, 'ticketTitle') ? $payload->ticketTitle : null; + $ticketUrl = property_exists($payload, 'ticketUrl') ? $payload->ticketUrl : null; + $actorId = property_exists($payload, 'actorId') ? $payload->actorId : null; + $actorName = property_exists($payload, 'actorName') ? $payload->actorName : null; + $fromStatusId = property_exists($payload, 'fromStatusId') ? $payload->fromStatusId : null; + $fromStatusLabel = property_exists($payload, 'fromStatusLabel') ? $payload->fromStatusLabel : null; + $toStatusId = property_exists($payload, 'toStatusId') ? $payload->toStatusId : null; + $toStatusLabel = property_exists($payload, 'toStatusLabel') ? $payload->toStatusLabel : null; + $fromAssigneeId = property_exists($payload, 'fromAssigneeId') ? $payload->fromAssigneeId : null; + $fromAssigneeName = property_exists($payload, 'fromAssigneeName') ? $payload->fromAssigneeName : null; + $toAssigneeId = property_exists($payload, 'toAssigneeId') ? $payload->toAssigneeId : null; + $toAssigneeName = property_exists($payload, 'toAssigneeName') ? $payload->toAssigneeName : null; + + return [ + 'project' => [ + 'id' => $projectId, + 'key' => $projectKey, + 'name' => $projectName, + ], + 'ticket' => [ + 'id' => $ticketId, + 'key' => $ticketKey, + 'title' => $ticketTitle, + 'url' => $ticketUrl, + ], + 'actor' => [ + 'id' => $actorId, + 'name' => $actorName, + ], + 'assignee' => [ + 'from' => [ + 'id' => $fromAssigneeId, + 'name' => $fromAssigneeName, + ], + 'to' => [ + 'id' => $toAssigneeId, + 'name' => $toAssigneeName, + ], + ], + 'status' => [ + 'from' => [ + 'id' => $fromStatusId, + 'label' => $fromStatusLabel, + ], + 'to' => [ + 'id' => $toStatusId, + 'label' => $toStatusLabel, + ], + ], + ]; + } +} diff --git a/app/Actions/Notifications/NormalizeNotificationFilters.php b/app/Actions/Notifications/NormalizeNotificationFilters.php new file mode 100644 index 0000000..a1afdc2 --- /dev/null +++ b/app/Actions/Notifications/NormalizeNotificationFilters.php @@ -0,0 +1,107 @@ +|string|null}> + */ + public function handle(mixed $filters): array + { + if (! is_array($filters)) { + return []; + } + + $normalizedFilters = []; + + foreach ($filters as $filter) { + if (! is_array($filter)) { + continue; + } + + $attribute = trim((string) Arr::get($filter, 'attribute', '')); + $operator = NotificationFilterOperator::tryFrom((string) Arr::get($filter, 'operator', '')); + + if ($attribute === '' || $operator === null) { + continue; + } + + $normalizedFilters[] = [ + 'attribute' => $attribute, + 'operator' => $operator->value, + 'value' => $this->normalizeValue($operator, Arr::get($filter, 'value')), + ]; + } + + return $normalizedFilters; + } + + /** + * @return array|string|null + */ + private function normalizeValue(NotificationFilterOperator $operator, mixed $value): array|string|null + { + return match ($operator) { + NotificationFilterOperator::Equals, + NotificationFilterOperator::NotEquals => $this->normalizeScalarValue($value), + NotificationFilterOperator::Exists, + NotificationFilterOperator::NotExists => null, + NotificationFilterOperator::In => $this->normalizeListValue($value), + }; + } + + private function normalizeScalarValue(mixed $value): ?string + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_int($value) || is_float($value)) { + return trim((string) $value); + } + + if (! is_string($value)) { + return null; + } + + $trimmedValue = trim($value); + + return $trimmedValue === '' ? null : $trimmedValue; + } + + /** + * @return array + */ + private function normalizeListValue(mixed $value): array + { + $normalizedValues = []; + + if (is_string($value)) { + $chunks = preg_split('/[,\r\n]+/', $value) ?: []; + + foreach ($chunks as $chunk) { + $trimmedValue = trim($chunk); + + if ($trimmedValue !== '') { + $normalizedValues[] = $trimmedValue; + } + } + } + + if (is_array($value)) { + foreach ($value as $candidate) { + $normalizedCandidate = $this->normalizeScalarValue($candidate); + + if ($normalizedCandidate !== null) { + $normalizedValues[] = $normalizedCandidate; + } + } + } + + return array_values(array_unique($normalizedValues)); + } +} diff --git a/app/Actions/Notifications/NotificationEvent.php b/app/Actions/Notifications/NotificationEvent.php new file mode 100644 index 0000000..0af8390 --- /dev/null +++ b/app/Actions/Notifications/NotificationEvent.php @@ -0,0 +1,12 @@ + + */ +interface NotificationEventPayload extends Arrayable +{ + public function projectId(): int; + + /** + * @return array + */ + public function deliveryMetadata(): array; + + /** + * @return array + */ + public function discordTemplateVariables(): array; +} diff --git a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php b/app/Actions/Notifications/QueueNotificationEventDeliveries.php similarity index 58% rename from app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php rename to app/Actions/Notifications/QueueNotificationEventDeliveries.php index eb195cd..c7fa854 100644 --- a/app/Actions/Notifications/QueueTicketStatusChangedNotificationDeliveries.php +++ b/app/Actions/Notifications/QueueNotificationEventDeliveries.php @@ -7,16 +7,26 @@ use App\Enums\NotificationDeliveryStatus; use App\Models\NotificationAction; use App\Models\NotificationDeliveryLog; -class QueueTicketStatusChangedNotificationDeliveries +class QueueNotificationEventDeliveries { - public function handle(TicketStatusChanged $event): void + public function __construct( + private readonly MatchNotificationFilters $matchNotificationFilters, + ) {} + + public function handle(NotificationEvent $event): void { + $payload = $event->payload(); + $eventType = $event->eventType(); + NotificationAction::query() ->enabled() - ->where('project_id', $event->payload->projectId) - ->where('event_type', $event->payload->eventType->value) - ->get(['channel', 'id']) - ->each(function (NotificationAction $notificationAction) use ($event): void { + ->where('project_id', $payload->projectId()) + ->where('event_type', $eventType->value) + ->get(['channel', 'filters', 'id']) + ->each(function (NotificationAction $notificationAction) use ($event, $payload): void { + if (! $this->matchNotificationFilters->handle($notificationAction->filters ?? [], $payload)) { + return; + } $notificationDeliveryLogId = null; if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { @@ -24,18 +34,15 @@ class QueueTicketStatusChangedNotificationDeliveries 'attempt_count' => 0, 'channel' => $notificationAction->channel, 'delivery_status' => NotificationDeliveryStatus::Pending, - 'event_type' => $event->payload->eventType, + 'event_type' => $event->eventType(), 'notification_action_id' => $notificationAction->id, - 'payload_metadata' => [ - 'project_key' => $event->payload->projectKey, - 'ticket_key' => $event->payload->ticketKey, - ], + 'payload_metadata' => $payload->deliveryMetadata(), ])->id; } DeliverNotificationAction::dispatch( notificationActionId: $notificationAction->id, - payload: $event->payload, + event: $event, notificationDeliveryLogId: $notificationDeliveryLogId, ); }); diff --git a/app/Actions/Notifications/RenderDiscordMessageTemplate.php b/app/Actions/Notifications/RenderDiscordMessageTemplate.php index d9be56e..aafd4e0 100644 --- a/app/Actions/Notifications/RenderDiscordMessageTemplate.php +++ b/app/Actions/Notifications/RenderDiscordMessageTemplate.php @@ -6,16 +6,8 @@ use Illuminate\Support\Str; class RenderDiscordMessageTemplate { - public function handle(?string $messageTemplate, TicketStatusChangedPayload $payload): string + public function handle(?string $messageTemplate, NotificationEvent $event): string { - return strtr((string) Str::of((string) $messageTemplate), [ - '$url' => $payload->ticketUrl, - '$ticket_key' => $payload->ticketKey, - '$ticket_title' => $payload->ticketTitle, - '$project_name' => $payload->projectName, - '$status_from' => $payload->fromStatusLabel, - '$status_to' => $payload->toStatusLabel, - '$actor_name' => $payload->actorName ?? '', - ]); + return strtr((string) Str::of((string) $messageTemplate), $event->payload()->discordTemplateVariables()); } } diff --git a/app/Actions/Notifications/StoreProjectNotificationAction.php b/app/Actions/Notifications/StoreProjectNotificationAction.php index 17d3270..3ab1564 100644 --- a/app/Actions/Notifications/StoreProjectNotificationAction.php +++ b/app/Actions/Notifications/StoreProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class StoreProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel: mixed, @@ -15,6 +19,7 @@ class StoreProjectNotificationAction * delivery_url: string, * enabled: bool, * event_type: mixed, + * filters?: mixed, * message_template?: string|null, * name: string * } $validated @@ -27,6 +32,7 @@ class StoreProjectNotificationAction 'delivery_url' => trim((string) Arr::get($validated, 'delivery_url')), 'enabled' => (bool) Arr::get($validated, 'enabled'), 'event_type' => Arr::get($validated, 'event_type'), + 'filters' => $this->normalizeNotificationFilters->handle(Arr::get($validated, 'filters')), 'message_template' => $this->normalizeMessageTemplate(Arr::get($validated, 'message_template')), 'name' => trim((string) Arr::get($validated, 'name')), ]); diff --git a/app/Actions/Notifications/TicketAssigneeChanged.php b/app/Actions/Notifications/TicketAssigneeChanged.php new file mode 100644 index 0000000..d49643e --- /dev/null +++ b/app/Actions/Notifications/TicketAssigneeChanged.php @@ -0,0 +1,26 @@ +payload; + } +} diff --git a/app/Actions/Notifications/TicketAssigneeChangedPayload.php b/app/Actions/Notifications/TicketAssigneeChangedPayload.php new file mode 100644 index 0000000..0eceb6b --- /dev/null +++ b/app/Actions/Notifications/TicketAssigneeChangedPayload.php @@ -0,0 +1,129 @@ +projectId; + } + + /** + * @return array + */ + public function deliveryMetadata(): array + { + return [ + 'project_key' => $this->projectKey, + 'ticket_key' => $this->ticketKey, + ]; + } + + /** + * @return array + */ + public function discordTemplateVariables(): array + { + return [ + '$url' => $this->ticketUrl, + '$ticket_key' => $this->ticketKey, + '$ticket_title' => $this->ticketTitle, + '$project_name' => $this->projectName, + '$status_from' => $this->fromStatusLabel ?? '', + '$status_to' => $this->toStatusLabel, + '$actor_name' => $this->actorName ?? '', + '$assignee_from' => $this->fromAssigneeName ?? '', + '$assignee_to' => $this->toAssigneeName ?? '', + ]; + } + + /** + * @return array{ + * event_type: string, + * project: array{id: int, key: string, name: string}, + * ticket: array{id: int, key: string, title: string}, + * actor: array{id: int, name: string}|null, + * assignee: array{ + * from: array{id: int, name: string}|null, + * to: array{id: int, name: string}|null + * }, + * from: array{id: int, label: string}|null, + * to: array{id: int, label: string}, + * ticket_url: string, + * occurred_at: string + * } + */ + public function toArray(): array + { + return [ + 'event_type' => NotificationEventType::TicketAssigneeChanged->value, + 'project' => [ + 'id' => $this->projectId, + 'key' => $this->projectKey, + 'name' => $this->projectName, + ], + 'ticket' => [ + 'id' => $this->ticketId, + 'key' => $this->ticketKey, + 'title' => $this->ticketTitle, + ], + 'actor' => $this->actorId === null || $this->actorName === null + ? null + : [ + 'id' => $this->actorId, + 'name' => $this->actorName, + ], + 'assignee' => [ + 'from' => $this->fromAssigneeId === null || $this->fromAssigneeName === null + ? null + : [ + 'id' => $this->fromAssigneeId, + 'name' => $this->fromAssigneeName, + ], + 'to' => $this->toAssigneeId === null || $this->toAssigneeName === null + ? null + : [ + 'id' => $this->toAssigneeId, + 'name' => $this->toAssigneeName, + ], + ], + 'from' => $this->fromStatusId === null || $this->fromStatusLabel === null + ? null + : [ + 'id' => $this->fromStatusId, + 'label' => $this->fromStatusLabel, + ], + 'to' => [ + 'id' => $this->toStatusId, + 'label' => $this->toStatusLabel, + ], + 'ticket_url' => $this->ticketUrl, + 'occurred_at' => $this->occurredAt->toISOString(), + ]; + } +} diff --git a/app/Actions/Notifications/TicketCreated.php b/app/Actions/Notifications/TicketCreated.php new file mode 100644 index 0000000..62bf62c --- /dev/null +++ b/app/Actions/Notifications/TicketCreated.php @@ -0,0 +1,26 @@ +payload; + } +} diff --git a/app/Actions/Notifications/TicketCreatedPayload.php b/app/Actions/Notifications/TicketCreatedPayload.php new file mode 100644 index 0000000..f9373f0 --- /dev/null +++ b/app/Actions/Notifications/TicketCreatedPayload.php @@ -0,0 +1,105 @@ +fromStatusId = null; + $this->fromStatusLabel = null; + } + + public function projectId(): int + { + return $this->projectId; + } + + /** + * @return array + */ + public function deliveryMetadata(): array + { + return [ + 'project_key' => $this->projectKey, + 'ticket_key' => $this->ticketKey, + ]; + } + + /** + * @return array + */ + public function discordTemplateVariables(): array + { + return [ + '$url' => $this->ticketUrl, + '$ticket_key' => $this->ticketKey, + '$ticket_title' => $this->ticketTitle, + '$project_name' => $this->projectName, + '$status_from' => '', + '$status_to' => $this->toStatusLabel, + '$actor_name' => $this->actorName ?? '', + ]; + } + + /** + * @return array{ + * event_type: string, + * project: array{id: int, key: string, name: string}, + * ticket: array{id: int, key: string, title: string}, + * actor: array{id: int, name: string}|null, + * from: null, + * to: array{id: int, label: string}, + * ticket_url: string, + * occurred_at: string + * } + */ + public function toArray(): array + { + return [ + 'event_type' => NotificationEventType::TicketCreate->value, + 'project' => [ + 'id' => $this->projectId, + 'key' => $this->projectKey, + 'name' => $this->projectName, + ], + 'ticket' => [ + 'id' => $this->ticketId, + 'key' => $this->ticketKey, + 'title' => $this->ticketTitle, + ], + 'actor' => $this->actorId === null || $this->actorName === null + ? null + : [ + 'id' => $this->actorId, + 'name' => $this->actorName, + ], + 'from' => null, + 'to' => [ + 'id' => $this->toStatusId, + 'label' => $this->toStatusLabel, + ], + 'ticket_url' => $this->ticketUrl, + 'occurred_at' => $this->occurredAt->toISOString(), + ]; + } +} diff --git a/app/Actions/Notifications/TicketStatusChanged.php b/app/Actions/Notifications/TicketStatusChanged.php index 245d0b7..41e2aa5 100644 --- a/app/Actions/Notifications/TicketStatusChanged.php +++ b/app/Actions/Notifications/TicketStatusChanged.php @@ -2,14 +2,25 @@ namespace App\Actions\Notifications; +use App\Enums\NotificationEventType; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; -final readonly class TicketStatusChanged implements ShouldDispatchAfterCommit +final readonly class TicketStatusChanged implements NotificationEvent, ShouldDispatchAfterCommit { use Dispatchable; public function __construct( public TicketStatusChangedPayload $payload, ) {} + + public function eventType(): NotificationEventType + { + return NotificationEventType::TicketStatusChanged; + } + + public function payload(): NotificationEventPayload + { + return $this->payload; + } } diff --git a/app/Actions/Notifications/TicketStatusChangedPayload.php b/app/Actions/Notifications/TicketStatusChangedPayload.php index 0bc7cc1..cc6997b 100644 --- a/app/Actions/Notifications/TicketStatusChangedPayload.php +++ b/app/Actions/Notifications/TicketStatusChangedPayload.php @@ -4,15 +4,10 @@ namespace App\Actions\Notifications; use App\Enums\NotificationEventType; use Carbon\CarbonImmutable; -use Illuminate\Contracts\Support\Arrayable; -/** - * @implements Arrayable - */ -final readonly class TicketStatusChangedPayload implements Arrayable +final readonly class TicketStatusChangedPayload implements NotificationEventPayload { public function __construct( - public NotificationEventType $eventType, public int $projectId, public string $projectKey, public string $projectName, @@ -29,6 +24,38 @@ final readonly class TicketStatusChangedPayload implements Arrayable public CarbonImmutable $occurredAt, ) {} + public function projectId(): int + { + return $this->projectId; + } + + /** + * @return array + */ + public function deliveryMetadata(): array + { + return [ + 'project_key' => $this->projectKey, + 'ticket_key' => $this->ticketKey, + ]; + } + + /** + * @return array + */ + public function discordTemplateVariables(): array + { + return [ + '$url' => $this->ticketUrl, + '$ticket_key' => $this->ticketKey, + '$ticket_title' => $this->ticketTitle, + '$project_name' => $this->projectName, + '$status_from' => $this->fromStatusLabel, + '$status_to' => $this->toStatusLabel, + '$actor_name' => $this->actorName ?? '', + ]; + } + /** * @return array{ * event_type: string, @@ -44,7 +71,7 @@ final readonly class TicketStatusChangedPayload implements Arrayable public function toArray(): array { return [ - 'event_type' => $this->eventType->value, + 'event_type' => NotificationEventType::TicketStatusChanged->value, 'project' => [ 'id' => $this->projectId, 'key' => $this->projectKey, diff --git a/app/Actions/Notifications/UpdateProjectNotificationAction.php b/app/Actions/Notifications/UpdateProjectNotificationAction.php index 59faabd..0a9c18d 100644 --- a/app/Actions/Notifications/UpdateProjectNotificationAction.php +++ b/app/Actions/Notifications/UpdateProjectNotificationAction.php @@ -8,6 +8,10 @@ use Illuminate\Support\Arr; class UpdateProjectNotificationAction { + public function __construct( + private readonly NormalizeNotificationFilters $normalizeNotificationFilters, + ) {} + /** * @param array{ * channel?: mixed, @@ -15,6 +19,7 @@ class UpdateProjectNotificationAction * delivery_url?: string|null, * enabled?: bool, * event_type?: mixed, + * filters?: mixed, * message_template?: string|null, * name?: string * } $validated @@ -54,6 +59,12 @@ class UpdateProjectNotificationAction ); } + if (array_key_exists('filters', $validated)) { + $attributes['filters'] = $this->normalizeNotificationFilters->handle( + Arr::get($validated, 'filters'), + ); + } + $deliveryUrl = Arr::get($validated, 'delivery_url'); if (is_string($deliveryUrl) && trim($deliveryUrl) !== '') { diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index c7b01e6..a2c26a5 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -2,23 +2,32 @@ namespace App\Actions\Tickets; +use App\Actions\Notifications\BuildTicketCreatedPayload; +use App\Actions\Notifications\TicketCreated; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\TicketWorklogEntryType; use App\Models\Project; use App\Models\Ticket; use App\Models\TicketStatus; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; class CreateProjectTicket { public function __construct( + private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, ) {} /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * description: string|null, * story_points?: int|null, @@ -31,6 +40,9 @@ class CreateProjectTicket public function handle(Project $project, array $attributes, ?User $actor = null): Ticket { return DB::transaction(function () use ($actor, $attributes, $project): Ticket { + $acceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( + Arr::get($attributes, 'acceptance_criteria'), + ); $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; @@ -39,7 +51,6 @@ class CreateProjectTicket ->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticket = Ticket::query()->create([ - 'acceptance_criteria' => Arr::get($attributes, 'acceptance_criteria'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'author_user_id' => $actor?->id, 'description' => Arr::get($attributes, 'description'), @@ -53,6 +64,10 @@ class CreateProjectTicket 'story_points' => Arr::get($attributes, 'story_points'), ]); + AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria); + + $occurredAt = CarbonImmutable::now(); + if ($actor !== null) { $this->createTicketWorklogEntry->handle( $ticket, @@ -69,6 +84,16 @@ class CreateProjectTicket ); } + TicketCreated::dispatch( + $this->buildTicketCreatedPayload->handle( + $project, + $ticket, + $ticketStatus, + $actor, + $occurredAt, + ), + ); + return $ticket; }); } diff --git a/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php new file mode 100644 index 0000000..edd9efc --- /dev/null +++ b/app/Actions/Tickets/Support/AcceptanceCriteriaChecklistMapper.php @@ -0,0 +1,224 @@ + $criteria + * @return array + */ + public static function fromModels(iterable $criteria): array + { + return self::flattenModels($criteria, false); + } + + /** + * @param iterable $criteria + * @return array + */ + public static function fromModelsWithIds(iterable $criteria): array + { + return self::flattenModels($criteria, true); + } + + /** + * @return array + */ + public static function normalize(mixed $value): array + { + if (is_array($value)) { + return self::normalizeFromArray($value); + } + + return []; + } + + /** + * @param array $items + */ + public static function syncTicket(Ticket $ticket, array $items): void + { + $normalized = self::normalize($items); + $existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id'); + + if ($normalized === []) { + $ticket->acceptanceCriteria()->delete(); + $ticket->unsetRelation('acceptanceCriteria'); + + return; + } + + $keptCriterionIds = []; + $parentStack = []; + $positionsByParent = []; + $fallbackCriteria = $existingCriteria->values(); + $fallbackIndex = 0; + + foreach ($normalized as $item) { + $indent = min($item['indent'], count($parentStack)); + $parentId = $indent === 0 ? null : $parentStack[$indent - 1]; + $positionKey = $parentId ?? 'root'; + $position = $positionsByParent[$positionKey] ?? 0; + $criterionId = $item['id'] ?? null; + $criterion = $criterionId === null + ? null + : $existingCriteria->get($criterionId); + + while ( + $criterion === null + && $fallbackIndex < $fallbackCriteria->count() + ) { + /** @var AcceptanceCriterion $candidate */ + $candidate = $fallbackCriteria[$fallbackIndex]; + $fallbackIndex++; + + if (in_array($candidate->id, $keptCriterionIds, true)) { + continue; + } + + $criterion = $candidate; + } + + if ($criterion === null) { + $criterion = $ticket->acceptanceCriteria()->create([ + 'parent_acceptance_criterion_id' => $parentId, + 'position' => $position, + 'text' => $item['text'], + 'is_checked' => $item['checked'], + ]); + } else { + $criterion->fill([ + 'parent_acceptance_criterion_id' => $parentId, + 'position' => $position, + 'text' => $item['text'], + 'is_checked' => $item['checked'], + ]); + + if ($criterion->isDirty()) { + $criterion->save(); + } + } + + $keptCriterionIds[] = $criterion->id; + $positionsByParent[$positionKey] = $position + 1; + $parentStack[$indent] = $criterion->id; + $parentStack = array_slice($parentStack, 0, $indent + 1); + } + + $idsToDelete = $existingCriteria->keys()->diff($keptCriterionIds); + + if ($idsToDelete->isNotEmpty()) { + $ticket->acceptanceCriteria() + ->whereIn('id', $idsToDelete->all()) + ->delete(); + } + + $ticket->unsetRelation('acceptanceCriteria'); + } + + /** + * @param array $value + * @return array + */ + private static function normalizeFromArray(array $value): array + { + $items = []; + + foreach ($value as $rawItem) { + if (! is_array($rawItem)) { + continue; + } + + $text = trim((string) Arr::get($rawItem, 'text', '')); + + if ($text === '') { + continue; + } + + $normalizedItem = [ + 'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN), + 'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)), + 'text' => $text, + ]; + + $id = filter_var(Arr::get($rawItem, 'id'), FILTER_VALIDATE_INT); + + if (is_int($id) && $id > 0) { + $normalizedItem['id'] = $id; + } + + $items[] = $normalizedItem; + } + + return $items; + } + + private static function clampIndent(int $indent): int + { + return max(0, min(self::MAX_INDENT_LEVEL, $indent)); + } + + /** + * @param iterable $criteria + * @return array + */ + private static function flattenModels(iterable $criteria, bool $includeIds): array + { + $models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria)) + ->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int { + return [ + $left->parent_acceptance_criterion_id ?? 0, + $left->position, + $left->id, + ] <=> [ + $right->parent_acceptance_criterion_id ?? 0, + $right->position, + $right->id, + ]; + }) + ->values(); + + if ($models->isEmpty()) { + return []; + } + + /** @var array> $grouped */ + $grouped = $models->groupBy('parent_acceptance_criterion_id')->all(); + $items = []; + + self::flattenItems($items, $grouped, null, 0, $includeIds); + + return $items; + } + + /** + * @param array $items + * @param array> $grouped + */ + private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent, bool $includeIds): void + { + foreach ($grouped[$parentId] ?? [] as $criterion) { + $item = [ + 'checked' => $criterion->is_checked, + 'indent' => $indent, + 'text' => $criterion->text, + ]; + + if ($includeIds) { + $item['id'] = $criterion->id; + } + + $items[] = $item; + + self::flattenItems($items, $grouped, $criterion->id, $indent + 1, $includeIds); + } + } +} diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php index 6cd6f95..5207647 100644 --- a/app/Actions/Tickets/UpdateProjectTicket.php +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -2,11 +2,15 @@ namespace App\Actions\Tickets; +use App\Actions\Notifications\BuildTicketAssigneeChangedPayload; +use App\Actions\Notifications\TicketAssigneeChanged; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\TicketWorklogEntryType; use App\Models\Ticket; use App\Models\TicketFieldDefinition; use App\Models\TicketFieldValue; use App\Models\User; +use Carbon\CarbonImmutable; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -14,13 +18,18 @@ use Illuminate\Support\Facades\DB; class UpdateProjectTicket { public function __construct( + private readonly BuildTicketAssigneeChangedPayload $buildTicketAssigneeChangedPayload, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues, ) {} /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -44,9 +53,12 @@ class UpdateProjectTicket array $validated, ): bool { $fieldValueAttributes = Arr::get($validated, 'field_values', []); + $nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize( + Arr::get($validated, 'acceptance_criteria'), + ); $ticketAttributes = $this->buildTicketAttributes($ticket, $validated); - return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool { + return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool { if (! $ticketAttributes['is_blocked']) { $ticketAttributes['blocked_comment'] = null; } @@ -54,11 +66,13 @@ class UpdateProjectTicket $ticket->loadMissing([ 'assignee', 'author', + 'acceptanceCriteria', 'fieldValues.ticketFieldDefinition', 'fieldValues.userValue', 'parentTicket', 'project', 'ticketPriority', + 'ticketStatus.boardColumn', 'ticketType', ]); @@ -77,9 +91,11 @@ class UpdateProjectTicket /** @var Collection $existingFieldValues */ $existingFieldValues = $ticket->fieldValues ->keyBy('ticket_field_definition_id'); + $currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria); $changes = []; $summaryLabels = []; + $assigneeChange = null; $this->trackScalarChange( $changes, @@ -97,13 +113,11 @@ class UpdateProjectTicket $ticket->description, $ticketAttributes['description'], ); - $this->trackScalarChange( + $this->trackAcceptanceCriteriaChange( $changes, $summaryLabels, - 'acceptance_criteria', - 'acceptance criteria', - $ticket->acceptance_criteria, - $ticketAttributes['acceptance_criteria'], + $currentAcceptanceCriteria, + $nextAcceptanceCriteria, ); $this->trackScalarChange( $changes, @@ -159,13 +173,23 @@ class UpdateProjectTicket } if ($ticket->assignee_user_id !== $ticketAttributes['assignee_user_id']) { + $toAssigneeId = $ticketAttributes['assignee_user_id']; + $toAssigneeName = $toAssigneeId === null + ? null + : User::query()->find($toAssigneeId)?->name; + $changes['assignee'] = [ 'from' => $ticket->assignee?->name, - 'to' => $ticketAttributes['assignee_user_id'] === null - ? null - : User::query()->find($ticketAttributes['assignee_user_id'])?->name, + 'to' => $toAssigneeName, ]; $summaryLabels[] = 'assignee'; + + $assigneeChange = [ + 'from_id' => $ticket->assignee_user_id, + 'from_name' => $ticket->assignee?->name, + 'to_id' => $toAssigneeId, + 'to_name' => $toAssigneeName, + ]; } if ($ticket->author_user_id !== $ticketAttributes['author_user_id']) { @@ -184,6 +208,10 @@ class UpdateProjectTicket $ticket->save(); } + if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) { + AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria); + } + $fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle( $ticket, $fieldDefinitions, @@ -211,13 +239,32 @@ class UpdateProjectTicket $changes, ); + if ($assigneeChange !== null) { + TicketAssigneeChanged::dispatch( + $this->buildTicketAssigneeChangedPayload->handle( + $ticket->project, + $ticket, + $actor, + $assigneeChange['from_id'], + $assigneeChange['from_name'], + $assigneeChange['to_id'], + $assigneeChange['to_name'], + CarbonImmutable::now(), + ), + ); + } + return true; }); } /** * @param array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id?: int|null, * author_user_id?: int|null, * blocked_comment: string|null, @@ -230,7 +277,6 @@ class UpdateProjectTicket * title: string * } $validated * @return array{ - * acceptance_criteria: string|null, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -246,7 +292,6 @@ class UpdateProjectTicket private function buildTicketAttributes(Ticket $ticket, array $validated): array { return [ - 'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'), 'assignee_user_id' => Arr::exists($validated, 'assignee_user_id') ? Arr::get($validated, 'assignee_user_id') : $ticket->assignee_user_id, @@ -266,6 +311,29 @@ class UpdateProjectTicket ]; } + /** + * @param array $changes + * @param array $summaryLabels + * @param array $currentValue + * @param array $newValue + */ + private function trackAcceptanceCriteriaChange( + array &$changes, + array &$summaryLabels, + array $currentValue, + array $newValue, + ): void { + if ($currentValue === $newValue) { + return; + } + + $changes['acceptance_criteria'] = [ + 'from' => $currentValue, + 'to' => $newValue, + ]; + $summaryLabels[] = 'acceptance criteria'; + } + /** * @param array $changes * @param array $summaryLabels diff --git a/app/Enums/DiscordAllowedMentionType.php b/app/Enums/DiscordAllowedMentionType.php new file mode 100644 index 0000000..50ea956 --- /dev/null +++ b/app/Enums/DiscordAllowedMentionType.php @@ -0,0 +1,45 @@ + '@everyone and @here', + self::Roles => 'Role mentions (@role)', + self::Users => 'User mentions (@username)', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $eventType): array => [ + 'label' => $eventType->label(), + 'value' => $eventType->value, + ], + self::cases(), + ); + } + + /** + * @param array|null $discordAllowedMentionTypes + * @return array + */ + public static function values(?array $discordAllowedMentionTypes = null): array + { + return array_map( + fn (self $discordAllowedMentionType): string => $discordAllowedMentionType->value, + $discordAllowedMentionTypes ?? self::cases(), + ); + } +} diff --git a/app/Enums/NotificationChannel.php b/app/Enums/NotificationChannel.php index a869ce7..902f647 100644 --- a/app/Enums/NotificationChannel.php +++ b/app/Enums/NotificationChannel.php @@ -28,4 +28,16 @@ enum NotificationChannel: string self::cases(), ); } + + /** + * @param array|null $notificationChannels + * @return array + */ + public static function values(?array $notificationChannels = null): array + { + return array_map( + fn (self $notificationChannel): string => $notificationChannel->value, + $notificationChannels ?? self::cases(), + ); + } } diff --git a/app/Enums/NotificationEventType.php b/app/Enums/NotificationEventType.php index c9e120b..2da3dfa 100644 --- a/app/Enums/NotificationEventType.php +++ b/app/Enums/NotificationEventType.php @@ -5,11 +5,15 @@ namespace App\Enums; enum NotificationEventType: string { case TicketStatusChanged = 'ticket_status_changed'; + case TicketCreate = 'ticket_create'; + case TicketAssigneeChanged = 'ticket_assignee_changed'; public function label(): string { return match ($this) { self::TicketStatusChanged => 'Ticket status changed', + self::TicketCreate => 'Ticket create', + self::TicketAssigneeChanged => 'Assignee changed', }; } @@ -26,4 +30,16 @@ enum NotificationEventType: string self::cases(), ); } + + /** + * @param array|null $notificationEventTypes + * @return array + */ + public static function values(?array $notificationEventTypes = null): array + { + return array_map( + fn (self $notificationEventType): string => $notificationEventType->value, + $notificationEventTypes ?? self::cases(), + ); + } } diff --git a/app/Enums/NotificationFilterAttribute.php b/app/Enums/NotificationFilterAttribute.php new file mode 100644 index 0000000..3eedd91 --- /dev/null +++ b/app/Enums/NotificationFilterAttribute.php @@ -0,0 +1,72 @@ + 'Project ID', + self::ProjectKey => 'Project key', + self::ProjectName => 'Project name', + self::TicketId => 'Ticket ID', + self::TicketKey => 'Ticket key', + self::TicketTitle => 'Ticket title', + self::TicketUrl => 'Ticket URL', + self::ActorId => 'Actor ID', + self::ActorName => 'Actor name', + self::AssigneeFromId => 'From assignee ID', + self::AssigneeFromName => 'From assignee name', + self::AssigneeToId => 'To assignee ID', + self::AssigneeToName => 'To assignee name', + self::StatusFromId => 'From status ID', + self::StatusFromLabel => 'From status label', + self::StatusToId => 'To status ID', + self::StatusToLabel => 'To status label', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $attribute): array => [ + 'label' => $attribute->label(), + 'value' => $attribute->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $attribute): string => $attribute->value, + self::cases(), + ); + } +} diff --git a/app/Enums/NotificationFilterOperator.php b/app/Enums/NotificationFilterOperator.php new file mode 100644 index 0000000..6d6cc7f --- /dev/null +++ b/app/Enums/NotificationFilterOperator.php @@ -0,0 +1,48 @@ + 'Equals', + self::NotEquals => 'Not equals', + self::Exists => 'Exists', + self::NotExists => 'Not exists', + self::In => 'In list', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + static fn (self $operator): array => [ + 'label' => $operator->label(), + 'value' => $operator->value, + ], + self::cases(), + ); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $operator): string => $operator->value, + self::cases(), + ); + } +} diff --git a/app/Http/Controllers/Admin/UserManagementController.php b/app/Http/Controllers/Admin/UserManagementController.php index 87204ef..2f0d53a 100644 --- a/app/Http/Controllers/Admin/UserManagementController.php +++ b/app/Http/Controllers/Admin/UserManagementController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Admin; use App\Actions\Permissions\PermissionService; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StoreManagedUserRequest; use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest; +use App\Http\Requests\Admin\UpdateManagedUserRequest; use App\Http\Resources\PermissionOptionResource; use App\Http\Resources\UserResource; use App\Models\User; @@ -35,6 +37,53 @@ class UserManagementController extends Controller ]); } + public function create(): Response + { + return Inertia::render('admin/users/Create'); + } + + public function store(StoreManagedUserRequest $request): RedirectResponse + { + $validated = $request->validated(); + + User::query()->create([ + 'name' => Arr::get($validated, 'name'), + 'email' => Arr::get($validated, 'email'), + 'password' => Arr::get($validated, 'password'), + 'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true), + ]); + + return to_route('admin.users.index') + ->with('status', 'User was created.'); + } + + public function edit(User $user): Response + { + return Inertia::render('admin/users/Edit', [ + 'user' => UserResource::make($user)->resolve(), + ]); + } + + public function save(UpdateManagedUserRequest $request, User $user): RedirectResponse + { + $validated = $request->validated(); + + $user->fill([ + 'name' => Arr::get($validated, 'name'), + 'email' => Arr::get($validated, 'email'), + 'must_change_password_on_next_login' => (bool) Arr::get($validated, 'must_change_password_on_next_login', true), + ]); + + if (filled(Arr::get($validated, 'password'))) { + $user->password = Arr::get($validated, 'password'); + } + + $user->save(); + + return to_route('admin.users.index') + ->with('status', "{$user->name} was updated."); + } + public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse { $validated = $request->validated(); diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 3dc1f78..42cfbb9 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -12,6 +12,7 @@ use App\Http\Requests\StoreProjectTicketRequest; use App\Http\Requests\UpdateProjectTicketRequest; use App\Models\Project; use App\Models\Ticket; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -85,7 +86,7 @@ class ProjectTicketController extends Controller UpdateProjectTicketRequest $request, Project $project, Ticket $ticket, - ): RedirectResponse { + ): RedirectResponse|JsonResponse { $user = $request->user(); abort_unless( @@ -105,6 +106,13 @@ class ProjectTicketController extends Controller ? "{$ticket->ticket_key} was updated." : 'No ticket changes were detected.'; + if ($request->expectsJson()) { + return response()->json([ + 'message' => $statusMessage, + 'updated' => $wasUpdated, + ]); + } + return to_route('projects.tickets.show', [$project, $ticket]) ->with('status', $statusMessage); } diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 4e47782..0563e1e 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Settings\PasswordUpdateRequest; use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; use Inertia\Inertia; @@ -31,6 +32,7 @@ class SecurityController extends Controller implements HasMiddleware public function edit(TwoFactorAuthenticationRequest $request): Response { $user = $request->user(); + $props = [ 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), ]; @@ -45,17 +47,37 @@ class SecurityController extends Controller implements HasMiddleware return Inertia::render('settings/Security', $props); } + /** + * Show the dedicated forced password change page. + */ + public function editForcedPassword(Request $request): Response|RedirectResponse + { + if (! $request->user()->must_change_password_on_next_login) { + return to_route('dashboard'); + } + + return Inertia::render('auth/ForcePasswordChange', [ + 'status' => session('status'), + ]); + } + /** * Update the user's password. */ public function update(PasswordUpdateRequest $request): RedirectResponse { $user = $request->user(); + $mustChangePassword = (bool) $user->must_change_password_on_next_login; $user->update([ + 'must_change_password_on_next_login' => false, 'password' => $request->password, ]); + if ($mustChangePassword) { + return redirect()->intended(route('dashboard')); + } + return back(); } } diff --git a/app/Http/Middleware/RequirePasswordChange.php b/app/Http/Middleware/RequirePasswordChange.php new file mode 100644 index 0000000..baef01e --- /dev/null +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -0,0 +1,40 @@ +user(); + + if ($user === null || ! $user->must_change_password_on_next_login) { + return $next($request); + } + + if ($request->routeIs([ + 'logout', + 'password.force-change.edit', + 'user-password.update', + ])) { + return $next($request); + } + + if ($request->isMethod('get') && ! $request->expectsJson()) { + $request->session()->put('url.intended', $request->fullUrl()); + } + + return redirect() + ->route('password.force-change.edit') + ->with('status', 'You must change your password before continuing.'); + } +} diff --git a/app/Http/Requests/Admin/StoreManagedUserRequest.php b/app/Http/Requests/Admin/StoreManagedUserRequest.php new file mode 100644 index 0000000..6304b5b --- /dev/null +++ b/app/Http/Requests/Admin/StoreManagedUserRequest.php @@ -0,0 +1,36 @@ +user()?->can('access-admin') ?? false; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + ...$this->profileRules(), + 'must_change_password_on_next_login' => ['required', 'boolean'], + 'password' => $this->passwordRules(), + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateManagedUserRequest.php b/app/Http/Requests/Admin/UpdateManagedUserRequest.php new file mode 100644 index 0000000..e3470f3 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateManagedUserRequest.php @@ -0,0 +1,35 @@ +user()?->can('access-admin') ?? false; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + ...$this->profileRules($this->route('user')?->id), + 'must_change_password_on_next_login' => ['required', 'boolean'], + 'password' => ['nullable', 'string', Password::default(), 'confirmed'], + ]; + } +} diff --git a/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php new file mode 100644 index 0000000..0cdfcbc --- /dev/null +++ b/app/Http/Requests/Concerns/InteractsWithNotificationFilters.php @@ -0,0 +1,124 @@ +input('filters'); + + if (! is_string($rawFilters)) { + return; + } + + $decodedFilters = json_decode($rawFilters, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $this->merge([ + 'filters' => $decodedFilters, + ]); + } + } + + /** + * @return array + */ + protected function notificationFilterRules(): array + { + return [ + 'filters' => ['nullable', 'array', 'max:25'], + 'filters.*' => ['required', 'array'], + 'filters.*.attribute' => ['required', 'string', Rule::in(NotificationFilterAttribute::values())], + 'filters.*.operator' => ['required', 'string', Rule::in(NotificationFilterOperator::values())], + 'filters.*.value' => ['nullable'], + ]; + } + + protected function withNotificationFilterValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $filters = $this->input('filters'); + + if (! is_array($filters)) { + return; + } + + $normalizer = app(NormalizeNotificationFilters::class); + + foreach ($filters as $index => $filter) { + if (! is_array($filter)) { + continue; + } + + $operator = NotificationFilterOperator::tryFrom((string) ($filter['operator'] ?? null)); + + if ($operator === null) { + continue; + } + + $normalizedValue = $normalizer->handle([$filter])[0]['value'] ?? null; + + if (in_array($operator, [NotificationFilterOperator::Equals, NotificationFilterOperator::NotEquals], true) + && ! is_string($normalizedValue) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'A non-empty value is required for equals and not equals operators.', + ); + } + + if ($operator === NotificationFilterOperator::In + && (! is_array($normalizedValue) || $normalizedValue === []) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'The in operator requires at least one value.', + ); + } + + if (in_array($operator, [NotificationFilterOperator::Exists, NotificationFilterOperator::NotExists], true) + && $this->hasProvidedFilterValue($filter['value'] ?? null) + ) { + $validator->errors()->add( + "filters.{$index}.value", + 'The exists and not exists operators do not accept a value.', + ); + } + } + }); + } + + private function hasProvidedFilterValue(mixed $value): bool + { + if ($value === null) { + return false; + } + + if (is_string($value)) { + return trim($value) !== ''; + } + + if (is_array($value)) { + foreach ($value as $candidate) { + if (is_string($candidate) && trim($candidate) !== '') { + return true; + } + + if (! is_string($candidate) && $candidate !== null) { + return true; + } + } + + return false; + } + + return true; + } +} diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Settings/PasswordUpdateRequest.php index 0e5ff2f..6536ae5 100644 --- a/app/Http/Requests/Settings/PasswordUpdateRequest.php +++ b/app/Http/Requests/Settings/PasswordUpdateRequest.php @@ -19,7 +19,19 @@ class PasswordUpdateRequest extends FormRequest { return [ 'current_password' => $this->currentPasswordRules(), - 'password' => $this->passwordRules(), + 'password' => [...$this->passwordRules(), 'different:current_password'], + ]; + } + + /** + * Get the custom validation messages for the request. + * + * @return array + */ + public function messages(): array + { + return [ + 'password.different' => 'The new password must be different from your current password.', ]; } } diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..590f2f9 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -2,14 +2,19 @@ namespace App\Http\Requests; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class StoreProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -25,16 +30,34 @@ class StoreProjectNotificationActionRequest extends FormRequest */ public function rules(): array { + $messageTemplateRules = $this->input('channel') === NotificationChannel::Discord->value + ? ['required', 'string', 'max:2000'] + : ['nullable', 'string', 'max:2000']; + return [ 'name' => ['required', 'string', 'max:255'], - 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], - 'channel' => ['required', 'string', Rule::in($this->channels())], + 'event_type' => ['required', 'string', Rule::in(NotificationEventType::values())], + 'channel' => ['required', 'string', Rule::in(NotificationChannel::values())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())], 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Requests/StoreProjectTicketRequest.php b/app/Http/Requests/StoreProjectTicketRequest.php index be0545f..505a267 100644 --- a/app/Http/Requests/StoreProjectTicketRequest.php +++ b/app/Http/Requests/StoreProjectTicketRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Models\Project; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -17,6 +18,24 @@ class StoreProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $acceptanceCriteria = $this->input('acceptance_criteria'); + + if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) { + return; + } + + $this->merge([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize( + $acceptanceCriteria, + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -28,7 +47,12 @@ class StoreProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + 'acceptance_criteria.*.id' => ['nullable', 'integer'], + 'acceptance_criteria.*.text' => ['required', 'string'], + 'acceptance_criteria.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'description' => ['nullable', 'string'], 'ticket_priority_id' => [ diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..0178abd 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -2,14 +2,19 @@ namespace App\Http\Requests; +use App\Enums\DiscordAllowedMentionType; use App\Enums\NotificationChannel; use App\Enums\NotificationEventType; +use App\Http\Requests\Concerns\InteractsWithNotificationFilters; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdateProjectNotificationActionRequest extends FormRequest { + use InteractsWithNotificationFilters; + /** * Determine if the user is authorized to make this request. */ @@ -25,16 +30,34 @@ class UpdateProjectNotificationActionRequest extends FormRequest */ public function rules(): array { + $messageTemplateRules = $this->input('channel') === NotificationChannel::Discord->value + ? ['required', 'string', 'max:2000'] + : ['nullable', 'string', 'max:2000']; + return [ 'name' => ['required', 'string', 'max:255'], - 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], - 'channel' => ['required', 'string', Rule::in($this->channels())], + 'event_type' => ['required', 'string', Rule::in(NotificationEventType::values())], + 'channel' => ['required', 'string', Rule::in(NotificationChannel::values())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => ['nullable', 'string', Rule::in(DiscordAllowedMentionType::values())], 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), + ...$this->notificationFilterRules(), ]; } + protected function prepareForValidation(): void + { + $this->prepareNotificationFiltersForValidation(); + } + + public function withValidator(Validator $validator): void + { + $this->withNotificationFilterValidator($validator); + } + /** * @return array */ diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php index fe705ff..1db69e5 100644 --- a/app/Http/Requests/UpdateProjectTicketRequest.php +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\Permission; use App\Models\Project; use App\Models\Ticket; @@ -23,6 +24,24 @@ class UpdateProjectTicketRequest extends FormRequest return $this->user() !== null; } + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $acceptanceCriteria = $this->input('acceptance_criteria'); + + if ($acceptanceCriteria !== null && ! is_array($acceptanceCriteria)) { + return; + } + + $this->merge([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::normalize( + $acceptanceCriteria, + ), + ]); + } + /** * Get the validation rules that apply to the request. * @@ -34,7 +53,12 @@ class UpdateProjectTicketRequest extends FormRequest $project = $this->route('project'); return [ - 'acceptance_criteria' => ['nullable', 'string'], + 'acceptance_criteria' => ['nullable', 'array'], + 'acceptance_criteria.*' => ['array'], + 'acceptance_criteria.*.id' => ['nullable', 'integer'], + 'acceptance_criteria.*.text' => ['required', 'string'], + 'acceptance_criteria.*.checked' => ['required', 'boolean'], + 'acceptance_criteria.*.indent' => ['required', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL], 'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'blocked_comment' => ['nullable', 'string'], diff --git a/app/Http/Resources/NotificationActionResource.php b/app/Http/Resources/NotificationActionResource.php index d510ae2..eae6c76 100644 --- a/app/Http/Resources/NotificationActionResource.php +++ b/app/Http/Resources/NotificationActionResource.php @@ -25,6 +25,7 @@ class NotificationActionResource extends JsonResource 'channel' => $this->channel->value, 'channel_label' => $this->channel->label(), 'channel_configuration' => $this->channel_configuration ?? [], + 'filters' => $this->filters ?? [], 'has_delivery_url' => $this->resource instanceof NotificationAction && filled($this->delivery_url), 'message_template' => $this->message_template, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php index ff7be1b..faacda9 100644 --- a/app/Http/Resources/TicketResource.php +++ b/app/Http/Resources/TicketResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -16,7 +17,11 @@ class TicketResource extends JsonResource { return [ 'id' => $this->id, - 'acceptance_criteria' => $this->acceptance_criteria, + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds( + $this->relationLoaded('acceptanceCriteria') + ? $this->acceptanceCriteria + : $this->acceptanceCriteria()->get(), + ), 'assignee' => $this->whenLoaded( 'assignee', fn (): ?array => $this->assignee === null diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 317fc44..353d589 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -19,6 +19,7 @@ class UserResource extends JsonResource 'name' => $this->name, 'email' => $this->email, 'email_verified_at' => $this->email_verified_at?->toISOString(), + 'must_change_password_on_next_login' => (bool) $this->must_change_password_on_next_login, 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; diff --git a/app/Logging/DiscordLogger.php b/app/Logging/DiscordLogger.php new file mode 100644 index 0000000..b8fcbad --- /dev/null +++ b/app/Logging/DiscordLogger.php @@ -0,0 +1,49 @@ +pushHandler(new DiscordWebhookHandler( + webhookUrl: $this->nullableString($config['url'] ?? null), + level: $config['level'] ?? 'error', + bubble: (bool) ($config['bubble'] ?? true), + mention: $this->nullableString($config['mention'] ?? null), + title: (string) ($config['title'] ?? '## Laravel error'), + timeout: $this->floatValue($config['timeout'] ?? 3), + connectTimeout: $this->floatValue($config['connect_timeout'] ?? 3), + maxContentLength: $this->intValue($config['max_content_length'] ?? 2000, 2000), + maxTraceLines: $this->intValue($config['max_trace_lines'] ?? 12, 12), + maxContextLength: $this->intValue($config['max_context_length'] ?? 1200, 1200), + )); + + return $logger; + } + + private function nullableString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } + + private function floatValue(mixed $value): float + { + return is_numeric($value) && (float) $value > 0 ? (float) $value : 3.0; + } + + private function intValue(mixed $value, int $fallback): int + { + return is_numeric($value) && (int) $value > 0 ? (int) $value : $fallback; + } +} diff --git a/app/Logging/DiscordWebhookHandler.php b/app/Logging/DiscordWebhookHandler.php new file mode 100644 index 0000000..91b9f90 --- /dev/null +++ b/app/Logging/DiscordWebhookHandler.php @@ -0,0 +1,178 @@ +webhookUrl === null) { + return; + } + + try { + $response = Http::asJson() + ->connectTimeout($this->connectTimeout) + ->timeout($this->timeout) + ->post($this->webhookUrl, [ + 'content' => $this->formatContent($record), + ]); + + if ($response->failed()) { + error_log(sprintf( + '[discord logger] webhook failed with status %d: %s', + $response->status(), + $this->truncateForDiscord($response->body()) + )); + } + } catch (Throwable $exception) { + error_log(sprintf('[discord logger] %s', $exception->getMessage())); + } + } + + private function formatContent(LogRecord $record): string + { + $lines = []; + + if ($this->mention !== null) { + $lines[] = $this->mention; + } + + $lines[] = $this->title; + $lines[] = sprintf('- App: %s (%s)', (string) config('app.name'), (string) config('app.env')); + $lines[] = sprintf('- Level: %s', $record->level->getName()); + $lines[] = sprintf('- Time: %s', $record->datetime->format(DATE_ATOM)); + $lines[] = sprintf('- Message: %s', $record->message); + + /** @var Throwable|null $exception */ + $exception = Arr::get($record->context, 'exception'); + + if ($exception instanceof Throwable) { + $lines[] = ''; + $lines[] = '**Exception**'; + $lines[] = sprintf('> %s', $exception->getMessage()); + $lines[] = sprintf('> %s', $exception::class); + $lines[] = sprintf('> %s:%d', $exception->getFile(), $exception->getLine()); + + $trace = $this->normalizeTrace($exception); + if ($trace !== []) { + $lines[] = '```text'; + $lines[] = implode("\n", $trace); + $lines[] = '```'; + } + } + + $context = Arr::except($record->context, ['exception']); + if ($context !== []) { + $lines[] = ''; + $lines[] = '**Context**'; + $lines[] = '```json'; + $lines[] = $this->truncateContext($this->jsonEncode($this->normalizeValue($context))); + $lines[] = '```'; + } + + return $this->truncateForDiscord(implode("\n", $lines)); + } + + /** + * @return array + */ + private function normalizeTrace(Throwable $exception): array + { + return array_map( + fn (string $line): string => $this->truncateLine($line, 240), + array_slice(explode("\n", $exception->getTraceAsString()), 0, $this->maxTraceLines), + ); + } + + private function truncateLine(string $value, int $limit): string + { + if (mb_strlen($value) <= $limit) { + return $value; + } + + return mb_substr($value, 0, max(0, $limit - 1)).'…'; + } + + private function truncateContext(string $value): string + { + if (mb_strlen($value) <= $this->maxContextLength) { + return $value; + } + + return mb_substr($value, 0, max(0, $this->maxContextLength - 1)).'…'; + } + + private function truncateForDiscord(string $value): string + { + if (mb_strlen($value) <= $this->maxContentLength) { + return $value; + } + + $limit = max(0, $this->maxContentLength - mb_strlen(self::TRUNCATION_SUFFIX)); + + return mb_substr($value, 0, $limit).self::TRUNCATION_SUFFIX; + } + + private function jsonEncode(mixed $value): string + { + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); + + return is_string($encoded) ? $encoded : '{}'; + } + + private function normalizeValue(mixed $value): mixed + { + if (is_array($value)) { + return array_map(fn (mixed $item): mixed => $this->normalizeValue($item), $value); + } + + if ($value instanceof Throwable) { + return [ + 'class' => $value::class, + 'message' => $value->getMessage(), + 'file' => $value->getFile(), + 'line' => $value->getLine(), + ]; + } + + if ($value instanceof Stringable) { + return (string) $value; + } + + if (is_object($value)) { + return ['class' => $value::class]; + } + + if (is_resource($value)) { + return sprintf('resource(%s)', get_resource_type($value)); + } + + return $value; + } +} diff --git a/app/Mcp/Support/KanbanGitMcp.php b/app/Mcp/Support/KanbanGitMcp.php index 5f562bb..c57cbd4 100644 --- a/app/Mcp/Support/KanbanGitMcp.php +++ b/app/Mcp/Support/KanbanGitMcp.php @@ -3,6 +3,7 @@ namespace App\Mcp\Support; use App\Actions\Permissions\PermissionService; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Enums\Permission; use App\Http\Resources\ProjectResource; use App\Http\Resources\TicketResource; @@ -207,7 +208,7 @@ class KanbanGitMcp /** * @return array{ - * acceptance_criteria: string|null, + * acceptance_criteria: array, * assignee_user_id: int|null, * author_user_id: int|null, * blocked_comment: string|null, @@ -223,7 +224,11 @@ class KanbanGitMcp public function updatePayload(Ticket $ticket): array { return [ - 'acceptance_criteria' => $ticket->acceptance_criteria, + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels( + $ticket->relationLoaded('acceptanceCriteria') + ? $ticket->acceptanceCriteria + : $ticket->acceptanceCriteria()->get(), + ), 'assignee_user_id' => $ticket->assignee_user_id, 'author_user_id' => $ticket->author_user_id, 'blocked_comment' => $ticket->blocked_comment, diff --git a/app/Models/AcceptanceCriterion.php b/app/Models/AcceptanceCriterion.php new file mode 100644 index 0000000..e1b112d --- /dev/null +++ b/app/Models/AcceptanceCriterion.php @@ -0,0 +1,45 @@ + + */ + protected function casts(): array + { + return [ + 'is_checked' => 'boolean', + ]; + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_acceptance_criterion_id') + ->orderBy('position') + ->orderBy('id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_acceptance_criterion_id'); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/app/Models/NotificationAction.php b/app/Models/NotificationAction.php index 3e42278..e9e6cc2 100644 --- a/app/Models/NotificationAction.php +++ b/app/Models/NotificationAction.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'delivery_url', 'enabled', 'event_type', + 'filters', 'message_template', 'name', 'project_id', @@ -40,6 +41,7 @@ class NotificationAction extends Model 'delivery_url' => 'encrypted', 'enabled' => 'boolean', 'event_type' => NotificationEventType::class, + 'filters' => 'array', ]; } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 7c1726c..692faca 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ - 'acceptance_criteria', 'assignee_user_id', 'author_user_id', 'blocked_comment', @@ -41,6 +40,13 @@ class Ticket extends Model ]; } + public function acceptanceCriteria(): HasMany + { + return $this->hasMany(AcceptanceCriterion::class) + ->orderBy('position') + ->orderBy('id'); + } + public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assignee_user_id'); diff --git a/app/Models/User.php b/app/Models/User.php index b3dc0ba..9ff2172 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,7 +17,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['name', 'email', 'password', 'must_change_password_on_next_login'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable { @@ -40,6 +40,7 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'must_change_password_on_next_login' => 'bool', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 00ab2d1..e2489fa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; -use App\Actions\Notifications\QueueTicketStatusChangedNotificationDeliveries; +use App\Actions\Notifications\QueueNotificationEventDeliveries; +use App\Actions\Notifications\TicketAssigneeChanged; +use App\Actions\Notifications\TicketCreated; use App\Actions\Notifications\TicketStatusChanged; use App\Actions\Permissions\PermissionService; use App\Enums\Permission; @@ -97,7 +99,17 @@ class AppServiceProvider extends ServiceProvider { Event::listen( TicketStatusChanged::class, - QueueTicketStatusChangedNotificationDeliveries::class, + QueueNotificationEventDeliveries::class, + ); + + Event::listen( + TicketCreated::class, + QueueNotificationEventDeliveries::class, + ); + + Event::listen( + TicketAssigneeChanged::class, + QueueNotificationEventDeliveries::class, ); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 2caea88..197cc13 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider { Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [ 'canResetPassword' => Features::enabled(Features::resetPasswords()), - 'canRegister' => Features::enabled(Features::registration()), 'status' => $request->session()->get('status'), ])); @@ -66,8 +65,6 @@ class FortifyServiceProvider extends ServiceProvider 'status' => $request->session()->get('status'), ])); - Fortify::registerView(fn () => Inertia::render('auth/Register')); - Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge')); Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword')); diff --git a/bootstrap/app.php b/bootstrap/app.php index 001ee1c..0e344cc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\AuthenticateApiApplicationKey; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\RequirePasswordChange; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -21,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ HandleAppearance::class, HandleInertiaRequests::class, + RequirePasswordChange::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/fortify.php b/config/fortify.php index ce67e2c..eb256f1 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -144,7 +144,6 @@ return [ */ 'features' => [ - Features::registration(), Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ diff --git a/config/logging.php b/config/logging.php index b09cb25..5e1a752 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,6 @@ true, ], + 'discord_errors' => [ + 'driver' => 'custom', + 'via' => DiscordLogger::class, + 'url' => env('LOG_DISCORD_WEBHOOK_URL'), + 'level' => env('LOG_DISCORD_LEVEL', 'error'), + 'mention' => env('LOG_DISCORD_MENTION'), + 'title' => env('LOG_DISCORD_TITLE', sprintf('## %s error', env('APP_NAME', 'Laravel'))), + 'timeout' => env('LOG_DISCORD_TIMEOUT', 3), + 'connect_timeout' => env('LOG_DISCORD_CONNECT_TIMEOUT', 3), + 'max_content_length' => env('LOG_DISCORD_MAX_CONTENT_LENGTH', 2000), + 'max_trace_lines' => env('LOG_DISCORD_MAX_TRACE_LINES', 12), + 'max_context_length' => env('LOG_DISCORD_MAX_CONTEXT_LENGTH', 1200), + ], + 'papertrail' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), diff --git a/database/factories/NotificationActionFactory.php b/database/factories/NotificationActionFactory.php index 59daa68..70cc628 100644 --- a/database/factories/NotificationActionFactory.php +++ b/database/factories/NotificationActionFactory.php @@ -26,6 +26,7 @@ class NotificationActionFactory extends Factory 'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(), 'enabled' => true, 'event_type' => NotificationEventType::TicketStatusChanged, + 'filters' => [], 'message_template' => null, 'name' => fake()->unique()->sentence(3), 'project_id' => Project::factory(), diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 3dd26b8..401d387 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -22,7 +22,6 @@ class TicketFactory extends Factory public function definition(): array { return [ - 'acceptance_criteria' => fake()->paragraph(), 'assignee_user_id' => null, 'author_user_id' => User::factory(), 'blocked_comment' => null, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index de5d2ba..1c93e9b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), + 'must_change_password_on_next_login' => false, 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, diff --git a/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php b/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php index 8dd33b8..b73d5d2 100644 --- a/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php +++ b/database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php @@ -26,8 +26,8 @@ return new class extends Migration $table->timestamp('delivered_at')->nullable(); $table->timestamps(); - $table->index(['notification_action_id', 'delivery_status']); - $table->index(['notification_action_id', 'created_at']); + $table->index(['notification_action_id', 'delivery_status'], 'notification_delivery_status_index'); + $table->index(['notification_action_id', 'created_at'], 'notification_delivery_created_index'); }); } diff --git a/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php new file mode 100644 index 0000000..6139f3d --- /dev/null +++ b/database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php @@ -0,0 +1,28 @@ +json('filters')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notification_actions', function (Blueprint $table): void { + $table->dropColumn('filters'); + }); + } +}; diff --git a/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php b/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php new file mode 100644 index 0000000..477da49 --- /dev/null +++ b/database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('ticket_id') + ->constrained() + ->cascadeOnDelete(); + $table->foreignId('parent_acceptance_criterion_id') + ->nullable() + ->constrained('acceptance_criteria') + ->cascadeOnDelete(); + $table->unsignedInteger('position')->default(0); + $table->text('text'); + $table->boolean('is_checked')->default(false); + $table->timestamps(); + + $table->index(['ticket_id', 'parent_acceptance_criterion_id', 'position'], 'ac_ticket_parent_position_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('acceptance_criteria'); + } +}; diff --git a/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php b/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php new file mode 100644 index 0000000..fc5a47b --- /dev/null +++ b/database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php @@ -0,0 +1,36 @@ +dropColumn('acceptance_criteria'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('tickets', 'acceptance_criteria')) { + return; + } + + Schema::table('tickets', function (Blueprint $table): void { + $table->text('acceptance_criteria')->nullable()->after('description'); + }); + } +}; diff --git a/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php b/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php new file mode 100644 index 0000000..fd68301 --- /dev/null +++ b/database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php @@ -0,0 +1,30 @@ +boolean('must_change_password_on_next_login') + ->default(false) + ->after('password'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('must_change_password_on_next_login'); + }); + } +}; diff --git a/database/seeders/TicketSeeder.php b/database/seeders/TicketSeeder.php index 2528980..54ecfc4 100644 --- a/database/seeders/TicketSeeder.php +++ b/database/seeders/TicketSeeder.php @@ -42,7 +42,11 @@ class TicketSeeder extends Seeder ->firstOrFail(); $createProjectTicket->handle($project, [ - 'acceptance_criteria' => 'The seeded ticket should be visible in the backlog list.', + 'acceptance_criteria' => [[ + 'checked' => false, + 'indent' => 0, + 'text' => 'The seeded ticket should be visible in the backlog list.', + ]], 'assignee_user_id' => $assignee?->id, 'description' => 'Seeded backlog item for demonstrating the first ticket workflow.', 'story_points' => 3, @@ -53,7 +57,11 @@ class TicketSeeder extends Seeder ], $author); $createProjectTicket->handle($project, [ - 'acceptance_criteria' => 'Duplicate titles must be allowed for project-local ticket keys.', + 'acceptance_criteria' => [[ + 'checked' => false, + 'indent' => 0, + 'text' => 'Duplicate titles must be allowed for project-local ticket keys.', + ]], 'assignee_user_id' => null, 'description' => 'Second seeded backlog item using the same title on purpose.', 'story_points' => 5, diff --git a/resources/js/components/PasswordInput.vue b/resources/js/components/PasswordInput.vue index f2694ed..74a90bd 100644 --- a/resources/js/components/PasswordInput.vue +++ b/resources/js/components/PasswordInput.vue @@ -11,6 +11,8 @@ const props = defineProps<{ class?: HTMLAttributes['class']; }>(); +const model = defineModel(); + const showPassword = ref(false); const inputRef = useTemplateRef('inputRef'); @@ -24,6 +26,7 @@ defineExpose({
[]; + +export const normalizeAcceptanceCriteria = ( + value: AcceptanceCriteriaModel | null | undefined, +): AcceptanceCriteriaModel => { + const items = Array.isArray(value) + ? value + .map((item) => ({ + ...(typeof item.id === 'number' && item.id > 0 + ? { id: item.id } + : {}), + checked: item.checked === true, + indent: clampIndent(item.indent), + text: item.text.trim(), + })) + .filter((item) => item.text !== '') + : []; + + return items; +}; + +export const parseChecklistLines = ( + checklistInput: string, +): AcceptanceCriteriaItemModel[] => { + if (checklistInput.trim() === '') { + return []; + } + + return checklistInput + .split(/\r?\n/u) + .map((line) => parseChecklistLine(line)) + .filter( + (parsedLine): parsedLine is AcceptanceCriteriaItemModel => + parsedLine !== null, + ); +}; + +export const formatChecklistLines = ( + items: AcceptanceCriteriaItemModel[], +): string => { + return items + .map((item) => { + const indentation = ' '.repeat(clampIndent(item.indent)); + const marker = item.checked ? '[x]' : '[ ]'; + + return `${indentation}${marker} ${item.text}`; + }) + .join('\n'); +}; + +export const buildAcceptanceCriteria = ( + checklistInput: string, +): AcceptanceCriteriaModel => { + return normalizeAcceptanceCriteria(parseChecklistLines(checklistInput)); +}; + +export const syncAcceptanceCriteriaDescendantCheckedState = ( + items: AcceptanceCriteriaModel, + index: number, + checked: boolean, +): AcceptanceCriteriaModel => { + if (index < 0 || index >= items.length) { + return items; + } + + const parentIndent = items[index]?.indent ?? 0; + let isWithinDescendantRange = true; + + return items.map((item, itemIndex) => { + if (itemIndex === index) { + return { ...item, checked }; + } + + if (itemIndex < index || !isWithinDescendantRange) { + return item; + } + + if (item.indent <= parentIndent) { + isWithinDescendantRange = false; + + return item; + } + + return { ...item, checked }; + }); +}; + +export const acceptanceCriteriaToEditorState = ( + value: AcceptanceCriteriaModel | null | undefined, +): AcceptanceCriteriaEditorState => { + const normalized = normalizeAcceptanceCriteria(value); + + return { + checklist: formatChecklistLines(normalized), + }; +}; + +const parseChecklistLine = ( + line: string, +): AcceptanceCriteriaItemModel | null => { + const normalizedLine = line.replaceAll('\t', ' ').trimEnd(); + + if (normalizedLine.trim() === '') { + return null; + } + + const match = /^(?\s*)(?.+)$/u.exec(normalizedLine); + + if (match === null || match.groups === undefined) { + return null; + } + + const indentLevel = clampIndent( + Math.floor(match.groups.indent.length / 2), + ); + let content = match.groups.content.trim(); + let checked = false; + + const checkboxMatch = + /^(?:[-*+]\s+)?\[(?[xX ])\]\s*(?.+)$/u.exec(content); + + if (checkboxMatch?.groups !== undefined) { + checked = checkboxMatch.groups.state.toLowerCase() === 'x'; + content = checkboxMatch.groups.text.trim(); + } else { + const bulletMatch = /^[-*+]\s+(?.+)$/u.exec(content); + + if (bulletMatch?.groups !== undefined) { + content = bulletMatch.groups.text.trim(); + } else { + return null; + } + } + + if (content === '') { + return null; + } + + return { + checked, + indent: indentLevel, + text: content, + }; +}; + +const clampIndent = (indent: number): number => { + if (!Number.isFinite(indent)) { + return 0; + } + + return Math.max(0, Math.min(MAX_INDENT_LEVEL, Math.trunc(indent))); +}; diff --git a/resources/js/pages/Welcome.vue b/resources/js/pages/Welcome.vue index 474a491..1ebd161 100644 --- a/resources/js/pages/Welcome.vue +++ b/resources/js/pages/Welcome.vue @@ -1,15 +1,6 @@ diff --git a/resources/js/pages/admin/users/Create.vue b/resources/js/pages/admin/users/Create.vue new file mode 100644 index 0000000..59a28bb --- /dev/null +++ b/resources/js/pages/admin/users/Create.vue @@ -0,0 +1,155 @@ + + + diff --git a/resources/js/pages/admin/users/Edit.vue b/resources/js/pages/admin/users/Edit.vue new file mode 100644 index 0000000..7b170e9 --- /dev/null +++ b/resources/js/pages/admin/users/Edit.vue @@ -0,0 +1,161 @@ + + + diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue index aaf2775..9c1ce93 100644 --- a/resources/js/pages/admin/users/Index.vue +++ b/resources/js/pages/admin/users/Index.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 6ed12ae..47b96bf 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -9,14 +9,12 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import AuthBase from '@/layouts/AuthLayout.vue'; -import { register } from '@/routes/index'; import { store } from '@/routes/login'; import { request } from '@/routes/password'; defineProps<{ status?: string; canResetPassword: boolean; - canRegister: boolean; }>(); @@ -97,14 +95,6 @@ defineProps<{ Log in
- -
- Don't have an account? - Sign up -
diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue deleted file mode 100644 index 95d5894..0000000 --- a/resources/js/pages/auth/Register.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index f8420b5..fac373b 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -2,6 +2,14 @@ import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3'; import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; +import { + acceptanceCriteriaToEditorState, + buildAcceptanceCriteria, + formatChecklistLines, + normalizeAcceptanceCriteria, + parseChecklistLines, + syncAcceptanceCriteriaDescendantCheckedState, +} from '@/lib/acceptance-criteria'; import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController'; import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; @@ -103,8 +111,18 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( + props.ticket.acceptance_criteria, +); +const acceptanceCriteriaChecklist = ref(acceptanceCriteriaEditorState.checklist); +const savedAcceptanceCriteria = ref( + normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), +); +const isPersistingAcceptanceCriteriaToggle = ref(false); +const acceptanceCriteriaToggleError = ref(''); + const form = useForm({ - acceptance_criteria: props.ticket.acceptance_criteria ?? '', + acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value), assignee_user_id: props.ticket.assignee_user_id ?? '', author_user_id: props.ticket.author_user_id ?? '', blocked_comment: props.ticket.blocked_comment ?? '', @@ -130,6 +148,26 @@ const form = useForm({ story_points: props.ticket.story_points, }); +const acceptanceCriteriaPreviewItems = computed(() => + savedAcceptanceCriteria.value, +); + +const hasUnsavedAcceptanceCriteriaDraft = computed( + () => + acceptanceCriteriaChecklist.value !== + formatChecklistLines(savedAcceptanceCriteria.value), +); + +const acceptanceCriteriaError = computed(() => { + return ( + form.errors.acceptance_criteria ?? + Object.entries(form.errors).find(([key]) => + key.startsWith('acceptance_criteria.'), + )?.[1] ?? + '' + ); +}); + const isCreateBranchDialogOpen = ref(false); const isCheckingBranchName = ref(false); const branchNameValidation = ref(null); @@ -313,11 +351,120 @@ const submitBranchCreation = (): void => { ); }; +const buildAcceptanceCriteriaForSubmit = () => { + const draftItems = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value); + + return draftItems.map((item, index) => ({ + ...item, + ...(savedAcceptanceCriteria.value[index]?.id !== undefined + ? { id: savedAcceptanceCriteria.value[index].id } + : {}), + })); +}; + +const buildSavedTicketPayload = (acceptanceCriteria = savedAcceptanceCriteria.value) => ({ + acceptance_criteria: acceptanceCriteria, + assignee_user_id: props.ticket.assignee_user_id, + author_user_id: props.ticket.author_user_id, + blocked_comment: props.ticket.blocked_comment, + description: props.ticket.description, + field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({ + ticket_field_definition_id: fieldValue.ticket_field_definition_id, + value_number: fieldValue.value_number, + value_user_id: fieldValue.value_user_id, + })), + is_blocked: props.ticket.is_blocked, + parent_ticket_id: props.ticket.parent_ticket_id, + story_points: props.ticket.story_points, + ticket_priority_id: props.ticket.ticket_priority_id, + ticket_type_id: props.ticket.ticket_type_id, + title: props.ticket.title, +}); + +const persistAcceptanceCriteriaToggle = async ( + index: number, + checked: boolean, +): Promise => { + if ( + !props.canEditTicket || + isPersistingAcceptanceCriteriaToggle.value || + hasUnsavedAcceptanceCriteriaDraft.value + ) { + return; + } + + const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState( + savedAcceptanceCriteria.value, + index, + checked, + ); + const previousChecklist = formatChecklistLines(savedAcceptanceCriteria.value); + const nextChecklist = formatChecklistLines(nextAcceptanceCriteria); + const csrfToken = document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); + + isPersistingAcceptanceCriteriaToggle.value = true; + acceptanceCriteriaToggleError.value = ''; + + try { + const response = await fetch( + ProjectTicketController.update.url({ + project: props.project.id, + ticket: props.ticket.id, + }), + { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken ?? '', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify( + buildSavedTicketPayload(nextAcceptanceCriteria), + ), + }, + ); + + if (!response.ok) { + throw new Error('Checklist toggle update failed.'); + } + + savedAcceptanceCriteria.value = nextAcceptanceCriteria; + + if (acceptanceCriteriaChecklist.value === previousChecklist) { + acceptanceCriteriaChecklist.value = nextChecklist; + } + } catch (_error) { + acceptanceCriteriaToggleError.value = + 'Checklist status could not be saved right now.'; + } finally { + isPersistingAcceptanceCriteriaToggle.value = false; + } +}; + +const onAcceptanceCriteriaCheckboxChange = ( + index: number, + event: Event, +): void => { + const target = event.target; + + if (!(target instanceof HTMLInputElement)) { + return; + } + + void persistAcceptanceCriteriaToggle(index, target.checked); +}; + const submit = (): void => { if (!props.canEditTicket) { return; } + form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); + form.submit( ProjectTicketController.update({ project: props.project.id, @@ -589,22 +736,77 @@ const formatDateTime = (value: string): string => { /> -
- -