user()->can('manageListings', $project), 403); $data = $request->validate([ 'listing_id' => ['required', 'integer', 'min:1'], // Per-variant mapping is not implemented or verified against Etsy yet, // so only the "all variations" mapping is accepted — anything else // would be an unverified ownership claim over a specific variant. 'variation_key' => ['sometimes', Rule::in([ProjectListingLink::ALL_VARIATIONS])], ]); $variationKey = $data['variation_key'] ?? ProjectListingLink::ALL_VARIATIONS; try { $connection = $etsy->connectedShop(); $listing = $etsy->getListing($connection, (int) $data['listing_id']); } catch (Throwable $exception) { return back()->withErrors(['listing_id' => $exception->getMessage()]); } // Re-checked against a fresh (non-cached) fetch above, so this also catches // a listing that was active/draft when the dropdown was rendered but has // since gone inactive, expired or sold out. if (! in_array($listing['state'] ?? null, ['active', 'draft'], true)) { return back()->withErrors(['listing_id' => 'That listing is no longer Live or Draft and cannot be linked.']); } try { $link = DB::transaction(function () use ($request, $project, $connection, $data, $listing, $variationKey): ProjectListingLink { $locked = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $existing = ProjectListingLink::query() ->where('shop_id', $connection->shop_id) ->where('listing_id', $data['listing_id']) ->where('variation_key', $variationKey) ->lockForUpdate() ->first(); if ($existing && $existing->project_id !== $locked->id) { throw new ListingAlreadyMappedException('That listing is already mapped to a different project.'); } $wasReleased = $existing !== null && $existing->live_release_id !== null; $link = ProjectListingLink::updateOrCreate( ['shop_id' => $connection->shop_id, 'listing_id' => $data['listing_id'], 'variation_key' => $variationKey], [ 'project_id' => $locked->id, 'title_snapshot' => $listing['title'] ?? null, 'state_snapshot' => $listing['state'] ?? null, 'remote_updated_at' => isset($listing['last_modified_timestamp']) ? Carbon::createFromTimestamp((int) $listing['last_modified_timestamp']) : null, 'synced_at' => now(), 'created_by' => $request->user()->id, 'detached_at' => null, ], ); // A brand-new (or re-synced-but-unreleased) mapping on a project already // marked Live would otherwise leave Live claiming full release coverage // it no longer has. Drop back to Design rather than silently keep Live. if ($locked->stage === 'live' && ! $wasReleased) { $locked->update(['stage' => 'design']); ProjectEvent::record($locked->id, 'stage_changed', $request->user()->id, metadata: [ 'from' => 'live', 'to' => 'design', 'reason' => 'unready_listing_mapping_attached', ]); } ProjectEvent::record( $locked->id, $existing ? 'listing_resynced' : 'listing_attached', $request->user()->id, ProjectListingLink::class, $link->id, ['listing_id' => (int) $data['listing_id'], 'variation_key' => $variationKey], ); return $link; }); } catch (ListingAlreadyMappedException $exception) { return back()->withErrors(['listing_id' => $exception->getMessage()]); } catch (QueryException $exception) { if (! $this->isUniqueConstraintViolation($exception)) { throw $exception; } return back()->withErrors(['listing_id' => 'That listing was just claimed by another request. Please try again.']); } return redirect()->route('projects.show', $project)->with('status', 'Listing linked.'); } public function destroy(Request $request, Project $project, ProjectListingLink $listingLink): RedirectResponse { abort_unless($listingLink->project_id === $project->id, 404); abort_unless($request->user()->can('manageListings', $project), 403); DB::transaction(function () use ($request, $project, $listingLink): void { Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); $listingLink->update(['detached_at' => now()]); ProjectEvent::record($project->id, 'listing_detached', $request->user()->id, ProjectListingLink::class, $listingLink->id); }); return redirect()->route('projects.show', $project)->with('status', 'Listing detached.'); } private function isUniqueConstraintViolation(QueryException $exception): bool { return in_array($exception->getCode(), ['23000', '23505'], true); } }