Add project listing selector, confirmed revision and private 3MF renders
This commit is contained in:
@@ -2,17 +2,30 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectEvent;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\ProjectCosting;
|
||||
use App\Support\ProjectLifecycle;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const LISTING_OPTION_STATES = ['active', 'draft'];
|
||||
|
||||
/** Etsy's own maximum page size for the listings endpoint. */
|
||||
private const LISTING_OPTIONS_PAGE_SIZE = 100;
|
||||
|
||||
/** Bounds each state to at most 2000 listings (20 pages * 100); exceeding this fails explicitly rather than silently truncating. */
|
||||
private const LISTING_OPTIONS_MAX_PAGES_PER_STATE = 20;
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->can('viewAny', Project::class), 403);
|
||||
@@ -55,12 +68,13 @@ class ProjectController extends Controller
|
||||
return redirect()->route('projects.show', $project)->with('status', 'Project created.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Project $project): View
|
||||
public function show(Request $request, Project $project, Etsy $etsy): View
|
||||
{
|
||||
abort_unless($request->user()->can('view', $project), 403);
|
||||
$project->load([
|
||||
'revisions' => fn ($q) => $q->orderByDesc('revision_number'),
|
||||
'revisions.files', 'revisions.creator', 'revisions.approver',
|
||||
'revisions.files', 'revisions.files.render', 'revisions.creator', 'revisions.approver',
|
||||
'currentRevision',
|
||||
'listingLinks' => fn ($q) => $q->orderByDesc('created_at'),
|
||||
'listingLinks.liveRelease.revision', 'listingLinks.liveRelease.costSnapshot',
|
||||
'costSnapshots' => fn ($q) => $q->orderByDesc('recorded_at'),
|
||||
@@ -68,7 +82,89 @@ class ProjectController extends Controller
|
||||
'events.actor',
|
||||
]);
|
||||
|
||||
return view('projects.show', ['project' => $project]);
|
||||
return view('projects.show', ['project' => $project] + $this->listingOptions($etsy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the "link an Etsy listing" dropdown: the complete set of
|
||||
* this shop's Live AND Draft listings together (not one state, not one
|
||||
* page), read-only via the existing Etsy service — which already caches
|
||||
* each page per shop/state/generation, so a warm cache typically serves
|
||||
* this without any new network call. Each state is bounded to at most
|
||||
* LISTING_OPTIONS_MAX_PAGES_PER_STATE pages; if the shop has more
|
||||
* listings than that bound, or any page fetch fails, the whole dropdown
|
||||
* fails explicitly (listingOptionsError) rather than silently showing a
|
||||
* truncated or partial list.
|
||||
*
|
||||
* @return array{
|
||||
* listingOptionsByState: array<string, list<array<string, mixed>>>,
|
||||
* listingOptionsError: ?string,
|
||||
* listingOptionsConnected: bool,
|
||||
* }
|
||||
*/
|
||||
private function listingOptions(Etsy $etsy): array
|
||||
{
|
||||
$connection = EtsyConnection::first();
|
||||
$connected = $connection !== null && $etsy->configured();
|
||||
|
||||
$optionsByState = [];
|
||||
$error = null;
|
||||
|
||||
if ($connected) {
|
||||
try {
|
||||
foreach (self::LISTING_OPTION_STATES as $state) {
|
||||
$optionsByState[$state] = $this->fetchAllListingsForState($etsy, $connection, $state);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = 'Listing data is temporarily unavailable or incomplete. Reconnect the shop if this continues.';
|
||||
$optionsByState = [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'listingOptionsByState' => $optionsByState,
|
||||
'listingOptionsError' => $error,
|
||||
'listingOptionsConnected' => $connected,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages through every listing in one state up to the bound, stopping
|
||||
* early once the API's own reported count is reached (the common case
|
||||
* for a shop with under a page's worth of listings). Throws rather than
|
||||
* returning a partial list if the bound is exceeded or a page comes
|
||||
* back short of the reported total.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fetchAllListingsForState(Etsy $etsy, EtsyConnection $connection, string $state): array
|
||||
{
|
||||
$results = [];
|
||||
$page = 0;
|
||||
$reportedCount = null;
|
||||
|
||||
do {
|
||||
if ($page >= self::LISTING_OPTIONS_MAX_PAGES_PER_STATE) {
|
||||
$max = self::LISTING_OPTIONS_MAX_PAGES_PER_STATE * self::LISTING_OPTIONS_PAGE_SIZE;
|
||||
throw new RuntimeException("This shop has more than {$max} {$state} listings, which exceeds what can be safely listed here.");
|
||||
}
|
||||
|
||||
$listingPage = $etsy->listListings($connection, $state, self::LISTING_OPTIONS_PAGE_SIZE, $page * self::LISTING_OPTIONS_PAGE_SIZE);
|
||||
$reportedCount = $listingPage['count'];
|
||||
$results = array_merge($results, $listingPage['results']);
|
||||
$page++;
|
||||
} while (count($results) < $reportedCount && $listingPage['results'] !== []);
|
||||
|
||||
if (count($results) < $reportedCount) {
|
||||
throw new RuntimeException("Could not load the complete list of {$state} listings (received ".count($results)." of {$reportedCount}).");
|
||||
}
|
||||
|
||||
if (count($results) !== $reportedCount || count(array_unique(array_column($results, 'listing_id'))) !== count($results)) {
|
||||
throw new RuntimeException('Listing pages changed or overlapped while loading; retry to load a complete selector.');
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function edit(Request $request, Project $project): View
|
||||
|
||||
Reference in New Issue
Block a user