Files
HickLab3D_Dashboard/app/Http/Controllers/ProjectListingLinkController.php
T
hermes d4928789c4
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Replace the old touchmark/PNG identity with the supplied lockup and
monogram, segmented role-aware navigation, Live/Draft listing filters
with generation-based cache invalidation, and admin Projects covering
immutable file revisions, listing live pointers, and exact-decimal
filament/packaging/shipping costing. Additive schema only; no Etsy
publication. Isolated clone tests: 301 tests, 1162 assertions.
2026-11-08 12:50:56 +00:00

123 lines
5.6 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()]);
}
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);
}
}