209 lines
8.5 KiB
PHP
209 lines
8.5 KiB
PHP
<?php
|
|
|
|
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);
|
|
|
|
$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<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
|
|
{
|
|
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.');
|
|
}
|
|
}
|