Migrate HickLab3D dashboard and add production auto deployment
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 19s
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 19s
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class Bambuddy
|
||||
{
|
||||
public function configured(): bool
|
||||
{
|
||||
return filled(config('services.bambuddy.url')) && filled(config('services.bambuddy.api_key'));
|
||||
}
|
||||
|
||||
public function printers(): array
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
throw new RuntimeException('BamBuddy API key is not configured.');
|
||||
}
|
||||
|
||||
$url = rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers';
|
||||
|
||||
$printers = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')])
|
||||
->acceptJson()->connectTimeout(3)->timeout(8)->get($url)->throw()->json() ?? [];
|
||||
|
||||
return collect($printers)->filter(fn ($printer) => is_array($printer) && isset($printer['id']))
|
||||
->take(20)->map(function (array $printer): array {
|
||||
try {
|
||||
$status = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')])
|
||||
->acceptJson()->connectTimeout(3)->timeout(5)
|
||||
->get(rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers/'.(int) $printer['id'].'/status')
|
||||
->throw()->json() ?? [];
|
||||
|
||||
return $printer + ['live' => $status];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return $printer + ['live' => []];
|
||||
}
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class DryerClient
|
||||
{
|
||||
private function url(string $path): string
|
||||
{
|
||||
$base = config('services.dryer.url');
|
||||
if (! $base || ! config('services.dryer.api_key')) {
|
||||
throw new RuntimeException('Dryer URL and API key must be configured.');
|
||||
}
|
||||
|
||||
$base = rtrim($base, '/');
|
||||
if (! str_ends_with($base, '/api/v1')) {
|
||||
$base .= '/api/v1';
|
||||
}
|
||||
|
||||
return $base.'/'.$path;
|
||||
}
|
||||
|
||||
public function status(): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function history(): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function start(string $profile, int $targetTemperature, int $dryingTime): array
|
||||
{
|
||||
return $this->command('cycles/start', [
|
||||
'profile' => $profile, 'targetC' => $targetTemperature, 'durationMinutes' => $dryingTime,
|
||||
]);
|
||||
}
|
||||
|
||||
public function stop(): array
|
||||
{
|
||||
return $this->command('cycles/stop');
|
||||
}
|
||||
|
||||
public function acknowledge(): array
|
||||
{
|
||||
return $this->command('faults/acknowledge');
|
||||
}
|
||||
|
||||
private function command(string $path, array $payload = []): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->post($this->url($path), [
|
||||
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
||||
])->throw()->json() ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class Etsy
|
||||
{
|
||||
private const API_URL = 'https://api.etsy.com/v3';
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return filled(config('services.etsy.keystring')) && filled(config('services.etsy.shared_secret'));
|
||||
}
|
||||
|
||||
public function authorizationUrl(string $state, string $verifier): string
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
throw new RuntimeException('Etsy application credentials are not configured.');
|
||||
}
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
return 'https://www.etsy.com/oauth/connect?'.http_build_query([
|
||||
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(),
|
||||
'scope' => 'transactions_r listings_r', 'client_id' => config('services.etsy.keystring'),
|
||||
'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public function connect(string $code, string $verifier): EtsyConnection
|
||||
{
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post(self::API_URL.'/public/oauth/token', [
|
||||
'grant_type' => 'authorization_code', 'client_id' => config('services.etsy.keystring'),
|
||||
'redirect_uri' => $this->redirectUri(), 'code' => $code, 'code_verifier' => $verifier,
|
||||
])->throw()->json();
|
||||
$userId = explode('.', $tokens['access_token'] ?? '')[0];
|
||||
if (! ctype_digit($userId) || empty($tokens['refresh_token'])) {
|
||||
throw new RuntimeException('Etsy returned an invalid OAuth response.');
|
||||
}
|
||||
|
||||
$shop = $this->get('application/users/'.$userId.'/shops', $tokens['access_token']);
|
||||
if (empty($shop['shop_id'])) {
|
||||
throw new RuntimeException('This Etsy account does not own a shop.');
|
||||
}
|
||||
if (strcasecmp($shop['shop_name'] ?? '', config('services.etsy.shop_name', 'HickLab3D')) !== 0) {
|
||||
throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.');
|
||||
}
|
||||
|
||||
return EtsyConnection::updateOrCreate(['shop_id' => $shop['shop_id']], [
|
||||
'shop_name' => $shop['shop_name'] ?? 'Etsy shop',
|
||||
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function receipts(EtsyConnection $connection): array
|
||||
{
|
||||
return $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
|
||||
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
|
||||
]);
|
||||
}
|
||||
|
||||
public function listings(EtsyConnection $connection): array
|
||||
{
|
||||
return $this->get('application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
|
||||
'state' => 'active', 'limit' => 20,
|
||||
]);
|
||||
}
|
||||
|
||||
private function accessToken(EtsyConnection $connection): string
|
||||
{
|
||||
if ($connection->expires_at->isFuture()) {
|
||||
return $connection->access_token;
|
||||
}
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post(self::API_URL.'/public/oauth/token', [
|
||||
'grant_type' => 'refresh_token', 'client_id' => config('services.etsy.keystring'),
|
||||
'refresh_token' => $connection->refresh_token,
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) {
|
||||
throw new RuntimeException('Etsy did not refresh the shop connection.');
|
||||
}
|
||||
$connection->update([
|
||||
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
|
||||
]);
|
||||
|
||||
return $tokens['access_token'];
|
||||
}
|
||||
|
||||
private function get(string $path, string $accessToken, array $query = []): array
|
||||
{
|
||||
return Http::withHeaders(['x-api-key' => config('services.etsy.keystring').':'.config('services.etsy.shared_secret')])
|
||||
->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->get(self::API_URL.'/'.$path, $query)->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
private function redirectUri(): string
|
||||
{
|
||||
return config('services.etsy.redirect_uri') ?: route('etsy.callback');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class Parcel2Go
|
||||
{
|
||||
public function fetch(string $path): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->get($this->url($path))->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function quote(array $details): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->post($this->url('quotes'), [
|
||||
'CollectionAddress' => ['Country' => 'GBR', 'Postcode' => $details['collection_postcode']],
|
||||
'DeliveryAddress' => ['Country' => 'GBR', 'Postcode' => $details['delivery_postcode']],
|
||||
'Parcels' => [[
|
||||
'Value' => $details['value'], 'Weight' => $details['weight'],
|
||||
'Length' => $details['length'], 'Width' => $details['width'], 'Height' => $details['height'],
|
||||
]],
|
||||
])->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function collectionDates(string $service, string $street, string $town, string $postcode): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->post($this->url('collectiondates'), [
|
||||
'ServiceSlug' => $service, 'Address' => $street, 'City' => $town,
|
||||
'Postcode' => $postcode, 'CountryISO3Code' => 'GBR',
|
||||
])->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function bookCollection(array $details, string $service, array $booking): array
|
||||
{
|
||||
$collectionAddress = [
|
||||
'ContactName' => $booking['sender_name'], 'Email' => $booking['sender_email'],
|
||||
'Phone' => $booking['sender_phone'], 'Property' => $booking['sender_property'],
|
||||
'Street' => $booking['sender_street'], 'Town' => $booking['sender_town'],
|
||||
'Postcode' => $details['collection_postcode'], 'CountryIsoCode' => 'GBR',
|
||||
];
|
||||
$deliveryAddress = [
|
||||
'ContactName' => $booking['recipient_name'], 'Email' => $booking['recipient_email'],
|
||||
'Phone' => $booking['recipient_phone'], 'Property' => $booking['recipient_property'],
|
||||
'Street' => $booking['recipient_street'], 'Town' => $booking['recipient_town'],
|
||||
'Postcode' => $details['delivery_postcode'], 'CountryIsoCode' => 'GBR',
|
||||
];
|
||||
[$forename, $surname] = array_pad(explode(' ', trim($booking['sender_name']), 2), 2, '');
|
||||
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
->post($this->url('orders'), [
|
||||
'Items' => [[
|
||||
'Id' => (string) Str::uuid(),
|
||||
'CollectionDate' => $booking['collection_date'],
|
||||
'OriginCountry' => 'GBR', 'Service' => $service,
|
||||
'CollectionAddress' => $collectionAddress,
|
||||
'Parcels' => [[
|
||||
'Id' => (string) Str::uuid(),
|
||||
'Height' => $details['height'], 'Length' => $details['length'],
|
||||
'Width' => $details['width'], 'Weight' => $details['weight'],
|
||||
'EstimatedValue' => $details['value'],
|
||||
'ContentsSummary' => $booking['contents'],
|
||||
'DeliveryAddress' => $deliveryAddress,
|
||||
]],
|
||||
]],
|
||||
'CustomerDetails' => ['Email' => $booking['sender_email'], 'Forename' => $forename, 'Surname' => $surname ?: $forename],
|
||||
])->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function labelPdf(string $reference, string $hash): string
|
||||
{
|
||||
$data = Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
->get($this->url('labels/'.$reference), [
|
||||
'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels',
|
||||
'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash,
|
||||
])->throw()->json();
|
||||
$encoded = $data['Base64EncodedLabels'][0] ?? '';
|
||||
$pdf = is_string($encoded) ? base64_decode($encoded, true) : false;
|
||||
if ($pdf === false || ! str_starts_with($pdf, '%PDF-')) {
|
||||
throw new RuntimeException('Parcel2Go did not return a valid PDF label.');
|
||||
}
|
||||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim(config('services.parcel2go.base_url'), '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
$settings = config('services.parcel2go');
|
||||
if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) {
|
||||
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
||||
}
|
||||
|
||||
$token = Http::asForm()->timeout(10)->post($settings['auth_url'], [
|
||||
'grant_type' => 'client_credentials',
|
||||
'scope' => 'public-api my-profile',
|
||||
'client_id' => $settings['client_id'],
|
||||
'client_secret' => $settings['client_secret'],
|
||||
])->throw()->json('access_token');
|
||||
|
||||
if (! $token) {
|
||||
throw new RuntimeException('Parcel2Go did not return an access token.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user