chore(platform): merge platform-dev into dev #302

Merged
ahmido merged 21 commits from platform-dev into dev 2026-04-29 20:53:45 +00:00
27 changed files with 3365 additions and 19 deletions

View File

@ -59,6 +59,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_DESK_ENABLED=false
SUPPORT_DESK_NAME="External support desk"
SUPPORT_DESK_CREATE_URL=
SUPPORT_DESK_API_TOKEN=
SUPPORT_DESK_TICKET_URL_TEMPLATE=
SUPPORT_DESK_TIMEOUT_SECONDS=5
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1

View File

@ -31,6 +31,7 @@
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
@ -49,6 +50,7 @@
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable;
@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label('Request support')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading('Request support')
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
->modalSubmitActionLabel('Submit support request')
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('primary_context')
->label('Primary context')
->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('included_context')
->label('Included context')
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label('Severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label('Summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label('Reproduction notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label('Contact name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label('Contact email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
@ -312,9 +345,21 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference)
->success()
->title(__('localization.dashboard.support_request_submitted'))
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
}
private function operationLatestSupportRequestHandoffSummary(): string
{
$user = $this->resolveViewerActor();
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
/**
* @param array<string, mixed> $bundle
*/

View File

@ -21,6 +21,7 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@ -30,6 +31,7 @@
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
@ -108,6 +110,37 @@ private function requestSupportAction(): Action
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
@ -138,8 +171,20 @@ private function requestSupportAction(): Action
Notification::make()
->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference)
->success()
->body($this->supportRequestNotificationBody($supportRequest))
->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send();
});
@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
}
private function tenantLatestSupportRequestHandoffSummary(): string
{
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$user = $this->resolveDashboardActor();
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
}

View File

@ -32,6 +32,20 @@ class SupportRequest extends Model
public const string SEVERITY_BLOCKING = 'blocking';
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
protected $guarded = [];
/**
@ -65,6 +79,53 @@ public static function severityValues(): array
return array_keys(self::severityOptions());
}
/**
* @return array<string, string>
*/
public static function externalHandoffModeOptions(): array
{
return [
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
];
}
/**
* @return list<string>
*/
public static function externalHandoffModeValues(): array
{
return array_keys(self::externalHandoffModeOptions());
}
public function hasExternalTicket(): bool
{
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
}
public function hasExternalHandoffFailure(): bool
{
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
}
public function externalHandoffOutcome(): string
{
if ($this->hasExternalHandoffFailure()) {
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
}
if (! $this->hasExternalTicket()) {
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
}
return match ($this->external_handoff_mode) {
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
};
}
/**
* @return list<string>
*/

View File

@ -173,4 +173,87 @@ public function logSupportRequestCreated(
tenant: $tenant,
);
}
public function logSupportRequestExternalTicketCreated(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketCreated,
status: 'success',
summaryPrefix: 'External ticket created for support request ',
);
}
public function logSupportRequestExternalTicketLinked(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketLinked,
status: 'success',
summaryPrefix: 'External ticket linked for support request ',
);
}
public function logSupportRequestExternalHandoffFailed(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalHandoffFailed,
status: 'failed',
summaryPrefix: 'External handoff failed for support request ',
);
}
private function logSupportRequestExternalHandoff(
SupportRequest $supportRequest,
User|PlatformUser|null $actor,
AuditActionId $action,
string $status,
string $summaryPrefix,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
$metadata = [
'internal_reference' => $supportRequest->internal_reference,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (string) $supportRequest->operation_run_id
: (string) $tenant->getKey(),
'external_handoff_mode' => $supportRequest->external_handoff_mode,
'external_ticket_reference' => $supportRequest->external_ticket_reference,
];
if ($supportRequest->external_handoff_failure_summary !== null) {
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
}
return $this->log(
workspace: $tenant->workspace,
action: $action,
context: $metadata,
actor: $actor,
status: $status,
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: $summaryPrefix.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
tenant: $tenant,
);
}
}

View File

@ -103,6 +103,9 @@ enum AuditActionId: string
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created';
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
@ -248,6 +251,9 @@ private static function labels(): array
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
@ -338,6 +344,9 @@ private static function summaries(): array
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\SupportRequest;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
final class ExternalSupportDeskHandoffService
{
private const int MAX_TIMEOUT_SECONDS = 5;
/**
* @return array{
* successful: bool,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* failure_summary: ?string
* }
*/
public function createTicket(SupportRequest $supportRequest): array
{
if (! $this->targetIsConfigured()) {
return $this->failed('External support desk target is not configured.');
}
try {
$response = Http::timeout($this->timeoutSeconds())
->acceptJson()
->asJson()
->withHeaders($this->headers())
->post($this->createUrl(), $this->payloadFor($supportRequest));
} catch (ConnectionException) {
return $this->failed('External support desk did not respond before the configured timeout.');
} catch (RequestException $exception) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').');
}
if (! $response->successful()) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').');
}
$responsePayload = $response->json();
$responsePayload = is_array($responsePayload) ? $responsePayload : [];
$reference = $this->normalizeReference(
data_get($responsePayload, 'ticket_reference')
?? data_get($responsePayload, 'external_ticket_reference')
?? data_get($responsePayload, 'reference')
?? data_get($responsePayload, 'key')
?? data_get($responsePayload, 'id'),
throwOnInvalid: false,
);
if ($reference === null) {
return $this->failed('External support desk did not return a ticket reference.');
}
$url = $this->normalizeUrl(
data_get($responsePayload, 'ticket_url')
?? data_get($responsePayload, 'external_ticket_url')
?? data_get($responsePayload, 'url')
?? data_get($responsePayload, 'web_url')
?? data_get($responsePayload, 'html_url'),
throwOnInvalid: false,
) ?? $this->urlFromTemplate($reference);
return [
'successful' => true,
'external_ticket_reference' => $reference,
'external_ticket_url' => $url,
'failure_summary' => null,
];
}
/**
* @return array{external_ticket_reference: string, external_ticket_url: ?string}
*/
public function normalizeLinkedTicket(mixed $reference, mixed $url): array
{
$normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true);
if ($normalizedReference === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
return [
'external_ticket_reference' => $normalizedReference,
'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference),
];
}
public function targetIsConfigured(): bool
{
return (bool) config('support_desk.target.enabled', false)
&& $this->createUrl() !== null;
}
public function targetName(): string
{
$name = config('support_desk.target.name', 'External support desk');
return is_string($name) && trim($name) !== ''
? trim($name)
: 'External support desk';
}
public function timeoutSeconds(): int
{
$configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS);
$seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS;
return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS));
}
/**
* @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string}
*/
private function failed(string $summary): array
{
return [
'successful' => false,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'failure_summary' => $this->boundedFailureSummary($summary),
];
}
private function createUrl(): ?string
{
return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false);
}
/**
* @return array<string, string>
*/
private function headers(): array
{
$headers = [];
$token = config('support_desk.target.api_token');
if (is_string($token) && trim($token) !== '') {
$headers['Authorization'] = 'Bearer '.trim($token);
}
return $headers;
}
/**
* @return array<string, mixed>
*/
private function payloadFor(SupportRequest $supportRequest): array
{
return [
'support_request' => [
'internal_reference' => $supportRequest->internal_reference,
'severity' => $supportRequest->severity,
'summary' => $supportRequest->summary,
'reproduction_notes' => $supportRequest->reproduction_notes,
'contact_name' => $supportRequest->contact_name,
'contact_email' => $supportRequest->contact_email,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? $supportRequest->operation_run_id
: $supportRequest->tenant_id,
'workspace_id' => $supportRequest->workspace_id,
'tenant_id' => $supportRequest->tenant_id,
'operation_run_id' => $supportRequest->operation_run_id,
],
'context_envelope' => $supportRequest->context_envelope,
];
}
private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$reference = trim((string) $value);
if ($reference === '') {
return null;
}
if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference format is invalid.',
]);
}
return null;
}
return $reference;
}
private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value)) {
return null;
}
$url = trim($value);
if ($url === '') {
return null;
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.',
]);
}
return null;
}
return $url;
}
private function urlFromTemplate(string $reference): ?string
{
$template = config('support_desk.target.ticket_url_template');
if (! is_string($template) || trim($template) === '') {
return null;
}
$url = str_replace(
['{reference}', '{ticket}'],
rawurlencode($reference),
trim($template),
);
return $this->normalizeUrl($url, throwOnInvalid: false);
}
private function boundedFailureSummary(string $summary): string
{
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
return mb_substr($summary, 0, 500);
}
}

View File

@ -20,6 +20,7 @@ public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
@ -95,7 +96,7 @@ private function submit(
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection();
return $connection->transaction(function () use (
$supportRequest = $connection->transaction(function () use (
$actor,
$contactEmail,
$contactName,
@ -127,6 +128,181 @@ private function submit(
return $supportRequest;
});
return $this->finalizeExternalHandoff($supportRequest, $actor, $validated);
}
/**
* @param array{
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string
* } $validated
*/
private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest
{
$mode = $validated['external_handoff_mode'];
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
])->save();
return $supportRequest->refresh();
}
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => $linkedTicket['external_ticket_reference'],
'external_ticket_url' => $linkedTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor);
return $supportRequest;
}
$createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest);
if ($createdTicket['successful']) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => $createdTicket['external_ticket_reference'],
'external_ticket_url' => $createdTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor);
return $supportRequest;
}
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => $createdTicket['failure_summary'],
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor);
return $supportRequest;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array
{
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT)
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $run->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->where('operation_run_id', (int) $run->getKey())
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }
*/
private function summaryFor(SupportRequest $supportRequest): array
{
return [
'internal_reference' => (string) $supportRequest->internal_reference,
'primary_context_type' => (string) $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null)
: (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null),
'submitted_at' => $supportRequest->created_at?->toIso8601String(),
'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY),
'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference),
'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url),
'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary),
'has_external_link' => $supportRequest->hasExternalTicket(),
'has_failure' => $supportRequest->hasExternalHandoffFailure(),
];
}
/**
@ -137,10 +313,20 @@ private function submit(
* reproduction_notes: ?string,
* contact_name: ?string,
* contact_email: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* }
*/
private function validate(array $data): array
{
$requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null)
?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) {
$requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
}
$validated = validator(
[
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
@ -148,6 +334,9 @@ private function validate(array $data): array
'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? null,
'contact_email' => $data['contact_email'] ?? null,
'external_handoff_mode' => $requestedHandoffMode,
'external_ticket_reference' => $data['external_ticket_reference'] ?? null,
'external_ticket_url' => $data['external_ticket_url'] ?? null,
],
[
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
@ -155,6 +344,9 @@ private function validate(array $data): array
'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'],
'contact_email' => ['nullable', 'email'],
'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())],
'external_ticket_reference' => ['nullable', 'string', 'max:255'],
'external_ticket_url' => ['nullable', 'url', 'max:2048'],
],
)->validate();
@ -169,6 +361,27 @@ private function validate(array $data): array
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
$validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null);
$validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null);
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET
&& $validated['external_ticket_reference'] === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
}
if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$validated['external_ticket_reference'] = null;
$validated['external_ticket_url'] = null;
}
return $validated;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'target' => [
'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false),
'name' => env('SUPPORT_DESK_NAME', 'External support desk'),
'create_url' => env('SUPPORT_DESK_CREATE_URL'),
'api_token' => env('SUPPORT_DESK_API_TOKEN'),
'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'),
'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5),
],
];

View File

@ -51,6 +51,10 @@ public function definition(): array
],
'omissions' => [],
],
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
];
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->string('external_handoff_mode')
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->after('context_envelope');
$table->string('external_ticket_reference')->nullable()->after('external_handoff_mode');
$table->text('external_ticket_url')->nullable()->after('external_ticket_reference');
$table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url');
});
}
public function down(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->dropColumn([
'external_handoff_mode',
'external_ticket_reference',
'external_ticket_url',
'external_handoff_failure_summary',
]);
});
}
};

View File

@ -80,14 +80,40 @@
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
'submit_request' => 'Anfrage senden',
'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.',
'submit_request' => 'Supportanfrage senden',
'primary_context' => 'Primärer Kontext',
'included_context' => 'Enthaltener Kontext',
'latest_external_handoff' => 'Letzte externe Übergabe',
'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.',
'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.',
'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.',
'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure',
'external_handoff_mode' => 'Externe Übergabe',
'handoff_mode_internal_only' => 'Nur TenantPilot',
'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen',
'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen',
'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.',
'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.',
'handoff_mutation_scope' => 'Änderungsumfang',
'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.',
'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.',
'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.',
'external_ticket_reference' => 'Externe Ticketreferenz',
'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.',
'external_ticket_url' => 'Externe Ticket-URL',
'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.',
'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.',
'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.',
'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure',
'open_external_ticket' => 'Externes Ticket öffnen',
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',

View File

