Add project listing selector, confirmed revision and private 3MF renders

This commit is contained in:
2026-11-08 18:49:32 +00:00
parent e62b6800a6
commit 4e675510fd
29 changed files with 3394 additions and 14 deletions
+99 -3
View File
@@ -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