130 lines
6.0 KiB
PHP
130 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Exceptions\ListingAlreadyMappedException;
|
|
use App\Models\Project;
|
|
use App\Models\ProjectEvent;
|
|
use App\Models\ProjectListingLink;
|
|
use App\Services\Etsy;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
use Throwable;
|
|
|
|
class ProjectListingLinkController extends Controller
|
|
{
|
|
public function store(Request $request, Project $project, Etsy $etsy): RedirectResponse
|
|
{
|
|
abort_unless($request->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);
|
|
}
|
|
}
|