@ -80,14 +80,40 @@
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'submit_request' => 'Submit request',
'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.',
'submit_request' => 'Submit support request',
'primary_context' => 'Primary context',
'included_context' => 'Included context',
'latest_external_handoff' => 'Latest external handoff',
'latest_external_handoff_none' => 'No support request has been submitted for this context yet.',
'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.',
'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.',
'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure',
'external_handoff_mode' => 'External handoff',
'handoff_mode_internal_only' => 'TenantPilot only',
'handoff_mode_create_external_ticket' => 'Create external ticket',
'handoff_mode_link_existing_ticket' => 'Link existing ticket',
'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.',
'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.',
'handoff_mutation_scope' => 'Mutation scope',
'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.',
'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.',
'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.',
'external_ticket_reference' => 'External ticket reference',
'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.',
'external_ticket_url' => 'External ticket URL',
'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.',
'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.',
'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.',
'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure',
'open_external_ticket' => 'Open external ticket',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureRunSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256RunHandoffComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function spec256OperationRun(Tenant $tenant): OperationRun
{
return OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'completed_at' => now(),
]);
}
it('creates an external ticket from the operation-run support action', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = spec256OperationRun($tenant);
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-RUN-256',
], 201),
]);
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Run create external ticket handoff.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-256')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-256');
});
it('links an existing external ticket from the operation-run support action without outbound create', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
$run = spec256OperationRun($tenant);
Http::fake();
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Run link existing external ticket.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN-LINK',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-LINK')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-LINK');
Http::assertNothingSent();
});
it('keeps the internal run support request when external create fails', function (): void {
spec256ConfigureRunSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = spec256OperationRun($tenant);
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256RunHandoffComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Run external handoff failure should keep internal support request.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
->and(OperationRun::query()->count())->toBe(1);
});

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureAuditSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256AuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('preserves support request created audit and records external ticket created audit', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-AUDIT-CREATED',
'raw_secret' => 'must-not-be-copied',
], 201),
]);
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket created.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$createdAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestCreated->value)
->sole();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalTicketCreated->value)
->sole();
expect($createdAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->tenant_id)->toBe((int) $tenant->getKey())
->and($externalAudit->status)->toBe('success')
->and(data_get($externalAudit->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
->and(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-CREATED')
->and((string) json_encode($externalAudit->metadata))->not->toContain('must-not-be-copied');
});
it('records external ticket linked audit without issuing outbound create', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket linked.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-AUDIT-LINKED',
])
->callMountedAction()
->assertHasNoActionErrors();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalTicketLinked->value)
->sole();
expect(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-LINKED')
->and($externalAudit->status)->toBe('success');
Http::assertNothingSent();
});
it('records external handoff failed audit with bounded failure metadata', function (): void {
spec256ConfigureAuditSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256AuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Audit external ticket failure.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$externalAudit = AuditLog::query()
->where('action', AuditActionId::SupportRequestExternalHandoffFailed->value)
->sole();
expect($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
->and($externalAudit->status)->toBe('failed')
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBeNull()
->and(data_get($externalAudit->metadata, 'external_handoff_failure_summary'))->toContain('configured timeout');
});

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256AuthorizationTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function spec256AuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function spec256AuthorizationRun(Tenant $tenant): OperationRun
{
return OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now(),
]);
}
it('keeps external handoff actions forbidden for entitled tenant members without support-create capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
spec256AuthorizationTenantComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeTenantSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});
it('keeps external handoff actions forbidden for entitled run viewers without support-create capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = spec256AuthorizationRun($tenant);
spec256AuthorizationOperationComponent($user, $run)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeOperationRunSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});
it('does not reveal latest tenant handoff summaries to workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-HIDDEN',
]);
try {
app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
$this->fail('Expected latest handoff summary to deny as not found.');
} catch (HttpExceptionInterface $exception) {
expect($exception->getStatusCode())->toBe(404);
}
});
it('does not reveal latest run handoff summaries outside the run tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = spec256AuthorizationRun($tenant);
SupportRequest::factory()
->forOperationRun($run)
->create([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN-HIDDEN',
]);
try {
app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($run, $user);
$this->fail('Expected latest run handoff summary to deny as not found.');
} catch (HttpExceptionInterface $exception) {
expect($exception->getStatusCode())->toBe(404);
}
});

View File

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function spec256ConfigureTenantSupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256TenantHandoffComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('creates an external ticket from the tenant dashboard support action', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create(['name' => 'Spec 256 Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-2561',
'ticket_url' => 'https://desk.example.test/tickets/PSA-2561',
], 201),
]);
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Tenant create external ticket handoff.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-2561')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-2561')
->and($supportRequest->external_handoff_failure_summary)->toBeNull()
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference);
});
it('links an existing external ticket from the tenant dashboard without creating a duplicate external ticket', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_NORMAL,
'summary' => 'Tenant link existing external ticket.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-256-LINK',
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-256-LINK',
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->and($supportRequest->external_ticket_reference)->toBe('PSA-256-LINK')
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-256-LINK')
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED);
Http::assertNothingSent();
});
it('rejects invalid linked external ticket input before storing a tenant support request', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_NORMAL,
'summary' => 'Tenant invalid link should not create support truth.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'not a ticket',
])
->callMountedAction()
->assertHasErrors(['external_ticket_reference']);
expect(SupportRequest::query()->count())->toBe(0);
Http::assertNothingSent();
});
it('keeps the internal tenant support request when external create fails', function (): void {
spec256ConfigureTenantSupportDesk();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_BLOCKING,
'summary' => 'Tenant external desk timeout should keep internal support request.',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-/')
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_ticket_url)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED);
});
it('forces tenant support requests to internal only when no external target is configured', function (): void {
spec256ConfigureTenantSupportDesk([
'enabled' => false,
]);
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Http::fake();
spec256TenantHandoffComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'summary' => 'Tenant support stays internal when no support desk target exists.',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->and($supportRequest->external_ticket_reference)->toBeNull()
->and($supportRequest->external_handoff_failure_summary)->toBeNull();
Http::assertNothingSent();
});

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
function configureSpec256SupportDesk(array $overrides = []): void
{
config([
'support_desk.target' => array_merge([
'enabled' => true,
'name' => 'Spec 256 Desk',
'create_url' => 'https://desk.example.test/api/tickets',
'api_token' => null,
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
'timeout_seconds' => 5,
], $overrides),
]);
}
function spec256SupportRequest(array $attributes = []): SupportRequest
{
$tenant = Tenant::factory()->create();
return SupportRequest::factory()->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'summary' => 'Need external support desk handoff.',
'severity' => SupportRequest::SEVERITY_HIGH,
], $attributes));
}
it('creates an external ticket through the configured target and normalizes the returned reference', function (): void {
configureSpec256SupportDesk([
'api_token' => 'secret-token',
]);
$supportRequest = spec256SupportRequest();
Http::fake([
'desk.example.test/*' => Http::response([
'ticket_reference' => 'PSA-12345',
'ticket_url' => 'https://desk.example.test/tickets/PSA-12345',
], 201),
]);
$result = app(ExternalSupportDeskHandoffService::class)->createTicket($supportRequest);
expect($result['successful'])->toBeTrue()
->and($result['external_ticket_reference'])->toBe('PSA-12345')
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-12345')
->and($result['failure_summary'])->toBeNull();
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference
&& data_get($request->data(), 'support_request.summary') === 'Need external support desk handoff.');
});
it('enforces the five second timeout budget and normalizes connection failures', function (): void {
configureSpec256SupportDesk([
'timeout_seconds' => 30,
]);
$supportRequest = spec256SupportRequest();
Http::fake([
'desk.example.test/*' => Http::failedConnection(),
]);
$service = app(ExternalSupportDeskHandoffService::class);
$result = $service->createTicket($supportRequest);
expect($service->timeoutSeconds())->toBe(5)
->and($result['successful'])->toBeFalse()
->and($result['external_ticket_reference'])->toBeNull()
->and($result['failure_summary'])->toContain('configured timeout');
});
it('falls back to unavailable when the single configured target is disabled', function (): void {
configureSpec256SupportDesk([
'enabled' => false,
]);
Http::fake();
$service = app(ExternalSupportDeskHandoffService::class);
$result = $service->createTicket(spec256SupportRequest());
expect($service->targetIsConfigured())->toBeFalse()
->and($result['successful'])->toBeFalse()
->and($result['failure_summary'])->toBe('External support desk target is not configured.');
Http::assertNothingSent();
});
it('normalizes linked tickets without issuing an outbound create call', function (): void {
configureSpec256SupportDesk();
Http::fake();
$result = app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket(' PSA-900 ', null);
expect($result['external_ticket_reference'])->toBe('PSA-900')
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-900');
Http::assertNothingSent();
});
it('rejects invalid linked ticket input before storing external truth', function (): void {
configureSpec256SupportDesk();
expect(fn (): array => app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket('not a ticket', 'javascript:alert(1)'))
->toThrow(ValidationException::class);
});

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns the latest tenant-scoped handoff summary without using run-scoped requests', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'internal_reference' => 'SR-OLDTENANT0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-OLD',
'created_at' => now()->subMinutes(10),
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'created_at' => now(),
]);
SupportRequest::factory()
->forOperationRun($run)
->create([
'internal_reference' => 'SR-RUN000000000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-RUN',
'created_at' => now(),
]);
SupportRequest::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'internal_reference' => 'SR-NEWTENANT0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_handoff_failure_summary' => 'External support desk did not respond before the configured timeout.',
'created_at' => now()->subMinute(),
]);
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
expect($summary)->not->toBeNull()
->and($summary['internal_reference'])->toBe('SR-NEWTENANT0000000000000001')
->and($summary['has_failure'])->toBeTrue()
->and($summary['has_external_link'])->toBeFalse()
->and($summary['external_handoff_failure_summary'])->toContain('configured timeout');
});
it('returns the latest run-scoped handoff summary for the current run only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$firstRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
$secondRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
]);
SupportRequest::factory()
->forOperationRun($secondRun)
->create([
'internal_reference' => 'SR-OTHERRUN0000000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-OTHER',
'created_at' => now(),
]);
SupportRequest::factory()
->forOperationRun($firstRun)
->create([
'internal_reference' => 'SR-CURRENTRUN0000000000001',
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => 'PSA-CURRENT',
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-CURRENT',
'created_at' => now()->subMinute(),
]);
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($firstRun, $user);
expect($summary)->not->toBeNull()
->and($summary['internal_reference'])->toBe('SR-CURRENTRUN0000000000001')
->and($summary['external_ticket_reference'])->toBe('PSA-CURRENT')
->and($summary['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-CURRENT')
->and($summary['has_external_link'])->toBeTrue();
});

View File

@ -0,0 +1,63 @@
# Preparation Review Checklist: External Support Desk / PSA Handoff
**Purpose**: Validate the prepared support-handoff package against the repo's guardrail, support-truth, provider-boundary, scoped-visibility, and close-out workflow requirements before implementation
**Created**: 2026-04-29
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing extension on two existing support-aware actions, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, tasks, and supporting artifacts carry the same bounded slice: existing `SupportRequest` truth stays authoritative, visibility stays on the current tenant or run support contexts only, handoff remains one-way, one configured target is allowed, and the close-out target remains `Guardrail / Exception / Smoke Coverage`.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The primary surfaces remain native Filament actions on `TenantDashboard` and `TenantlessOperationRunViewer` instead of a support-request resource, support queue, helpdesk shell, or standalone external-desk page.
- [x] CHK004 Shared support families remain shared: the internal `SR-...` support request stays the canonical truth, the latest handoff summary stays attached to the same two support actions, and the package does not invent a parallel support history or ticket-register surface.
- [x] CHK005 Page, detail, action-form, and persisted state owners are named once: `support_requests` is the only planned persisted truth, while the tenant and run pages own only current-context presentation and submit-time form state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: the operator uses the existing `Request support` action, chooses one handoff mode inside that form, and does not branch into a second workflow family.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-aware action surfaces.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and the only allowed deviation is one concrete provider-owned handoff service plus one tiny latest-summary read helper if implementation proves it necessary, not a generic helpdesk registry or page-local HTTP path.
- [x] CHK009 The package does not create a parallel operator UX language; `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` stay consistent across tenant, run, notification, and audit wording.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the run surface uses the current `OperationRun` only as support context and does not create, queue, deduplicate, resume, block, complete, or deep-link to a run workflow as part of the handoff slice.
- [x] CHK020 Run-specific workflow contracts stay on the existing canonical run page; queued toast/link/browser-event/dedupe behavior is not reintroduced locally for support handoff.
- [x] CHK021 No queued DB notification or terminal-notification path is added because the slice stays synchronous inside the current support-request submit path.
- [x] CHK022 No `OperationRun` exception is required in the preparation package; if implementation later adds retries, queueing, or run-orchestration semantics, that must be recorded as out-of-scope drift in the active close-out entry.
## Provider Boundary And Vocabulary
- [x] CHK010 Provider-specific semantics stay behind one concrete provider-owned handoff service and one preconfigured target-resolution seam; the planned persisted truth stays neutral on `SupportRequest` with handoff mode, external reference, external URL, and bounded failure summary only.
- [x] CHK011 No retained provider-specific shared boundary or second-target abstraction is introduced; multi-provider support, target-management UI, and broader ITSM or helpdesk modeling remain follow-up-spec work only.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`: the package adds a new provider seam and new persisted fields, but it does so on the existing support-request truth without hidden queue, resource, or support-framework drift.
- [x] CHK013 One bounded contract exception is explicit in the preparation package: Spec 256 allows exactly one synchronous finalization write on the same `SupportRequest` row after internal creation, limited to external handoff fields only. Any wider mutability, retry orchestration, or support-history spread must still be documented in the active feature close-out entry instead of becoming silent scope growth.
- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the tenant dashboard action, `monitoring-state-page` for the run action, and focused `Unit` plus `Feature` proof for handoff branching, scoped summary reuse, authorization, and audit behavior.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused Pest unit plus feature coverage with narrow manual smoke after implementation, and no implicit browser-only, global-search, or new resource coverage obligation is invented.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first: current support context, one handoff-mode decision, one latest bounded handoff summary, and one submit action remain primary.
- [x] CHK024 The package keeps raw/provider-heavy material out of default-visible truth: no raw payloads, credentials, provider response bodies, assignee or SLA fields, retry status, or cross-scope lookup shortcuts are allowed into the support-request row or default UI copy.
- [x] CHK025 Exactly one dominant next action remains primary: `Submit support request`; external create or link is modeled as a form choice, not as a competing primary action or second workflow entry point.
- [x] CHK026 Duplicate visible truth is avoided by naming one internal support reference and one latest context-scoped handoff summary instead of introducing a ticket history block, queue summary, or separate support register surface.
- [x] CHK027 Support or raw detail stays hidden or provider-owned, and latest handoff visibility remains bounded to the same entitled tenant or current run context with the existing `404` versus `403` rules.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` should record target-prerequisite status, any bounded implementation exception, and the final proof or smoke outcome.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and the conceptual contract. It does not claim application code exists.
- The slice remains bounded to the existing support-request truth and the two existing support-aware actions only. No support-request resource, support queue, helpdesk framework, global-search surface, or `OperationRun` workflow is approved by this package.
- Preparation note: the package now makes the single-target resolution seam explicit through `apps/platform/config/support_desk.php` and keeps workspace settings UI, per-workspace target management, second-target support, and retry or relink orchestration as later follow-up scope.
- Preparation note: Spec 256 explicitly narrows Spec 246 immutability for one synchronous handoff-finalization write only; no broader edit, reopen, merge, or lifecycle workflow is approved by this package.

