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,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Printer;
|
||||
use App\Models\PrinterTelemetry;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use PhpMqtt\Client\ConnectionSettings;
|
||||
use PhpMqtt\Client\MqttClient;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('printers:collect')]
|
||||
#[Description('Collect a short telemetry sample from each registered Bambu printer')]
|
||||
class CollectPrinterTelemetry extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
foreach (Printer::cursor() as $printer) {
|
||||
$client = new MqttClient($printer->printerIp, config('services.mqtt.port'), 'hicklab3d_'.bin2hex(random_bytes(6)));
|
||||
try {
|
||||
$settings = (new ConnectionSettings)
|
||||
->setUsername(config('services.mqtt.username'))
|
||||
->setPassword($printer->mqttPassword)
|
||||
->setConnectTimeout(5)
|
||||
->setUseTls(true)
|
||||
->setTlsVerifyPeer(config('services.mqtt.verify_tls'))
|
||||
->setTlsVerifyPeerName(config('services.mqtt.verify_tls'));
|
||||
$client->connect($settings, true);
|
||||
$received = false;
|
||||
$client->subscribe('device/'.$printer->serialNumber.'/report', function (string $topic, string $message) use ($printer, &$received): void {
|
||||
$telemetry = json_decode($message, true);
|
||||
if (is_array($telemetry)) {
|
||||
PrinterTelemetry::create(['printerId' => $printer->id, 'telemetry' => $telemetry]);
|
||||
$received = true;
|
||||
}
|
||||
});
|
||||
$started = microtime(true);
|
||||
while (! $received && microtime(true) - $started < 15) {
|
||||
$client->loopOnce($started, true);
|
||||
}
|
||||
$client->disconnect();
|
||||
$this->line($printer->printerName.($received ? ': received telemetry' : ': no telemetry received'));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn($printer->printerName.': connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('app:create-admin {username} {email}')]
|
||||
#[Description('Create the first HickLab3D administrator')]
|
||||
class CreateAdmin extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$username = (string) $this->argument('username');
|
||||
$email = (string) $this->argument('email');
|
||||
if (User::where('username', $username)->orWhere('email', $email)->exists()) {
|
||||
$this->error('That username or email already exists.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$password = (string) $this->secret('Password (at least 12 characters)');
|
||||
if (mb_strlen($password) < 12) {
|
||||
$this->error('Password is too short.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
User::create(['username' => $username, 'email' => $email, 'role' => 'admin', 'password' => $password]);
|
||||
$this->info('Administrator created.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Filament;
|
||||
use App\Models\FilamentPrice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('filaments:refresh')]
|
||||
#[Description('Collect current Amazon prices for tracked filament')]
|
||||
class RefreshFilamentPrices extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$drops = [];
|
||||
foreach (Filament::cursor() as $filament) {
|
||||
$host = strtolower((string) parse_url($filament->amazonUrl, PHP_URL_HOST));
|
||||
if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) {
|
||||
$this->warn($filament->filamentName.': unsupported product host');
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$html = Http::timeout(15)->withHeaders(['User-Agent' => 'Mozilla/5.0 HickLab3D/1.0'])
|
||||
->get($filament->amazonUrl)->throw()->body();
|
||||
if (! preg_match('/class="[^"]*a-price-whole[^"]*"[^>]*>([0-9,.]+)/', $html, $whole)
|
||||
|| ! preg_match('/class="[^"]*a-price-fraction[^"]*"[^>]*>([0-9]{2})/', $html, $fraction)) {
|
||||
$this->warn($filament->filamentName.': price unavailable');
|
||||
|
||||
continue;
|
||||
}
|
||||
$price = (float) (str_replace(',', '', $whole[1]).'.'.$fraction[1]);
|
||||
if ($price <= 0) {
|
||||
continue;
|
||||
}
|
||||
preg_match('/class="[^"]*savingsPercentage[^"]*"[^>]*>[^0-9]*([0-9]+)%/', $html, $discount);
|
||||
$previous = FilamentPrice::where('filamentId', $filament->id)->orderByDesc('recordedAt')->orderByDesc('id')->first();
|
||||
FilamentPrice::create([
|
||||
'filamentId' => $filament->id,
|
||||
'price' => $price,
|
||||
'currentDiscount' => ['discount' => ['value' => (int) ($discount[1] ?? 0), 'type' => 'percentage'], 'voucher' => ['value' => 0, 'type' => 'none']],
|
||||
]);
|
||||
if ($previous && $price < (float) $previous->price) {
|
||||
$drops[] = $filament->filamentName.': £'.number_format((float) $previous->price, 2).' → £'.number_format($price, 2).' ('.$filament->amazonUrl.')';
|
||||
}
|
||||
$this->line($filament->filamentName.': £'.number_format($price, 2));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn($filament->filamentName.': collection failed');
|
||||
}
|
||||
}
|
||||
if ($drops) {
|
||||
$body = "HickLab3D filament price drops\n\n".implode("\n", $drops);
|
||||
foreach (User::where('alertEmails', true)->where('disabled', false)->pluck('email') as $email) {
|
||||
Mail::raw($body, fn ($message) => $message->to($email)->subject('HickLab3D filament price drops'));
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate(['username' => ['required', 'string'], 'password' => ['required', 'string']]);
|
||||
$user = User::where('username', $credentials['username'])->first();
|
||||
if (! $user || $user->disabled || ! Auth::attempt($credentials)) {
|
||||
return back()->withErrors(['username' => 'These credentials do not match an active account.'])->onlyInput('username');
|
||||
}
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Bambuddy;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class BambuddyController extends Controller
|
||||
{
|
||||
public function __invoke(Bambuddy $bambuddy): View
|
||||
{
|
||||
$printers = [];
|
||||
$integrationError = null;
|
||||
|
||||
try {
|
||||
$printers = $bambuddy->printers();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$integrationError = 'BamBuddy is unavailable. Check its API key and connection.';
|
||||
}
|
||||
|
||||
return view('printers.index', compact('printers', 'integrationError'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Filament;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
return view('dashboard', [
|
||||
'filamentCount' => Filament::count(),
|
||||
'userCount' => $request->user()->role === 'admin' ? User::count() : null,
|
||||
'recentFilaments' => Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)])->orderByDesc('id')->limit(5)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\DryerClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class DryerController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('dryer');
|
||||
}
|
||||
|
||||
public function status(DryerClient $dryer): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(['status' => $dryer->status(), 'history' => $dryer->history()]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json(['message' => 'Dryer is unavailable.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public function control(Request $request, DryerClient $dryer): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$data = $request->validate([
|
||||
'dryerOn' => ['required', 'boolean'],
|
||||
'profile' => ['required_if:dryerOn,1', 'nullable', 'string', 'in:PLA,PETG,ABS,Nylon,Custom'],
|
||||
'targetTemperature' => ['required_if:dryerOn,1', 'nullable', 'integer', 'between:45,70'],
|
||||
'dryingTime' => ['required_if:dryerOn,1', 'nullable', 'integer', 'between:5,2880'],
|
||||
]);
|
||||
try {
|
||||
$result = $data['dryerOn']
|
||||
? $dryer->start($data['profile'], $data['targetTemperature'], $data['dryingTime'])
|
||||
: $dryer->stop();
|
||||
|
||||
return response()->json($result);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json(['message' => 'Dryer is unavailable.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public function acknowledge(Request $request, DryerClient $dryer): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
try {
|
||||
return response()->json($dryer->acknowledge());
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json(['message' => 'Dryer fault could not be acknowledged.'], 503);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Services\Etsy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class EtsyController extends Controller
|
||||
{
|
||||
public function index(Request $request, Etsy $etsy): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$connection = EtsyConnection::first();
|
||||
$receipts = [];
|
||||
$listings = [];
|
||||
$integrationError = null;
|
||||
|
||||
if ($connection && $etsy->configured()) {
|
||||
try {
|
||||
$receipts = $etsy->receipts($connection)['results'] ?? [];
|
||||
$listings = $etsy->listings($connection)['results'] ?? [];
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$integrationError = 'Etsy data is temporarily unavailable. Reconnect the shop if this continues.';
|
||||
}
|
||||
}
|
||||
|
||||
return view('etsy', compact('connection', 'receipts', 'listings', 'integrationError') + ['configured' => $etsy->configured()]);
|
||||
}
|
||||
|
||||
public function connect(Request $request, Etsy $etsy): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
if (! $etsy->configured()) {
|
||||
return back()->withErrors(['etsy' => 'Configure the Etsy app key and shared secret first.']);
|
||||
}
|
||||
|
||||
$state = bin2hex(random_bytes(24));
|
||||
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
|
||||
$request->session()->put('etsy_oauth', ['state' => $state, 'verifier' => $verifier, 'started' => time()]);
|
||||
|
||||
return redirect()->away($etsy->authorizationUrl($state, $verifier));
|
||||
}
|
||||
|
||||
public function callback(Request $request, Etsy $etsy): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$pending = $request->session()->pull('etsy_oauth');
|
||||
if (! is_array($pending) || time() - ($pending['started'] ?? 0) > 600
|
||||
|| ! is_string($request->query('state'))
|
||||
|| ! hash_equals($pending['state'] ?? '', $request->query('state'))
|
||||
|| ! is_string($request->query('code'))) {
|
||||
return redirect()->route('etsy.index')->withErrors(['etsy' => 'Etsy connection expired or was not verified. Please try again.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$connection = $etsy->connect($request->query('code'), $pending['verifier']);
|
||||
|
||||
return redirect()->route('etsy.index')->with('status', $connection->shop_name.' connected to Etsy.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('etsy.index')->withErrors(['etsy' => 'Etsy rejected the connection. Check the app credentials and callback URL.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FilamentController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('filaments.index', ['filaments' => Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(10)])->orderBy('filamentName')->paginate(20)]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
return view('filaments.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$data = $request->validate([
|
||||
'filamentName' => ['required', 'string', 'max:255'],
|
||||
'amazonUrl' => ['required', 'url:http,https', 'max:2048'],
|
||||
'brand' => ['nullable', 'string', 'max:255'],
|
||||
'material' => ['nullable', 'string', 'max:255'],
|
||||
'color' => ['nullable', 'string', 'max:255'],
|
||||
'filamentWeight' => ['nullable', 'numeric', 'min:0'],
|
||||
'itemDiameter' => ['nullable', 'numeric', 'min:0'],
|
||||
]);
|
||||
$host = strtolower((string) parse_url($data['amazonUrl'], PHP_URL_HOST));
|
||||
if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) {
|
||||
return back()->withErrors(['amazonUrl' => 'Enter an Amazon UK or US product URL.'])->withInput();
|
||||
}
|
||||
Filament::create([...$data, 'userId' => $request->user()->id]);
|
||||
|
||||
return redirect()->route('filaments.index')->with('status', 'Filament added. Price collection runs on the schedule.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Filament $filament): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$filament->delete();
|
||||
|
||||
return redirect()->route('filaments.index')->with('status', 'Filament removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Printer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PrinterController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$printers = Printer::where('userId', $request->user()->id)
|
||||
->with(['telemetry' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)])
|
||||
->orderBy('printerName')->get();
|
||||
|
||||
return view('printers.index', compact('printers'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('printers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'printerName' => ['required', 'string', 'max:255'],
|
||||
'printerIp' => ['required', 'ip'],
|
||||
'serialNumber' => ['required', 'string', 'max:255', 'unique:bambuPrinters,serialNumber'],
|
||||
'mqttPassword' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
Printer::create([...$data, 'userId' => $request->user()->id]);
|
||||
|
||||
return redirect()->route('printers.index')->with('status', 'Printer added.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Printer $printer): RedirectResponse
|
||||
{
|
||||
abort_unless($printer->userId === $request->user()->id, 404);
|
||||
$printer->delete();
|
||||
|
||||
return redirect()->route('printers.index')->with('status', 'Printer removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class ShippingController extends Controller
|
||||
{
|
||||
public function index(Request $request, Parcel2Go $parcel2Go): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
try {
|
||||
$account = $parcel2Go->fetch('me/detail');
|
||||
$balance = $parcel2Go->fetch('me/prepay/balance');
|
||||
$orders = $parcel2Go->fetch('me/orders/detail');
|
||||
|
||||
return view('shipping', compact('account', 'balance', 'orders') + [
|
||||
'quotes' => $request->session()->get('shipping_quotes', []),
|
||||
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
|
||||
'selectedService' => $request->session()->get('shipping_selected_service'),
|
||||
'bookingResult' => $request->session()->get('shipping_booking_result'),
|
||||
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return view('shipping', [
|
||||
'account' => [], 'balance' => [], 'orders' => [],
|
||||
'quotes' => $request->session()->get('shipping_quotes', []),
|
||||
'collectionDates' => $request->session()->get('shipping_collection_dates', []),
|
||||
'selectedService' => $request->session()->get('shipping_selected_service'),
|
||||
'bookingResult' => $request->session()->get('shipping_booking_result'),
|
||||
'bookingPending' => $request->session()->get('shipping_booking_pending', false),
|
||||
'integrationError' => 'Parcel2Go is unavailable. Check API credentials and connectivity.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function quote(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
if ($request->session()->get('shipping_booking_pending')) {
|
||||
return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
|
||||
}
|
||||
$details = $request->validate([
|
||||
'collection_postcode' => ['required', 'string', 'max:12'],
|
||||
'delivery_postcode' => ['required', 'string', 'max:12'],
|
||||
'weight' => ['required', 'numeric', 'gt:0', 'lte:30'],
|
||||
'length' => ['required', 'numeric', 'gt:0', 'lte:200'],
|
||||
'width' => ['required', 'numeric', 'gt:0', 'lte:200'],
|
||||
'height' => ['required', 'numeric', 'gt:0', 'lte:200'],
|
||||
'value' => ['required', 'numeric', 'gte:0', 'lte:10000'],
|
||||
]);
|
||||
try {
|
||||
$quotes = $parcel2Go->quote($details)['Quotes'] ?? [];
|
||||
$request->session()->put('shipping_quote', $details);
|
||||
$request->session()->put('shipping_quotes', $quotes);
|
||||
$request->session()->forget(['shipping_collection_dates', 'shipping_selected_service']);
|
||||
|
||||
return redirect()->route('shipping')->with('status', count($quotes).' services available.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('shipping')->withErrors(['quote' => 'Parcel2Go could not quote this parcel. Check the postcodes and parcel details.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function collectionDates(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$data = $request->validate([
|
||||
'service' => ['required', 'string', 'max:200'],
|
||||
'sender_street' => ['required', 'string', 'max:200'],
|
||||
'sender_town' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
$details = $request->session()->get('shipping_quote');
|
||||
$quote = collect($request->session()->get('shipping_quotes', []))->first(fn ($quote) => ($quote['Service']['Slug'] ?? null) === $data['service'] && ($quote['Service']['CollectionType'] ?? null) === 'Collection');
|
||||
if (! $details || ! $quote) {
|
||||
return redirect()->route('shipping')->withErrors(['service' => 'Select a collection service from a current quote.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$dates = $parcel2Go->collectionDates($data['service'], $data['sender_street'], $data['sender_town'], $details['collection_postcode'])['CollectionDates'] ?? [];
|
||||
$request->session()->put('shipping_collection_dates', $dates);
|
||||
$request->session()->put('shipping_selected_service', $data['service']);
|
||||
$request->session()->put('shipping_sender_location', $data);
|
||||
|
||||
return redirect()->route('shipping')->with('status', 'Choose a collection date and enter the addresses.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('shipping')->withErrors(['collection' => 'Parcel2Go could not return collection dates for this service.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function bookCollection(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
if ($request->session()->get('shipping_booking_pending')) {
|
||||
return redirect()->route('shipping')->withErrors(['collection' => 'A booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
|
||||
}
|
||||
$booking = $request->validate([
|
||||
'collection_date' => ['required', 'date', 'after:now'],
|
||||
'sender_name' => ['required', 'string', 'max:100'],
|
||||
'sender_email' => ['required', 'email', 'max:150'],
|
||||
'sender_phone' => ['required', 'string', 'max:30'],
|
||||
'sender_property' => ['required', 'string', 'max:100'],
|
||||
'sender_street' => ['required', 'string', 'max:200'],
|
||||
'sender_town' => ['required', 'string', 'max:100'],
|
||||
'recipient_name' => ['required', 'string', 'max:100'],
|
||||
'recipient_email' => ['required', 'email', 'max:150'],
|
||||
'recipient_phone' => ['required', 'string', 'max:30'],
|
||||
'recipient_property' => ['required', 'string', 'max:100'],
|
||||
'recipient_street' => ['required', 'string', 'max:200'],
|
||||
'recipient_town' => ['required', 'string', 'max:100'],
|
||||
'contents' => ['required', 'string', 'max:200'],
|
||||
]);
|
||||
$details = $request->session()->get('shipping_quote');
|
||||
$service = $request->session()->get('shipping_selected_service');
|
||||
$dates = $request->session()->get('shipping_collection_dates', []);
|
||||
$senderLocation = $request->session()->get('shipping_sender_location', []);
|
||||
$selectedQuote = collect($request->session()->get('shipping_quotes', []))
|
||||
->first(fn ($quote) => ($quote['Service']['Slug'] ?? null) === $service && ($quote['Service']['CollectionType'] ?? null) === 'Collection');
|
||||
if (! $details || ! $service || ! collect($dates)->contains(fn ($date) => ($date['CollectionDate'] ?? null) === $booking['collection_date'])
|
||||
|| ! $selectedQuote || ! empty($selectedQuote['RequiresCustoms']) || ! empty($selectedQuote['RequiresCommercialInvoice'])
|
||||
|| ($senderLocation['sender_street'] ?? null) !== $booking['sender_street'] || ($senderLocation['sender_town'] ?? null) !== $booking['sender_town']) {
|
||||
return redirect()->route('shipping')->withErrors(['collection' => 'Choose an available collection date for the quoted address.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$request->session()->put('shipping_booking_pending', true);
|
||||
$result = $parcel2Go->bookCollection($details, $service, $booking);
|
||||
if (empty($result['OrderId'])) {
|
||||
throw new \RuntimeException('Parcel2Go did not return an order reference.');
|
||||
}
|
||||
$request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
|
||||
|
||||
return redirect()->route('shipping')->with('shipping_booking_result', $result)->with('status', 'Collection order created. Complete payment with Parcel2Go.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('shipping')->withErrors(['collection' => 'Booking outcome is uncertain. Check your Parcel2Go account before trying again.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetBooking(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$request->validate(['checked_account' => ['accepted']]);
|
||||
$request->session()->forget(['shipping_quote', 'shipping_quotes', 'shipping_collection_dates', 'shipping_selected_service', 'shipping_sender_location', 'shipping_booking_pending']);
|
||||
|
||||
return redirect()->route('shipping')->with('status', 'Booking workflow reset. Start with a fresh quote.');
|
||||
}
|
||||
|
||||
public function label(Request $request, Parcel2Go $parcel2Go, string $orderLineId): Response
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
abort_unless(ctype_digit($orderLineId), 404);
|
||||
$orders = $parcel2Go->fetch('me/orders/detail')['Orders'] ?? [];
|
||||
$order = collect($orders)->first(fn ($order) => (string) ($order['OrderLineId'] ?? '') === $orderLineId);
|
||||
abort_unless($order && ! empty($order['Hash']), 404);
|
||||
$pdf = $parcel2Go->labelPdf($orderLineId, $order['Hash']);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="parcel2go-'.$orderLineId.'.pdf"',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorizeAdmin($request);
|
||||
|
||||
return view('users.index', ['users' => User::orderBy('username')->paginate(20)]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin($request);
|
||||
$data = $request->validate([
|
||||
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'role' => ['required', Rule::in(['admin', 'user'])],
|
||||
'password' => ['required', 'string', 'min:12'],
|
||||
'alertEmails' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
User::create($data);
|
||||
|
||||
return back()->with('status', 'User created.');
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin($request);
|
||||
$data = $request->validate([
|
||||
'email' => ['sometimes', 'email', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'role' => ['sometimes', Rule::in(['admin', 'user'])],
|
||||
'disabled' => ['sometimes', 'boolean'],
|
||||
'alertEmails' => ['sometimes', 'boolean'],
|
||||
'password' => ['nullable', 'string', 'min:12'],
|
||||
]);
|
||||
if (empty($data['password'])) {
|
||||
unset($data['password']);
|
||||
}
|
||||
if ($user->id === $request->user()->id && (($data['disabled'] ?? false) || ($data['role'] ?? 'admin') !== 'admin')) {
|
||||
return back()->withErrors(['user' => 'You cannot disable or demote your own admin account.']);
|
||||
}
|
||||
$user->update($data);
|
||||
|
||||
return back()->with('status', 'User updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin($request);
|
||||
abort_if($user->id === $request->user()->id, 422, 'You cannot delete your own account.');
|
||||
$user->delete();
|
||||
|
||||
return back()->with('status', 'User deleted.');
|
||||
}
|
||||
|
||||
private function authorizeAdmin(Request $request): void
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAccountActive
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user()?->disabled) {
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors(['username' => 'This account is disabled.']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EtsyConnection extends Model
|
||||
{
|
||||
protected $fillable = ['shop_id', 'shop_name', 'access_token', 'refresh_token', 'expires_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FilamentFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Filament extends Model
|
||||
{
|
||||
/** @use HasFactory<FilamentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'filamentTracker';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['userId', 'filamentName', 'amazonUrl', 'filamentWeight', 'brand', 'material', 'color', 'itemDiameter'];
|
||||
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(FilamentPrice::class, 'filamentId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FilamentPriceFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FilamentPrice extends Model
|
||||
{
|
||||
/** @use HasFactory<FilamentPriceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'filamentPriceHistory';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['filamentId', 'price', 'currentDiscount'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['currentDiscount' => 'array', 'recordedAt' => 'datetime', 'price' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\PrinterFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Printer extends Model
|
||||
{
|
||||
/** @use HasFactory<PrinterFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'bambuPrinters';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['printerName', 'printerIp', 'mqttPassword', 'serialNumber', 'userId'];
|
||||
|
||||
protected $hidden = ['mqttPassword'];
|
||||
|
||||
public function telemetry(): HasMany
|
||||
{
|
||||
return $this->hasMany(PrinterTelemetry::class, 'printerId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\PrinterTelemetryFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PrinterTelemetry extends Model
|
||||
{
|
||||
/** @use HasFactory<PrinterTelemetryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'printerTelemetry';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['printerId', 'telemetry'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['telemetry' => 'array', 'recordedAt' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['username', 'email', 'role', 'disabled', 'alertEmails', 'password'])]
|
||||
#[Hidden(['password'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
'disabled' => 'boolean',
|
||||
'alertEmails' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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