M3 Integration fixes

This commit is contained in:
Mark Gregory
2026-09-21 10:14:57 +01:00
parent 4af997c465
commit 6d60cbff84
5853 changed files with 79032 additions and 1825 deletions
View File
View File
+1 -2
View File
@@ -6,12 +6,12 @@ namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Modules\Helpdesk\Models\Ticket;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -198,5 +198,4 @@ final class AttachmentDownloadController extends Controller
default => 'text',
};
}
}
View File
+146 -100
View File
@@ -15,6 +15,20 @@ use Modules\Helpdesk\Services\GitWebhookIngestionService;
final class GitWebhookController
{
private const REJECTED = 'rejected';
private const SIGNATURE_FAILED = 'Git webhook signature or token validation failed.';
private const SHA256_PREFIX = 'sha256=';
private const SHA1_PREFIX = 'sha1=';
private const REQUEST_ID_HEADER = 'X-Request-Id';
private const GITHUB_DELIVERY_HEADER = 'X-GitHub-Delivery';
private const GITHUB_EVENT_HEADER = 'X-GitHub-Event';
/**
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
*/
@@ -24,34 +38,31 @@ final class GitWebhookController
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
$status = 202;
$payload = [];
if (! $repository->is_active) {
$result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 403);
$result = $this->rejectedResult('Repository is inactive.');
$status = 403;
} elseif (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
$status = 403;
} else {
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
} catch (JsonException) {
$result = $this->rejectedResult('Invalid JSON webhook payload.');
$status = 400;
}
}
if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 403);
if ($status !== 202) {
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
}
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
return response()->json($result, 400);
}
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
return response()->json($result, 202);
return response()->json($result, $status);
}
/**
@@ -63,42 +74,34 @@ final class GitWebhookController
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
$status = 202;
$repository = null;
try {
/** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
$repository = $this->repositoryForGiteaPayload($payload);
if (! $repository instanceof GitRepository) {
$result = $this->rejectedResult('No active Helpdesk Git repository matched the Gitea webhook payload.');
$status = 404;
} elseif (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
$result = $this->rejectedResult(self::SIGNATURE_FAILED);
$status = 403;
} else {
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
}
} catch (JsonException) {
return response()->json([
'status' => 'rejected',
'message' => 'Invalid JSON webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 400);
$payload = [];
$result = $this->rejectedResult('Invalid JSON webhook payload.');
$status = 400;
}
$repository = $this->repositoryForGiteaPayload($payload);
if (! $repository instanceof GitRepository) {
return response()->json([
'status' => 'rejected',
'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.',
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
], 404);
}
if (! $this->hasValidGiteaSecurity($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
if ($repository instanceof GitRepository && $status !== 202) {
$service->logWebhook($repository, $provider, $deliveryId, $event, $result, $payload);
return response()->json($result, 403);
}
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
return response()->json($result, 202);
return response()->json($result, $status);
}
/**
@@ -143,8 +146,8 @@ final class GitWebhookController
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
}
/**
@@ -158,8 +161,8 @@ final class GitWebhookController
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
return hash_equals($sha256, $giteaSignature)
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|| hash_equals(self::SHA256_PREFIX . $sha256, $giteaSignature)
|| hash_equals(self::SHA1_PREFIX . $sha1, $giteaSignature)
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
}
@@ -171,7 +174,7 @@ final class GitWebhookController
{
$signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
return hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
}
@@ -184,8 +187,8 @@ final class GitWebhookController
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
return hash_equals($secret, $token)
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
|| hash_equals(self::SHA256_PREFIX . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals(self::SHA1_PREFIX . hash_hmac('sha1', $rawPayload, $secret), $signature);
}
/**
@@ -230,63 +233,94 @@ final class GitWebhookController
->where('is_active', true)
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
->get();
$matchedRepository = null;
if ($repositories->isEmpty()) {
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
} else {
$normalisedPayloadValues = $this->normalisedPayloadRepositoryValues($payload);
$matchedRepository = $repositories->first(
fn (GitRepository $repository): bool => $this->repositoryMatchesPayload($repository, $normalisedPayloadValues),
);
return null;
if (! $matchedRepository instanceof GitRepository && $repositories->count() === 1) {
$matchedRepository = $repositories->first();
} elseif (! $matchedRepository instanceof GitRepository) {
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
'configured_repository_count' => $repositories->count(),
]);
}
}
$payloadValues = array_values(array_filter([
return $matchedRepository instanceof GitRepository ? $matchedRepository : null;
}
/** @param array<string, mixed> $payload
* @return list<string>
*/
private function normalisedPayloadRepositoryValues(array $payload): array
{
$name = Arr::get($payload, 'repository.name');
$owner = Arr::get($payload, 'repository.owner.username');
return $this->normaliseRepositoryValues([
Arr::get($payload, 'repository.full_name'),
Arr::get($payload, 'repository.name'),
$name,
Arr::get($payload, 'repository.html_url'),
Arr::get($payload, 'repository.clone_url'),
Arr::get($payload, 'repository.ssh_url'),
Arr::get($payload, 'repository.url'),
Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name')
? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name')
: null,
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''));
$owner && $name ? $owner . '/' . $name : null,
]);
}
$normalisedPayloadValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$payloadValues,
)));
/** @param list<string> $payloadValues */
private function repositoryMatchesPayload(GitRepository $repository, array $payloadValues): bool
{
$repositoryValues = $this->normaliseRepositoryValues([
$repository->remote_url,
$repository->name,
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
]);
foreach ($repositories as $repository) {
$repositoryValues = array_filter([
$repository->remote_url,
$repository->name,
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '');
$normalisedRepositoryValues = array_values(array_filter(array_map(
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
$repositoryValues,
)));
foreach ($normalisedPayloadValues as $payloadValue) {
foreach ($normalisedRepositoryValues as $repositoryValue) {
if ($payloadValue === $repositoryValue
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|| str_ends_with($payloadValue, '/' . $repositoryValue)) {
return $repository;
}
foreach ($payloadValues as $payloadValue) {
foreach ($repositoryValues as $repositoryValue) {
if ($this->repositoryIdentitiesMatch($payloadValue, $repositoryValue)) {
return true;
}
}
}
if ($repositories->count() === 1) {
return $repositories->first();
return false;
}
/** @param array<int, mixed> $values
* @return list<string>
*/
private function normaliseRepositoryValues(array $values): array
{
$normalised = [];
foreach ($values as $value) {
if (! is_scalar($value) || trim((string) $value) === '') {
continue;
}
$identity = $this->normaliseRepositoryIdentity((string) $value);
if ($identity !== null) {
$normalised[] = $identity;
}
}
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
'configured_repository_count' => $repositories->count(),
]);
return $normalised;
}
return null;
private function repositoryIdentitiesMatch(string $payloadValue, string $repositoryValue): bool
{
return $payloadValue === $repositoryValue
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|| str_ends_with($payloadValue, '/' . $repositoryValue);
}
/**
@@ -325,17 +359,29 @@ final class GitWebhookController
return implode('/', array_slice($segments, -2));
}
/** @return array{status: string, message: string, commit_count: int, linked_count: int, ignored_count: int} */
private function rejectedResult(string $message): array
{
return [
'status' => self::REJECTED,
'message' => $message,
'commit_count' => 0,
'linked_count' => 0,
'ignored_count' => 1,
];
}
/**
* Read the event name from provider-specific headers.
*/
private function eventName(Request $request, string $provider): ?string
{
return match ($provider) {
'github' => $request->header('X-GitHub-Event'),
'github' => $request->header(self::GITHUB_EVENT_HEADER),
'gitlab' => $request->header('X-Gitlab-Event'),
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header(self::GITHUB_EVENT_HEADER),
'bitbucket' => $request->header('X-Event-Key'),
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
default => $request->header('X-Helpdesk-Event') ?: $request->header(self::GITHUB_EVENT_HEADER) ?: $request->header('X-Gitlab-Event') ?: 'push',
};
}
@@ -345,11 +391,11 @@ final class GitWebhookController
private function deliveryId(Request $request, string $provider): ?string
{
return match ($provider) {
'github' => $request->header('X-GitHub-Delivery'),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
'github' => $request->header(self::GITHUB_DELIVERY_HEADER),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header(self::REQUEST_ID_HEADER),
default => $request->header('X-Helpdesk-Delivery') ?: $request->header(self::GITHUB_DELIVERY_HEADER) ?: $request->header(self::REQUEST_ID_HEADER),
};
}
}
View File
+1 -1
View File
@@ -45,6 +45,7 @@ final class TicketAttachmentStoreController extends Controller
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
*
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
@@ -71,5 +72,4 @@ final class TicketAttachmentStoreController extends Controller
->values()
->all();
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Exceptions\TicketPersistenceException;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
@@ -68,7 +69,7 @@ final class TicketBoardController extends Controller
->update($updates);
if ($affected < 1) {
throw new \RuntimeException('Ticket board update did not persist.');
throw new TicketPersistenceException('Ticket board update did not persist.');
}
return Ticket::query()
+42 -41
View File
@@ -25,51 +25,52 @@ final class TicketCommentDeleteController extends Controller
abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404);
DB::transaction(function () use ($comment, $ticket, $user): void {
$updates = [];
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
$updates['tombstoned_at'] = now();
}
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
$updates['deleted_by'] = (int) $user->id;
}
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
$updates['delete_reason'] = 'Deleted by MPBA platform administrator.';
}
if (Schema::hasColumn('helpdesk_comments', 'sync_sequence')) {
$updates['sync_sequence'] = ((int) ($comment->sync_sequence ?? 0)) + 1;
}
if (Schema::hasColumn('helpdesk_comments', 'sync_hash')) {
$updates['sync_hash'] = hash('sha256', implode('|', [
(string) ($comment->guid ?? $comment->id),
'deleted',
now()->toISOString(),
]));
}
if (Schema::hasColumn('helpdesk_comments', 'updated_at')) {
$updates['updated_at'] = now();
}
if ($updates !== []) {
DB::table('helpdesk_comments')
->where('id', $comment->id)
->update($updates);
} else {
$comment->delete();
}
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
$ticket->forceFill(['last_activity_at' => now()])->save();
}
$this->deleteComment($comment, (int) $user->id);
$this->touchTicket($ticket);
});
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-deleted', 'Response deleted.');
}
private function deleteComment(Comment $comment, int $userId): void
{
$updates = $this->tombstoneUpdates($comment, $userId);
if ($updates === []) {
$comment->delete();
return;
}
DB::table('helpdesk_comments')->where('id', $comment->id)->update($updates);
}
/** @return array<string, mixed> */
private function tombstoneUpdates(Comment $comment, int $userId): array
{
$now = now();
$candidates = [
'tombstoned_at' => $now,
'deleted_by' => $userId,
'delete_reason' => 'Deleted by MPBA platform administrator.',
'sync_sequence' => ((int) ($comment->sync_sequence ?? 0)) + 1,
'sync_hash' => hash('sha256', implode('|', [(string) ($comment->guid ?? $comment->id), 'deleted', $now->toISOString()])),
'updated_at' => $now,
];
return array_filter(
$candidates,
static fn (mixed $value, string $column): bool => Schema::hasColumn('helpdesk_comments', $column),
ARRAY_FILTER_USE_BOTH,
);
}
private function touchTicket(Ticket $ticket): void
{
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
$ticket->forceFill(['last_activity_at' => now()])->save();
}
}
}
+1 -1
View File
@@ -170,6 +170,7 @@ final class TicketCommentStoreController extends Controller
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
*
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
@@ -196,5 +197,4 @@ final class TicketCommentStoreController extends Controller
->values()
->all();
}
}
View File
+1 -1
View File
@@ -63,6 +63,7 @@ final class TicketStoreController extends Controller
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
*
* @param bool $required Whether at least one uploaded file is required.
*
* @return array<int, string>
*/
private function attachmentFileRules(bool $required = false): array
@@ -89,5 +90,4 @@ final class TicketStoreController extends Controller
->values()
->all();
}
}
+61 -52
View File
@@ -35,62 +35,71 @@ final class TicketTimeLogController extends Controller
$loggedAt = now();
DB::transaction(function () use ($ticket, $request, $minutes, $description, $loggedAt): void {
if (Schema::hasColumn($ticket->getTable(), 'time_spent_minutes')) {
$ticket->increment('time_spent_minutes', $minutes, [
'last_activity_at' => $loggedAt,
]);
} elseif (Schema::hasColumn($ticket->getTable(), 'last_activity_at')) {
$ticket->forceFill(['last_activity_at' => $loggedAt])->save();
}
if (Schema::hasTable('helpdesk_time_logs')) {
DB::table('helpdesk_time_logs')->insert([
'ticket_id' => $ticket->getKey(),
'user_id' => $request->user()->getKey(),
'minutes' => $minutes,
'description' => $description,
'logged_at' => $loggedAt,
'created_at' => $loggedAt,
'updated_at' => $loggedAt,
]);
}
if (Schema::hasTable('helpdesk_ticket_time_logs')) {
$payload = [
'ticket_id' => $ticket->getKey(),
'user_id' => $request->user()->getKey(),
'minutes' => $minutes,
'description' => $description,
'created_at' => $loggedAt,
'updated_at' => $loggedAt,
];
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'started_at')) {
$payload['started_at'] = $loggedAt->copy()->subMinutes($minutes);
}
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'ended_at')) {
$payload['ended_at'] = $loggedAt;
}
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) {
$payload['logged_at'] = $loggedAt;
}
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'duration_seconds')) {
$payload['duration_seconds'] = $minutes * 60;
}
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
$payload['seconds'] = $minutes * 60;
}
DB::table('helpdesk_ticket_time_logs')->insert($payload);
}
$this->updateTicketActivity($ticket, $minutes, $loggedAt);
$this->storeLegacyTimeLog($ticket, $request, $minutes, $description, $loggedAt);
$this->storeTicketTimeLog($ticket, $request, $minutes, $description, $loggedAt);
});
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-action-updated', 'Time logged successfully.');
}
private function updateTicketActivity(Ticket $ticket, int $minutes, $loggedAt): void
{
if (Schema::hasColumn($ticket->getTable(), 'time_spent_minutes')) {
$ticket->increment('time_spent_minutes', $minutes, ['last_activity_at' => $loggedAt]);
} elseif (Schema::hasColumn($ticket->getTable(), 'last_activity_at')) {
$ticket->forceFill(['last_activity_at' => $loggedAt])->save();
}
}
private function storeLegacyTimeLog(Ticket $ticket, Request $request, int $minutes, ?string $description, $loggedAt): void
{
if (! Schema::hasTable('helpdesk_time_logs')) {
return;
}
DB::table('helpdesk_time_logs')->insert([
'ticket_id' => $ticket->getKey(),
'user_id' => $request->user()->getKey(),
'minutes' => $minutes,
'description' => $description,
'logged_at' => $loggedAt,
'created_at' => $loggedAt,
'updated_at' => $loggedAt,
]);
}
private function storeTicketTimeLog(Ticket $ticket, Request $request, int $minutes, ?string $description, $loggedAt): void
{
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
return;
}
$payload = [
'ticket_id' => $ticket->getKey(),
'user_id' => $request->user()->getKey(),
'minutes' => $minutes,
'description' => $description,
'created_at' => $loggedAt,
'updated_at' => $loggedAt,
];
$optional = [
'started_at' => $loggedAt->copy()->subMinutes($minutes),
'ended_at' => $loggedAt,
'logged_at' => $loggedAt,
'duration_seconds' => $minutes * 60,
'seconds' => $minutes * 60,
];
foreach ($optional as $column => $value) {
if (Schema::hasColumn('helpdesk_ticket_time_logs', $column)) {
$payload[$column] = $value;
}
}
DB::table('helpdesk_ticket_time_logs')->insert($payload);
}
}
View File
View File
View File