View File

@ -0,0 +1,216 @@
openapi: 3.0.3
info:
title: TenantPilot Admin — External Support Desk Handoff (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the first external support desk handoff slice.
NOTE: These flows are implemented as Filament (Livewire) actions on
existing pages. This file captures the expected action payload, outcome
semantics, and authorization boundaries rather than a public REST API.
servers:
- url: /admin
paths:
/t/{tenant}/support-requests/actions/submit:
post:
summary: Submit a tenant-context support request with optional external handoff
description: |
Existing tenant dashboard support action, extended with one-way external
handoff behavior.
Authorization:
- Workspace non-member or non-entitled tenant actor: 404
- Entitled tenant member without `support_requests.create`: 403
- Authorized actor: 200 with one support-request submission result
Behavior:
- Always creates the internal `SR-...` support request first
- `internal_only` performs no outbound handoff
- `link_existing_ticket` stores the provided external reference and must not call external create
- `create_external_ticket` uses one application-configured external target only
- `create_external_ticket` applies a maximum 5 second outbound timeout budget
- External create failure keeps the internal support request and returns an explicit failed-handoff outcome
- No queue, `OperationRun`, retry scheduler, or bidirectional sync is introduced
parameters:
- name: tenant
in: path
required: true
schema:
type: string
description: Filament tenancy slug (`tenants.external_id`)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SupportRequestHandoffSubmission'
responses:
'200':
description: Support request accepted with internal-only, linked, created, or failed-handoff outcome
content:
application/json:
schema:
$ref: '#/components/schemas/SupportRequestHandoffResult'
'403':
description: Forbidden (entitled tenant member lacks support-request capability)
'404':
description: Not found (wrong workspace, non-member, or missing tenant entitlement)
/operations/{run}/support-requests/actions/submit:
post:
summary: Submit a run-context support request with optional external handoff
description: |
Existing canonical run detail support action, extended with one-way
external handoff behavior.
Authorization:
- Inaccessible run or run outside entitled tenant scope: 404
- Entitled member without `support_requests.create`: 403
- Authorized actor: 200 with one support-request submission result
Behavior:
- The run must resolve to an entitled tenant before any support truth is revealed
- Uses the same payload contract and outcome semantics as the tenant-context action
- Does not create, resume, or update an `OperationRun`
parameters:
- name: run
in: path
required: true
schema:
type: integer
description: Internal `operation_runs.id`
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SupportRequestHandoffSubmission'
responses:
'200':
description: Support request accepted with internal-only, linked, created, or failed-handoff outcome
content:
application/json:
schema:
$ref: '#/components/schemas/SupportRequestHandoffResult'
'403':
description: Forbidden (entitled member lacks support-request capability)
'404':
description: Not found (run inaccessible under workspace or tenant scope)
components:
schemas:
SupportRequestHandoffSubmission:
type: object
required:
- severity
- summary
- handoff_mode
properties:
severity:
type: string
enum: [low, normal, high, blocking]
summary:
type: string
reproduction_notes:
type: string
nullable: true
contact_name:
type: string
nullable: true
contact_email:
type: string
format: email
nullable: true
handoff_mode:
type: string
enum: [internal_only, create_external_ticket, link_existing_ticket]
external_ticket_reference:
type: string
nullable: true
description: Required when `handoff_mode = link_existing_ticket`
external_ticket_url:
type: string
format: uri
nullable: true
target_available:
type: boolean
nullable: true
description: Derived UI hint only; the server remains authoritative
SupportRequestHandoffResult:
type: object
required:
- support_request_id
- internal_reference
- primary_context_type
- handoff_mode
- handoff_outcome
- latest_summary
properties:
support_request_id:
type: integer
internal_reference:
type: string
primary_context_type:
type: string
enum: [tenant, operation_run]
primary_context_id:
type: integer
nullable: true
handoff_mode:
type: string
enum: [internal_only, create_external_ticket, link_existing_ticket]
handoff_outcome:
type: string
enum:
- internal_only
- external_ticket_created
- external_ticket_linked
- external_handoff_failed
external_ticket_reference:
type: string
nullable: true
external_ticket_url:
type: string
format: uri
nullable: true
failure_summary:
type: string
nullable: true
latest_summary:
$ref: '#/components/schemas/LatestSupportRequestHandoffSummary'
LatestSupportRequestHandoffSummary:
type: object
required:
- internal_reference
- primary_context_type
- submitted_at
- handoff_mode
- has_external_link
- has_failure
properties:
internal_reference:
type: string
primary_context_type:
type: string
enum: [tenant, operation_run]
primary_context_id:
type: integer
nullable: true
submitted_at:
type: string
format: date-time
handoff_mode:
type: string
enum: [internal_only, create_external_ticket, link_existing_ticket]
external_ticket_reference:
type: string
nullable: true
external_ticket_url:
type: string
format: uri
nullable: true
external_handoff_failure_summary:
type: string
nullable: true
has_external_link:
type: boolean
has_failure:
type: boolean

View File

@ -0,0 +1,161 @@
# Data Model — External Support Desk / PSA Handoff
**Spec**: [spec.md](spec.md)
Spec 256 extends the existing support-request truth. No new support-ticket table, resource, or queue artifact is introduced.
## Existing Canonical Entity Extended
### SupportRequest (`support_requests`)
**Purpose**: Canonical tenant-owned support-request truth. Spec 256 extends it so the same row can carry one-way external handoff continuity.
**Existing key fields (already in repo)**:
- `id`
- `workspace_id`
- `tenant_id`
- `operation_run_id`
- `initiated_by_user_id`
- `internal_reference`
- `primary_context_type`
- `attachment_mode`
- `severity`
- `summary`
- `reproduction_notes`
- `contact_name`
- `contact_email`
- `context_envelope`
- `created_at`
- `updated_at`
**New fields (planned)**:
- `external_handoff_mode`
- type: string
- required: yes
- default: `internal_only`
- allowed values:
- `internal_only`
- `create_external_ticket`
- `link_existing_ticket`
- `external_ticket_reference`
- type: nullable string
- stored when an external ticket was created or linked successfully
- `external_ticket_url`
- type: nullable text
- stored only when the target returns or the operator provides a valid URL
- `external_handoff_failure_summary`
- type: nullable text
- bounded human-readable failure summary for the current request only
**Relationships (unchanged)**:
- belongs to `Workspace`
- belongs to `Tenant`
- optionally belongs to `OperationRun`
- optionally belongs to initiator `User`
**Behavioral rules**:
- `internal_reference` remains the canonical TenantPilot support identifier even when an external ticket exists.
- `external_handoff_mode` records the operators chosen path and replaces the need for a second persisted status family.
- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request is created, the same row may receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No later edit, reopen, merge, or status workflow is introduced.
- `external_ticket_reference` and `external_ticket_url` remain null for `internal_only` and for failed create attempts.
- `external_handoff_failure_summary` remains null on successful create, successful link, and internal-only submissions.
- On a failed external create, the row persists with:
- `external_handoff_mode = create_external_ticket`
- `external_ticket_reference = null`
- `external_ticket_url = null`
- `external_handoff_failure_summary` populated
- When the failed external create was caused by timeout, `external_handoff_failure_summary` stores the same bounded timeout-oriented message that the UI and audit path use. Raw transport detail is never persisted.
**Latest-summary query rules**:
- Tenant dashboard summary queries the latest support request for the current entitled tenant where `primary_context_type = tenant`.
- Operation-run summary queries the latest support request for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run.
- Existing indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient. No new lookup path by external reference is planned.
**Validation rules**:
- `external_handoff_mode` must be one of the three allowed values.
- `external_ticket_reference` is required when `external_handoff_mode = link_existing_ticket`.
- `external_ticket_url` is optional but must be a valid URL when present.
- When no external target is configured for the application, the form must force or constrain the effective mode to `internal_only`.
## Application-Configured External Target (Config Contract In Scope, Not New Persisted Truth)
### External Support Desk Target
**Purpose**: Supplies the one configured outbound target for create or link normalization.
**Status in Spec 256**:
- minimal application config contract in scope
- not a new persisted entity in this slice
- not a workspace settings domain or UI surface in this slice
**Repo-grounded note**:
- The repo has no existing `support` settings domain, so Spec 256 makes the target seam explicit through one application config file: `apps/platform/config/support_desk.php` with environment-backed values for the single supported target.
- This config contract may define availability, create endpoint settings, reference-link normalization defaults, and the five-second outbound timeout budget.
- Per-workspace target selection, settings UI, or a second target remain follow-up scope.
## Derived Runtime Entities
### SupportRequestHandoffOutcome (computed, not persisted)
**Purpose**: Gives the Filament page actions one normalized outcome for notification copy and tests after submission completes.
**Expected shape**:
- `support_request_id`
- `internal_reference`
- `primary_context_type`
- `handoff_mode`
- `handoff_outcome`
- `internal_only`
- `external_ticket_created`
- `external_ticket_linked`
- `external_handoff_failed`
- `external_ticket_reference`
- `external_ticket_url`
- `failure_summary`
**Why derived only**:
- The outcome is an execution summary for one request cycle.
- Persisting it separately would duplicate the support-request truth and audit log.
- The bounded synchronous finalization write on `SupportRequest` remains the only allowed post-create mutation for this slice.
### LatestSupportRequestHandoffSummary (computed, not persisted)
**Purpose**: Supplies the existing tenant and run support actions with one scoped summary of the latest linkage for the current primary context.
**Expected shape**:
- `internal_reference`
- `primary_context_type`
- `primary_context_id`
- `submitted_at`
- `external_handoff_mode`
- `external_ticket_reference`
- `external_ticket_url`
- `external_handoff_failure_summary`
- `has_external_link`
- `has_failure`
**Why derived only**:
- It is a read model over the latest `support_requests` row for one context.
- A separate table or persisted summary would violate `PERSIST-001` without solving a distinct lifecycle problem.
## Audit Events (Persistent Audit Truth, Not Product Truth)
The implementation should add these stable audit actions in addition to the existing `support_request.created` event:
- `support_request.external_ticket_created`
- `support_request.external_ticket_linked`
- `support_request.external_handoff_failed`
**Audit context should include**:
- `workspace_id`
- `tenant_id`
- `internal_reference`
- `primary_context_type`
- `primary_context_id`
- `external_handoff_mode`
- `external_ticket_reference` when present
**Audit context should not include**:
- raw provider request payloads
- secrets or credentials
- unrestricted provider response bodies

View File

@ -0,0 +1,319 @@
# Implementation Plan: External Support Desk / PSA Handoff
**Branch**: `256-external-support-desk-handoff` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`
## Summary
- Extend the existing support-request submission flow so the two current support-aware surfaces can keep a request internal-only, create one external desk ticket, or link one existing external ticket without adding a new support product surface.
- Persist only the minimal neutral linkage truth on the existing `support_requests` row: `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`.
- Keep the flow synchronous and auditable inside the existing support-request path: create the internal `SR-...` request first, allow exactly one bounded synchronous finalization write for external create, link, or failure fields on the same row, enforce a five-second outbound timeout on the create path, and surface the latest linkage summary only in the current tenant or run support context.
## Technical Context
**Language/Version**: PHP 8.4 on Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and `CapabilityResolver`
**Storage**: PostgreSQL; extend the tenant-owned `support_requests` table, keep `workspace_id` and `tenant_id` required, and do not add a second support-ticket table
**Testing**: Pest unit + feature tests
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}`
**Project Type**: web
**Performance Goals**: keep the submit path synchronous, apply a maximum five-second outbound timeout on the create path, and avoid queue or `OperationRun` overhead
**Constraints**: one application-configured external target only, no new support-request resource/list/detail page, no global-search surface, no bidirectional sync, no retry scheduler, no raw provider payload persistence, no provider registration changes, and no runtime asset changes
**Scale/Scope**: one additive migration on `support_requests`, one concrete provider-owned handoff service, one small derived latest-summary helper or equivalent shared read path, two Filament action-form extensions, audit additions, and focused unit plus feature coverage only
## Key Design Decisions
### Persistence and source of truth
- `support_requests` is the only persisted truth for this slice. No `support_tickets` table, no queue artifact, and no new support page model is justified.
- The plan adds these columns to `support_requests`:
- `external_handoff_mode` as a non-null string with default `internal_only`
- `external_ticket_reference` as a nullable string
- `external_ticket_url` as a nullable text field
- `external_handoff_failure_summary` as a nullable text field
- The plan does not add `external_handoff_status`, `external_target_type`, `external_target_id`, raw payload JSON, or a dedicated failure timestamp. Those are not needed for the current operator contract because:
- the handoff mode already captures operator intent
- success is derivable from `external_ticket_reference`
- failure visibility only needs a bounded summary on revisit
- audit timestamps already provide exact event timing
- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request row exists, the same row may receive exactly one synchronous finalization write limited to the external handoff fields above. After that finalization step, the row is immutable again.
- Existing `support_requests` indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient for latest-summary lookups. No external-reference index is planned because cross-scope lookup by external ticket reference is explicitly out of scope.
### Failure truth and auditable outcomes
- External create failure is not audit-only. A bounded failure summary must be persisted back on the same `support_requests` row so the current support context can show the last failure on revisit.
- Timeout is treated as the same failure family as any other create failure. The provider-owned service must enforce the five-second outbound timeout budget and return a normalized bounded failure summary rather than raw transport details.
- Detailed provider-specific error payloads remain out of persisted product truth. They stay confined to the provider-owned handoff service, log redaction rules, and audit metadata where appropriate.
- The internal support request remains durable even when external create fails. The implementation must therefore split the flow into:
1. authorize and validate the existing request
2. persist the internal support request and `support_request.created` audit event
3. perform link or create handoff work after the internal row exists
4. perform the one allowed synchronous finalization write back to the same row and emit the corresponding audit event
### Visible linkage stays inside existing support contexts only
- External ticket references do not become a new dashboard card, run section, support history block, global search result, or Filament resource.
- The narrowest correct visibility path is:
- success or partial-success notification immediately after submit
- a latest-handoff summary placeholder inside the existing `Request support` slide-over on `TenantDashboard`
- the same latest-handoff summary placeholder inside the grouped `Request support` slide-over on `TenantlessOperationRunViewer`
- Tenant context summary scopes to the latest support request where `primary_context_type = tenant` for the current entitled tenant.
- Run context summary scopes to the latest support request where `primary_context_type = operation_run` and `operation_run_id` matches the currently opened run.
### Minimal application config contract is in scope; support settings UI is not
- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation drift.
- This plan therefore brings one minimal application config contract into scope: `apps/platform/config/support_desk.php` backed by environment values for the single supported target.
- The implementation may resolve availability, create endpoint configuration, and timeout settings from that config file only.
- This spec still forbids workspace settings UI, a new settings domain, per-workspace target management, provider-connection product work, or multi-target support.
### Timeout and latency rule
- The one application-configured create path must use a maximum five-second outbound timeout.
- A timeout is normalized into the same bounded failure-summary and audit path as any other external create failure.
- The timeout budget is part of the feature contract and must be covered by the handoff-service unit tests.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament actions plus shared support primitives
- **Shared-family relevance**: header actions, grouped detail actions, support-request slide-overs, success or warning notifications, latest-handoff summaries, and external-link navigation
- **State layers in scope**: page, detail, action form
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first support form, diagnostics-second through the existing neighboring diagnostics action, provider/raw evidence third and hidden
- **Raw/support gating plan**: provider-specific payloads, secrets, and raw responses stay provider-owned and hidden; only bounded human-readable linkage or failure summary becomes default-visible
- **One-primary-action / duplicate-truth control**: the dominant action remains `Submit support request`; handoff choice is a form field, not a second primary action, and the visible summary names one internal support reference so the surface does not become a history register
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract, manual smoke after implementation
- **Exception path and spread control**: the tenant dashboard keeps its existing bounded action-surface exception; the run viewer keeps both support actions grouped under `More` and does not add a new top-level support action family
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**:
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- `apps/platform/app/Models/SupportRequest.php`
- `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`
- `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`
- `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php`
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- `apps/platform/app/Support/Audit/AuditActionId.php`
- `apps/platform/database/factories/SupportRequestFactory.php`
- `apps/platform/config/support_desk.php`
- `apps/platform/lang/en/localization.php`
- `apps/platform/lang/de/localization.php`
- **Shared abstractions reused**: existing support-request submission path, existing redacted context builder, existing internal reference generator, existing audit logger, and existing `UiEnforcement` capability gating
- **New abstraction introduced? why?**: one concrete provider-owned external handoff service is justified because both existing surfaces must call or normalize one real external target without page-local HTTP logic; one tiny shared latest-summary read helper is allowed if needed to avoid duplicating the same context-scoped query and copy twice
- **Why the existing abstraction was sufficient or insufficient**: the existing abstractions already solve context capture, internal request creation, and audit logging, but they stop at internal persistence and cannot yet persist external linkage or explicit handoff failure truth
- **Bounded deviation / spread control**: no interface registry, no adapter catalog, no support-desk framework, no second persistence model, and no new support history vocabulary
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: N/A
- **Delegated UX behaviors**: N/A
- **Surface-owned behavior kept local**: the run viewer uses the current run only as support context and as the scoping key for its latest-handoff summary; it does not create, resume, or link an `OperationRun`
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: outbound create payload, authentication, target-specific reference normalization, URL normalization, and remote error parsing
- **Platform-core seams**: `SupportRequest`, internal support reference, external ticket reference and URL, handoff mode, latest-handoff summary, and bounded failure summary
- **Neutral platform terms / contracts preserved**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk`
- **Retained provider-specific semantics and why**: provider-specific ticket identifiers, auth requirements, and request payload shape remain inside one concrete provider-owned service because the current release has exactly one real external target
- **Bounded extraction or follow-up path**: multi-provider support, target-management UI, and broader ITSM modeling remain follow-up-spec work only
## Constitution Check
*GATE: Passed against repo truth before artifact write. Re-checked after Phase 1 design artifacts were drafted.*
- Inventory-first / snapshots-second: PASS. The slice does not alter inventory or snapshot truth.
- Read/write separation: PASS. The mutation remains an explicit operator submit action with auditable outcomes and planned tests.
- Graph contract path: PASS. No Microsoft Graph calls are introduced.
- Deterministic capabilities: PASS. Capability checks stay on `Capabilities::SUPPORT_REQUESTS_CREATE`; no raw capability strings or role-string checks are planned.
- RBAC-UX / workspace isolation / tenant isolation: PASS. Non-members or actors outside workspace or tenant scope remain `404`; in-scope members missing the capability remain `403`; latest-handoff visibility uses the same boundary as submit.
- Global search safety: PASS. No new Filament resource or globally searchable surface is introduced.
- Run observability / Ops UX: PASS. The slice is intentionally synchronous and does not add queue work or `OperationRun` usage.
- Proportionality / `PROP-001`, `ABSTR-001`, `PERSIST-001`, `STATE-001`, `BLOAT-001`: PASS. The only new persisted truth is four bounded columns on an existing canonical row, one small handoff mode family, and one concrete provider-owned service for one real target.
- Shared pattern reuse / `XCUT-001`: PASS. The plan extends the existing support-request service and existing support-aware action surfaces instead of creating page-local handoff logic.
- Provider boundary / `PROV-001`: PASS. Provider semantics stay confined to the concrete handoff service; platform truth remains neutral.
- Filament-native UI / `UI-FIL-001`: PASS. The flow stays inside native Filament action forms and notifications.
- Livewire v4 / Filament v5 compliance: PASS. The plan stays on the current Filament v5 and Livewire v4 stack.
- Provider registration location: PASS. No provider registration changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
- Destructive action confirmation: PASS. No destructive action is added, so no new `->requiresConfirmation()` path is introduced.
- Asset strategy: PASS. No new panel or shared assets are required; deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged.
- Test governance / `TEST-GOV-001`: PASS. Proof remains in focused unit plus feature lanes, with manual smoke only as implementation close-out.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for handoff branching, target-unavailable fallback, provider normalization, and latest-summary derivation; Feature for tenant and run action behavior, authorization boundaries, persisted linkage truth, partial-success feedback, and audit events
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and synchronous; business truth lives in the submission service, persistence, and authorization boundaries, so browser automation would mostly duplicate what Pest can already prove through Livewire and domain tests
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, tenant, operation run, user membership, and support-request fixtures; add only a small fake for the one external target and a narrow latest-summary assertion helper if needed
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native-filament relief applies on the tenant dashboard action; the run viewer remains under its monitoring-state-page contract and needs the same tenant-entitlement checks as the current support action
- **Closing validation and reviewer handoff**: re-run the exact unit and feature commands above, then manually smoke create, link, and failure handling from both existing surfaces; reviewers should explicitly verify that no support-request resource, queue, settings UI, or global-search behavior was added
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
- **Review-stop questions**: did implementation add a new support table, a support-request resource, a support settings UI, a multi-provider registry, or queue or `OperationRun` behavior that the spec forbids?
- **Escalation path**: reject-or-split if target-configuration management, multi-provider support, or retry orchestration appears during implementation
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: the delivery cost stays local to the existing support-request path; broader configuration or multi-provider expansion is separate work, not latent scope inside this slice
## Implementation Close-Out — Guardrail / Exception / Smoke Coverage
- **Guardrail outcome**: PASS. The implementation extends only the existing tenant-dashboard and operation-run `Request support` actions, keeps the run support action grouped under `More`, and does not add a support-request resource, support queue, global-search surface, target-management UI, provider registry, or new `OperationRun` behavior.
- **Finalization exception outcome**: PASS. The only post-create mutation on `support_requests` is the Spec 256 bounded finalization write to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`; invalid linked-ticket input is rejected before the internal support request is created.
- **Smoke coverage outcome**: PASS. A temporary Pest Browser smoke harness loaded the tenant dashboard and run detail, submitted tenant `create_external_ticket`, submitted run `link_existing_ticket`, forced run create failure, reopened the run support action to verify the latest failure summary, and asserted no browser console or JavaScript errors. The temporary browser test was removed after execution so the permanent coverage remains the planned unit plus feature lanes.
- **Follow-up decision**: No in-scope follow-up spec is required. Target-management UI, retry/relink workflows, and multi-provider support remain explicit future-spec candidates only if product pressure proves them necessary.
## Project Structure
### Documentation (this feature)
```text
specs/256-external-support-desk-handoff/
├── checklists/
│ └── requirements.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── external-support-desk-handoff.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Pages/
│ │ ├── Operations/
│ │ │ └── TenantlessOperationRunViewer.php
│ │ └── TenantDashboard.php
│ ├── Models/
│ │ └── SupportRequest.php
│ ├── Services/
│ │ └── Audit/
│ │ └── WorkspaceAuditLogger.php
│ └── Support/
│ ├── Audit/
│ │ └── AuditActionId.php
│ └── SupportRequests/
│ ├── SupportRequestContextBuilder.php
│ ├── SupportRequestReferenceGenerator.php
│ ├── SupportRequestSubmissionService.php
│ └── ExternalSupportDeskHandoffService.php
├── config/
│ └── support_desk.php
├── database/
│ ├── factories/
│ │ └── SupportRequestFactory.php
│ └── migrations/
│ └── *_add_external_handoff_fields_to_support_requests_table.php
├── lang/
│ ├── de/
│ │ └── localization.php
│ └── en/
│ └── localization.php
└── tests/
├── Feature/SupportRequests/
│ ├── OperationRunSupportRequestExternalHandoffTest.php
│ ├── SupportRequestExternalHandoffAuditTest.php
│ ├── SupportRequestExternalHandoffAuthorizationTest.php
│ └── TenantSupportRequestExternalHandoffTest.php
└── Unit/Support/SupportRequests/
├── ExternalSupportDeskHandoffServiceTest.php
└── SupportRequestLatestHandoffSummaryTest.php
```
**Structure Decision**: Single Laravel application. The slice extends the existing support-request domain and two existing Filament pages only. One minimal application config contract in `config/support_desk.php` is in scope so target resolution is explicit, while workspace settings UI and a support settings domain remain out of scope. The constitution-mandated checklist in `checklists/requirements.md` stays part of the implementation handoff set.
## Complexity Tracking
| Violation / review item | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Extend `support_requests` with four external-handoff columns | The operator must be able to revisit the current support context and still see the same external linkage or failure truth on the canonical support request | A separate `support_tickets` table would create a second lifecycle and a new surface the current slice does not need |
| Add one concrete provider-owned handoff service | One real external target must be called or normalized from both existing support-aware surfaces without page-local HTTP logic | A generic interface, registry, or multi-provider adapter catalog would be premature because the repo has exactly one current-release target case |
## Proportionality Review
- **Current operator problem**: the product can already create an internal support request with redacted context, but operators still have to create or paste an external desk ticket manually outside TenantPilot and then remember that linkage separately
- **Existing structure is insufficient because**: the current service ends at internal persistence and cannot carry durable external linkage or explicit failure truth back into the current support context
- **Narrowest correct implementation**: extend the existing `SupportRequest` row with minimal neutral linkage fields, route create or link decisions through the existing submission service, and render the latest linkage only inside the same two support-aware actions
- **Ownership cost created**: one additive migration, one concrete provider-owned service, a few audit IDs and audit-logger methods, modest action-form growth on two pages, and focused tests
- **Alternative intentionally rejected**: a new support-ticket model, support-request resource or detail page, target-management UI, provider registry, background retry path, or `OperationRun` delivery orchestration were all rejected as broader than current-release truth
- **Release truth**: current-release support follow-through and commercialization gap, not future ITSM platform preparation
## Implementation Outline
### 1. Support request persistence extension
- Add the four external-handoff columns to `support_requests`.
- Default `external_handoff_mode` to `internal_only` so existing rows remain truthful without compatibility shims.
- Keep the internal `SR-...` reference canonical for every request.
### 2. Submission service orchestration
- Continue to authorize and validate through the current `SupportRequestSubmissionService` path.
- Persist the internal support request first and keep `WorkspaceAuditLogger::logSupportRequestCreated(...)` unchanged for that stage.
- Branch by handoff mode after the internal row exists:
- `internal_only`: return immediately with no external fields populated
- `link_existing_ticket`: validate and normalize the provided reference or URL locally, persist linkage, and audit `linked`
- `create_external_ticket`: call one concrete provider-owned handoff service outside the initial DB transaction with the five-second timeout budget, then perform the one allowed synchronous finalization write back to the same row and audit the outcome
### 3. Latest-summary derivation
- Add one shared read path for the latest handoff summary per primary context.
- Tenant summary queries the latest `support_requests` row for the current tenant where `primary_context_type = tenant`.
- Run summary queries the latest `support_requests` row for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run.
- The visible summary always includes the internal support reference it belongs to.
### 4. Filament surface extension
- Extend the existing `Request support` action on both pages with:
- mutation-scope guidance (`TenantPilot only` versus `TenantPilot + external support desk`)
- handoff mode choice
- conditional external reference and URL inputs for `link_existing_ticket`
- a read-only latest-handoff summary placeholder scoped to the current context
- Keep `Open support diagnostics` unchanged as the diagnostics-secondary affordance.
- Success notifications include the internal reference and, when present, the external reference.
- External create failure uses explicit partial-success or warning feedback: internal request created, external handoff failed.
### 5. Audit and copy consistency
- Add stable audit action IDs for:
- external ticket created
- external ticket linked
- external handoff failed
- Keep audit context bounded to workspace, tenant, internal support reference, primary context, handoff mode, and external ticket reference when present.
- Preserve neutral UI copy and do not surface provider product names as the primary operator vocabulary.
## Implementation Phases
1. **Foundation**: add the migration shape, model casts and constants, audit IDs, the concrete handoff service contract for one target, and the minimal `config/support_desk.php` contract.
2. **Submission flow**: refactor `SupportRequestSubmissionService` so internal creation commits first, then link or create outcome persists back to the same row.
3. **Surface wiring**: extend the tenant dashboard and run viewer forms with handoff mode, latest-summary placeholder, and outcome-sensitive notification copy.
4. **Hardening**: add latest-summary derivation, target-unavailable fallback to `internal_only`, authorization proof, and audit proof.
## Guardrail Close-Out Expectations
- Livewire v4 compatibility remains unchanged because the flow stays inside existing Filament v5 page actions.
- Laravel 12 provider registration facts remain unchanged: panel providers stay in `bootstrap/providers.php`.
- No globally searchable resource is added, so there is no new global-search contract to satisfy.
- No destructive action is introduced, so there is no new confirmation flow requirement.
- No new assets are required; `cd apps/platform && php artisan filament:assets` stays part of the general deployment path but does not change for this feature.

View File

@ -0,0 +1,48 @@
# Quickstart — External Support Desk / PSA Handoff
## Prereqs
- Docker is running.
- Laravel Sail dependencies are installed.
- The support-request foundation from Spec 246 is already present in the workspace.
- One application-configured external support desk target is available through `apps/platform/config/support_desk.php`, or a fake target is available for implementation tests.
## Run locally after implementation
- Start containers:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
- Run targeted unit proof:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
- Run targeted feature proof:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
- Format after implementation:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Manual smoke after implementation
1. Sign in to `/admin` as a workspace member with tenant entitlement and `support_requests.create` capability.
2. Open one tenant at `/admin/t/{tenant}` and trigger `Request support`.
3. Verify the action shows the existing context summary plus the new handoff mode controls. If no external target is configured in `config/support_desk.php`, verify the action clearly stays in `internal_only` mode.
4. Submit the tenant-context flow with `create_external_ticket` and verify the success notification includes the internal support reference plus the created external ticket reference.
5. Reopen the tenant-context action and verify the latest-handoff summary names the same internal support reference and external ticket reference.
6. Submit the tenant-context flow with `link_existing_ticket` and verify the stored summary shows the linked external reference without issuing a create call.
7. Force the external create path to fail, including the five-second timeout path, and verify the action returns explicit partial-success or warning feedback, the internal support request still exists, and the latest-handoff summary shows the persisted failure summary.
8. Open one canonical run detail page at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and repeat create, link, and failure checks there.
9. Verify a non-member or non-entitled actor receives `404`, while an in-scope member without `support_requests.create` sees the action disabled and receives `403` on execution.
10. Verify no new support-request resource, support queue, global-search result, or `OperationRun` side effect appears in this slice.
## Notes
- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays inside native Filament page actions.
- No provider registration change is part of this slice; Laravel 12 panel providers remain registered in `bootstrap/providers.php`.
- No globally searchable resource is added, so there is no new global-search contract to satisfy.
- No destructive action is introduced, so `->requiresConfirmation()` is not newly involved here.
- No asset strategy changes are required. The general deploy step `cd apps/platform && php artisan filament:assets` remains unchanged.
## Implementation Close-Out Expectations
- The targeted unit and feature commands above pass.
- Manual smoke proves create, link, and explicit failure handling from both existing support-aware surfaces.
- Audit review shows `support_request.created`, `support_request.external_ticket_created`, `support_request.external_ticket_linked`, and `support_request.external_handoff_failed` events with the expected bounded metadata.
- Internal-only support-request submission still works when the external target is unavailable or intentionally bypassed.
- No new support product surface appears beyond the two existing actions.

View File

@ -0,0 +1,167 @@
# Research — External Support Desk / PSA Handoff
**Date**: 2026-04-29
**Spec**: [spec.md](spec.md)
This document records the repo-grounded decisions that make the Spec 256 plan implementation-ready without expanding into a generic helpdesk product.
## Decision 1 — Extend `support_requests` instead of adding a second support-ticket truth
**Decision**: Keep `App\Models\SupportRequest` as the only persisted truth for this slice and add the external linkage fields directly to `support_requests`.
**Rationale**:
- The repo already has one canonical support-request model, migration, factory, and submission service.
- The operator workflow needs one durable record that still carries the internal `SR-...` reference after create, link, or failure.
- Constitution `PERSIST-001` and `PROP-001` reject a second lifecycle unless it solves a distinct product problem. Spec 256 does not need one.
**Evidence**:
- Existing model: `apps/platform/app/Models/SupportRequest.php`
- Existing persistence: `apps/platform/database/migrations/2026_04_27_095518_create_support_requests_table.php`
- Existing write path: `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`
- Candidate scope: `docs/product/spec-candidates.md`
**Alternatives considered**:
- Add a new `SupportTicket` or `SupportRequestLink` model.
- Rejected: creates a second truth and encourages a support register or detail page the spec explicitly forbids.
- Keep external linkage entirely derived from audit logs.
- Rejected: the current support context must show the latest linkage on revisit, which audit-only storage cannot do safely or cheaply.
## Decision 2 — Persist a bounded failure summary on the same row; keep detailed provider failure out of product truth
**Decision**: Store `external_handoff_failure_summary` on `support_requests` and keep detailed provider payloads or raw errors out of persisted support-request truth.
**Rationale**:
- The spec requires explicit, revisitable failure handling in the same support context.
- A purely audited failure would satisfy compliance but fail the operator need to reopen the action and see what happened.
- A bounded human-readable summary is enough for revisit. Provider-specific payloads remain provider-owned and redaction-sensitive.
**Evidence**:
- Existing audit path is already separate from product truth: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- Current support-request row has no external linkage or failure fields, so the revisit contract is impossible without row-level extension.
**Alternatives considered**:
- Audit failure only.
- Rejected: failure becomes invisible in the current support context.
- Persist raw provider response JSON.
- Rejected: violates the specs minimal neutral truth and increases leakage risk.
## Decision 3 — Keep the flow synchronous, preserve internal durability, and document the one bounded finalization write
**Decision**: Preserve the existing synchronous submit flow, move any external create call outside the current internal-request creation transaction, enforce a five-second outbound timeout, and explicitly allow one bounded post-create finalization write on the same `SupportRequest` row.
**Rationale**:
- The current service wraps internal create plus audit in a transaction.
- Spec 256 explicitly requires the internal support request to survive external create failure.
- Spec 246 declared the row immutable after creation, so Spec 256 must make its one bounded finalization exception explicit instead of mutating the row silently.
- Holding a database transaction open across remote HTTP is unnecessary and increases latency and failure risk.
- A hard timeout budget is needed so the operator-visible submit path stays bounded and timeout behavior is testable.
- The repo truth does not require `OperationRun`, queueing, or retry scheduling for this slice.
**Evidence**:
- Existing transaction structure in `SupportRequestSubmissionService`
- Existing synchronous page actions on `TenantDashboard` and `TenantlessOperationRunViewer`
- The specs explicit non-goal for queues, retries, and `OperationRun`
- Spec 246 FR-246-011 immutability contract
**Alternatives considered**:
- Perform external HTTP inside the current DB transaction.
- Rejected: risks long transactions and makes internal request durability harder to guarantee.
- Introduce queue work or `OperationRun`.
- Rejected: broader than current-release truth and not required for one synchronous target.
- Keep Spec 246 immutability unchanged and infer final handoff state only from audit.
- Rejected: the current support context must show revisitable success or failure on the canonical `SupportRequest`, so the one bounded finalization write has to be explicit.
## Decision 4 — Keep external linkage visibility inside the existing support request actions only
**Decision**: Show the latest linkage summary inside the existing `Request support` slide-overs and in submit feedback. Do not add a new support page, dashboard card, or run-detail history section.
**Rationale**:
- The spec says visibility must stay attached to the existing tenant and run support contexts.
- The acceptance criteria require reopening the action and seeing the latest linkage summary for that same context.
- A broader always-visible history surface would deepen support-product scope and duplicate truth.
**Evidence**:
- Existing support-aware surfaces: `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- No existing `SupportRequest` resource, list, or detail page exists in the repo today.
**Alternatives considered**:
- Add a support-request resource or detail page.
- Rejected: explicitly out of scope.
- Add a new page-level widget or card for support linkage.
- Rejected: broader than the acceptance requirement and would create duplicate visible truth.
## Decision 5 — Add one minimal application config contract; do not hide target resolution behind an undefined prerequisite
**Decision**: Bring one minimal application config contract into scope through `apps/platform/config/support_desk.php` and environment-backed values for the single supported target. Do not add workspace settings UI, a support settings domain, or provider-connection product work.
**Rationale**:
- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation work.
- The product contract only needs one target for v1, so an application config contract is the narrowest explicit source of truth.
- Pulling workspace administration into Spec 256 would still expand scope from handoff to setup and administration.
**Evidence**:
- Existing repo truth has no support-target config seam yet, so a new app config file is the explicit minimal source of truth for one target.
**Alternatives considered**:
- Add a new `support` settings domain and UI in the same spec.
- Rejected: becomes a second feature slice.
- Reuse `ProviderConnection` as the support target model.
- Rejected: not justified by current repo truth for one external desk handoff target.
- Leave target resolution as an undefined prerequisite.
- Rejected: the tasks and plan already depend on a concrete resolution seam, so the config contract must be explicit inside the package.
## Decision 6 — Use one concrete provider-owned handoff service, not a registry or interface framework
**Decision**: Add one concrete provider-owned handoff service under the support-request path for the single real external target.
**Rationale**:
- Both existing support surfaces need the same create-or-normalize behavior.
- Constitution `ABSTR-001` rejects a provider registry or interface framework before two real targets exist.
- Page-local HTTP logic would duplicate failure handling, normalization, and audit shape.
**Evidence**:
- One configured target only in the spec and roadmap candidate
- Existing shared write path already centralizes support-request submission across both surfaces
**Alternatives considered**:
- Add a provider interface plus registry.
- Rejected: future-proofing without current-release variance.
- Duplicate HTTP logic inside both Filament pages.
- Rejected: immediate drift risk and weaker audit consistency.
## Decision 7 — Keep queries context-scoped and avoid new search or indexing semantics
**Decision**: Derive the latest visible linkage from the latest support request for the same primary context, using the existing context indexes. Do not add cross-scope lookup or search by external ticket reference.
**Rationale**:
- Tenant summary and run summary have different scope rules in the spec.
- Existing indexes already support latest-by-tenant and latest-by-run queries.
- Cross-scope lookup by external reference is explicitly out of scope and would create a new leakage risk.
**Evidence**:
- Existing indexes on `support_requests(tenant_id, created_at)` and `support_requests(operation_run_id, created_at)`
- Context scoping in `SupportRequest::PRIMARY_CONTEXT_TENANT` and `SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN`
**Alternatives considered**:
- Add an index and lookup flow for external ticket reference.
- Rejected: no current surface needs it, and it would conflict with the no-cross-scope-shortcuts rule.
## Decision 8 — Proof stays in Unit + Feature lanes with manual smoke only
**Decision**: Keep the proving strategy in focused Pest unit and feature suites, then use a narrow manual smoke path after implementation.
**Rationale**:
- Business truth is server-side: branching, persistence, audit, and authorization.
- Existing support-request tests already cover the same two Filament entry surfaces.
- Browser coverage would mostly duplicate the existing action-form semantics.
**Evidence**:
- Existing test family:
- `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php`
- `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php`
- `apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php`
- `apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php`
**Alternatives considered**:
- Add browser tests in the first slice.
- Rejected: not required to prove the current business truth.

View File

@ -0,0 +1,331 @@
# Feature Specification: External Support Desk / PSA Handoff
**Feature Branch**: `256-external-support-desk-handoff`
**Created**: 2026-04-29
**Status**: Draft
**Input**: User description: "Prepare the next open candidate External Support Desk / PSA Handoff as the narrowest repo-grounded slice that extends the already-implemented in-app support request flow with one-way external ticket create or link behavior, stores the resulting external reference on the existing support-request truth, and keeps visibility on the existing tenant and operation-run support contexts only."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already captures support requests with internal `SR-...` references, redacted context, and audit truth, but support follow-through still breaks at the product boundary because operators must create or paste external service-desk tickets manually outside the current workflow.
- **Today's failure**: A tenant or run-scoped support request can be submitted from the product, yet the product cannot tell the operator whether an external ticket was created, linked, or failed. That creates manual duplicate work, weakens audit continuity, and leaves no durable external-ticket linkage in the current support context.
- **User-visible improvement**: The existing `Request support` action can create a new external desk ticket or link an already-created ticket through one configured external desk target, then show the resulting external reference or explicit failure in the same tenant or run support context.
- **Smallest enterprise-capable version**: Extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` so one configured external support desk target can be used during the existing tenant-dashboard and operation-run support flows, persist the resulting external ticket reference or last handoff failure on the same support request, audit create or link outcomes, and surface the latest handoff summary only on those same support contexts.
- **Explicit non-goals**: No new support-request creation flow, no support-request resource/list/detail page, no support inbox or queue product, no generic helpdesk framework, no multi-provider adapter registry, no bidirectional sync, no external ticket status polling, no SLA engine, no retry scheduler, no AI support automation, and no cross-workspace or cross-tenant handoff shortcuts.
- **Permanent complexity imported**: One bounded provider-owned handoff adapter for a single configured external target, a small external-handoff mode family on the existing support-request truth, a nullable persisted external ticket reference and URL on `support_requests`, a bounded persisted handoff failure summary, targeted audit action IDs, and focused unit plus feature coverage.
- **Why now**: `docs/product/spec-candidates.md` and `docs/product/roadmap.md` both confirm that support-request creation is already repo-real and that the remaining commercialization gap is external handoff plus visible ticket linkage, not another internal support intake feature.
- **Why not local**: Page-local ticket creation or a manual copy field on each surface would duplicate logic that already lives in `SupportRequestSubmissionService`, would drift audit and failure behavior between tenant and run contexts, and would still not create one durable support-request-to-external-ticket truth.
- **Approval class**: Workflow Compression
- **Red flags triggered**: New provider seam, new persisted fields on an existing truth model, and multi-surface action changes. Defense: the slice extends one existing model and one existing submission path, stays on two already-support-aware surfaces, and explicitly forbids a generic helpdesk or queue framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant, canonical-view
- **Primary Routes**:
- existing tenant dashboard at `/admin/t/{tenant}` via `App\Filament\Pages\TenantDashboard`
- existing canonical operation detail at `/admin/operations/{run}` via `App\Filament\Pages\Operations\TenantlessOperationRunViewer`
- no new dedicated support desk or support-request route in v1
- **Data Ownership**:
- `support_requests` remains the canonical tenant-owned truth and continues to carry required `workspace_id` and `tenant_id`
- external handoff truth extends that same record only: external ticket reference, external ticket URL, handoff mode, and last handoff failure are stored on the existing support request rather than in a new ticket-link model or table
- one configured external support desk target is treated as application-configured integration truth and is referenced during handoff, but it is not mirrored into tenant-owned support-request records beyond the neutral external linkage fields needed for operator continuity and auditability
- **RBAC**:
- workspace membership and tenant entitlement remain the first isolation boundaries
- the existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability continues to gate support-request submission and any visible create-or-link external handoff controls
- non-members or actors not entitled to the workspace or tenant scope receive `404`
- members inside scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`
- run-context handoff and any latest-handoff summary on the run page resolve the run's tenant first and must not reveal linkage state for a tenant the actor cannot access
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: `N/A` - the feature does not add a canonical collection page; the operation-run surface stays bound to the currently opened run only.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any lookup used to show a latest external handoff summary on the operation-run support context must resolve through the current run's entitled tenant scope and the current workspace. Known internal support references or external ticket references must not bypass that scope check.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: header actions, contextual support capture, success and failure notifications, support-context summaries, audit events, and external-link navigation
- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\SupportRequests\SupportRequestSubmissionService`, `App\Support\SupportRequests\SupportRequestContextBuilder`, `App\Support\SupportRequests\SupportRequestReferenceGenerator`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and existing `UiEnforcement` capability gating on both support actions
- **Existing pattern(s) to extend**: the current `Request support` slide-over actions, current support-request success feedback, current support-diagnostics context summary, current audit logging path, and current tenant/run support authorization boundaries
- **Shared contract / presenter / builder / renderer to reuse**: `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-diagnostics bundle as the canonical redacted context source
- **Why the existing shared path is sufficient or insufficient**: The current shared path already assembles support-safe context, issues the internal `SR-...` reference, and writes audit truth consistently from both existing entry surfaces. It is insufficient only because it stops at internal persistence and cannot persist or surface external desk follow-through.
- **Allowed deviation and why**: One provider-owned external handoff adapter or service is allowed inside the support-request path because one configured external desk target must be called or normalized from both surfaces. No second page-local handoff client, no generic helpdesk registry, and no parallel support-summary vocabulary are allowed.
- **Consistency impact**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and handoff failure wording must have the same meaning on tenant and run surfaces, in success or failure notifications, and in audit summaries.
- **Review focus**: Reviewers must block any page-local external desk payload builder, any second support-ticket persistence model, and any new support status language that duplicates the existing support-request truth instead of extending it.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: The operation-run page continues to use the current run only as support context. External desk handoff must not create, resume, or otherwise mutate an `OperationRun`.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: provider-owned
- **Seams affected**: outbound create request payloads, external ticket URL and reference normalization, external-target credential resolution, provider-specific response parsing, and provider-specific failure normalization
- **Neutral platform terms preserved or introduced**: support request, support reference, external ticket, external ticket reference, external ticket URL, external handoff mode, external handoff failure, and latest handoff summary
- **Provider-specific semantics retained and why**: Authentication, request payload shape, URL templates, provider-specific ticket IDs, and provider-specific validation rules remain inside the one configured external desk adapter because only one concrete target exists in the current release slice.
- **Why this does not deepen provider coupling accidentally**: The `SupportRequest` record stores only neutral linkage truth needed for operator continuity: the external reference, optional URL, selected handoff mode, and explicit last failure summary. It does not store provider-specific fields such as assignee, queue, SLA, raw payloads, or external status history.
- **Follow-up path**: `follow-up-spec` only if a second real external desk target exists or the first target proves that a provider-neutral shared boundary is genuinely needed.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant dashboard `Request support` action | yes | Native Filament action + shared support primitives | header actions, support capture, support diagnostics, success or failure notifications | page, action form, bounded latest-handoff summary | yes | Existing tenant-dashboard action-surface exception remains bounded; the feature extends the current slide-over instead of adding a support page |
| Operation run `Request support` action | yes | Native Filament action + shared support primitives | grouped detail actions, support capture, monitoring-state support context, success or failure notifications | detail page, action form, bounded latest-handoff summary | no | Extends an already support-aware run-detail action instead of adding a second run-support surface |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant dashboard `Request support` action | Secondary Context Surface | The operator decides that the current tenant issue needs external escalation or explicit desk linkage | current support summary, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper support diagnostics remain on the neighboring `Open support diagnostics` action | Secondary because the operator is still primarily troubleshooting the tenant, not working in a support-desk inbox | Follows current tenant troubleshooting and support-escalation flow | Removes manual copy-paste and out-of-band ticket bookkeeping from the tenant troubleshooting path |
| Operation run `Request support` action | Secondary Context Surface | The operator decides that the current run already contains enough context to hand off or link to an external desk | run identity, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper run diagnostics remain on the existing support-diagnostics action and run detail sections | Secondary because the operator is still primarily inspecting one run | Follows current run drill-in workflow | Removes the need to recreate the same run context in an external desk after the operator has already drilled into it |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Tenant dashboard `Request support` action | operator-MSP, support-platform | support summary, selected mutation scope, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted support diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; any provider-specific fields stay inside the adapter and never appear as default-visible operator content | the slide-over states the current handoff truth once and links it to a specific internal support reference instead of duplicating support-request history blocks |
| Operation run `Request support` action | operator-MSP, support-platform | run identity, mutation scope note, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted run diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; run detail stays the primary evidence surface | the slide-over shows only the latest bounded linkage summary for this run context instead of becoming a support-request register |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard `Request support` action | Dashboard / Overview / Actions | Tenant support escalation entry point | submit the support request with one chosen external handoff mode | explicit header action opens the existing slide-over | forbidden | `Open support diagnostics` remains the neighboring secondary action; any external link stays inside the same support context summary | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | active workspace, active tenant, and current support context summary | Support request / External ticket | whether the next submit stays internal-only, creates an external ticket, or links an existing ticket | dashboard_exception - existing tenant dashboard action-surface exception remains bounded and justified by the dashboard's role as the tenant troubleshooting hub |
| Operation run `Request support` action | Record / Detail / Actions | Run-centered support escalation entry point | submit the support request with one chosen external handoff mode | explicit detail action in the existing grouped support actions | forbidden | `Open support diagnostics` remains grouped beside `Request support`; any external link stays inside the same support slide-over | none | `/admin/operations` | `/admin/operations/{run}` | workspace context, entitled tenant context, and current operation identifier | Support request / External ticket | whether the current run context already has an external ticket linkage or a visible last handoff failure | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard `Request support` action | Workspace manager or support-capable tenant operator | Decide whether this tenant issue should stay internal, create a new external ticket, or link an existing ticket | Dashboard action + contextual slide-over | How do I hand this tenant issue off without losing the current support-request truth? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and context attachment summary | full support diagnostics remain in the neighboring diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none |
| Operation run `Request support` action | Workspace manager or support-capable operator | Decide whether this run issue should stay internal, create a new external ticket, or link an existing ticket | Detail action + contextual slide-over | How do I hand this run issue off without recreating the case outside the product? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and run-context attachment summary | full run diagnostics remain in the run detail and diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no - the feature extends the existing `SupportRequest` truth rather than introducing a second ticket-link model or support queue truth
- **New persisted entity/table/artifact?**: no new table; yes, the existing `support_requests` truth gains bounded external handoff fields needed for operator continuity and auditability
- **New abstraction?**: yes - one provider-owned external handoff adapter or service for the single configured target
- **New enum/state/reason family?**: yes - one small external handoff mode family (`create_external_ticket`, `link_existing_ticket`, `internal_only`) with direct operator and mutation consequences
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: support requests already exist, but the product still cannot show whether an external desk ticket exists or was attempted from the same support context
- **Existing structure is insufficient because**: the current submission service and UI end at internal persistence and cannot safely call or normalize an external desk, store the resulting reference, or keep failure truth visible for the current context
- **Narrowest correct implementation**: extend the current `SupportRequest` submission path and current tenant or run support actions only, add one provider-owned handoff adapter for one configured target, and store only the minimal linkage truth on the same support request
- **Ownership cost**: extra `support_requests` columns, one provider-owned handoff adapter or service, stable audit IDs for external handoff outcomes, slightly richer action forms, and focused unit plus feature coverage
- **Alternative intentionally rejected**: a new `SupportTicket` model, a support-request detail resource, or a generic multi-provider helpdesk framework was rejected because the repo currently has only one real external desk use case and already has the support-request truth needed for v1
- **Release truth**: current-release support follow-through and commercialization gap, not future multi-provider preparation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: Unit coverage can prove the external handoff adapter normalization rules, handoff mode branching, and latest-handoff summary derivation cheaply. Focused Filament feature coverage can prove tenant and run action behavior, `404` versus `403` boundaries, persisted linkage truth, explicit failure handling, and audit events without needing browser-only coverage.
- **New or expanded test families**: one bounded `Unit/Support/SupportRequests/ExternalSupportDesk*` family and one bounded `Feature/SupportRequests/*ExternalHandoff*` family
- **Fixture / helper cost impact**: moderate. Reuse existing workspace, tenant, operation run, support request, and authorization fixtures. Add only the narrow target-configuration and adapter-fake setup needed for create or link success and failure paths.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, monitoring-state-page
- **Standard-native relief or required special coverage**: standard Filament action coverage is sufficient for the tenant dashboard action. The run-context action must also preserve the existing canonical monitoring-state-page authorization and context rules.
- **Reviewer handoff**: Reviewers must confirm that no support-request resource or queue page appears, that create failures keep the internal support request, that latest external linkage stays scoped to the current entitled context, and that no provider-specific payloads leak into the persisted support-request truth.
- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit plus feature coverage only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
## External Handoff Contract
The first slice extends the existing support-request truth instead of creating a second support-ticket product model.
| Handoff mode | Operator intent | External effect | Stored support-request truth | Default-visible result |
|---|---|---|---|---|
| `create_external_ticket` | Create a new external desk ticket from the current support context | One outbound create call through the configured external desk adapter | external ticket reference, optional external ticket URL, chosen mode, and cleared failure summary on success | success feedback shows the internal support reference plus the created external ticket reference |
| `link_existing_ticket` | Record an external ticket that already exists outside the product | No outbound ticket-create call; the bounded adapter may normalize the provided reference or URL for the configured target | operator-supplied external ticket reference, optional external ticket URL, chosen mode, and no provider payload mirror | success feedback shows the internal support reference plus the linked external ticket reference |
| `internal_only` | Keep the request internal-only when the operator intentionally defers external follow-through or when no target is configured for the application | No outbound call | no external ticket reference, chosen mode, and no external failure summary | the support context clearly states that no external ticket is linked yet |
Additional rules for v1:
- The internal `SR-...` support reference remains the canonical TenantPilot support-request identifier even when an external ticket exists.
- V1 does not store external assignee, SLA, comments, status history, or raw provider payloads.
- V1 does not auto-retry failed create calls. If a retry or relink path becomes necessary later, it requires a follow-up spec.
## Scope Boundaries
### In Scope
- extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` rather than introducing a new support domain model
- offer one bounded external handoff mode selector inside the current tenant and run `Request support` actions
- allow one support request to either create an external ticket, link an existing external ticket, or stay internal-only
- call exactly one application-configured external support desk or PSA target when the operator chooses `create_external_ticket`
- store the resulting external ticket reference and optional URL on the same support request record
- store a bounded last handoff failure summary on the same support request when external create fails after the internal request exists
- write explicit audit events for external ticket created, external ticket linked, and external handoff failed
- show the latest external handoff summary for the current tenant or run support context without adding a broad support-product surface
- keep current redacted support context attachment behavior from `SupportRequestContextBuilder`
### Non-Goals
- re-specifying or replacing support-request creation from Spec 246
- creating a `SupportRequestResource`, support-request register, or support-request detail page
- a generic ticketing or helpdesk framework with provider discovery or multiple adapters
- bidirectional sync, external ticket status refresh, webhook ingestion, or comment sync
- SLA, priority routing, assignment, support inbox, triage queue, or customer portal work
- AI-generated support summaries or automation
- background jobs or scheduled retries for external desk delivery
- cross-workspace or cross-tenant linking shortcuts based on a known support reference or ticket reference alone
## Assumptions
- Exactly one application-configured external support desk target can be resolved through a minimal config contract added in this slice. This spec does not introduce workspace settings UI, per-workspace target management, or a broader support-desk configuration product surface.
- The existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability is sufficient for v1. No new role family or support-only secondary capability is required.
- The current redacted support context envelope produced by `SupportRequestContextBuilder` is already the canonical payload basis for external handoff. This feature does not redefine the support context contract.
- Internal support-request creation remains allowed even when the external target is unavailable or an external create attempt fails, because the product must preserve the internal support truth and auditability.
## Risks
- A synchronous external create call can slow the current support action if the provider-owned handoff service does not enforce the v1 five-second timeout budget and normalize timeout failures into the same bounded failure-summary path.
- If a tenant or run has multiple support requests, the latest-handoff summary can mislead operators unless it also names the internal support reference it belongs to.
- Provider-specific response fields can leak into the support-request truth if the adapter boundary is not enforced strictly.
- The manual `link_existing_ticket` path could grow into a broader external-ticket management surface if it is allowed outside support-request submission. That growth is out of scope for v1.
## Follow-up Candidates
- a second external support desk or PSA target only after a concrete second target exists and the first target proves real operator value
- a bounded retry or relink flow from the same support contexts only if repeated external create failures become a proven operator pain point
- a read-only support-request register only if current tenant or run context visibility is no longer sufficient
- bidirectional sync or external ticket status refresh only if operators demonstrate a real need beyond stored reference continuity
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Create a new external ticket from the existing support flow (Priority: P1)
As a support-capable operator, I want the existing support-request action to create an external desk ticket from the current tenant or run context so I do not have to recreate the same case manually outside TenantPilot.
**Why this priority**: This is the direct commercialization gap named by the roadmap and candidate. Without outbound create, the product still stops at an internal support request only.
**Independent Test**: Submit a support request from the tenant dashboard and from the operation-run viewer with `create_external_ticket`, fake the configured external desk target, and verify that the support request keeps the internal `SR-...` reference while also storing the returned external ticket reference and URL.
**Acceptance Scenarios**:
1. **Given** an entitled operator opens the tenant dashboard and the application has one configured external desk target, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system creates the internal support request, creates one external ticket through the bounded adapter, stores the resulting external ticket reference on that same support request, and returns both references in success feedback.
2. **Given** an entitled operator opens an operation run that resolves to an entitled tenant, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system stores the internal support request with the run as primary context and persists the external ticket linkage on that same request.
3. **Given** the current context already has an earlier external handoff summary, **When** the operator opens the current `Request support` action again, **Then** the action shows the latest external linkage summary for that same context without turning the surface into a support-request history page.
---
### User Story 2 - Link an already-existing external ticket during support submission (Priority: P1)
As a support-capable operator who already opened a desk ticket outside TenantPilot, I want to link that ticket during support-request submission so the product records the same external reference without creating a duplicate external case.
**Why this priority**: The candidate explicitly requires create or link behavior, and linking an already-created external ticket is the smallest way to avoid duplicates without inventing a broader support-ticket management surface.
**Independent Test**: Submit the existing tenant or run `Request support` action with `link_existing_ticket`, provide a ticket reference and optional URL, and verify that the support request stores that linkage truth without issuing an external create call.
**Acceptance Scenarios**:
1. **Given** an entitled operator already has an external ticket reference, **When** the operator submits the existing tenant-context support action with `link_existing_ticket`, **Then** the system persists the provided external reference on the same support request and records an explicit audit event that the ticket was linked rather than created.
2. **Given** an entitled operator is on the operation-run support context, **When** the operator submits the action with `link_existing_ticket`, **Then** the system links the external reference to the run-scoped support request without creating a new external desk ticket.
3. **Given** the operator leaves the ticket reference blank or otherwise invalid for the bounded target format, **When** the action is submitted with `link_existing_ticket`, **Then** the system rejects the linkage input and does not create misleading external-ticket truth.
---
### User Story 3 - Keep failures explicit, scoped, and auditable (Priority: P2)
As a support-capable operator, I want external handoff failures to be explicit without losing the internal support request so I can continue follow-through safely and without guessing what happened.
**Why this priority**: The value of external handoff depends on failure honesty. Silent loss of the desk ticket or silent loss of the internal request would be worse than the current manual workflow.
**Independent Test**: Force the external adapter to fail during `create_external_ticket`, then verify that the internal support request remains persisted, the current support context shows the latest failure summary for that same support reference, and audit truth records the failed handoff.
**Acceptance Scenarios**:
1. **Given** the internal support request is created successfully but the external create call fails, **When** the action completes, **Then** the internal support request remains persisted, the operator receives explicit partial-success feedback with the internal support reference plus the handoff failure, and the failed handoff is audited.
2. **Given** a user is not entitled to the current workspace or tenant scope, **When** they attempt to access tenant or run external handoff state or submit the support action, **Then** the system returns `404` and reveals neither the internal support reference nor any external ticket reference.
3. **Given** a user is entitled to the tenant but lacks `Capabilities::SUPPORT_REQUESTS_CREATE`, **When** they attempt the same action, **Then** the system returns `403` and does not create, link, or reveal external handoff truth.
### Edge Cases
- The application may not have an external desk target configured. In that case the existing support-request flow must remain available in `internal_only` mode with an explicit note that no external target is configured.
- An external create call may fail after the internal support request is already committed. The request must remain the canonical support truth and must keep a bounded failure summary rather than disappearing or rolling back silently.
- A tenant or run can have multiple support requests over time. The visible handoff summary on the current support context must clearly identify which internal support reference the shown external ticket reference belongs to.
- An operator may know an external ticket reference but not a URL. The product may store the reference alone in v1 and must not invent a URL it cannot prove.
- The operation-run viewer can only surface latest handoff state when the run resolves to an entitled tenant. Runs without an entitled tenant must continue to resolve as `404` without leaking any linkage hint.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds a synchronous outbound create call to one configured external support desk target as part of an existing support-request mutation. It does not create a new `OperationRun`, queue, or scheduler. Successful internal request creation, external ticket creation, external ticket linking, and external handoff failure MUST all be auditable.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature extends the existing `SupportRequest` truth instead of adding a second support-ticket model or queue. The only new semantic family is one bounded handoff mode family because operator choice and resulting mutation behavior differ materially between create, link, and internal-only paths.
**Constitution alignment (XCUT-001):** Existing `Request support` actions, support-diagnostics context, `SupportRequestSubmissionService`, and `WorkspaceAuditLogger` must be reused. Any new external handoff behavior must plug into that shared path instead of creating separate tenant and run implementations.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** External handoff truth must stay secondary to the current tenant or run troubleshooting workflow. The current support contexts should show only the bounded latest linkage summary or failure, while diagnostics remain separately opened and raw provider details remain hidden.
**Constitution alignment (PROV-001):** External desk payloads, authentication, and provider-specific identifiers remain provider-owned. The shared product truth remains the existing `SupportRequest` plus neutral external linkage fields only.
**Constitution alignment (TEST-GOV-001):** Proof stays in unit plus feature lanes only. Browser and heavy-governance coverage are out of scope for the first slice.
**Constitution alignment (RBAC-UX):** The affected authorization plane remains the tenant-admin `/admin` plane. Non-members and non-entitled users receive `404`. Entitled users lacking `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`. No raw capability strings or role-string checks may appear in feature code.
**Constitution alignment (UI-FIL-001):** The feature must continue to use native Filament actions and action forms on the current pages. No custom standalone support desk page or local replacement shell is allowed.
**Constitution alignment (UI-NAMING-001):** Primary operator-facing copy must preserve `Request support`, `Support reference`, and `External ticket`. Provider-specific product names, payload terminology, or API vocabulary must not replace those primary labels.
### Functional Requirements
- **FR-256-001 Existing surfaces only**: The system MUST extend only the existing tenant dashboard and canonical operation-run `Request support` actions for v1. It MUST NOT introduce a new support-request resource, support-request detail view, or support queue page.
- **FR-256-002 Bounded handoff mode choice**: When the application has a configured external desk target, the existing `Request support` action MUST let the operator choose exactly one of `create_external_ticket`, `link_existing_ticket`, or `internal_only`. When no target is configured, the action MUST remain available in `internal_only` mode and MUST explain that no external desk target is configured.
- **FR-256-003 Internal request remains canonical**: Every path in this feature MUST create or preserve the existing internal `SupportRequest` truth first. The internal `SR-...` reference remains the canonical support-request identifier even when an external ticket is created or linked.
- **FR-256-003A Bounded finalization exception to Spec 246 immutability**: Spec 256 explicitly narrows Spec 246 FR-246-011 in one bounded way: after internal request creation, the same `SupportRequest` row MAY receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No broader edit, reopen, merge, or status workflow is introduced.
- **FR-256-004 External create path**: When the operator selects `create_external_ticket`, the system MUST call exactly one application-configured external support desk target through one bounded provider-owned adapter, apply a maximum five-second outbound timeout, store the returned external ticket reference on the same support request, and store the external ticket URL when the target returns one.
- **FR-256-005 External link path**: When the operator selects `link_existing_ticket`, the system MUST store the provided external ticket reference on the same support request and MUST NOT issue an external ticket-create call for that request.
- **FR-256-006 Persisted linkage truth**: The existing `support_requests` truth MUST be extended with only the neutral external linkage fields needed for operator continuity: external ticket reference, optional external ticket URL, selected handoff mode, and bounded last handoff failure summary.
- **FR-256-007 No mirrored external lifecycle**: V1 MUST NOT persist or display external assignee, SLA, queue, comment stream, status history, or raw provider payloads.
- **FR-256-008 Failure honesty**: If the external create path fails after the internal support request exists, the system MUST keep the internal request, persist a bounded last handoff failure summary on that same request, and show explicit feedback that the internal request succeeded but the external handoff failed.
- **FR-256-009 Context-safe visibility**: The current tenant and run support contexts MUST show the latest external handoff summary for that same primary context, including the internal support reference it belongs to, without becoming a broad support-request history surface.
- **FR-256-010 Audit coverage**: The system MUST write stable audit entries for support request created, external ticket created, external ticket linked, and external handoff failed, with workspace and tenant context plus the internal support reference and the external ticket reference when present.
- **FR-256-011 Authorization boundaries**: Non-members and non-entitled actors MUST receive `404`. Members in scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` MUST receive `403`. Latest-handoff visibility, create, and link behavior MUST all enforce the same boundary.
- **FR-256-012 Provider boundary**: Provider-specific authentication, request payload shape, response parsing, and URL normalization MUST remain inside one provider-owned adapter or service. Shared platform code MUST work only with the neutral external linkage truth stored on `SupportRequest`.
- **FR-256-013 No background expansion**: V1 MUST NOT add background jobs, retry scheduling, webhook ingestion, or `OperationRun` usage for external desk delivery.
- **FR-256-014 No cross-scope shortcuts**: A known internal support reference or external ticket reference MUST NOT be sufficient to reveal or mutate linkage truth outside the current entitled workspace and tenant scope.
- **FR-256-015 Mutation-scope clarity**: The existing support actions MUST make it clear whether the current submission writes to `TenantPilot only` or to `TenantPilot + external support desk`, based on the selected handoff mode.
- **FR-256-016 Timeout normalization**: When `create_external_ticket` exceeds the five-second outbound timeout budget or times out for any other target-level reason, the system MUST keep the internal support request, persist a bounded timeout-oriented failure summary on the same row, and route the outcome through the same explicit feedback and audit path as other external create failures.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard support context | `App\Filament\Pages\TenantDashboard` | `Request support`, `Open support diagnostics` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | Existing dashboard action-surface exemption remains. The feature only extends the current `Request support` action with handoff mode choice and latest linkage summary. |
| Operation-run support context | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | grouped `Open support diagnostics`, `Request support` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | No new run action group or support page. The feature only extends the existing support action with handoff mode choice and latest linkage summary. |
## Key Entities *(include if feature involves data)*
- **Support Request**: Existing tenant-owned support truth with internal reference, primary context, redacted context envelope, severity, and the new bounded external linkage fields needed for external handoff continuity.
- **External Support Desk Target**: The single application-configured external desk or PSA destination used for v1 handoff. It owns provider-specific authentication and payload semantics.
- **External Ticket Linkage**: The bounded support-request extension that records whether the current request stayed internal-only, created an external ticket, or linked an existing one, together with the neutral external ticket reference, optional URL, and last failure summary.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: From the existing tenant dashboard or operation-run support context, an authorized operator can complete support-request submission with external create or link behavior in one flow without leaving the current page to recreate the case manually.
- **SC-002**: 100% of successful external create or link submissions persist an external ticket reference on the same support request and make that reference visible again from the same entitled support context on revisit.
- **SC-003**: 100% of external create failures leave the internal support request intact, produce explicit operator-visible failure feedback, and write an audit entry for the failed handoff.
- **SC-004**: Authorization tests prove that operators never see or mutate external ticket linkage for a workspace or tenant they are not entitled to, even when they know an internal support reference or external ticket reference.

View File

@ -0,0 +1,192 @@
---
description: "Task list for External Support Desk / PSA Handoff"
---
# Tasks: External Support Desk / PSA Handoff
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md`
**Support truth**: Spec 246 and the existing repo code remain authoritative, except for one bounded Spec 256 finalization exception: after internal request creation, the same `SupportRequest` row may receive exactly one synchronous write limited to the external handoff fields. Extend `apps/platform/app/Models/SupportRequest.php` and the current support-request submission path only; do not add a second support-ticket entity, support queue, support register, or support-request resource.
**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in focused unit plus feature lanes only, then run the narrow manual smoke path from `quickstart.md`.
**Operations**: This slice must stay synchronous inside the existing support-request path. Do not create, queue, resume, or complete an `OperationRun`.
**RBAC**: Workspace membership and tenant entitlement remain `404` boundaries; in-scope members missing `Capabilities::SUPPORT_REQUESTS_CREATE` remain `403`; latest-handoff visibility must follow the same boundary.
**Provider boundary**: One configured external desk target only. No helpdesk registry, no target-management UI, and no multi-provider framework in this slice.
**Organization**: Tasks are grouped by user story so create, link, and explicit-failure behavior can be implemented and validated independently once the shared foundation exists.
## Phase 1: Setup (Shared Preparation)
**Purpose**: Lock the bounded repo-grounded scope before runtime work begins.
- [x] T001 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` and confirm the slice stays one-way, single-target, and SupportRequest-backed.
- [x] T002 [P] Verify the exact reuse seams from Spec 246 in `apps/platform/app/Models/SupportRequest.php`, `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and the new app-config seam `apps/platform/config/support_desk.php` before adding any new handoff behavior.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the bounded persistence, target-resolution, audit, and shared summary seams that every story depends on.
**Critical**: No user story work should begin until this phase is complete.
- [x] T003 Extend the existing support-request truth with `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary` in `apps/platform/database/migrations/*_add_external_handoff_fields_to_support_requests_table.php`, `apps/platform/app/Models/SupportRequest.php`, and `apps/platform/database/factories/SupportRequestFactory.php` without creating a second support-ticket model or table.
- [x] T004 [P] Add the one concrete provider-owned handoff seam and the single-target app-config contract in `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php` and `apps/platform/config/support_desk.php`, enforce the five-second outbound timeout there, and avoid introducing a support settings UI, provider registry, or generic helpdesk framework.
- [x] T005 [P] Preserve the existing `support_request.created` audit path and add stable audit action IDs plus bounded audit payload helpers for external ticket created, external ticket linked, and external handoff failed in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`.
- [x] T006 Add one shared latest-handoff summary read path for tenant and run primary contexts in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so both existing support actions reuse the same scoped query, naming, and no-cross-scope-shortcut rules.
**Checkpoint**: Foundation ready. The existing support-request path can now persist neutral external-linkage truth, resolve one target, and read the latest scoped handoff summary.
---
## Phase 3: User Story 1 - Create A New External Ticket From The Existing Support Flow (Priority: P1) 🎯 MVP
**Goal**: An entitled operator can submit the existing support action and create one external desk ticket from the current tenant or run context without leaving the product.
**Independent Test**: Submit `Request support` from the tenant dashboard and the operation-run viewer with `create_external_ticket`, fake one configured target, and verify the same `SupportRequest` row keeps the internal `SR-...` reference while storing the returned external reference and URL.
### Tests for User Story 1
- [x] T007 [P] [US1] Add unit coverage for `create_external_ticket` branching, single-target availability fallback, the five-second timeout path, and created-ticket reference or URL normalization in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`.
- [x] T008 [P] [US1] Add feature coverage for tenant and run `create_external_ticket` success paths in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
### Implementation for User Story 1
- [x] T009 [US1] Extend `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so internal support-request creation commits first, `create_external_ticket` runs synchronously afterward, and the one allowed Spec 256 finalization write records the external reference or URL back onto the same `SupportRequest` row.
- [x] T010 [US1] Extend the tenant dashboard support action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with handoff-mode choice, target-availability guidance, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, latest-handoff summary copy, and success feedback that shows both internal and external references when a ticket is created.
- [x] T011 [US1] Extend the run-context support action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with the same create flow, scoped latest-handoff summary, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, and success feedback without adding a new run-support surface.
- [x] T012 [US1] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any create-path regressions before moving to the link flow.
**Checkpoint**: User Story 1 is independently functional when both existing support actions can create one external ticket and immediately show the persisted linkage on the same support-request truth.
---
## Phase 4: User Story 2 - Link An Already-Existing External Ticket During Support Submission (Priority: P1)
**Goal**: An entitled operator can record an external ticket that already exists without creating a duplicate external case.
**Independent Test**: Submit the existing tenant and run `Request support` actions with `link_existing_ticket`, provide a valid reference and optional URL, and verify the `SupportRequest` stores that linkage without issuing an external create call.
### Tests for User Story 2
- [x] T013 [P] [US2] Add unit coverage for `link_existing_ticket` reference normalization, optional URL normalization, and invalid-link rejection in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`.
- [x] T014 [P] [US2] Add feature coverage for tenant and run `link_existing_ticket` submissions, including the no-create-call guarantee and linked-flow success feedback that shows both references, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
### Implementation for User Story 2
- [x] T015 [US2] Implement `link_existing_ticket` branching, conditional validation, and persisted external reference or URL behavior in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` and `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`.
- [x] T016 [US2] Add conditional external reference and URL inputs plus linked-flow success feedback that shows the internal and external references on the tenant dashboard action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
- [x] T017 [US2] Add the same link controls plus linked-flow success feedback to the run-context action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
- [x] T018 [US2] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any link-path regressions before moving to failure hardening.
**Checkpoint**: User Story 2 is independently functional when both existing support actions can link an already-created external ticket without producing a duplicate external create call.
---
## Phase 5: User Story 3 - Keep Failures Explicit, Scoped, And Auditable (Priority: P2)
**Goal**: External handoff failure remains visible and auditable while the internal support request stays durable and the same tenant or run contexts stay the only visibility surfaces.
**Independent Test**: Force `create_external_ticket` to fail after internal request creation, then verify the internal `SupportRequest` remains persisted, the current support context shows the latest failure summary for that same support reference, and audit plus authorization behavior stays correct.
### Tests for User Story 3
- [x] T019 [P] [US3] Add unit coverage for latest-handoff summary derivation, latest-per-context selection, and persisted failure-summary semantics in `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`.
- [x] T020 [P] [US3] Add feature coverage for tenant and run failed-create partial-success behavior, including timeout-normalized failure feedback, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
- [x] T021 [P] [US3] Add feature coverage for `404` versus `403` boundaries and context-scoped latest-summary visibility in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php`.
- [x] T022 [P] [US3] Add feature coverage for preserved `support_request.created` auditing plus created, linked, and failed external-handoff audit events in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`.
### Implementation for User Story 3
- [x] T023 [US3] Persist bounded `external_handoff_failure_summary` semantics, the one allowed Spec 256 finalization-write contract, and latest-summary scoping rules in `apps/platform/app/Models/SupportRequest.php` and `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` without adding support history pages, external-reference lookup routes, or a second support product surface.
- [x] T024 [US3] Implement explicit partial-success or warning feedback plus revisit-time failure-summary rendering in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
- [x] T025 [US3] Enforce the shared authorization and audit boundary for create, link, failure, and latest-summary visibility in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` without introducing queues, retries, or `OperationRun` orchestration.
- [x] T026 [US3] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` and fix any failure, audit, or authorization regressions before final polish.
**Checkpoint**: User Story 3 is independently functional when explicit failure truth, scoped visibility, and audit coverage all hold without losing the internal support request.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Close the slice without widening scope, and leave a clean validation and guardrail trail for review.
- [x] T027 Confirm `Request support`, `Support reference`, `External ticket`, handoff-mode labels, mutation-scope wording, and latest-summary copy stay aligned across `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` without leaking provider-specific product names into primary operator copy.
- [x] T028 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` on the touched platform files before final validation.
- [x] T029 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` as the focused unit close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`.
- [x] T030 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` as the focused feature close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`.
- [x] T031 Execute the manual smoke path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md` for tenant and run create, link, and failure handling, including the no-new-support-surface and no-`OperationRun` checks. Completed through a temporary Pest Browser smoke harness covering tenant create, run link, run failure, latest failure summary, no console errors, and no persistent browser-test surface.
- [x] T032 Record the final implementation close-out in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, including the guardrail outcome and any explicit `document-in-feature` or named `follow-up-spec` decision for target configuration, retry pressure, or multi-provider pressure instead of hiding that scope in code review.
---
## Dependencies & Execution Order
### Phase Dependencies
- Phase 1 starts immediately.
- Phase 2 depends on Phase 1 and blocks all story work.
- Phase 3 depends on Phase 2 and delivers the MVP create flow.
- Phase 4 depends on Phase 2 and is safest after Phase 3 because it extends the same submission service and the same two action forms.
- Phase 5 depends on Phases 3 and 4 because failure, visibility, and audit proof must cover both create and link behavior on both existing surfaces.
- Phase 6 depends on every prior phase.
### User Story Dependencies
- US1 is the MVP and first shippable increment.
- US2 is independently testable but should follow US1 because both stories extend the same `SupportRequestSubmissionService` and support-action forms.
- US3 depends on US1 and US2 because explicit failure, audit, and scoped-visibility rules must cover every handoff mode.
### Within Each User Story
- Write the listed Pest coverage first and ensure it fails before implementation.
- Land shared submission-service changes before surface wiring whenever both are required.
- Re-run the story-specific validation task before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T001 and T002 can run in parallel.
### Phase 2
- T004 and T005 can run in parallel after T003 establishes the persisted handoff fields.
### User Story 1
- T007 and T008 can run in parallel before implementation work starts.
### User Story 2
- T013 and T014 can run in parallel before implementation work starts.
### User Story 3
- T019, T020, T021, and T022 can run in parallel before the failure hardening pass.
---
## Implementation Strategy
### MVP First
1. Complete Phase 1.
2. Complete Phase 2.
3. Complete Phase 3.
4. Stop and review the create-only external handoff slice before adding link and failure hardening.
### Incremental Delivery
1. Ship US1 so the product can create one external ticket from the two existing support-aware surfaces.
2. Add US2 so operators can link an already-opened external ticket without duplicate create behavior.
3. Add US3 so failure honesty, scoped visibility, and audit proof hold across every handoff mode.
### Team Strategy
1. Finish Phase 2 together before splitting story work.
2. Parallelize test authoring inside each story.
3. Sequence merges carefully around `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/lang/en/localization.php` plus `apps/platform/lang/de/localization.php`, because every story touches those same shared seams.