*/ 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); $query = Project::query()->with('creator')->withCount(['revisions', 'listingLinks']); if ($request->filled('stage') && in_array($request->string('stage')->toString(), Project::STAGES, true)) { $query->where('stage', $request->string('stage')->toString()); } if ($request->filled('search')) { $term = '%'.$request->string('search')->toString().'%'; $query->where(fn ($q) => $q->where('title', 'like', $term)->orWhere('reference', 'like', $term)); } return view('projects.index', [ 'projects' => $query->orderByDesc('created_at')->paginate(20)->withQueryString(), 'stages' => Project::STAGES, ]); } public function create(Request $request): View { abort_unless($request->user()->can('create', Project::class), 403); return view('projects.create'); } public function store(Request $request): RedirectResponse { abort_unless($request->user()->can('create', Project::class), 403); $data = $request->validate([ 'reference' => ['required', 'string', 'max:64', 'unique:projects,reference'], 'title' => ['required', 'string', 'max:255'], 'notes' => ['nullable', 'string'], 'currency' => ['required', 'string', 'size:3', Rule::in(ProjectCosting::ALLOWED_CURRENCIES)], ]); $project = Project::create($data + ['created_by' => $request->user()->id]); ProjectEvent::record($project->id, 'created', $request->user()->id); return redirect()->route('projects.show', $project)->with('status', 'Project created.'); } 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.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'), 'costSnapshots.lines', 'costSnapshots.revision', 'events.actor', ]); 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>>, * 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> */ 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 { abort_unless($request->user()->can('update', $project), 403); return view('projects.edit', ['project' => $project]); } public function update(Request $request, Project $project): RedirectResponse { abort_unless($request->user()->can('update', $project), 403); $data = $request->validate([ 'title' => ['sometimes', 'string', 'max:255'], 'notes' => ['sometimes', 'nullable', 'string'], 'stage' => ['sometimes', Rule::in(Project::STAGES)], ]); if (($data['stage'] ?? null) === 'live' && ! ProjectLifecycle::canMoveToLive($project)) { return back()->withErrors(['stage' => 'A project can only move to Live once a listing mapping has an active release.']); } $fromStage = $project->stage; $project->update($data); if (isset($data['stage']) && $data['stage'] !== $fromStage) { ProjectEvent::record($project->id, 'stage_changed', $request->user()->id, metadata: ['from' => $fromStage, 'to' => $data['stage']]); } return redirect()->route('projects.show', $project)->with('status', 'Project updated.'); } public function archive(Request $request, Project $project): RedirectResponse { abort_unless($request->user()->can('archive', $project), 403); $project->update(['archived_at' => now()]); ProjectEvent::record($project->id, 'archived', $request->user()->id); return redirect()->route('projects.show', $project)->with('status', 'Project archived.'); } }