Merge branch 'main' into epic/ui-rework
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class BuildTicketAssigneeChangedPayload
|
||||
{
|
||||
public function handle(
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
?User $actor,
|
||||
?int $fromAssigneeId,
|
||||
?string $fromAssigneeName,
|
||||
?int $toAssigneeId,
|
||||
?string $toAssigneeName,
|
||||
CarbonImmutable $occurredAt,
|
||||
): TicketAssigneeChangedPayload {
|
||||
$ticket->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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class BuildTicketCreatedPayload
|
||||
{
|
||||
public function handle(
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
TicketStatus $ticketStatus,
|
||||
?User $actor,
|
||||
CarbonImmutable $occurredAt,
|
||||
): TicketCreatedPayload {
|
||||
return new TicketCreatedPayload(
|
||||
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,
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>
|
||||
*/
|
||||
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<string, scalar|null>
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class MatchNotificationFilters
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{attribute?: mixed, operator?: mixed, value?: mixed}> $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<string, mixed> $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<int, string>|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<int, string>|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<int, string>|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<int, string>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class NormalizeNotificationFilters
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{attribute: string, operator: string, value: array<int, string>|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<int, string>|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<int, string>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
|
||||
interface NotificationEvent
|
||||
{
|
||||
public function eventType(): NotificationEventType;
|
||||
|
||||
public function payload(): NotificationEventPayload;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* @extends Arrayable<string, mixed>
|
||||
*/
|
||||
interface NotificationEventPayload extends Arrayable
|
||||
{
|
||||
public function projectId(): int;
|
||||
|
||||
/**
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
public function deliveryMetadata(): array;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function discordTemplateVariables(): array;
|
||||
}
|
||||
+19
-12
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
final readonly class TicketAssigneeChanged implements NotificationEvent, ShouldDispatchAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public TicketAssigneeChangedPayload $payload,
|
||||
) {}
|
||||
|
||||
public function eventType(): NotificationEventType
|
||||
{
|
||||
return NotificationEventType::TicketAssigneeChanged;
|
||||
}
|
||||
|
||||
public function payload(): NotificationEventPayload
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final readonly class TicketAssigneeChangedPayload implements NotificationEventPayload
|
||||
{
|
||||
public function __construct(
|
||||
public int $projectId,
|
||||
public string $projectKey,
|
||||
public string $projectName,
|
||||
public int $ticketId,
|
||||
public string $ticketKey,
|
||||
public string $ticketTitle,
|
||||
public string $ticketUrl,
|
||||
public ?int $actorId,
|
||||
public ?string $actorName,
|
||||
public ?int $fromStatusId,
|
||||
public ?string $fromStatusLabel,
|
||||
public int $toStatusId,
|
||||
public string $toStatusLabel,
|
||||
public CarbonImmutable $occurredAt,
|
||||
public ?int $fromAssigneeId,
|
||||
public ?string $fromAssigneeName,
|
||||
public ?int $toAssigneeId,
|
||||
public ?string $toAssigneeName,
|
||||
) {}
|
||||
|
||||
public function projectId(): int
|
||||
{
|
||||
return $this->projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
public function deliveryMetadata(): array
|
||||
{
|
||||
return [
|
||||
'project_key' => $this->projectKey,
|
||||
'ticket_key' => $this->ticketKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
final readonly class TicketCreated implements NotificationEvent, ShouldDispatchAfterCommit
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public TicketCreatedPayload $payload,
|
||||
) {}
|
||||
|
||||
public function eventType(): NotificationEventType
|
||||
{
|
||||
return NotificationEventType::TicketCreate;
|
||||
}
|
||||
|
||||
public function payload(): NotificationEventPayload
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final readonly class TicketCreatedPayload implements NotificationEventPayload
|
||||
{
|
||||
public ?int $fromStatusId;
|
||||
|
||||
public ?string $fromStatusLabel;
|
||||
|
||||
public function __construct(
|
||||
public int $projectId,
|
||||
public string $projectKey,
|
||||
public string $projectName,
|
||||
public int $ticketId,
|
||||
public string $ticketKey,
|
||||
public string $ticketTitle,
|
||||
public string $ticketUrl,
|
||||
public ?int $actorId,
|
||||
public ?string $actorName,
|
||||
public int $toStatusId,
|
||||
public string $toStatusLabel,
|
||||
public CarbonImmutable $occurredAt,
|
||||
) {
|
||||
$this->fromStatusId = null;
|
||||
$this->fromStatusLabel = null;
|
||||
}
|
||||
|
||||
public function projectId(): int
|
||||
{
|
||||
return $this->projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, scalar|null>
|
||||
*/
|
||||
public function deliveryMetadata(): array
|
||||
{
|
||||
return [
|
||||
'project_key' => $this->projectKey,
|
||||
'ticket_key' => $this->ticketKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,10 @@ namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationEventType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
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<string, scalar|null>
|
||||
*/
|
||||
public function deliveryMetadata(): array
|
||||
{
|
||||
return [
|
||||
'project_key' => $this->projectKey,
|
||||
'ticket_key' => $this->ticketKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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) !== '') {
|
||||
|
||||
@@ -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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>,
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets\Support;
|
||||
|
||||
use App\Models\AcceptanceCriterion;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AcceptanceCriteriaChecklistMapper
|
||||
{
|
||||
public const MAX_INDENT_LEVEL = 8;
|
||||
|
||||
/**
|
||||
* @param iterable<AcceptanceCriterion> $criteria
|
||||
* @return array<int, array{checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function fromModels(iterable $criteria): array
|
||||
{
|
||||
return self::flattenModels($criteria, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<AcceptanceCriterion> $criteria
|
||||
* @return array<int, array{id: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function fromModelsWithIds(iterable $criteria): array
|
||||
{
|
||||
return self::flattenModels($criteria, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
public static function normalize(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return self::normalizeFromArray($value);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $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<int|string, mixed> $value
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
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<AcceptanceCriterion> $criteria
|
||||
* @return array<int, array{id?: int, checked: bool, indent: int, text: string}>
|
||||
*/
|
||||
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<int|string, iterable<AcceptanceCriterion>> $grouped */
|
||||
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
|
||||
$items = [];
|
||||
|
||||
self::flattenItems($items, $grouped, null, 0, $includeIds);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
* @param array<int|string, iterable<AcceptanceCriterion>> $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>,
|
||||
* 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<int, TicketFieldValue> $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<int, array{
|
||||
* checked: bool,
|
||||
* indent: int,
|
||||
* text: string
|
||||
* }>,
|
||||
* 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<string, mixed> $changes
|
||||
* @param array<int, string> $summaryLabels
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $currentValue
|
||||
* @param array<int, array{checked: bool, indent: int, text: string}> $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<string, mixed> $changes
|
||||
* @param array<int, string> $summaryLabels
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DiscordAllowedMentionType: string
|
||||
{
|
||||
case Everyone = 'everyone';
|
||||
case Roles = 'roles';
|
||||
case Users = 'users';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Everyone => '@everyone and @here',
|
||||
self::Roles => 'Role mentions (@role)',
|
||||
self::Users => 'User mentions (@username)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $eventType): array => [
|
||||
'label' => $eventType->label(),
|
||||
'value' => $eventType->value,
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, self>|null $discordAllowedMentionTypes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(?array $discordAllowedMentionTypes = null): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $discordAllowedMentionType): string => $discordAllowedMentionType->value,
|
||||
$discordAllowedMentionTypes ?? self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,16 @@ enum NotificationChannel: string
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, self>|null $notificationChannels
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(?array $notificationChannels = null): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $notificationChannel): string => $notificationChannel->value,
|
||||
$notificationChannels ?? self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, self>|null $notificationEventTypes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(?array $notificationEventTypes = null): array
|
||||
{
|
||||
return array_map(
|
||||
fn (self $notificationEventType): string => $notificationEventType->value,
|
||||
$notificationEventTypes ?? self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationFilterAttribute: string
|
||||
{
|
||||
case ProjectId = 'project.id';
|
||||
case ProjectKey = 'project.key';
|
||||
case ProjectName = 'project.name';
|
||||
case TicketId = 'ticket.id';
|
||||
case TicketKey = 'ticket.key';
|
||||
case TicketTitle = 'ticket.title';
|
||||
case TicketUrl = 'ticket.url';
|
||||
case ActorId = 'actor.id';
|
||||
case ActorName = 'actor.name';
|
||||
case AssigneeFromId = 'assignee.from.id';
|
||||
case AssigneeFromName = 'assignee.from.name';
|
||||
case AssigneeToId = 'assignee.to.id';
|
||||
case AssigneeToName = 'assignee.to.name';
|
||||
case StatusFromId = 'status.from.id';
|
||||
case StatusFromLabel = 'status.from.label';
|
||||
case StatusToId = 'status.to.id';
|
||||
case StatusToLabel = 'status.to.label';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ProjectId => '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<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $attribute): array => [
|
||||
'label' => $attribute->label(),
|
||||
'value' => $attribute->value,
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $attribute): string => $attribute->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NotificationFilterOperator: string
|
||||
{
|
||||
case Equals = 'equals';
|
||||
case NotEquals = 'not_equals';
|
||||
case Exists = 'exists';
|
||||
case NotExists = 'not_exists';
|
||||
case In = 'in';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Equals => 'Equals',
|
||||
self::NotEquals => 'Not equals',
|
||||
self::Exists => 'Exists',
|
||||
self::NotExists => 'Not exists',
|
||||
self::In => 'In list',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $operator): array => [
|
||||
'label' => $operator->label(),
|
||||
'value' => $operator->value,
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $operator): string => $operator->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequirePasswordChange
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreManagedUserRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
use ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
...$this->profileRules(),
|
||||
'must_change_password_on_next_login' => ['required', 'boolean'],
|
||||
'password' => $this->passwordRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UpdateManagedUserRequest extends FormRequest
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||
use App\Enums\NotificationFilterAttribute;
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
trait InteractsWithNotificationFilters
|
||||
{
|
||||
protected function prepareNotificationFiltersForValidation(): void
|
||||
{
|
||||
$rawFilters = $this->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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'password.different' => 'The new password must be different from your current password.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\Logger;
|
||||
|
||||
class DiscordLogger
|
||||
{
|
||||
public function __invoke(array $config): Logger
|
||||
{
|
||||
$logger = new Logger((string) ($config['name'] ?? 'discord-errors'));
|
||||
|
||||
$logger->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Monolog\Handler\AbstractProcessingHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
|
||||
class DiscordWebhookHandler extends AbstractProcessingHandler
|
||||
{
|
||||
private const string TRUNCATION_SUFFIX = "\n...(truncated)";
|
||||
|
||||
public function __construct(
|
||||
private readonly ?string $webhookUrl,
|
||||
Level|string|int $level = 'error',
|
||||
bool $bubble = true,
|
||||
private readonly ?string $mention = null,
|
||||
private readonly string $title = '## Laravel error',
|
||||
private readonly float $timeout = 3.0,
|
||||
private readonly float $connectTimeout = 3.0,
|
||||
private readonly int $maxContentLength = 2000,
|
||||
private readonly int $maxTraceLines = 12,
|
||||
private readonly int $maxContextLength = 1200,
|
||||
) {
|
||||
parent::__construct($level, $bubble);
|
||||
}
|
||||
|
||||
protected function write(LogRecord $record): void
|
||||
{
|
||||
if ($this->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<int, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{checked: bool, indent: int, text: string}>,
|
||||
* 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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'ticket_id',
|
||||
'parent_acceptance_criterion_id',
|
||||
'position',
|
||||
'text',
|
||||
'is_checked',
|
||||
])]
|
||||
class AcceptanceCriterion extends Model
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
+2
-1
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ return [
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Logging\DiscordLogger;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
@@ -82,6 +83,20 @@ return [
|
||||
'replace_placeholders' => 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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notification_actions', function (Blueprint $table): void {
|
||||
$table->json('filters')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notification_actions', function (Blueprint $table): void {
|
||||
$table->dropColumn('filters');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('acceptance_criteria', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('tickets', 'acceptance_criteria')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
|
||||
@@ -24,6 +26,7 @@ defineExpose({
|
||||
<div class="relative">
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:class="cn('pr-10', props.class)"
|
||||
v-bind="$attrs"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
AcceptanceCriteriaItemModel,
|
||||
AcceptanceCriteriaModel,
|
||||
} from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
const MAX_INDENT_LEVEL = 8;
|
||||
|
||||
export type AcceptanceCriteriaEditorState = {
|
||||
checklist: string;
|
||||
};
|
||||
|
||||
export const emptyAcceptanceCriteria = (): AcceptanceCriteriaModel => [];
|
||||
|
||||
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 = /^(?<indent>\s*)(?<content>.+)$/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+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u.exec(content);
|
||||
|
||||
if (checkboxMatch?.groups !== undefined) {
|
||||
checked = checkboxMatch.groups.state.toLowerCase() === 'x';
|
||||
content = checkboxMatch.groups.text.trim();
|
||||
} else {
|
||||
const bulletMatch = /^[-*+]\s+(?<text>.+)$/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)));
|
||||
};
|
||||
@@ -1,15 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { dashboard, login, register } from '@/routes/index';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
canRegister: boolean;
|
||||
}>(),
|
||||
{
|
||||
canRegister: true,
|
||||
},
|
||||
);
|
||||
import { dashboard, login } from '@/routes/index';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,13 +29,6 @@ withDefaults(
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
<Link
|
||||
v-if="canRegister"
|
||||
:href="register()"
|
||||
class="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index } from '@/routes/admin/users';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'User management',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'Create user',
|
||||
href: create(),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Create user" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
title="Create user"
|
||||
description="Create a user account and optionally force a password change on the next login."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User details</CardTitle>
|
||||
<CardDescription>
|
||||
Set an initial password that the user can replace after
|
||||
their first sign-in.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="UserManagementController.store.form()"
|
||||
:transform="(data) => ({
|
||||
...data,
|
||||
must_change_password_on_next_login:
|
||||
data.must_change_password_on_next_login === true ||
|
||||
data.must_change_password_on_next_login === 'on',
|
||||
})"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
placeholder="Jane Admin"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="Temporary password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation"
|
||||
>Confirm password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="must_change_password_on_next_login"
|
||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<input
|
||||
id="must_change_password_on_next_login"
|
||||
name="must_change_password_on_next_login"
|
||||
type="checkbox"
|
||||
checked
|
||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium">
|
||||
Must change password on next login
|
||||
</span>
|
||||
<span class="text-muted-foreground block text-sm">
|
||||
Redirect the user to the security settings page until they save a new password.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:message="errors.must_change_password_on_next_login"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="processing">
|
||||
Create user
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { edit, index, save } from '@/routes/admin/users';
|
||||
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
||||
|
||||
const props = defineProps<AdminUserEditPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'User management',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: 'Edit user',
|
||||
href: edit(props.user.id),
|
||||
},
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
must_change_password_on_next_login:
|
||||
props.user.must_change_password_on_next_login,
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
form.patch(save(props.user.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`Edit ${user.name}`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<Heading
|
||||
:title="`Edit ${user.name}`"
|
||||
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User details</CardTitle>
|
||||
<CardDescription>
|
||||
Leave the password fields empty if the current password
|
||||
should stay unchanged.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
placeholder="Jane Admin"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
<InputError :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">New password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Leave blank to keep current password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation"
|
||||
>Confirm new password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.password_confirmation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="must_change_password_on_next_login"
|
||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<input
|
||||
id="must_change_password_on_next_login"
|
||||
v-model="form.must_change_password_on_next_login"
|
||||
type="checkbox"
|
||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium">
|
||||
Must change password on next login
|
||||
</span>
|
||||
<span class="text-muted-foreground block text-sm">
|
||||
Keep the user locked to the security settings page until they save a new password.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:message="form.errors.must_change_password_on_next_login"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Save user
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { FolderPlus, Shield, Users } from 'lucide-vue-next';
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { FolderPlus, KeyRound, Pencil, Shield, UserPlus, Users } from 'lucide-vue-next';
|
||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import {
|
||||
create as createAdminUser,
|
||||
edit as editAdminUser,
|
||||
index as adminUsers,
|
||||
} from '@/routes/admin/users';
|
||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
||||
|
||||
const props = defineProps<AdminUsersPageProps>();
|
||||
@@ -40,10 +44,19 @@ const hasPermission = (actorId: number, permission: string) =>
|
||||
description="Assign the global permissions that unlock platform-wide administration and project creation."
|
||||
/>
|
||||
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
<Users class="size-3.5" />
|
||||
Admin-only area
|
||||
</Badge>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button as-child>
|
||||
<Link :href="createAdminUser()">
|
||||
<UserPlus class="size-4" />
|
||||
User create
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
||||
<Users class="size-3.5" />
|
||||
Admin-only area
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -108,6 +121,14 @@ const hasPermission = (actorId: number, permission: string) =>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-if="user.must_change_password_on_next_login"
|
||||
variant="outline"
|
||||
class="gap-1.5"
|
||||
>
|
||||
<KeyRound class="size-3.5" />
|
||||
Must change password
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="
|
||||
hasPermission(
|
||||
@@ -149,48 +170,57 @@ const hasPermission = (actorId: number, permission: string) =>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
UserManagementController.update.form(
|
||||
user.id,
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="option.value"
|
||||
:checked="
|
||||
hasPermission(
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<div class="w-full sm:hidden" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
<div class="flex flex-wrap justify-end gap-3">
|
||||
<Button as-child size="sm" variant="outline">
|
||||
<Link :href="editAdminUser(user.id)">
|
||||
<Pencil class="size-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
UserManagementController.update.form(
|
||||
user.id,
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap justify-end gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${user.id}-${option.value}`"
|
||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
:value="option.value"
|
||||
:checked="
|
||||
hasPermission(
|
||||
user.id,
|
||||
option.value,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<div class="w-full sm:hidden" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { LogOut } from 'lucide-vue-next';
|
||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
status?: string | null;
|
||||
}>(),
|
||||
{
|
||||
status: null,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Change your password"
|
||||
description="You must update your password before you can continue using the application."
|
||||
>
|
||||
<Head title="Change your password" />
|
||||
|
||||
<div class="mb-6 flex justify-end">
|
||||
<Button as-child variant="ghost" size="sm">
|
||||
<Link :href="logout()" as="button">
|
||||
<LogOut class="size-4" />
|
||||
Log out
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="SecurityController.update.form()"
|
||||
reset-on-success
|
||||
:reset-on-error="[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="current_password">Current password</Label>
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="force-password-change-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Save new password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
@@ -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;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -97,14 +95,6 @@ defineProps<{
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-muted-foreground text-center text-sm"
|
||||
v-if="canRegister"
|
||||
>
|
||||
Don't have an account?
|
||||
<TextLink :href="register()" :tabindex="5">Sign up</TextLink>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthBase>
|
||||
</template>
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { login } from '@/routes/index';
|
||||
import { store } from '@/routes/register';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthBase
|
||||
title="Create an account"
|
||||
description="Enter your details below to create your account"
|
||||
>
|
||||
<Head title="Register" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
:reset-on-success="['password', 'password_confirmation']"
|
||||
v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
:tabindex="1"
|
||||
autocomplete="name"
|
||||
name="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
:tabindex="2"
|
||||
autocomplete="email"
|
||||
name="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
:tabindex="3"
|
||||
autocomplete="new-password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation">Confirm password</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
required
|
||||
:tabindex="4"
|
||||
autocomplete="new-password"
|
||||
name="password_confirmation"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="mt-2 w-full"
|
||||
tabindex="5"
|
||||
:disabled="processing"
|
||||
data-test="register-user-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
Already have an account?
|
||||
<TextLink
|
||||
:href="login()"
|
||||
class="underline underline-offset-4"
|
||||
:tabindex="6"
|
||||
>Log in</TextLink
|
||||
>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthBase>
|
||||
</template>
|
||||
@@ -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<BranchNameValidationModel | null>(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<void> => {
|
||||
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 => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria">
|
||||
Acceptance criteria
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria"
|
||||
v-model="form.acceptance_criteria"
|
||||
rows="5"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<InputError
|
||||
:message="form.errors.acceptance_criteria"
|
||||
/>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria_checklist">
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="5"
|
||||
:disabled="
|
||||
!canEditTicket || form.processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
|
||||
Text changes are saved with the main ticket update.
|
||||
</p>
|
||||
<p
|
||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Save checklist text changes before toggling completion directly.
|
||||
</p>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaError"
|
||||
/>
|
||||
<InputError
|
||||
:message="acceptanceCriteriaToggleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Saved checklist
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(item, index) in acceptanceCriteriaPreviewItems"
|
||||
:key="item.id ?? index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<input
|
||||
:id="`acceptance-criteria-${item.id ?? index}`"
|
||||
:checked="item.checked"
|
||||
type="checkbox"
|
||||
:disabled="
|
||||
!canEditTicket ||
|
||||
form.processing ||
|
||||
isPersistingAcceptanceCriteriaToggle ||
|
||||
hasUnsavedAcceptanceCriteriaDraft
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
||||
@change="onAcceptanceCriteriaCheckboxChange(index, $event)"
|
||||
/>
|
||||
<label
|
||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ item.text }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
@@ -56,6 +61,43 @@ const hasTicketMetadata =
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
const acceptanceCriteriaChecklist = ref('');
|
||||
|
||||
const form = useForm({
|
||||
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
|
||||
assignee_user_id: '',
|
||||
description: '',
|
||||
story_points: '',
|
||||
ticket_priority_id: String(props.ticketPriorities[0]?.id ?? ''),
|
||||
ticket_status_id: String(
|
||||
props.defaultTicketStatusId ?? props.ticketStatusOptions[0]?.id ?? '',
|
||||
),
|
||||
ticket_type_id: String(props.ticketTypes[0]?.id ?? ''),
|
||||
title: '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('acceptance_criteria.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
|
||||
|
||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
@@ -116,21 +158,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="ProjectTicketController.store.form(project.id)"
|
||||
class="grid gap-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<form class="grid gap-6" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
v-model="form.title"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
placeholder="Implement repository sync"
|
||||
/>
|
||||
<InputError :message="errors.title" />
|
||||
<InputError :message="form.errors.title" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
@@ -139,28 +178,55 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="Describe the expected behavior and relevant context."
|
||||
/>
|
||||
<InputError :message="errors.description" />
|
||||
<InputError :message="form.errors.description" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria">
|
||||
Acceptance criteria
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria"
|
||||
name="acceptance_criteria"
|
||||
rows="4"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
placeholder="List the conditions that must be true before this ticket is done."
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.acceptance_criteria"
|
||||
/>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria_checklist">
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="6"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
|
||||
</p>
|
||||
<InputError :message="acceptanceCriteriaError" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Checklist preview
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(item, index) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5">{{ item.checked ? '☑' : '☐' }}</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,20 +235,19 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Label for="ticket_type_id">Type</Label>
|
||||
<select
|
||||
id="ticket_type_id"
|
||||
name="ticket_type_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.ticket_type_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="ticketType.id"
|
||||
:value="String(ticketType.id)"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_type_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_type_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -191,44 +256,38 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<select
|
||||
id="ticket_priority_id"
|
||||
name="ticket_priority_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.ticket_priority_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="ticketPriority.id"
|
||||
:value="String(ticketPriority.id)"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_priority_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_priority_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ticket_status_id">Status</Label>
|
||||
<select
|
||||
id="ticket_status_id"
|
||||
name="ticket_status_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.ticket_status_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option
|
||||
v-for="ticketStatus in ticketStatusOptions"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
:selected="
|
||||
ticketStatus.id ===
|
||||
defaultTicketStatusId
|
||||
"
|
||||
:value="String(ticketStatus.id)"
|
||||
>
|
||||
{{ ticketStatusLabel(ticketStatus) }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.ticket_status_id"
|
||||
/>
|
||||
<InputError :message="form.errors.ticket_status_id" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -237,21 +296,20 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<select
|
||||
id="assignee_user_id"
|
||||
name="assignee_user_id"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
v-model="form.assignee_user_id"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:value="user.id"
|
||||
:value="String(user.id)"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="errors.assignee_user_id"
|
||||
/>
|
||||
<InputError :message="form.errors.assignee_user_id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -263,19 +321,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Label>
|
||||
<Input
|
||||
id="story_points"
|
||||
name="story_points"
|
||||
v-model="form.story_points"
|
||||
type="number"
|
||||
step="1"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.story_points"
|
||||
/>
|
||||
<InputError :message="form.errors.story_points" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" :disabled="processing">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create ticket
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
@@ -284,7 +341,7 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -28,18 +28,45 @@ import type {
|
||||
BreadcrumbItem,
|
||||
ProjectNotificationSettingsPageProps,
|
||||
} from '@/types';
|
||||
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||
|
||||
type NotificationFilterFormState = {
|
||||
attribute: string;
|
||||
id: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type NotificationFilterPayload = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
|
||||
const props = defineProps<ProjectNotificationSettingsPageProps>();
|
||||
|
||||
const defaultCreateChannel =
|
||||
props.channelOptions.find((option) => option.value === 'webhook')?.value ??
|
||||
props.channelOptions[0]?.value ??
|
||||
'';
|
||||
const defaultFilterAttribute = props.filterAttributeOptions[0]?.value ?? '';
|
||||
const defaultFilterOperator =
|
||||
props.filterOperatorOptions.find((option) => option.value === 'equals')
|
||||
?.value ??
|
||||
props.filterOperatorOptions[0]?.value ??
|
||||
'';
|
||||
|
||||
const createChannelState = reactive<{ value: string }>({
|
||||
value: defaultCreateChannel,
|
||||
});
|
||||
const createFilterState = reactive<{ filters: NotificationFilterFormState[] }>({
|
||||
filters: [],
|
||||
});
|
||||
const editChannelState = reactive<Record<number, string>>({});
|
||||
const editFilterState = reactive<Record<number, NotificationFilterFormState[]>>(
|
||||
{},
|
||||
);
|
||||
const timestampFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
@@ -60,13 +87,146 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
let filterStateCounter = 0;
|
||||
|
||||
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
|
||||
|
||||
const parseInFilterValues = (value: string): string[] =>
|
||||
value
|
||||
.split(/[\n,]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
const supportsFilterValue = (operator: string): boolean =>
|
||||
!['exists', 'not_exists'].includes(operator);
|
||||
|
||||
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
|
||||
|
||||
const createFilter = (): NotificationFilterFormState => ({
|
||||
attribute: defaultFilterAttribute,
|
||||
id: nextFilterStateId(),
|
||||
operator: defaultFilterOperator,
|
||||
value: '',
|
||||
});
|
||||
|
||||
const toFilterFormState = (
|
||||
filter: NotificationActionFilterModel,
|
||||
): NotificationFilterFormState => ({
|
||||
attribute: filter.attribute,
|
||||
id: nextFilterStateId(),
|
||||
operator: filter.operator,
|
||||
value: Array.isArray(filter.value)
|
||||
? filter.value.join(', ')
|
||||
: (filter.value ?? ''),
|
||||
});
|
||||
|
||||
const serializeFilters = (
|
||||
filters: NotificationFilterFormState[],
|
||||
): NotificationFilterPayload[] =>
|
||||
filters.map((filter) => ({
|
||||
attribute: filter.attribute,
|
||||
operator: filter.operator,
|
||||
value: supportsArrayFilterValue(filter.operator)
|
||||
? parseInFilterValues(filter.value)
|
||||
: supportsFilterValue(filter.operator)
|
||||
? filter.value.trim()
|
||||
: null,
|
||||
}));
|
||||
|
||||
const notificationFilterError = (
|
||||
errors: Record<string, string>,
|
||||
): string | undefined => {
|
||||
if (errors.filters) {
|
||||
return errors.filters;
|
||||
}
|
||||
|
||||
const nestedErrorKey = Object.keys(errors).find((key) =>
|
||||
key.startsWith('filters.'),
|
||||
);
|
||||
|
||||
return nestedErrorKey === undefined ? undefined : errors[nestedErrorKey];
|
||||
};
|
||||
|
||||
const addCreateFilter = (): void => {
|
||||
createFilterState.filters.push(createFilter());
|
||||
};
|
||||
|
||||
const removeCreateFilter = (index: number): void => {
|
||||
createFilterState.filters.splice(index, 1);
|
||||
};
|
||||
|
||||
const resetCreateFilters = (): void => {
|
||||
createFilterState.filters = [];
|
||||
};
|
||||
|
||||
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
|
||||
editFilterState[notificationActionId] ?? [];
|
||||
|
||||
const addEditFilter = (notificationActionId: number): void => {
|
||||
editFilterState[notificationActionId] = [
|
||||
...editFilters(notificationActionId),
|
||||
createFilter(),
|
||||
];
|
||||
};
|
||||
|
||||
const removeEditFilter = (notificationActionId: number, index: number): void => {
|
||||
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
|
||||
(_, candidateIndex) => candidateIndex !== index,
|
||||
);
|
||||
};
|
||||
|
||||
const createSerializedFilters = computed<string>(() =>
|
||||
JSON.stringify(serializeFilters(createFilterState.filters)),
|
||||
);
|
||||
|
||||
const editSerializedFilters = (notificationActionId: number): string =>
|
||||
JSON.stringify(serializeFilters(editFilters(notificationActionId)));
|
||||
|
||||
props.notificationActions.forEach((notificationAction) => {
|
||||
editChannelState[notificationAction.id] = notificationAction.channel;
|
||||
editFilterState[notificationAction.id] = (
|
||||
notificationAction.filters ?? []
|
||||
).map(toFilterFormState);
|
||||
});
|
||||
|
||||
const selectedChannel = (notificationAction: NotificationActionModel): string =>
|
||||
editChannelState[notificationAction.id] ?? notificationAction.channel;
|
||||
|
||||
const notificationActionAllowedMentions = (
|
||||
notificationAction: NotificationActionModel,
|
||||
): string[] => {
|
||||
const allowedMentions =
|
||||
notificationAction.channel_configuration['allowed_mentions'];
|
||||
|
||||
if (!Array.isArray(allowedMentions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allowedMentions
|
||||
.filter(
|
||||
(allowedMention): allowedMention is string =>
|
||||
typeof allowedMention === 'string',
|
||||
)
|
||||
.map((allowedMention) => allowedMention.trim().toLowerCase())
|
||||
.filter((allowedMention) => allowedMention !== '');
|
||||
};
|
||||
|
||||
const allowedMentionsError = (
|
||||
errors: Record<string, string>,
|
||||
): string | undefined => {
|
||||
const generalError = errors['channel_configuration.allowed_mentions'];
|
||||
|
||||
if (generalError !== undefined) {
|
||||
return generalError;
|
||||
}
|
||||
|
||||
const itemErrorKey = Object.keys(errors).find((key) =>
|
||||
key.startsWith('channel_configuration.allowed_mentions.'),
|
||||
);
|
||||
|
||||
return itemErrorKey === undefined ? undefined : errors[itemErrorKey];
|
||||
};
|
||||
|
||||
const notificationActionTimestamp = (
|
||||
notificationAction: NotificationActionModel,
|
||||
): string => {
|
||||
@@ -99,7 +259,7 @@ const notificationActionTimestamp = (
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} notification settings`"
|
||||
description="Configure project-scoped webhook and Discord notification actions for ticket status changes."
|
||||
description="Configure project-scoped webhook and Discord notification actions for ticket events."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +312,7 @@ const notificationActionTimestamp = (
|
||||
<CardTitle>Create notification</CardTitle>
|
||||
<CardDescription>
|
||||
Add a project-scoped notification action for
|
||||
the supported ticket status change event.
|
||||
supported ticket events.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -167,7 +327,8 @@ const notificationActionTimestamp = (
|
||||
reset-on-success
|
||||
@success="
|
||||
createChannelState.value =
|
||||
defaultCreateChannel
|
||||
defaultCreateChannel;
|
||||
resetCreateFilters();
|
||||
"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
@@ -210,7 +371,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-notification-channel">
|
||||
<Label
|
||||
for="create-notification-channel"
|
||||
>
|
||||
Channel
|
||||
</Label>
|
||||
<select
|
||||
@@ -232,7 +395,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-notification-delivery-url">
|
||||
<Label
|
||||
for="create-notification-delivery-url"
|
||||
>
|
||||
Delivery URL
|
||||
</Label>
|
||||
<Input
|
||||
@@ -246,7 +411,55 @@ const notificationActionTimestamp = (
|
||||
never rendered back to the browser after
|
||||
save.
|
||||
</p>
|
||||
<InputError :message="errors.delivery_url" />
|
||||
<InputError
|
||||
:message="errors.delivery_url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label>Allowed mentions</Label>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2',
|
||||
createChannelState.value !==
|
||||
'discord'
|
||||
? 'hidden'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<label
|
||||
v-for="allowedMentionOption in discordAllowedMentionOptions"
|
||||
:key="`create-allowed-mention-${allowedMentionOption.value}`"
|
||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="channel_configuration[allowed_mentions][]"
|
||||
:value="
|
||||
allowedMentionOption.value
|
||||
"
|
||||
:disabled="
|
||||
createChannelState.value !==
|
||||
'discord'
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>
|
||||
{{ allowedMentionOption.label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
createChannelState.value ===
|
||||
'discord'
|
||||
? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.'
|
||||
: 'Allowed mentions are only used when the channel is Discord.'
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
:message="allowedMentionsError(errors)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -270,7 +483,7 @@ const notificationActionTimestamp = (
|
||||
? 'hidden'
|
||||
: 'block',
|
||||
]"
|
||||
placeholder="$ticket_key moved from $status_from to $status_to by $actor_name: $url"
|
||||
placeholder="$ticket_key in $project_name ($status_to) by $actor_name: $url"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
@@ -285,6 +498,148 @@ const notificationActionTimestamp = (
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="space-y-1">
|
||||
<Label>Notification filters</Label>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Optional rules that must match the
|
||||
ticket payload before this
|
||||
notification is dispatched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="filters"
|
||||
:value="createSerializedFilters"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="createFilterState.filters.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No filters configured. This action runs
|
||||
for every matching event.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
) in createFilterState.filters"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`create-filter-attribute-${filter.id}`"
|
||||
>
|
||||
Attribute
|
||||
</Label>
|
||||
<select
|
||||
:id="`create-filter-attribute-${filter.id}`"
|
||||
v-model="filter.attribute"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`create-filter-operator-${filter.id}`"
|
||||
>
|
||||
Operator
|
||||
</Label>
|
||||
<select
|
||||
:id="`create-filter-operator-${filter.id}`"
|
||||
v-model="filter.operator"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="`create-filter-value-${filter.id}`"
|
||||
>
|
||||
{{
|
||||
supportsArrayFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
? 'Values (comma or newline separated)'
|
||||
: 'Value'
|
||||
}}
|
||||
</Label>
|
||||
<textarea
|
||||
:id="`create-filter-value-${filter.id}`"
|
||||
v-model="filter.value"
|
||||
rows="2"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
:placeholder="
|
||||
supportsArrayFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
? 'CORE-1, CORE-2'
|
||||
: 'Doing'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
removeCreateFilter(
|
||||
filterIndex,
|
||||
)
|
||||
"
|
||||
>
|
||||
Remove filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="addCreateFilter"
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
for="create-notification-enabled"
|
||||
@@ -393,7 +748,11 @@ const notificationActionTimestamp = (
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{{ notificationActionTimestamp(notificationAction) }}
|
||||
{{
|
||||
notificationActionTimestamp(
|
||||
notificationAction,
|
||||
)
|
||||
}}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -405,25 +764,19 @@ const notificationActionTimestamp = (
|
||||
[project.id, notificationAction.id],
|
||||
)
|
||||
"
|
||||
:error-bag="
|
||||
`updateNotificationAction-${notificationAction.id}`
|
||||
"
|
||||
:error-bag="`updateNotificationAction-${notificationAction.id}`"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-name-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-name-${notificationAction.id}`"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="
|
||||
`notification-name-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-name-${notificationAction.id}`"
|
||||
name="name"
|
||||
:default-value="
|
||||
notificationAction.name
|
||||
@@ -434,16 +787,12 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-delivery-url-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-delivery-url-${notificationAction.id}`"
|
||||
>
|
||||
Delivery URL
|
||||
</Label>
|
||||
<Input
|
||||
:id="
|
||||
`notification-delivery-url-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-delivery-url-${notificationAction.id}`"
|
||||
name="delivery_url"
|
||||
type="url"
|
||||
:placeholder="
|
||||
@@ -468,16 +817,12 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-event-type-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-event-type-${notificationAction.id}`"
|
||||
>
|
||||
Event type
|
||||
</Label>
|
||||
<select
|
||||
:id="
|
||||
`notification-event-type-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-event-type-${notificationAction.id}`"
|
||||
name="event_type"
|
||||
:default-value="
|
||||
notificationAction.event_type
|
||||
@@ -499,16 +844,12 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-channel-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-channel-${notificationAction.id}`"
|
||||
>
|
||||
Channel
|
||||
</Label>
|
||||
<select
|
||||
:id="
|
||||
`notification-channel-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-channel-${notificationAction.id}`"
|
||||
name="channel"
|
||||
v-model="
|
||||
editChannelState[
|
||||
@@ -529,18 +870,70 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label>Allowed mentions</Label>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2',
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) !== 'discord'
|
||||
? 'hidden'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<label
|
||||
v-for="allowedMentionOption in discordAllowedMentionOptions"
|
||||
:key="`notification-allowed-mention-${notificationAction.id}-${allowedMentionOption.value}`"
|
||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="channel_configuration[allowed_mentions][]"
|
||||
:value="
|
||||
allowedMentionOption.value
|
||||
"
|
||||
:default-checked="
|
||||
notificationActionAllowedMentions(
|
||||
notificationAction,
|
||||
).includes(
|
||||
allowedMentionOption.value,
|
||||
)
|
||||
"
|
||||
:disabled="
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) !== 'discord'
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>
|
||||
{{ allowedMentionOption.label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) === 'discord'
|
||||
? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.'
|
||||
: 'Allowed mentions are only used when the channel is Discord.'
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
:message="allowedMentionsError(errors)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-message-template-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-message-template-${notificationAction.id}`"
|
||||
>
|
||||
Discord message template
|
||||
</Label>
|
||||
<textarea
|
||||
:id="
|
||||
`notification-message-template-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-message-template-${notificationAction.id}`"
|
||||
name="message_template"
|
||||
rows="4"
|
||||
:value="
|
||||
@@ -548,22 +941,25 @@ const notificationActionTimestamp = (
|
||||
''
|
||||
"
|
||||
:disabled="
|
||||
selectedChannel(notificationAction) !==
|
||||
'discord'
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) !== 'discord'
|
||||
"
|
||||
:class="[
|
||||
'border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-28 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
|
||||
selectedChannel(notificationAction) !==
|
||||
'discord'
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) !== 'discord'
|
||||
? 'hidden'
|
||||
: 'block',
|
||||
]"
|
||||
placeholder="$ticket_key moved from $status_from to $status_to by $actor_name: $url"
|
||||
placeholder="$ticket_key in $project_name ($status_to) by $actor_name: $url"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
selectedChannel(notificationAction) ===
|
||||
'discord'
|
||||
selectedChannel(
|
||||
notificationAction,
|
||||
) === 'discord'
|
||||
? 'Available for Discord actions only. Use the fixed variables listed in the sidebar.'
|
||||
: 'Discord message templates are only used when the channel is Discord.'
|
||||
}}
|
||||
@@ -573,14 +969,165 @@ const notificationActionTimestamp = (
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="space-y-1">
|
||||
<Label>Notification filters</Label>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Optional rules that must all match
|
||||
this event payload before delivery.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="filters"
|
||||
:value="
|
||||
editSerializedFilters(
|
||||
notificationAction.id,
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
editFilters(notificationAction.id)
|
||||
.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No filters configured. This action runs
|
||||
for every matching event.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
) in editFilters(notificationAction.id)"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
|
||||
>
|
||||
Attribute
|
||||
</Label>
|
||||
<select
|
||||
:id="`notification-filter-attribute-${notificationAction.id}-${filter.id}`"
|
||||
v-model="filter.attribute"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
|
||||
>
|
||||
Operator
|
||||
</Label>
|
||||
<select
|
||||
:id="`notification-filter-operator-${notificationAction.id}-${filter.id}`"
|
||||
v-model="filter.operator"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="`notification-filter-value-${notificationAction.id}-${filter.id}`"
|
||||
>
|
||||
{{
|
||||
supportsArrayFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
? 'Values (comma or newline separated)'
|
||||
: 'Value'
|
||||
}}
|
||||
</Label>
|
||||
<textarea
|
||||
:id="`notification-filter-value-${notificationAction.id}-${filter.id}`"
|
||||
v-model="filter.value"
|
||||
rows="2"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
:placeholder="
|
||||
supportsArrayFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
? 'CORE-1, CORE-2'
|
||||
: 'Doing'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
removeEditFilter(
|
||||
notificationAction.id,
|
||||
filterIndex,
|
||||
)
|
||||
"
|
||||
>
|
||||
Remove filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="
|
||||
addEditFilter(
|
||||
notificationAction.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="
|
||||
`notification-enabled-${notificationAction.id}`
|
||||
"
|
||||
:for="`notification-enabled-${notificationAction.id}`"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<input
|
||||
@@ -589,9 +1136,7 @@ const notificationActionTimestamp = (
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
:id="
|
||||
`notification-enabled-${notificationAction.id}`
|
||||
"
|
||||
:id="`notification-enabled-${notificationAction.id}`"
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
value="1"
|
||||
|
||||
@@ -19,13 +19,17 @@ import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
canManageTwoFactor?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
status?: string | null;
|
||||
twoFactorEnabled?: boolean;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
canManageTwoFactor: false,
|
||||
mustChangePassword: false,
|
||||
requiresConfirmation: false,
|
||||
status: null,
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
|
||||
@@ -49,6 +53,17 @@ onUnmounted(() => clearTwoFactorAuthData());
|
||||
<h1 class="sr-only">Security settings</h1>
|
||||
|
||||
<SettingsLayout>
|
||||
<div
|
||||
v-if="mustChangePassword || status"
|
||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
{{
|
||||
mustChangePassword
|
||||
? 'You must change your password before continuing anywhere else in the app.'
|
||||
: status
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type AcceptanceCriteriaItemModel = {
|
||||
id?: number;
|
||||
checked: boolean;
|
||||
indent: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AcceptanceCriteriaModel = AcceptanceCriteriaItemModel[];
|
||||
@@ -0,0 +1,5 @@
|
||||
export type NotificationActionFilterModel = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||
|
||||
export type NotificationActionModel = {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@@ -8,6 +10,7 @@ export type NotificationActionModel = {
|
||||
channel: string;
|
||||
channel_label: string;
|
||||
channel_configuration: Record<string, unknown>;
|
||||
filters: NotificationActionFilterModel[];
|
||||
has_delivery_url: boolean;
|
||||
message_template: string | null;
|
||||
created_at: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
import type { TicketFieldValueModel } from '@/types/models/ticket-field-value-model';
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -7,7 +8,7 @@ import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type TicketModel = {
|
||||
id: number;
|
||||
acceptance_criteria: string | null;
|
||||
acceptance_criteria: AcceptanceCriteriaModel;
|
||||
assignee: UserModel | null;
|
||||
assignee_user_id: number | null;
|
||||
author: UserModel | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type UserModel = {
|
||||
email: string;
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
must_change_password_on_next_login: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,10 @@ export type AdminUsersPageProps = {
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type AdminUserEditPageProps = {
|
||||
user: UserModel;
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
status?: string | null;
|
||||
@@ -109,6 +113,9 @@ export type ProjectRepositorySettingsPageProps = {
|
||||
export type ProjectNotificationSettingsPageProps = {
|
||||
channelOptions: NotificationActionOptionModel[];
|
||||
eventTypeOptions: NotificationActionOptionModel[];
|
||||
filterAttributeOptions: NotificationActionOptionModel[];
|
||||
filterOperatorOptions: NotificationActionOptionModel[];
|
||||
discordAllowedMentionOptions: NotificationActionOptionModel[];
|
||||
notificationActions: NotificationActionModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
|
||||
+1
-4
@@ -3,11 +3,8 @@
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
Route::inertia('/', 'Welcome', [
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
])->name('home');
|
||||
Route::inertia('/', 'Welcome')->name('home');
|
||||
|
||||
Route::get('api/docs/openapi.json', function (): Response {
|
||||
$path = base_path('docs/openapi/kanbangit-api.openapi.json');
|
||||
|
||||
@@ -12,6 +12,10 @@ Route::middleware('can:access-admin')
|
||||
->controller(UserManagementController::class)
|
||||
->group(function (): void {
|
||||
Route::get('/', 'index')->name('index');
|
||||
Route::get('create', 'create')->name('create');
|
||||
Route::post('/', 'store')->name('store');
|
||||
Route::get('{user}/edit', 'edit')->name('edit');
|
||||
Route::patch('{user}', 'save')->name('save');
|
||||
Route::patch('{user}/permissions', 'update')->name('update');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,13 @@ Route::middleware('auth')
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])
|
||||
->controller(SecurityController::class)
|
||||
->group(function (): void {
|
||||
Route::get('user/force-password-change', 'editForcedPassword')
|
||||
->name('password.force-change.edit');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])
|
||||
->prefix('settings')
|
||||
->group(function (): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -46,6 +47,64 @@ class UserManagementControllerTest extends TestCase
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can view the create user page.
|
||||
*/
|
||||
public function test_admin_can_view_the_create_user_page(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.users.create'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/users/Create'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can view the edit user page.
|
||||
*/
|
||||
public function test_admin_can_view_the_edit_user_page(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.users.edit', $member))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/users/Edit')
|
||||
->where('user.id', $member->id)
|
||||
->where('user.email', $member->email),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can create managed users.
|
||||
*/
|
||||
public function test_admin_can_create_a_user_that_must_change_password_on_next_login(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.users.store'), [
|
||||
'name' => 'New Member',
|
||||
'email' => 'new-member@example.com',
|
||||
'password' => 'temporary-password',
|
||||
'password_confirmation' => 'temporary-password',
|
||||
'must_change_password_on_next_login' => true,
|
||||
])
|
||||
->assertRedirect(route('admin.users.index'))
|
||||
->assertSessionHas('status', 'User was created.');
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'name' => 'New Member',
|
||||
'email' => 'new-member@example.com',
|
||||
'must_change_password_on_next_login' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can update another user's global permissions.
|
||||
*/
|
||||
@@ -80,6 +139,37 @@ class UserManagementControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can update managed users, including password and forced password changes.
|
||||
*/
|
||||
public function test_admin_can_update_a_managed_user(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$member = User::factory()->create([
|
||||
'name' => 'Regular User',
|
||||
'email' => 'member@example.com',
|
||||
'must_change_password_on_next_login' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.users.save', $member), [
|
||||
'name' => 'Updated User',
|
||||
'email' => 'updated-user@example.com',
|
||||
'password' => 'fresh-password',
|
||||
'password_confirmation' => 'fresh-password',
|
||||
'must_change_password_on_next_login' => true,
|
||||
])
|
||||
->assertRedirect(route('admin.users.index'))
|
||||
->assertSessionHas('status', 'Updated User was updated.');
|
||||
|
||||
$member->refresh();
|
||||
|
||||
$this->assertSame('Updated User', $member->name);
|
||||
$this->assertSame('updated-user@example.com', $member->email);
|
||||
$this->assertTrue($member->must_change_password_on_next_login);
|
||||
$this->assertTrue(Hash::check('fresh-password', $member->password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admins can also clear a user's global permissions.
|
||||
*/
|
||||
@@ -126,4 +216,49 @@ class UserManagementControllerTest extends TestCase
|
||||
'scope_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-admin users cannot create managed users.
|
||||
*/
|
||||
public function test_non_admin_cannot_create_a_user(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
|
||||
$this->actingAs($member)
|
||||
->post(route('admin.users.store'), [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'blocked-user@example.com',
|
||||
'password' => 'temporary-password',
|
||||
'password_confirmation' => 'temporary-password',
|
||||
'must_change_password_on_next_login' => true,
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertDatabaseMissing('users', [
|
||||
'email' => 'blocked-user@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure non-admin users cannot edit managed users.
|
||||
*/
|
||||
public function test_non_admin_cannot_edit_a_user(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($member)
|
||||
->patch(route('admin.users.save', $otherUser), [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'blocked-user@example.com',
|
||||
'password' => 'fresh-password',
|
||||
'password_confirmation' => 'fresh-password',
|
||||
'must_change_password_on_next_login' => true,
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertDatabaseMissing('users', [
|
||||
'email' => 'blocked-user@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ class SettingsApiTest extends TestCase
|
||||
{
|
||||
[$user, $project] = $this->projectContext();
|
||||
$viewer = User::factory()->create(['name' => 'Visible User']);
|
||||
User::factory()->create(['name' => 'Hidden User']);
|
||||
$hiddenUser = User::factory()->create(['name' => 'Hidden User']);
|
||||
$teamViewer = User::factory()->create(['name' => 'Team Viewer']);
|
||||
$team = Team::factory()->create();
|
||||
|
||||
@@ -172,21 +172,31 @@ class SettingsApiTest extends TestCase
|
||||
'title' => 'Scoped users ticket',
|
||||
]);
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
$expectedUserIds = [$user->id, $viewer->id, $teamViewer->id];
|
||||
|
||||
$backlogResponse = $this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.backlog', $project))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.users.0.name', $user->name)
|
||||
->assertJsonPath('data.users.1.name', 'Team Viewer')
|
||||
->assertJsonPath('data.users.2.name', 'Visible User')
|
||||
->assertJsonCount(3, 'data.users');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
$backlogUserIds = collect($backlogResponse->json('data.users'))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$this->assertEqualsCanonicalizing($expectedUserIds, $backlogUserIds);
|
||||
$this->assertNotContains($hiddenUser->id, $backlogUserIds);
|
||||
|
||||
$ticketResponse = $this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.users.0.name', $user->name)
|
||||
->assertJsonPath('data.users.1.name', 'Team Viewer')
|
||||
->assertJsonPath('data.users.2.name', 'Visible User')
|
||||
->assertJsonCount(3, 'data.users');
|
||||
|
||||
$ticketUserIds = collect($ticketResponse->json('data.users'))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$this->assertEqualsCanonicalizing($expectedUserIds, $ticketUserIds);
|
||||
$this->assertNotContains($hiddenUser->id, $ticketUserIds);
|
||||
}
|
||||
|
||||
public function test_project_repositories_and_user_git_connections_are_available(): void
|
||||
|
||||
@@ -43,7 +43,18 @@ class TicketApiTest extends TestCase
|
||||
|
||||
$createResponse = $this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.store', $project), [
|
||||
'acceptance_criteria' => 'Can sign in',
|
||||
'acceptance_criteria' => [
|
||||
[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Credentials are validated',
|
||||
],
|
||||
[
|
||||
'checked' => true,
|
||||
'indent' => 1,
|
||||
'text' => 'Nested validation is supported',
|
||||
],
|
||||
],
|
||||
'description' => 'Build the API-created ticket',
|
||||
'story_points' => 3,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
@@ -53,6 +64,7 @@ class TicketApiTest extends TestCase
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.ticket_key', 'CORE-1')
|
||||
->assertJsonPath('data.acceptance_criteria.1.indent', 1)
|
||||
->assertJsonPath('meta.message', 'CORE-1 was created.');
|
||||
|
||||
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
|
||||
@@ -64,7 +76,13 @@ class TicketApiTest extends TestCase
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||
'acceptance_criteria' => 'Can sign in from the API',
|
||||
'acceptance_criteria' => [
|
||||
[
|
||||
'checked' => true,
|
||||
'indent' => 0,
|
||||
'text' => 'API update preserves checked state',
|
||||
],
|
||||
],
|
||||
'blocked_comment' => null,
|
||||
'description' => 'Updated through the API',
|
||||
'is_blocked' => false,
|
||||
@@ -76,6 +94,7 @@ class TicketApiTest extends TestCase
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'Updated API ticket')
|
||||
->assertJsonPath('data.acceptance_criteria.0.checked', true)
|
||||
->assertJsonPath('meta.message', 'CORE-1 was updated.');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
@@ -117,7 +136,7 @@ class TicketApiTest extends TestCase
|
||||
|
||||
$this->withApplicationKey($writer)
|
||||
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
|
||||
'acceptance_criteria' => $ticket->acceptance_criteria,
|
||||
'acceptance_criteria' => [],
|
||||
'blocked_comment' => $ticket->blocked_comment,
|
||||
'description' => $ticket->description,
|
||||
'is_blocked' => $ticket->is_blocked,
|
||||
@@ -233,8 +252,8 @@ class TicketApiTest extends TestCase
|
||||
|
||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool {
|
||||
return $job->notificationActionId === $notificationAction->id
|
||||
&& $job->payload->ticketKey === 'CORE-1'
|
||||
&& $job->payload->toStatusLabel === 'Doing'
|
||||
&& $job->event->payload()->ticketKey === 'CORE-1'
|
||||
&& $job->event->payload()->toStatusLabel === 'Doing'
|
||||
&& $job->notificationDeliveryLogId !== null;
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Auth;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -32,6 +33,24 @@ class AuthenticationTest extends TestCase
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_users_who_must_change_their_password_are_redirected_to_the_dedicated_force_change_page_after_login()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'must_change_password_on_next_login' => true,
|
||||
]);
|
||||
|
||||
$response = $this->followingRedirects()->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('auth/ForcePasswordChange')
|
||||
->where('status', 'You must change your password before continuing.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge()
|
||||
{
|
||||
$this->skipUnlessFortifyFeature(Features::twoFactorAuthentication());
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Logging;
|
||||
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DiscordLoggerTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Log::forgetChannel('discord_errors');
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Discord error logger posts a formatted exception payload.
|
||||
*/
|
||||
public function test_discord_error_logger_posts_a_formatted_exception_payload(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://discord.example.test/webhook' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
config([
|
||||
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
|
||||
'logging.channels.discord_errors.mention' => '@here',
|
||||
'logging.channels.discord_errors.title' => '## KanbanGit error',
|
||||
]);
|
||||
|
||||
Log::forgetChannel('discord_errors');
|
||||
Log::channel('discord_errors')->error('Unhandled exception while syncing board', [
|
||||
'exception' => new RuntimeException('Discord logger boom'),
|
||||
'project' => 'CORE',
|
||||
]);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
$content = $request->data()['content'] ?? '';
|
||||
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://discord.example.test/webhook'
|
||||
&& $request->hasHeader('Content-Type', 'application/json')
|
||||
&& str_contains($content, '@here')
|
||||
&& str_contains($content, '## KanbanGit error')
|
||||
&& str_contains($content, 'Unhandled exception while syncing board')
|
||||
&& str_contains($content, 'Discord logger boom')
|
||||
&& str_contains($content, 'RuntimeException')
|
||||
&& str_contains($content, '"project": "CORE"');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Discord error logger becomes a no-op when no webhook is configured.
|
||||
*/
|
||||
public function test_discord_error_logger_skips_delivery_without_a_webhook(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'logging.channels.discord_errors.url' => null,
|
||||
]);
|
||||
|
||||
Log::forgetChannel('discord_errors');
|
||||
Log::channel('discord_errors')->error('This should stay local');
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Discord error logger truncates oversized payloads safely.
|
||||
*/
|
||||
public function test_discord_error_logger_truncates_oversized_payloads(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://discord.example.test/webhook' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
config([
|
||||
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
|
||||
'logging.channels.discord_errors.max_content_length' => 180,
|
||||
]);
|
||||
|
||||
Log::forgetChannel('discord_errors');
|
||||
Log::channel('discord_errors')->error(str_repeat('A', 600));
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
$content = $request->data()['content'] ?? '';
|
||||
|
||||
return mb_strlen($content) <= 180
|
||||
&& str_ends_with($content, '...(truncated)');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -359,8 +359,8 @@ class KanbanGitMcpTest extends TestCase
|
||||
|
||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool {
|
||||
return $job->notificationActionId === $notificationAction->id
|
||||
&& $job->payload->ticketKey === 'CORE-1'
|
||||
&& $job->payload->toStatusLabel === 'Doing'
|
||||
&& $job->event->payload()->ticketKey === 'CORE-1'
|
||||
&& $job->event->payload()->toStatusLabel === 'Doing'
|
||||
&& $job->notificationDeliveryLogId !== null;
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class NotificationActionTest extends TestCase
|
||||
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->fresh()->event_type);
|
||||
$this->assertSame(NotificationChannel::Webhook, $notificationAction->fresh()->channel);
|
||||
$this->assertSame([], $notificationAction->fresh()->channel_configuration);
|
||||
$this->assertSame([], $notificationAction->fresh()->filters ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,5 +62,6 @@ class NotificationActionTest extends TestCase
|
||||
$this->assertArrayNotHasKey('delivery_url', $notificationAction->fresh()->toArray());
|
||||
$this->assertArrayNotHasKey('delivery_url', $resource);
|
||||
$this->assertTrue($resource['has_delivery_url']);
|
||||
$this->assertSame([], $resource['filters']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace Tests\Feature\Notifications;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\Notifications\TicketStatusChanged;
|
||||
use App\Actions\Notifications\TicketStatusChangedPayload;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\NotificationDeliveryLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -35,7 +35,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
|
||||
(new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->handle();
|
||||
|
||||
@@ -77,7 +77,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
|
||||
(new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->handle();
|
||||
|
||||
@@ -106,6 +106,44 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$this->assertNotNull($deliveryLog->delivered_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Discord deliveries include configured allowed mention parse values.
|
||||
*/
|
||||
public function test_discord_delivery_includes_configured_allowed_mentions(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->discord()->create([
|
||||
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
||||
'channel_configuration' => [
|
||||
'allowed_mentions' => ['users', 'roles', 'users', 'EVERYONE', 'invalid'],
|
||||
],
|
||||
'message_template' => '$ticket_key moved to $status_to',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
|
||||
(new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->handle();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://discord.com/api/webhooks/123456/discord-alerts'
|
||||
&& $request->data() === [
|
||||
'content' => 'CORE-42 moved to Doing',
|
||||
'allowed_mentions' => [
|
||||
'parse' => ['users', 'roles', 'everyone'],
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure retryable webhook responses release the job and update the delivery log.
|
||||
*/
|
||||
@@ -124,7 +162,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -162,7 +200,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -199,7 +237,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -236,7 +274,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -272,7 +310,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -311,7 +349,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -347,7 +385,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -375,7 +413,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(actorName: null),
|
||||
event: $this->event(actorName: null),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -407,7 +445,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(ticketTitle: str_repeat('A', 2001)),
|
||||
event: $this->event(ticketTitle: str_repeat('A', 2001)),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -444,7 +482,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
@@ -470,7 +508,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
{
|
||||
$job = new DeliverNotificationAction(
|
||||
notificationActionId: 1,
|
||||
payload: $this->payload(),
|
||||
event: $this->event(),
|
||||
notificationDeliveryLogId: 1,
|
||||
);
|
||||
|
||||
@@ -492,12 +530,11 @@ class DeliverNotificationActionTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function payload(
|
||||
private function event(
|
||||
?string $actorName = 'Erica Example',
|
||||
string $ticketTitle = 'Fix webhook delivery',
|
||||
): TicketStatusChangedPayload {
|
||||
return new TicketStatusChangedPayload(
|
||||
eventType: NotificationEventType::TicketStatusChanged,
|
||||
): TicketStatusChanged {
|
||||
return new TicketStatusChanged(new TicketStatusChangedPayload(
|
||||
projectId: 10,
|
||||
projectKey: 'CORE',
|
||||
projectName: 'Core Platform',
|
||||
@@ -512,7 +549,7 @@ class DeliverNotificationActionTest extends TestCase
|
||||
toStatusId: 2,
|
||||
toStatusLabel: 'Doing',
|
||||
occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||
'channel' => NotificationChannel::Discord,
|
||||
'channel_configuration' => ['mentions' => ['ops']],
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'status.to.label',
|
||||
'operator' => 'equals',
|
||||
'value' => 'Doing',
|
||||
],
|
||||
],
|
||||
'message_template' => '$ticket_key changed to $status_to',
|
||||
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
|
||||
]);
|
||||
@@ -58,6 +65,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
||||
$this->assertCount(2, $listedActions);
|
||||
$this->assertSame(['Discord alerts', 'Webhook alerts'], $listedActions->pluck('name')->all());
|
||||
$this->assertSame(['mentions' => ['ops']], $listedActions->first()->channel_configuration);
|
||||
$this->assertSame([
|
||||
[
|
||||
'attribute' => 'status.to.label',
|
||||
'operator' => 'equals',
|
||||
'value' => 'Doing',
|
||||
],
|
||||
], $listedActions->first()->filters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +88,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
||||
|
||||
$updatedAction = $updateAction->handle($project, $notificationAction, [
|
||||
'enabled' => false,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'ticket.key',
|
||||
'operator' => 'in',
|
||||
'value' => 'CORE-1, CORE-2',
|
||||
],
|
||||
],
|
||||
'message_template' => '$ticket_key moved from $status_from to $status_to',
|
||||
'channel_configuration' => ['mentions' => ['support']],
|
||||
'delivery_url' => '',
|
||||
@@ -85,6 +106,13 @@ class ProjectNotificationActionManagementTest extends TestCase
|
||||
$updatedAction->message_template,
|
||||
);
|
||||
$this->assertSame(['mentions' => ['support']], $updatedAction->channel_configuration);
|
||||
$this->assertSame([
|
||||
[
|
||||
'attribute' => 'ticket.key',
|
||||
'operator' => 'in',
|
||||
'value' => ['CORE-1', 'CORE-2'],
|
||||
],
|
||||
], $updatedAction->filters);
|
||||
$this->assertSame(
|
||||
'https://discord.com/api/webhooks/123456/original-secret',
|
||||
$updatedAction->delivery_url,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace Tests\Feature\Notifications;
|
||||
|
||||
use App\Actions\Notifications\RenderDiscordMessageTemplate;
|
||||
use App\Actions\Notifications\TicketStatusChangedPayload;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Actions\Notifications\TicketAssigneeChanged;
|
||||
use App\Actions\Notifications\TicketAssigneeChangedPayload;
|
||||
use Carbon\CarbonImmutable;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
@@ -45,6 +45,14 @@ class RenderDiscordMessageTemplateTest extends TestCase
|
||||
'template' => '$actor_name',
|
||||
'expected' => 'Erica Example',
|
||||
],
|
||||
'assignee_from' => [
|
||||
'template' => '$assignee_from',
|
||||
'expected' => 'Alex Analyst',
|
||||
],
|
||||
'assignee_to' => [
|
||||
'template' => '$assignee_to',
|
||||
'expected' => 'Priya Planner',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,7 +62,7 @@ class RenderDiscordMessageTemplateTest extends TestCase
|
||||
#[DataProvider('supportedVariableProvider')]
|
||||
public function test_it_replaces_supported_template_variables(string $template, string $expected): void
|
||||
{
|
||||
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->payload());
|
||||
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->event());
|
||||
|
||||
$this->assertSame($expected, $renderedMessage);
|
||||
}
|
||||
@@ -66,16 +74,15 @@ class RenderDiscordMessageTemplateTest extends TestCase
|
||||
{
|
||||
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle(
|
||||
'[$ticket_key] $unknown moved to $status_to',
|
||||
$this->payload(),
|
||||
$this->event(),
|
||||
);
|
||||
|
||||
$this->assertSame('[CORE-42] $unknown moved to Doing', $renderedMessage);
|
||||
}
|
||||
|
||||
private function payload(): TicketStatusChangedPayload
|
||||
private function event(): TicketAssigneeChanged
|
||||
{
|
||||
return new TicketStatusChangedPayload(
|
||||
eventType: NotificationEventType::TicketStatusChanged,
|
||||
return new TicketAssigneeChanged(new TicketAssigneeChangedPayload(
|
||||
projectId: 10,
|
||||
projectKey: 'CORE',
|
||||
projectName: 'Core Platform',
|
||||
@@ -90,6 +97,10 @@ class RenderDiscordMessageTemplateTest extends TestCase
|
||||
toStatusId: 2,
|
||||
toStatusLabel: 'Doing',
|
||||
occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'),
|
||||
);
|
||||
fromAssigneeId: 11,
|
||||
fromAssigneeName: 'Alex Analyst',
|
||||
toAssigneeId: 12,
|
||||
toAssigneeName: 'Priya Planner',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Notifications;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\Notifications\TicketAssigneeChangedPayload;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketAssigneeChangedNotificationDispatchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure assignee changes queue deliveries only for enabled matching actions in the ticket project.
|
||||
*/
|
||||
public function test_assignee_changes_queue_delivery_jobs_for_enabled_matching_actions(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$actor = User::factory()->create([
|
||||
'name' => 'Erica Example',
|
||||
]);
|
||||
$fromAssignee = User::factory()->create([
|
||||
'name' => 'Alex Analyst',
|
||||
]);
|
||||
$toAssignee = User::factory()->create([
|
||||
'name' => 'Priya Planner',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
'name' => 'Core Platform',
|
||||
]);
|
||||
$otherProject = Project::factory()->create();
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
|
||||
$backlogStatus = $this->backlogStatus($project);
|
||||
$ticket = $this->createTicket(
|
||||
$project,
|
||||
$backlogStatus,
|
||||
'Fix webhook dispatch',
|
||||
$fromAssignee,
|
||||
);
|
||||
|
||||
$webhookAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Primary webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
]);
|
||||
$discordAction = NotificationAction::factory()
|
||||
->discord()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Discord alerts',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
]);
|
||||
$disabledAction = NotificationAction::factory()
|
||||
->disabled()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Disabled webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
]);
|
||||
$statusChangedAction = NotificationAction::factory()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Status changed webhook',
|
||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||
]);
|
||||
$filterEqualsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered to assignee webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'assignee.to.id',
|
||||
'operator' => 'equals',
|
||||
'value' => (string) $toAssignee->id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered from assignee exists webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'assignee.from.id',
|
||||
'operator' => 'exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterNotExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered from assignee not exists webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'assignee.from.id',
|
||||
'operator' => 'not_exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterInAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered in assignee webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'assignee.to.name',
|
||||
'operator' => 'in',
|
||||
'value' => ['Priya Planner', 'Someone Else'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$foreignProjectAction = NotificationAction::factory()
|
||||
->for($otherProject)
|
||||
->create([
|
||||
'name' => 'Foreign webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $actor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||
'assignee_user_id' => $toAssignee->id,
|
||||
]))
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertSessionHas('status', 'CORE-1 was updated.');
|
||||
|
||||
Queue::assertPushed(DeliverNotificationAction::class, 5);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($actor, $backlogStatus, $fromAssignee, $project, $ticket, $toAssignee, $webhookAction): bool {
|
||||
$payload = $job->event->payload();
|
||||
|
||||
return $job->notificationActionId === $webhookAction->id
|
||||
&& $payload instanceof TicketAssigneeChangedPayload
|
||||
&& $payload->projectId === $project->id
|
||||
&& $payload->projectKey === 'CORE'
|
||||
&& $payload->projectName === 'Core Platform'
|
||||
&& $payload->ticketId === $ticket->id
|
||||
&& $payload->ticketKey === 'CORE-1'
|
||||
&& $payload->ticketTitle === 'Fix webhook dispatch'
|
||||
&& $payload->ticketUrl === route('projects.tickets.show', [$project, $ticket])
|
||||
&& $payload->actorId === $actor->id
|
||||
&& $payload->actorName === 'Erica Example'
|
||||
&& $payload->fromStatusId === null
|
||||
&& $payload->fromStatusLabel === null
|
||||
&& $payload->toStatusId === $backlogStatus->id
|
||||
&& $payload->toStatusLabel === 'Backlog'
|
||||
&& $payload->fromAssigneeId === $fromAssignee->id
|
||||
&& $payload->fromAssigneeName === 'Alex Analyst'
|
||||
&& $payload->toAssigneeId === $toAssignee->id
|
||||
&& $payload->toAssigneeName === 'Priya Planner'
|
||||
&& $payload->toArray()['event_type'] === NotificationEventType::TicketAssigneeChanged->value
|
||||
&& $job->notificationDeliveryLogId !== null;
|
||||
});
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketAssigneeChanged
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterEqualsAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketAssigneeChanged
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketAssigneeChanged
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterInAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketAssigneeChanged
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
||||
|
||||
$this->assertDatabaseCount('notification_delivery_logs', 5);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $webhookAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $discordAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterEqualsAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterExistsAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterInAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'actor_id' => $actor->id,
|
||||
'entry_type' => 'updated',
|
||||
'summary' => 'Updated assignee.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure no-op assignee updates keep their response behavior and do not queue deliveries.
|
||||
*/
|
||||
public function test_no_op_assignee_updates_do_not_queue_notification_deliveries(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$actor = User::factory()->create();
|
||||
$assignee = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$backlogStatus = $this->backlogStatus($project);
|
||||
$ticket = $this->createTicket(
|
||||
$project,
|
||||
$backlogStatus,
|
||||
'Keep the same assignee',
|
||||
$assignee,
|
||||
);
|
||||
|
||||
NotificationAction::factory()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Assignee webhook',
|
||||
'event_type' => NotificationEventType::TicketAssigneeChanged,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $actor, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||
'assignee_user_id' => $assignee->id,
|
||||
]))
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertSessionHas('status', 'No ticket changes were detected.');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
$this->assertDatabaseCount('notification_delivery_logs', 0);
|
||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||
}
|
||||
|
||||
private function backlogStatus(Project $project): TicketStatus
|
||||
{
|
||||
return $project->ticketStatuses()
|
||||
->whereNull('board_column_id')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function createTicket(
|
||||
Project $project,
|
||||
TicketStatus $ticketStatus,
|
||||
string $title,
|
||||
?User $assignee = null,
|
||||
): Ticket {
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'High',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
return Ticket::factory()->create([
|
||||
'assignee_user_id' => $assignee?->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $ticketStatus->id,
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'title' => $title,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function updatePayload(Ticket $ticket, array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels(
|
||||
$ticket->acceptanceCriteria()->get(),
|
||||
),
|
||||
'assignee_user_id' => $ticket->assignee_user_id,
|
||||
'author_user_id' => $ticket->author_user_id,
|
||||
'blocked_comment' => $ticket->blocked_comment,
|
||||
'description' => $ticket->description,
|
||||
'field_values' => [],
|
||||
'is_blocked' => $ticket->is_blocked,
|
||||
'parent_ticket_id' => $ticket->parent_ticket_id,
|
||||
'story_points' => $ticket->story_points,
|
||||
'ticket_priority_id' => $ticket->ticket_priority_id,
|
||||
'ticket_type_id' => $ticket->ticket_type_id,
|
||||
'title' => $ticket->title,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Notifications;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketCreatedNotificationDispatchTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure ticket creation queues deliveries only for enabled matching actions in the ticket project.
|
||||
*/
|
||||
public function test_ticket_creation_queues_delivery_jobs_for_enabled_matching_actions(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Erica Example',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
'name' => 'Core Platform',
|
||||
]);
|
||||
$otherProject = Project::factory()->create();
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
|
||||
$backlogStatus = $this->backlogStatus($project);
|
||||
$ticketType = $project->ticketTypes()
|
||||
->where('is_parent', false)
|
||||
->orderBy('position')
|
||||
->firstOrFail();
|
||||
$priority = $project->ticketPriorities()->firstOrFail();
|
||||
|
||||
$webhookAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Primary webhook',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
]);
|
||||
$discordAction = NotificationAction::factory()
|
||||
->discord()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Discord alerts',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
]);
|
||||
$disabledAction = NotificationAction::factory()
|
||||
->disabled()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Disabled webhook',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
]);
|
||||
$statusChangedAction = NotificationAction::factory()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Status changed webhook',
|
||||
'event_type' => NotificationEventType::TicketStatusChanged,
|
||||
]);
|
||||
$filterNotExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Ticket create without origin status',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'status.from.id',
|
||||
'operator' => 'not_exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Ticket create with origin status',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'status.from.id',
|
||||
'operator' => 'exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$foreignProjectAction = NotificationAction::factory()
|
||||
->for($otherProject)
|
||||
->create([
|
||||
'name' => 'Foreign webhook',
|
||||
'event_type' => NotificationEventType::TicketCreate,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'acceptance_criteria' => [[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Ship it.',
|
||||
]],
|
||||
'assignee_user_id' => null,
|
||||
'description' => 'Created from notification dispatch test.',
|
||||
'story_points' => 3,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $backlogStatus->id,
|
||||
'ticket_type_id' => $ticketType->id,
|
||||
'title' => 'Trigger ticket created notification',
|
||||
]);
|
||||
|
||||
$ticket = $project->tickets()
|
||||
->where('ticket_key', 'CORE-1')
|
||||
->firstOrFail();
|
||||
|
||||
$response
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertSessionHas('status', 'CORE-1 was created.');
|
||||
|
||||
Queue::assertPushed(DeliverNotificationAction::class, 3);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool {
|
||||
return $job->notificationActionId === $webhookAction->id
|
||||
&& $job->event->payload()->projectId === $project->id
|
||||
&& $job->event->payload()->projectKey === 'CORE'
|
||||
&& $job->event->payload()->projectName === 'Core Platform'
|
||||
&& $job->event->payload()->ticketId === $ticket->id
|
||||
&& $job->event->payload()->ticketKey === $ticket->ticket_key
|
||||
&& $job->event->payload()->ticketTitle === 'Trigger ticket created notification'
|
||||
&& $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket])
|
||||
&& $job->event->payload()->actorId === $user->id
|
||||
&& $job->event->payload()->actorName === 'Erica Example'
|
||||
&& $job->event->payload()->fromStatusId === null
|
||||
&& $job->event->payload()->fromStatusLabel === null
|
||||
&& $job->event->payload()->toStatusId === $backlogStatus->id
|
||||
&& $job->event->payload()->toStatusLabel === 'Backlog'
|
||||
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
||||
&& $job->notificationDeliveryLogId !== null;
|
||||
});
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id
|
||||
&& $job->event->eventType() === NotificationEventType::TicketCreate
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
||||
|
||||
$this->assertDatabaseCount('notification_delivery_logs', 3);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $webhookAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketCreate->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $discordAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketCreate->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterNotExistsAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'event_type' => NotificationEventType::TicketCreate->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function backlogStatus(Project $project): TicketStatus
|
||||
{
|
||||
return $project->ticketStatuses()
|
||||
->whereNull('board_column_id')
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,71 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
||||
->create([
|
||||
'name' => 'Disabled webhook',
|
||||
]);
|
||||
$filterEqualsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered equals webhook',
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'status.to.id',
|
||||
'operator' => 'equals',
|
||||
'value' => (string) $doingStatus->id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterNotEqualsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered not equals webhook',
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'status.to.id',
|
||||
'operator' => 'not_equals',
|
||||
'value' => (string) $doingStatus->id,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered exists webhook',
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'actor.id',
|
||||
'operator' => 'exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterNotExistsAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered not exists webhook',
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'actor.id',
|
||||
'operator' => 'not_exists',
|
||||
'value' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
$filterInAction = NotificationAction::factory()
|
||||
->webhook()
|
||||
->for($project)
|
||||
->create([
|
||||
'name' => 'Filtered in webhook',
|
||||
'filters' => [
|
||||
[
|
||||
'attribute' => 'ticket.key',
|
||||
'operator' => 'in',
|
||||
'value' => ['CORE-1', 'CORE-99'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$foreignProjectAction = NotificationAction::factory()
|
||||
->for($otherProject)
|
||||
->create([
|
||||
@@ -82,7 +147,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
||||
'project_id' => $project->id,
|
||||
'name' => 'Wrong event',
|
||||
'enabled' => true,
|
||||
'event_type' => 'ticket_created',
|
||||
'event_type' => NotificationEventType::TicketCreate->value,
|
||||
'channel' => NotificationChannel::Webhook->value,
|
||||
'channel_configuration' => json_encode([]),
|
||||
'message_template' => null,
|
||||
@@ -101,31 +166,39 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
||||
->assertRedirect(route('projects.backlog.index', $project))
|
||||
->assertSessionHas('status', 'CORE-1 moved to Doing.');
|
||||
|
||||
Queue::assertPushed(DeliverNotificationAction::class, 2);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, 5);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool {
|
||||
return $job->notificationActionId === $webhookAction->id
|
||||
&& $job->payload->projectId === $project->id
|
||||
&& $job->payload->projectKey === 'CORE'
|
||||
&& $job->payload->projectName === 'Core Platform'
|
||||
&& $job->payload->ticketId === $ticket->id
|
||||
&& $job->payload->ticketKey === 'CORE-1'
|
||||
&& $job->payload->ticketTitle === 'Fix webhook dispatch'
|
||||
&& $job->payload->ticketUrl === route('projects.tickets.show', [$project, $ticket])
|
||||
&& $job->payload->actorId === $user->id
|
||||
&& $job->payload->actorName === 'Erica Example'
|
||||
&& $job->payload->fromStatusId === $backlogStatus->id
|
||||
&& $job->payload->fromStatusLabel === 'Backlog'
|
||||
&& $job->payload->toStatusId === $doingStatus->id
|
||||
&& $job->payload->toStatusLabel === 'Doing'
|
||||
&& $job->payload->eventType === NotificationEventType::TicketStatusChanged
|
||||
&& $job->event->payload()->projectId === $project->id
|
||||
&& $job->event->payload()->projectKey === 'CORE'
|
||||
&& $job->event->payload()->projectName === 'Core Platform'
|
||||
&& $job->event->payload()->ticketId === $ticket->id
|
||||
&& $job->event->payload()->ticketKey === 'CORE-1'
|
||||
&& $job->event->payload()->ticketTitle === 'Fix webhook dispatch'
|
||||
&& $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket])
|
||||
&& $job->event->payload()->actorId === $user->id
|
||||
&& $job->event->payload()->actorName === 'Erica Example'
|
||||
&& $job->event->payload()->fromStatusId === $backlogStatus->id
|
||||
&& $job->event->payload()->fromStatusLabel === 'Backlog'
|
||||
&& $job->event->payload()->toStatusId === $doingStatus->id
|
||||
&& $job->event->payload()->toStatusLabel === 'Doing'
|
||||
&& $job->event->eventType() === NotificationEventType::TicketStatusChanged
|
||||
&& $job->notificationDeliveryLogId !== null;
|
||||
});
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterEqualsAction->id
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterInAction->id
|
||||
&& $job->notificationDeliveryLogId !== null);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotEqualsAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id);
|
||||
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id);
|
||||
|
||||
$this->assertDatabaseCount('notification_delivery_logs', 2);
|
||||
$this->assertDatabaseCount('notification_delivery_logs', 5);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $webhookAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
@@ -138,6 +211,24 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterEqualsAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterExistsAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('notification_delivery_logs', [
|
||||
'notification_action_id' => $filterInAction->id,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending->value,
|
||||
'attempt_count' => 0,
|
||||
'http_status' => null,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $ticket->id,
|
||||
|
||||
@@ -84,7 +84,11 @@ class ProjectBacklogTest extends TestCase
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'New ticket',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => [[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Criteria',
|
||||
]],
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $backlogStatus->id,
|
||||
@@ -118,7 +122,11 @@ class ProjectBacklogTest extends TestCase
|
||||
$payload = [
|
||||
'title' => 'Same title',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => [[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Criteria',
|
||||
]],
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'story_points' => 3,
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
@@ -192,7 +200,11 @@ class ProjectBacklogTest extends TestCase
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'New ticket',
|
||||
'description' => 'Description',
|
||||
'acceptance_criteria' => 'Criteria',
|
||||
'acceptance_criteria' => [[
|
||||
'checked' => false,
|
||||
'indent' => 0,
|
||||
'text' => 'Criteria',
|
||||
]],
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $boardStatus->id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user