Merge branch 'main' into epic/ui-rework
linter / quality (pull_request) Successful in 1m16s
tests / ci (8.4) (pull_request) Successful in 1m42s

This commit is contained in:
2026-05-23 11:23:24 +02:00
103 changed files with 5758 additions and 472 deletions
+9
View File
@@ -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;
}
@@ -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) !== '') {
+27 -2
View File
@@ -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);
}
}
}
+81 -13
View File
@@ -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
+45
View File
@@ -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(),
);
}
}
+12
View File
@@ -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(),
);
}
}
+16
View File
@@ -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(),
);
}
}
+72
View File
@@ -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(),
);
}
}
+48
View File
@@ -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(),
+6 -1
View File
@@ -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
+1
View File
@@ -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(),
];
+49
View File
@@ -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;
}
}
+178
View File
@@ -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;
}
}
+7 -2
View File
@@ -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,
+45
View File
@@ -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);
}
}
+2
View File
@@ -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',
];
}
+7 -1
View File
@@ -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
View File
@@ -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',
];
+14 -2
View File
@@ -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,
);
}
}
-3
View File
@@ -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'));
+2
View File
@@ -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,
]);
-1
View File
@@ -144,7 +144,6 @@ return [
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
+15
View File
@@ -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(),
-1
View File
@@ -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,
+1
View File
@@ -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');
});
}
};
@@ -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');
});
}
};
+10 -2
View File
@@ -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"
+163
View File
@@ -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 -17
View File
@@ -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>
+155
View File
@@ -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>
+161
View File
@@ -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>
+78 -48
View File
@@ -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>
-10
View File
@@ -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>
-107
View File
@@ -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>
+219 -17
View File
@@ -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&#10; [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
+116 -59
View File
@@ -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&#10; [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"
+15
View File
@@ -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;
+2 -1
View File
@@ -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;
+1
View File
@@ -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;
};
+7
View File
@@ -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
View File
@@ -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');
+4
View File
@@ -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');
});
});
+7
View File
@@ -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',
]);
}
}
+19 -9
View File
@@ -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
+24 -5
View File
@@ -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;
});
+19
View File
@@ -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());
+101
View File
@@ -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)');
});
}
}
+2 -2
View File
@@ -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,
+15 -3
View File
@@ -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