diff --git a/README.md b/README.md index ff6c68f..088310f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,28 @@ For local tests in the existing serviceengineering toolchain image, explicitly s Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not. +## Projects (admin) + +Projects are the product aggregate: a reversible internal lifecycle (Design, Ready, Live, Retired — distinct from Etsy's own listing state), approved immutable revisions with private STL/3MF/G-code file bundles, Etsy listing mappings verified by reading the listing from Etsy (never an arbitrary shop/listing ID), and append-only cost snapshots and releases. + +- Uploads are limited to `.stl`, `.3mf` and `.gcode`, 100 MiB per file, 250 MiB combined per request, and 10 files per request, and are structurally validated (STL triangle/size cross-check, bounded 3MF zip/XML with no path traversal, symlink entries, external references or DOCTYPE/ENTITY declarations, plain-text G-code). Nothing uploaded here is ever parsed as a slicer job, executed, or served from a public URL — files live on the `project_files` disk (`storage/app/project-files`, not web-served) under a random generated key, streamed rather than buffered in memory, and only stream back through the authenticated, policy-checked `projects.files.download` route. Any failure partway through a multi-file upload (validation or otherwise) deletes every byte already written for that request. +- Releasing a revision to a listing mapping requires a cost snapshot for that same revision with every category costed (filament, packaging and shipping — even as an explicit `0`, but not missing) and pins an exact revision + cost snapshot; uploading a newer revision never moves that pointer on its own, and both release and revert create a new append-only `project_releases` row under an optimistic lock (`project_listing_links.lock_version`) rather than overwriting history. A detached listing mapping can no longer be released or reverted to. +- Cost snapshots use fixed-point integer arithmetic (`App\Support\FixedPoint` / `App\Services\ProjectCosting`), not floats: the bcmath/gmp PHP extensions are not installed in this project's runtime, so exact-decimal math is done by scaling decimal strings into bounded native integers with explicit overflow and storable-precision checks. Estimate and actual snapshots are both append-only and never overwrite each other; a missing cost category is stored as `null` ("not costed"), distinct from an intentional `0.0000`. Each category accepts exactly one quantity unit (filament `g`, packaging `unit`, shipping `parcel`) so a mistyped unit is rejected rather than silently mis-costed. Snapshot currency must match the project (no implied FX). Shipping quantities are parcels; `units_per_parcel` allocates that parcel cost across the saleable batch yield (`batch_units / units_per_parcel * parcel cost`) independently of `perUnit()`. Actual shipping needs a confirmed shipment or invoice reference. The form starts with separate filament, packaging and shipping lines and can add more materials/components; Fusion project/document/version fields on a revision are optional manual provenance only. +- Stage can only move to Live once *every* non-detached listing mapping has an active release (not merely one of several); moving a project's internal stage never calls the Etsy API or publishes anything. Attaching a new, not-yet-released listing mapping to a project already at Live drops its stage back to Design rather than leaving Live claiming coverage it no longer has. + +## Branding + +The sidebar/login logo, favicon and in-app colour tokens use the supplied HL3D brand assets under `public/branding/` (carbon `#111316`, alloy `#A7ADB5`, fluorescent yellow `#E7FF00`, ice `#F4F6F8`, slate `#515963`). The CSS font stack (`resources/css/app.css`) names "Avenir Next" first and falls back to the local system sans/monospace stack; no font files are bundled or fetched from Google/Bunny Fonts, and the wordmark is supplied as vector outlines so no font is needed to render the logo itself. **Follow-up:** no redistributable Avenir Next web-font licence has been secured — if a licensed web font becomes available, add it as a self-hosted asset (no external font CDN calls) and keep the system-stack fallback. + +## Backups + +A restorable backup needs **both** of the following, taken together (the private files reference projects/revisions by ID, so restoring one without the other breaks file bundles): + +1. The database (`mysqldump` in production; `database/database.sqlite` only exists for local/dev use). +2. The private file store at `storage/app/project-files/` (dedicated, non-public disk for uploaded STL/3MF/G-code bytes — distinct from `storage/app/public`). + +Verify a restore against an isolated copy before trusting it, and spot-check a `project_files.sha256` value against the restored bytes on disk. + ## Verification ```sh diff --git a/app/Exceptions/ListingAlreadyMappedException.php b/app/Exceptions/ListingAlreadyMappedException.php new file mode 100644 index 0000000..e14efcb --- /dev/null +++ b/app/Exceptions/ListingAlreadyMappedException.php @@ -0,0 +1,10 @@ + */ + private const UI_STATES = ['active', 'draft']; + + private const PER_PAGE = 20; + public function index(Request $request, Etsy $etsy): View { abort_unless($request->user()->role === 'admin', 403); $connection = EtsyConnection::first(); $receipts = []; $listings = []; - $integrationError = null; + $listingCount = 0; + $receiptsError = null; + $listingsError = null; + $stateError = null; + $pageError = null; + + $rawState = $request->query('state', 'active'); + $state = is_string($rawState) ? strtolower($rawState) : ''; + if (! in_array($state, self::UI_STATES, true)) { + $stateError = 'This is not a supported listing state. Select Live or Drafts.'; + $state = 'active'; + } + + $rawPage = $request->query('page', '1'); + if (! is_string($rawPage) || ! ctype_digit($rawPage) || strlen($rawPage) > 5 || (int) $rawPage < 1 || (int) $rawPage > 10000) { + $pageError = 'This is not a valid page number. Use pages 1–10000.'; + $page = 1; + } else { + $page = (int) $rawPage; + } if ($connection && $etsy->configured()) { try { $receipts = $etsy->receipts($connection)['results'] ?? []; - $listings = $etsy->listings($connection)['results'] ?? []; } catch (Throwable $exception) { report($exception); - $integrationError = 'Etsy data is temporarily unavailable. Reconnect the shop if this continues.'; + $receiptsError = 'Order data is temporarily unavailable. Reconnect the shop if this continues.'; + } + + if (! $stateError && ! $pageError) { + try { + $listingPage = $etsy->listListings($connection, $state, self::PER_PAGE, (max(1, $page) - 1) * self::PER_PAGE); + $listings = $listingPage['results']; + $listingCount = $listingPage['count']; + } catch (Throwable $exception) { + report($exception); + $listingsError = 'Listing data is temporarily unavailable. Reconnect the shop if this continues.'; + } } } - return view('etsy', compact('connection', 'receipts', 'listings', 'integrationError') + ['configured' => $etsy->configured()]); + return view('etsy', [ + 'connection' => $connection, + 'receipts' => $receipts, + 'listings' => $listings, + 'listingSnapshot' => $listingPage ?? null, + 'listingCount' => $listingCount, + 'state' => $state, + 'page' => $page, + 'perPage' => self::PER_PAGE, + 'receiptsError' => $receiptsError, + 'listingsError' => $listingsError, + 'stateError' => $stateError, + 'pageError' => $pageError, + 'configured' => $etsy->configured(), + ]); } public function connect(Request $request, Etsy $etsy): RedirectResponse diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php new file mode 100644 index 0000000..00e6410 --- /dev/null +++ b/app/Http/Controllers/ProjectController.php @@ -0,0 +1,112 @@ +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): View + { + abort_unless($request->user()->can('view', $project), 403); + $project->load([ + 'revisions' => fn ($q) => $q->orderByDesc('revision_number'), + 'revisions.files', 'revisions.creator', 'revisions.approver', + '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]); + } + + 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.'); + } +} diff --git a/app/Http/Controllers/ProjectCostSnapshotController.php b/app/Http/Controllers/ProjectCostSnapshotController.php new file mode 100644 index 0000000..846f9f1 --- /dev/null +++ b/app/Http/Controllers/ProjectCostSnapshotController.php @@ -0,0 +1,116 @@ +user()->can('manageCosts', $project), 403); + $data = $request->validate([ + 'revision_id' => ['required', 'integer'], + 'basis' => ['required', Rule::in([ProjectCostSnapshot::BASIS_ESTIMATE, ProjectCostSnapshot::BASIS_ACTUAL])], + 'currency' => ['required', 'string', 'size:3', Rule::in(ProjectCosting::ALLOWED_CURRENCIES)], + 'batch_units' => ['required', 'integer', 'min:1', 'max:100000'], + 'lines.*.confirmed_reference' => ['nullable', 'string', 'max:255'], + 'source_reference' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + 'lines' => ['required', 'array', 'min:1'], + 'lines.*.category' => ['required', Rule::in(ProjectCostLine::CATEGORIES)], + 'lines.*.description' => ['required', 'string', 'max:255'], + 'lines.*.quantity' => ['required', 'string'], + 'lines.*.quantity_unit' => ['required', 'string', 'max:16'], + 'lines.*.unit_rate' => ['required', 'string'], + 'lines.*.waste_quantity' => ['sometimes', 'string'], + 'lines.*.units_per_parcel' => ['required_if:lines.*.category,shipping', 'nullable', 'integer', 'min:1'], + 'lines.*.source_price_date' => ['nullable', 'date'], + 'lines.*.source_reference' => ['nullable', 'string', 'max:255'], + ]); + + if ($data['currency'] !== $project->currency) { + throw ValidationException::withMessages(['currency' => 'Use the project currency; no currency conversion is implied.']); + } + $revision = $project->revisions()->find($data['revision_id']); + abort_unless($revision, 404); + + try { + $linesWithTotals = array_map(function (array $line) use ($data): array { + $line['line_total'] = ProjectCosting::lineTotal( + $line['category'], (string) $line['quantity'], $line['quantity_unit'], + (string) $line['unit_rate'], (string) ($line['waste_quantity'] ?? '0'), + ); + if ($line['category'] === ProjectCostLine::CATEGORY_SHIPPING) { + if ($data['basis'] === 'actual' && empty($line['source_reference'])) { + throw new \InvalidArgumentException('Actual shipping needs a confirmed shipment or invoice reference.'); + } + $scaled = FixedPoint::toScaled($line['line_total'], 4); + $allocated = FixedPoint::divideByInt( + FixedPoint::multiply($scaled, (int) $data['batch_units']), + (int) $line['units_per_parcel'], + ); + $line['line_total'] = FixedPoint::format($allocated, 4); + } + + return $line; + }, $data['lines']); + $totals = ProjectCosting::aggregate($linesWithTotals); + } catch (Throwable $exception) { + throw ValidationException::withMessages(['lines' => $exception->getMessage()]); + } + + $snapshot = DB::transaction(function () use ($request, $project, $revision, $data, $linesWithTotals, $totals): ProjectCostSnapshot { + $snapshot = ProjectCostSnapshot::create([ + 'project_id' => $project->id, + 'revision_id' => $revision->id, + 'currency' => $data['currency'], + 'basis' => $data['basis'], + 'batch_units' => $data['batch_units'], + 'filament_total' => $totals['filament_total'], + 'packaging_total' => $totals['packaging_total'], + 'shipping_total' => $totals['shipping_total'], + 'grand_total' => $totals['grand_total'], + 'source_reference' => $data['source_reference'] ?? null, + 'notes' => $data['notes'] ?? null, + 'recorded_by' => $request->user()->id, + 'recorded_at' => now(), + ]); + + foreach ($linesWithTotals as $line) { + ProjectCostLine::create([ + 'cost_snapshot_id' => $snapshot->id, + 'category' => $line['category'], + 'description' => $line['description'], + 'quantity' => $line['quantity'], + 'quantity_unit' => $line['quantity_unit'], + 'unit_rate' => $line['unit_rate'], + 'waste_quantity' => $line['waste_quantity'] ?? '0', + 'units_per_parcel' => $line['units_per_parcel'] ?? null, + 'line_total' => $line['line_total'], + 'source_price_date' => $line['source_price_date'] ?? null, + 'source_reference' => $line['source_reference'] ?? null, + ]); + } + + ProjectEvent::record($project->id, 'cost_snapshot_recorded', $request->user()->id, ProjectCostSnapshot::class, $snapshot->id, [ + 'basis' => $data['basis'], 'grand_total' => $totals['grand_total'], + ]); + + return $snapshot; + }); + + return redirect()->route('projects.show', $project)->with('status', 'Cost snapshot recorded.'); + } +} diff --git a/app/Http/Controllers/ProjectFileController.php b/app/Http/Controllers/ProjectFileController.php new file mode 100644 index 0000000..b769f89 --- /dev/null +++ b/app/Http/Controllers/ProjectFileController.php @@ -0,0 +1,20 @@ +revision->project_id === $project->id, 404); + abort_unless($request->user()->can('download', [$file, $project]), 403); + + return Storage::disk('project_files')->download($file->storage_key, $file->original_filename); + } +} diff --git a/app/Http/Controllers/ProjectListingLinkController.php b/app/Http/Controllers/ProjectListingLinkController.php new file mode 100644 index 0000000..22cfbd5 --- /dev/null +++ b/app/Http/Controllers/ProjectListingLinkController.php @@ -0,0 +1,122 @@ +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); + } +} diff --git a/app/Http/Controllers/ProjectReleaseController.php b/app/Http/Controllers/ProjectReleaseController.php new file mode 100644 index 0000000..0ddc27d --- /dev/null +++ b/app/Http/Controllers/ProjectReleaseController.php @@ -0,0 +1,143 @@ +project_id === $project->id, 404); + abort_unless($request->user()->can('release', $project), 403); + + $data = $request->validate([ + 'revision_id' => ['required', 'integer'], + 'cost_snapshot_id' => ['required', 'integer'], + 'lock_version' => ['required', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + if ($listingLink->isDetached()) { + return back()->withErrors(['listing_link' => 'That listing mapping has been detached and can no longer be released to.'])->setStatusCode(422); + } + + $revision = $project->revisions()->find($data['revision_id']); + if (! $revision) { + return back()->withErrors(['revision_id' => 'That revision does not belong to this project.'])->setStatusCode(422); + } + if (! $revision->isApproved()) { + return back()->withErrors(['revision_id' => 'Only an approved revision can be released.'])->setStatusCode(422); + } + + $costSnapshot = $project->costSnapshots()->find($data['cost_snapshot_id']); + if (! $costSnapshot || (int) $costSnapshot->revision_id !== $revision->id) { + return back()->withErrors(['cost_snapshot_id' => 'That cost snapshot does not match this project and revision.'])->setStatusCode(422); + } + if (! $costSnapshot->isComplete()) { + return back()->withErrors(['cost_snapshot_id' => 'The cost snapshot must have filament, packaging and shipping all costed (even as an explicit zero) before it can be released.'])->setStatusCode(422); + } + + try { + $release = DB::transaction(function () use ($request, $project, $listingLink, $revision, $costSnapshot, $data): ProjectRelease { + Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail(); + if ($locked->lock_version !== $data['lock_version']) { + throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.'); + } + + $release = ProjectRelease::create([ + 'project_id' => $project->id, + 'revision_id' => $revision->id, + 'cost_snapshot_id' => $costSnapshot->id, + 'action' => ProjectRelease::ACTION_RELEASE, + 'released_by' => $request->user()->id, + 'released_at' => now(), + 'notes' => $data['notes'] ?? null, + ]); + $locked->update(['live_release_id' => $release->id, 'lock_version' => $locked->lock_version + 1]); + + ProjectEvent::record($project->id, 'released', $request->user()->id, ProjectRelease::class, $release->id, [ + 'listing_link_id' => $listingLink->id, 'revision_id' => $revision->id, + ]); + + return $release; + }); + } catch (OptimisticLockConflictException $exception) { + return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409); + } + + return redirect()->route('projects.show', $project)->with('status', 'Revision released.'); + } + + public function revert(Request $request, Project $project, ProjectListingLink $listingLink): RedirectResponse + { + abort_unless($listingLink->project_id === $project->id, 404); + abort_unless($request->user()->can('release', $project), 403); + + $data = $request->validate([ + 'release_id' => ['required', 'integer'], + 'lock_version' => ['required', 'integer'], + 'notes' => ['nullable', 'string'], + ]); + + if ($listingLink->isDetached()) { + return back()->withErrors(['listing_link' => 'That listing mapping has been detached and can no longer be reverted.'])->setStatusCode(422); + } + + $target = $project->releases()->find($data['release_id']); + if (! $target) { + return back()->withErrors(['release_id' => 'That release does not belong to this project.'])->setStatusCode(422); + } + $targetSnapshot = $target->cost_snapshot_id ? ProjectCostSnapshot::find($target->cost_snapshot_id) : null; + if (! $targetSnapshot || ! $targetSnapshot->isComplete()) { + return back()->withErrors(['release_id' => 'That release no longer has a complete cost snapshot and cannot be restored.'])->setStatusCode(422); + } + + try { + $release = DB::transaction(function () use ($request, $project, $listingLink, $target, $data): ProjectRelease { + Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $locked = ProjectListingLink::query()->whereKey($listingLink->id)->lockForUpdate()->firstOrFail(); + if ($locked->lock_version !== $data['lock_version']) { + throw new OptimisticLockConflictException('The listing mapping changed since you loaded it.'); + } + + $release = ProjectRelease::create([ + 'project_id' => $project->id, + 'revision_id' => $target->revision_id, + 'cost_snapshot_id' => $target->cost_snapshot_id, + 'action' => ProjectRelease::ACTION_REVERT, + 'released_by' => $request->user()->id, + 'released_at' => now(), + 'notes' => $data['notes'] ?? null, + ]); + $locked->update(['live_release_id' => $release->id, 'lock_version' => $locked->lock_version + 1]); + + ProjectEvent::record($project->id, 'release_reverted', $request->user()->id, ProjectRelease::class, $release->id, [ + 'listing_link_id' => $listingLink->id, 'reverted_to_release_id' => $target->id, + ]); + + return $release; + }); + } catch (OptimisticLockConflictException $exception) { + return back()->withErrors(['lock_version' => $exception->getMessage()])->setStatusCode(409); + } + + return redirect()->route('projects.show', $project)->with('status', 'Reverted to a previous release.'); + } +} diff --git a/app/Http/Controllers/ProjectRevisionController.php b/app/Http/Controllers/ProjectRevisionController.php new file mode 100644 index 0000000..3453882 --- /dev/null +++ b/app/Http/Controllers/ProjectRevisionController.php @@ -0,0 +1,150 @@ +user()->can('manageFiles', $project), 403); + $data = $request->validate([ + 'change_notes' => ['nullable', 'string', 'max:2000'], + 'revision_id' => ['nullable', 'integer'], + 'files' => ['required', 'array', 'min:1', 'max:10'], + 'files.*' => ['file', 'max:'.(ManufacturingFileValidator::MAX_FILE_SIZE / 1024)], + 'roles' => ['sometimes', 'array'], + 'roles.*' => ['nullable', 'string', 'max:64'], + 'fusion_project_id' => ['nullable', 'string', 'max:255'], + 'fusion_document_id' => ['nullable', 'string', 'max:255'], + 'fusion_version' => ['nullable', 'string', 'max:255'], + ]); + + $totalBytes = array_sum(array_map(fn ($file) => (int) $file->getSize(), $data['files'])); + if ($totalBytes > ManufacturingFileValidator::MAX_TOTAL_BATCH_SIZE) { + throw ValidationException::withMessages(['files' => 'The combined size of all files in this upload is too large.']); + } + + $validator = new ManufacturingFileValidator; + $disk = Storage::disk('project_files'); + $storedKeys = []; + + try { + $revision = DB::transaction(function () use ($request, $project, $data, $validator, $disk, &$storedKeys): ProjectRevision { + Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $revision = $this->resolveWritableRevision($project, $request->user()->id, $data); + + foreach ($data['files'] as $index => $uploadedFile) { + try { + $validated = $validator->validate($uploadedFile); + } catch (RuntimeException $exception) { + throw ValidationException::withMessages(["files.{$index}" => $exception->getMessage()]); + } + + $key = bin2hex(random_bytes(20)).'.'.$validated['extension']; + $stream = fopen($uploadedFile->getRealPath(), 'rb'); + if ($stream === false || $disk->put($key, $stream) === false) { + if (is_resource($stream)) { + fclose($stream); + } + + throw new RuntimeException("Unable to store uploaded file: {$uploadedFile->getClientOriginalName()}."); + } + if (is_resource($stream)) { + fclose($stream); + } + $storedKeys[] = $key; + + ProjectFile::create([ + 'revision_id' => $revision->id, + 'role' => $data['roles'][$index] ?? 'component', + 'original_filename' => $uploadedFile->getClientOriginalName(), + 'storage_key' => $key, + 'extension' => $validated['extension'], + 'media_type' => $validated['media_type'], + 'byte_size' => $uploadedFile->getSize(), + 'sha256' => hash_file('sha256', $uploadedFile->getRealPath()), + 'uploaded_by' => $request->user()->id, + ]); + } + + ProjectEvent::record($project->id, 'revision_uploaded', $request->user()->id, ProjectRevision::class, $revision->id, [ + 'revision_number' => $revision->revision_number, + 'file_count' => count($data['files']), + ]); + + return $revision; + }); + } catch (Throwable $exception) { + foreach ($storedKeys as $key) { + $disk->delete($key); + } + + throw $exception; + } + + return redirect()->route('projects.show', $project)->with('status', 'Revision uploaded.'); + } + + public function approve(Request $request, Project $project, ProjectRevision $revision): RedirectResponse + { + abort_unless($revision->project_id === $project->id, 404); + abort_unless($request->user()->can('manageFiles', $project), 403); + + DB::transaction(function () use ($request, $project, $revision): void { + Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail(); + $locked = ProjectRevision::query()->whereKey($revision->id)->lockForUpdate()->firstOrFail(); + + abort_if($locked->isApproved(), 422, 'This revision is already approved.'); + abort_if($locked->files()->count() === 0, 422, 'A revision needs at least one file before it can be approved.'); + + $locked->update([ + 'status' => ProjectRevision::STATUS_APPROVED, + 'approved_by' => $request->user()->id, + 'approved_at' => now(), + ]); + ProjectEvent::record($project->id, 'revision_approved', $request->user()->id, ProjectRevision::class, $locked->id, [ + 'revision_number' => $locked->revision_number, + ]); + }); + + return redirect()->route('projects.show', $project)->with('status', 'Revision approved.'); + } + + /** + * @param array $data + */ + private function resolveWritableRevision(Project $project, int $userId, array $data): ProjectRevision + { + if (! empty($data['revision_id'])) { + $candidate = $project->revisions()->find($data['revision_id']); + if ($candidate && ! $candidate->isApproved()) { + return $candidate; + } + } + + $nextNumber = (int) $project->revisions()->max('revision_number') + 1; + + return $project->revisions()->create([ + 'revision_number' => $nextNumber, + 'change_notes' => $data['change_notes'] ?? null, + 'fusion_project_id' => $data['fusion_project_id'] ?? null, + 'fusion_document_id' => $data['fusion_document_id'] ?? null, + 'fusion_version' => $data['fusion_version'] ?? null, + 'status' => ProjectRevision::STATUS_DRAFT, + 'created_by' => $userId, + ]); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..a9bfed4 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + /** @var list */ + public const STAGES = ['design', 'ready', 'live', 'retired']; + + protected $fillable = ['reference', 'title', 'notes', 'stage', 'currency', 'created_by', 'archived_at']; + + protected function casts(): array + { + return [ + 'archived_at' => 'datetime', + ]; + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function revisions(): HasMany + { + return $this->hasMany(ProjectRevision::class); + } + + public function listingLinks(): HasMany + { + return $this->hasMany(ProjectListingLink::class); + } + + public function releases(): HasMany + { + return $this->hasMany(ProjectRelease::class); + } + + public function costSnapshots(): HasMany + { + return $this->hasMany(ProjectCostSnapshot::class); + } + + public function events(): HasMany + { + return $this->hasMany(ProjectEvent::class)->latest('created_at'); + } + + public function isArchived(): bool + { + return $this->archived_at !== null; + } +} diff --git a/app/Models/ProjectCostLine.php b/app/Models/ProjectCostLine.php new file mode 100644 index 0000000..2861f4e --- /dev/null +++ b/app/Models/ProjectCostLine.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + public const CATEGORY_FILAMENT = 'filament'; + + public const CATEGORY_PACKAGING = 'packaging'; + + public const CATEGORY_SHIPPING = 'shipping'; + + /** @var list */ + public const CATEGORIES = [self::CATEGORY_FILAMENT, self::CATEGORY_PACKAGING, self::CATEGORY_SHIPPING]; + + protected $fillable = [ + 'cost_snapshot_id', 'category', 'description', 'quantity', 'quantity_unit', + 'unit_rate', 'waste_quantity', 'units_per_parcel', 'line_total', 'source_price_date', 'source_reference', + ]; + + protected function casts(): array + { + return [ + 'quantity' => 'decimal:3', + 'unit_rate' => 'decimal:6', + 'waste_quantity' => 'decimal:3', + 'units_per_parcel' => 'integer', + 'line_total' => 'decimal:4', + 'source_price_date' => 'date', + ]; + } + + public function snapshot(): BelongsTo + { + return $this->belongsTo(ProjectCostSnapshot::class, 'cost_snapshot_id'); + } +} diff --git a/app/Models/ProjectCostSnapshot.php b/app/Models/ProjectCostSnapshot.php new file mode 100644 index 0000000..6d95747 --- /dev/null +++ b/app/Models/ProjectCostSnapshot.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + + public const BASIS_ESTIMATE = 'estimate'; + + public const BASIS_ACTUAL = 'actual'; + + protected $fillable = [ + 'project_id', 'revision_id', 'currency', 'basis', 'batch_units', + 'filament_total', 'packaging_total', 'shipping_total', 'grand_total', + 'source_reference', 'notes', 'recorded_by', 'recorded_at', + ]; + + protected function casts(): array + { + return [ + 'filament_total' => 'decimal:4', + 'packaging_total' => 'decimal:4', + 'shipping_total' => 'decimal:4', + 'grand_total' => 'decimal:4', + 'recorded_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function revision(): BelongsTo + { + return $this->belongsTo(ProjectRevision::class, 'revision_id'); + } + + public function recorder(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by'); + } + + public function lines(): HasMany + { + return $this->hasMany(ProjectCostLine::class, 'cost_snapshot_id'); + } + + /** + * A release requires every cost category to have been explicitly costed — + * even as an intentional zero — rather than merely recorded. A category left + * null ("not costed") is incomplete and must block a release going live. + */ + public function isComplete(): bool + { + return $this->filament_total !== null + && $this->packaging_total !== null + && $this->shipping_total !== null; + } + + public function perUnitGrandTotal(): ?string + { + if ($this->grand_total === null) { + return null; + } + + return ProjectCosting::perUnit((string) $this->grand_total, (int) $this->batch_units); + } +} diff --git a/app/Models/ProjectEvent.php b/app/Models/ProjectEvent.php new file mode 100644 index 0000000..8fc987f --- /dev/null +++ b/app/Models/ProjectEvent.php @@ -0,0 +1,44 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + public static function record(int $projectId, string $eventType, ?int $actorId, ?string $subjectType = null, ?int $subjectId = null, array $metadata = []): self + { + return self::create([ + 'project_id' => $projectId, + 'event_type' => $eventType, + 'actor_id' => $actorId, + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'metadata' => $metadata, + 'created_at' => now(), + ]); + } +} diff --git a/app/Models/ProjectFile.php b/app/Models/ProjectFile.php new file mode 100644 index 0000000..7f85ad4 --- /dev/null +++ b/app/Models/ProjectFile.php @@ -0,0 +1,32 @@ + */ + use HasFactory; + + /** @var list */ + public const EXTENSIONS = ['stl', '3mf', 'gcode']; + + protected $fillable = [ + 'revision_id', 'role', 'original_filename', 'storage_key', 'extension', + 'media_type', 'byte_size', 'sha256', 'uploaded_by', 'printer_profile', 'material', + ]; + + public function revision(): BelongsTo + { + return $this->belongsTo(ProjectRevision::class, 'revision_id'); + } + + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } +} diff --git a/app/Models/ProjectListingLink.php b/app/Models/ProjectListingLink.php new file mode 100644 index 0000000..782e3e1 --- /dev/null +++ b/app/Models/ProjectListingLink.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + public const ALL_VARIATIONS = 'all'; + + protected $fillable = [ + 'project_id', 'shop_id', 'listing_id', 'variation_key', 'title_snapshot', + 'state_snapshot', 'remote_updated_at', 'synced_at', 'live_release_id', + 'lock_version', 'created_by', 'detached_at', + ]; + + protected function casts(): array + { + return [ + 'remote_updated_at' => 'datetime', + 'synced_at' => 'datetime', + 'detached_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function liveRelease(): BelongsTo + { + return $this->belongsTo(ProjectRelease::class, 'live_release_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isDetached(): bool + { + return $this->detached_at !== null; + } +} diff --git a/app/Models/ProjectRelease.php b/app/Models/ProjectRelease.php new file mode 100644 index 0000000..735efd7 --- /dev/null +++ b/app/Models/ProjectRelease.php @@ -0,0 +1,49 @@ + */ + use HasFactory; + + public const ACTION_RELEASE = 'release'; + + public const ACTION_REVERT = 'revert'; + + protected $fillable = [ + 'project_id', 'revision_id', 'cost_snapshot_id', 'action', 'released_by', 'released_at', 'notes', + ]; + + protected function casts(): array + { + return [ + 'released_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function revision(): BelongsTo + { + return $this->belongsTo(ProjectRevision::class, 'revision_id'); + } + + public function costSnapshot(): BelongsTo + { + return $this->belongsTo(ProjectCostSnapshot::class, 'cost_snapshot_id'); + } + + public function releasedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'released_by'); + } +} diff --git a/app/Models/ProjectRevision.php b/app/Models/ProjectRevision.php new file mode 100644 index 0000000..5ae25cb --- /dev/null +++ b/app/Models/ProjectRevision.php @@ -0,0 +1,63 @@ + */ + use HasFactory; + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_SUPERSEDED = 'superseded'; + + protected $fillable = [ + 'project_id', 'revision_number', 'change_notes', 'status', 'created_by', + 'approved_by', 'approved_at', 'fusion_project_id', 'fusion_document_id', 'fusion_version', + ]; + + protected function casts(): array + { + return [ + 'approved_at' => 'datetime', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function files(): HasMany + { + return $this->hasMany(ProjectFile::class, 'revision_id'); + } + + public function costSnapshots(): HasMany + { + return $this->hasMany(ProjectCostSnapshot::class, 'revision_id'); + } + + public function isApproved(): bool + { + return $this->status === self::STATUS_APPROVED; + } +} diff --git a/app/Policies/ProjectFilePolicy.php b/app/Policies/ProjectFilePolicy.php new file mode 100644 index 0000000..684f2a5 --- /dev/null +++ b/app/Policies/ProjectFilePolicy.php @@ -0,0 +1,18 @@ +role === 'admin' && $file->revision->project_id === $project->id; + } +} diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php new file mode 100644 index 0000000..16caea4 --- /dev/null +++ b/app/Policies/ProjectPolicy.php @@ -0,0 +1,58 @@ +role === 'admin'; + } + + public function view(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function create(User $user): bool + { + return $user->role === 'admin'; + } + + public function update(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function archive(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function manageFiles(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function manageListings(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function manageCosts(User $user, Project $project): bool + { + return $user->role === 'admin'; + } + + public function release(User $user, Project $project): bool + { + return $user->role === 'admin'; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index acbef09..cb42fcf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Services\IntegrationCache; +use App\Support\Navigation; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -20,6 +22,9 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + View::composer('layouts.app', function ($view): void { + $user = auth()->user(); + $view->with('navigationGroups', $user ? Navigation::groups($user) : []); + }); } } diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index c01caf6..7fa7b0b 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\EtsyAppCredential; use App\Models\EtsyConnection; use Illuminate\Http\Client\RequestException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -188,24 +189,34 @@ class Etsy /** * @return array{count: int, limit: int, offset: int, state: string, results: list>} */ - public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0): array + public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0, bool $fresh = false): array { $state = strtolower($state); if (! in_array($state, self::LISTING_STATES, true)) { throw new RuntimeException('Listing state must be one of: '.implode(', ', self::LISTING_STATES).'.'); } [$limit, $offset] = $this->page($limit, $offset); - $response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ - 'state' => $state, 'limit' => $limit, 'offset' => $offset, - ]); + $generation = Cache::get('etsy.listing-generation.'.$connection->shop_id, '0'); + $cacheKey = 'etsy.listListings.'.$connection->shop_id.'.'.$generation.'.'.$state.'.'.$limit.'.'.$offset; - return [ - 'count' => (int) ($response['count'] ?? count($response['results'] ?? [])), - 'limit' => $limit, - 'offset' => $offset, - 'state' => $state, - 'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)), - ]; + $result = $this->cache->remember($cacheKey, 600, function () use ($connection, $state, $limit, $offset): array { + $response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ + 'state' => $state, 'limit' => $limit, 'offset' => $offset, + ]); + + return [ + 'count' => (int) ($response['count'] ?? count($response['results'] ?? [])), + 'limit' => $limit, + 'offset' => $offset, + 'state' => $state, + 'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)), + ]; + }, $fresh); + $entry = Cache::get('integration:'.$cacheKey); + $result['fetched_at'] = is_array($entry) ? $entry['fetched_at'] : null; + $result['stale'] = $result['fetched_at'] === null || time() >= $result['fetched_at'] + 600; + + return $result; } /** @@ -270,6 +281,7 @@ class Etsy $payload = $this->validatedListingPayload($payload, true); $listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload); $this->cache->forget('etsy.listings.'.$connection->shop_id); + $this->forgetListingStateCaches($connection); return $this->summarizeListing($listing, detailed: true); } @@ -289,6 +301,7 @@ class Etsy $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); + $this->forgetListingStateCaches($connection); return $this->summarizeListing($listing, detailed: true); } @@ -306,10 +319,21 @@ class Etsy $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); + $this->forgetListingStateCaches($connection); return $this->summarizeListing($listing, detailed: true); } + /** + * Invalidate the first page of both Live and Draft state caches so a + * write immediately shown to the operator doesn't keep serving a stale + * cached page. Older pages expire on their own TTL. + */ + private function forgetListingStateCaches(EtsyConnection $connection): void + { + Cache::put('etsy.listing-generation.'.$connection->shop_id, bin2hex(random_bytes(16)), 172800); + } + /** * @return array */ @@ -359,6 +383,7 @@ class Etsy } $inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, jsonBody: true); $this->cache->forget('etsy.listings.'.$connection->shop_id); + $this->forgetListingStateCaches($connection); return $inventory; } diff --git a/app/Services/ManufacturingFileValidator.php b/app/Services/ManufacturingFileValidator.php new file mode 100644 index 0000000..284b3fd --- /dev/null +++ b/app/Services/ManufacturingFileValidator.php @@ -0,0 +1,610 @@ + */ + private const MEDIA_TYPES = [ + 'stl' => 'model/stl', + '3mf' => 'model/3mf', + 'gcode' => 'text/x-gcode', + ]; + + /** + * @return array{extension: string, media_type: string} + */ + public function validate(UploadedFile $file): array + { + $size = $file->getSize(); + if ($size === false || $size <= 0) { + throw new RuntimeException('File is empty or unreadable.'); + } + if ($size > self::MAX_FILE_SIZE) { + throw new RuntimeException('File exceeds the maximum allowed size of 100 MiB.'); + } + + $extension = strtolower((string) $file->getClientOriginalExtension()); + if (! in_array($extension, ProjectFile::EXTENSIONS, true)) { + throw new RuntimeException("Unsupported file type: .{$extension}"); + } + + $path = $file->getRealPath() ?: $file->getPathname(); + + match ($extension) { + 'stl' => $this->validateStl($path, $size), + '3mf' => $this->validate3mf($path), + 'gcode' => $this->validateGcode($path, $size), + default => throw new RuntimeException("Unsupported file type: .{$extension}"), + }; + + return ['extension' => $extension, 'media_type' => self::MEDIA_TYPES[$extension]]; + } + + private function validateStl(string $path, int $size): void + { + if ($size >= 84) { + $header = $this->readPrefix($path, 84); + if (strlen($header) === 84) { + $count = unpack('V', substr($header, 80, 4))[1] ?? 0; + if ($count > 0 && $count <= self::MAX_STL_TRIANGLES && $size === 84 + $count * 50) { + $this->assertBinaryStlTrianglesFinite($path, $count); + + return; + } + } + } + + $prefix = strtolower(ltrim($this->readPrefix($path, 16))); + if (! str_starts_with($prefix, 'solid')) { + throw new RuntimeException('File does not match the declared STL format.'); + } + if (! $this->isPlainText($path)) { + throw new RuntimeException('ASCII STL contains unexpected binary content.'); + } + $this->validateAsciiStlGrammar($path); + } + + /** + * A real facet/vertex grammar: "solid garbage endsolid" with zero facets, + * or any facet missing its outer loop/vertices, is rejected — not just + * files that merely start with "solid" and contain "endsolid" somewhere. + * Streamed line-by-line so an up-to-100-MiB file never needs to be loaded + * into memory at once. + */ + private function validateAsciiStlGrammar(string $path): void + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new RuntimeException('Unable to read uploaded file.'); + } + + try { + $first = fgets($handle); + if ($first === false || preg_match('/^\s*solid\b/i', $first) !== 1) { + throw new RuntimeException('ASCII STL must begin with a "solid" declaration.'); + } + + $facetCount = 0; + $state = 'facet_or_end'; + $done = false; + + while (($line = fgets($handle)) !== false) { + $trimmed = trim($line); + if ($trimmed === '') { + continue; + } + + switch ($state) { + case 'facet_or_end': + if (preg_match('/^endsolid\b/i', $trimmed) === 1) { + $done = true; + break 2; + } + if (preg_match('/^facet\s+normal\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) { + throw new RuntimeException('ASCII STL contains an unexpected line outside a facet block.'); + } + $this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'facet normal'); + $state = 'outer_loop'; + break; + + case 'outer_loop': + if (preg_match('/^outer\s+loop$/i', $trimmed) !== 1) { + throw new RuntimeException('Expected "outer loop" after a facet normal.'); + } + $state = 'vertex1'; + break; + + case 'vertex1': + case 'vertex2': + case 'vertex3': + if (preg_match('/^vertex\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s+('.self::FLOAT_PATTERN.')\s*$/i', $trimmed, $m) !== 1) { + throw new RuntimeException('Expected a "vertex x y z" line inside a facet loop.'); + } + $this->assertFiniteTokens([$m[1], $m[2], $m[3]], 'vertex'); + $state = match ($state) { + 'vertex1' => 'vertex2', + 'vertex2' => 'vertex3', + 'vertex3' => 'endloop', + }; + break; + + case 'endloop': + if (preg_match('/^endloop$/i', $trimmed) !== 1) { + throw new RuntimeException('Expected "endloop" after three vertices.'); + } + $state = 'endfacet'; + break; + + case 'endfacet': + if (preg_match('/^endfacet$/i', $trimmed) !== 1) { + throw new RuntimeException('Expected "endfacet" to close a facet.'); + } + $facetCount++; + if ($facetCount > self::MAX_STL_TRIANGLES) { + throw new RuntimeException('ASCII STL exceeds the maximum supported facet count.'); + } + $state = 'facet_or_end'; + break; + } + } + + if (! $done || $state !== 'facet_or_end') { + throw new RuntimeException('ASCII STL is missing an endsolid terminator or has an incomplete facet.'); + } + if ($facetCount < 1) { + throw new RuntimeException('ASCII STL must contain at least one facet.'); + } + } finally { + fclose($handle); + } + } + + /** + * @param list $tokens + */ + private function assertFiniteTokens(array $tokens, string $context): void + { + foreach ($tokens as $token) { + $value = (float) $token; + if (! is_finite($value)) { + throw new RuntimeException("ASCII STL {$context} contains a non-finite value."); + } + } + } + + private function assertBinaryStlTrianglesFinite(string $path, int $count): void + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new RuntimeException('Unable to read uploaded file.'); + } + + try { + fseek($handle, 84); + $remaining = $count; + while ($remaining > 0) { + $batch = min($remaining, 2000); + $bytes = $batch * 50; + $chunk = fread($handle, $bytes); + if ($chunk === false || strlen($chunk) !== $bytes) { + throw new RuntimeException('Binary STL triangle data is truncated.'); + } + for ($i = 0; $i < $batch; $i++) { + $floats = unpack('f12', substr($chunk, $i * 50, 48)); + if ($floats === false) { + throw new RuntimeException('Binary STL triangle data is malformed.'); + } + foreach ($floats as $value) { + if (! is_finite($value)) { + throw new RuntimeException('Binary STL contains a non-finite (NaN/Infinity) normal or vertex value.'); + } + } + } + $remaining -= $batch; + } + } finally { + fclose($handle); + } + } + + private function validateGcode(string $path, int $size): void + { + if (! $this->isPlainText($path)) { + throw new RuntimeException('G-code file contains unexpected binary content.'); + } + } + + private function validate3mf(string $path): void + { + $zip = new ZipArchive; + if ($zip->open($path) !== true) { + throw new RuntimeException('Unable to read 3MF archive.'); + } + + try { + $count = $zip->count(); + if ($count > self::MAX_3MF_ENTRIES) { + throw new RuntimeException('3MF archive has too many entries.'); + } + + $totalUncompressed = 0; + /** @var array $seenNames */ + $seenNames = []; + /** @var list $modelParts */ + $modelParts = []; + /** @var list $relsEntries */ + $relsEntries = []; + /** @var list $xmlEntries */ + $xmlEntries = []; + + for ($i = 0; $i < $count; $i++) { + $stat = $zip->statIndex($i); + if ($stat === false) { + throw new RuntimeException('Unable to read 3MF archive entry.'); + } + $name = (string) $stat['name']; + + if (isset($seenNames[$name])) { + throw new RuntimeException("3MF archive contains a duplicate entry path: {$name}."); + } + $seenNames[$name] = true; + + $this->assertSafeZipEntryName($name); + $this->assertNotSymlinkEntry($zip, $i, $name); + + if (($stat['encryption_method'] ?? ZipArchive::EM_NONE) !== ZipArchive::EM_NONE) { + throw new RuntimeException("3MF archive contains an encrypted entry: {$name}."); + } + + $uncompressed = (int) $stat['size']; + $compressed = (int) $stat['comp_size']; + $totalUncompressed += $uncompressed; + + if ($compressed > 0 && ($uncompressed / $compressed) > self::MAX_3MF_RATIO) { + throw new RuntimeException('3MF entry has a suspicious compression ratio.'); + } + if ($compressed === 0 && $uncompressed > 10 * 1024 * 1024) { + throw new RuntimeException('3MF entry has a suspicious compression ratio.'); + } + + if (str_ends_with($name, '.model')) { + $modelParts[] = $name; + } elseif (str_ends_with($name, '.rels')) { + $relsEntries[] = $name; + } elseif (str_ends_with($name, '.xml')) { + $xmlEntries[] = $name; + } + } + + if ($totalUncompressed > self::MAX_3MF_TOTAL_UNCOMPRESSED) { + throw new RuntimeException('3MF archive exceeds the maximum uncompressed size.'); + } + if (! in_array('3D/3dmodel.model', $modelParts, true)) { + throw new RuntimeException('3MF archive is missing the required 3D model part.'); + } + if (! in_array('_rels/.rels', $relsEntries, true)) { + throw new RuntimeException('3MF archive is missing the required root relationships part.'); + } + + $budget = self::MAX_3MF_TOTAL_PARSED_BYTES; + + $rootTargets = []; + foreach ($relsEntries as $relsName) { + $xml = $this->readBoundedZipEntry($zip, $relsName, self::MAX_3MF_XML_ENTRY_BYTES, $budget); + $targets = $this->parseRelationships($xml, $relsName); + if ($relsName === '_rels/.rels') { + $rootTargets = $targets; + } + } + + $hasModelRelationship = false; + foreach ($rootTargets as $target) { + $resolved = ltrim($target, '/'); + if (isset($seenNames[$resolved]) && str_ends_with($resolved, '.model')) { + $hasModelRelationship = true; + } + } + if (! $hasModelRelationship) { + throw new RuntimeException('3MF root relationships do not point to an internal model part.'); + } + + foreach ($xmlEntries as $xmlName) { + $xml = $this->readBoundedZipEntry($zip, $xmlName, self::MAX_3MF_XML_ENTRY_BYTES, $budget); + $this->assertGenericWellFormedXml($xml, $xmlName); + } + + foreach ($modelParts as $modelName) { + $cap = $modelName === '3D/3dmodel.model' ? self::MAX_3MF_MODEL_PART_BYTES : self::MAX_3MF_XML_ENTRY_BYTES; + $xml = $this->readBoundedZipEntry($zip, $modelName, $cap, $budget); + $this->assertWellFormedModelXml($xml, $modelName); + } + } finally { + $zip->close(); + } + } + + /** + * Reads a zip entry through a bounded stream rather than trusting the + * archive's own declared size, so a size lie in the central directory + * can't blow past the per-entry or whole-archive parse budgets. + */ + private function readBoundedZipEntry(ZipArchive $zip, string $name, int $perEntryCap, int &$remainingBudget): string + { + $stream = $zip->getStream($name); + if ($stream === false) { + throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); + } + + $buffer = ''; + $total = 0; + try { + while (! feof($stream)) { + $chunk = fread($stream, 65536); + if ($chunk === false) { + throw new RuntimeException("Unable to read 3MF archive entry: {$name}."); + } + $total += strlen($chunk); + if ($total > $perEntryCap) { + throw new RuntimeException("3MF archive entry exceeds its size cap: {$name}."); + } + $remainingBudget -= strlen($chunk); + if ($remainingBudget < 0) { + throw new RuntimeException('3MF archive exceeds the total parse budget.'); + } + $buffer .= $chunk; + } + } finally { + fclose($stream); + } + + return $buffer; + } + + /** + * @return list the raw Target attribute values of every Relationship + */ + private function parseRelationships(string $xml, string $context): array + { + $reader = $this->openSafeXmlReader($xml, $context); + $targets = []; + + try { + while (@$reader->read()) { + if ($reader->nodeType === XMLReader::DOC_TYPE) { + throw new RuntimeException("3MF relationships part contains a disallowed DOCTYPE declaration: {$context}."); + } + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'Relationship') { + continue; + } + + $targetMode = $reader->getAttribute('TargetMode'); + if ($targetMode !== null && strcasecmp($targetMode, 'External') === 0) { + throw new RuntimeException("3MF relationship declares an external TargetMode: {$context}."); + } + + $target = $reader->getAttribute('Target'); + if ($target === null || $target === '') { + throw new RuntimeException("3MF relationship is missing a Target attribute: {$context}."); + } + if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $target) === 1 || str_contains($target, '..')) { + throw new RuntimeException("3MF relationship target is not a safe internal package reference: {$context}."); + } + + $targets[] = $target; + } + } finally { + $this->closeSafeXmlReader($reader, $context); + } + + return $targets; + } + + private function assertGenericWellFormedXml(string $xml, string $context): void + { + $reader = $this->openSafeXmlReader($xml, $context); + + try { + while (@$reader->read()) { + if ($reader->nodeType === XMLReader::DOC_TYPE) { + throw new RuntimeException("3MF XML part contains a disallowed DOCTYPE declaration: {$context}."); + } + } + } finally { + $this->closeSafeXmlReader($reader, $context); + } + } + + private function assertWellFormedModelXml(string $xml, string $context): void + { + $reader = $this->openSafeXmlReader($xml, $context); + $sawRoot = false; + $sawResources = false; + $sawBuild = false; + $rootDepth = null; + + try { + while (@$reader->read()) { + if ($reader->nodeType === XMLReader::DOC_TYPE) { + throw new RuntimeException("3MF model part contains a disallowed DOCTYPE declaration: {$context}."); + } + if ($reader->nodeType !== XMLReader::ELEMENT) { + continue; + } + + if (! $sawRoot) { + if ($reader->localName !== 'model' || ! str_starts_with((string) $reader->namespaceURI, self::MODEL_NAMESPACE_PREFIX)) { + throw new RuntimeException("3MF model part does not have a valid root element in the 3MF core namespace: {$context}."); + } + $sawRoot = true; + $rootDepth = $reader->depth; + + continue; + } + + if ($reader->depth === $rootDepth + 1) { + if ($reader->localName === 'resources') { + $sawResources = true; + } + if ($reader->localName === 'build') { + $sawBuild = true; + } + } + } + } finally { + $this->closeSafeXmlReader($reader, $context); + } + + if (! $sawRoot || ! $sawResources || ! $sawBuild) { + throw new RuntimeException("3MF model part is missing a valid model root with resources/build elements: {$context}."); + } + } + + /** + * No DTD loading, no network access, no entity substitution — any DOCTYPE + * encountered while reading is explicitly rejected by the callers above + * rather than relied upon to be harmless. + */ + private function openSafeXmlReader(string $xml, string $context): XMLReader + { + libxml_use_internal_errors(true); + libxml_clear_errors(); + + $reader = XMLReader::XML($xml, null, LIBXML_NONET); + if ($reader === false) { + throw new RuntimeException("3MF part is not well-formed XML: {$context}."); + } + + return $reader; + } + + private function closeSafeXmlReader(XMLReader $reader, string $context): void + { + $errors = libxml_get_errors(); + libxml_clear_errors(); + $reader->close(); + + foreach ($errors as $error) { + if ($error->level >= LIBXML_ERR_ERROR) { + throw new RuntimeException("3MF part is not well-formed XML: {$context}."); + } + } + } + + private function assertSafeZipEntryName(string $name): void + { + if ($name === '' + || str_contains($name, '..') + || str_starts_with($name, '/') + || str_contains($name, '\\') + || preg_match('/^[A-Za-z]:/', $name) === 1) { + throw new RuntimeException('3MF archive contains an unsafe entry path.'); + } + } + + /** + * Unix "external file attributes" pack the st_mode bits into the top 16 bits. + * A symlink entry (S_IFLNK octal 0120000) must never be trusted, even though + * nothing in this codebase currently extracts 3MF content to disk — a future + * caller doing so should not inherit a symlink-following vulnerability. + */ + private function assertNotSymlinkEntry(ZipArchive $zip, int $index, string $name): void + { + $external = $zip->getExternalAttributesIndex($index, $opsys, $attr) ? $attr : null; + if ($external === null) { + return; + } + + $unixMode = ($external >> 16) & 0xFFFF; + if (($unixMode & 0xF000) === 0xA000) { + throw new RuntimeException("3MF archive contains a symlink entry: {$name}."); + } + } + + private function readPrefix(string $path, int $length): string + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new RuntimeException('Unable to read uploaded file.'); + } + $data = fread($handle, $length); + fclose($handle); + + return $data === false ? '' : $data; + } + + private function isPlainText(string $path): bool + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new RuntimeException('Unable to read uploaded file.'); + } + while (! feof($handle)) { + $chunk = fread($handle, 1024 * 1024); + if ($chunk === false) { + break; + } + if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $chunk) === 1) { + fclose($handle); + + return false; + } + } + fclose($handle); + + return true; + } +} diff --git a/app/Services/ProjectCosting.php b/app/Services/ProjectCosting.php new file mode 100644 index 0000000..50858ac --- /dev/null +++ b/app/Services/ProjectCosting.php @@ -0,0 +1,185 @@ + */ + private const UNIT_BY_CATEGORY = [ + ProjectCostLine::CATEGORY_FILAMENT => 'g', + ProjectCostLine::CATEGORY_PACKAGING => 'unit', + ProjectCostLine::CATEGORY_SHIPPING => 'parcel', + ]; + + /** @var list */ + public const ALLOWED_CURRENCIES = [ + 'GBP', 'USD', 'EUR', 'CAD', 'AUD', 'NZD', 'JPY', 'CHF', 'SEK', 'NOK', + 'DKK', 'PLN', 'CZK', 'SGD', 'HKD', 'MXN', 'ZAR', 'ILS', + ]; + + public static function isValidCurrency(string $currency): bool + { + return in_array(strtoupper($currency), self::ALLOWED_CURRENCIES, true); + } + + /** + * Each category has exactly one accepted unit so a mistyped or mislabelled + * unit (e.g. "G", "grams", "kg") is rejected at validation time instead of + * silently skipping or mis-applying the gram-to-kilogram conversion. + */ + public static function isValidUnitForCategory(string $category, string $unit): bool + { + return isset(self::UNIT_BY_CATEGORY[$category]) && self::UNIT_BY_CATEGORY[$category] === $unit; + } + + /** + * @param list $currencies + */ + public static function assertSameCurrency(array $currencies): void + { + $unique = array_unique(array_map('strtoupper', $currencies)); + if (count($unique) > 1) { + throw new InvalidArgumentException('Cannot combine cost figures in different currencies without an explicit conversion: '.implode(', ', $unique)); + } + } + + public static function lineTotal(string $category, string $quantity, string $quantityUnit, string $unitRate, string $waste = '0'): string + { + if (! in_array($category, ProjectCostLine::CATEGORIES, true)) { + throw new InvalidArgumentException("Unknown cost category: {$category}"); + } + if (! self::isValidUnitForCategory($category, $quantityUnit)) { + throw new InvalidArgumentException("Invalid quantity unit '{$quantityUnit}' for category '{$category}'; expected '".self::UNIT_BY_CATEGORY[$category]."'."); + } + + $quantityScaled = FixedPoint::toScaled($quantity, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false); + $wasteScaled = FixedPoint::toScaled($waste, self::QUANTITY_SCALE, self::MAX_QUANTITY, allowNegative: false); + $rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false); + + $effectiveQuantity = FixedPoint::add($quantityScaled, $wasteScaled); + $product = FixedPoint::multiply($effectiveQuantity, $rateScaled); + $productScale = self::QUANTITY_SCALE + self::RATE_SCALE; + + if ($category === ProjectCostLine::CATEGORY_FILAMENT && $quantityUnit === 'g') { + // Rate is per kilogram, so the result needs an extra /1000. Treating the + // product as if it carried 3 more scale places folds that division into + // the normal rescale-down-to-amount-scale step. + $amount = FixedPoint::rescale($product, $productScale + 3, self::AMOUNT_SCALE); + self::assertWithinStorableAmount($amount); + + return FixedPoint::format($amount, self::AMOUNT_SCALE); + } + + $amount = FixedPoint::rescale($product, $productScale, self::AMOUNT_SCALE); + self::assertWithinStorableAmount($amount); + + return FixedPoint::format($amount, self::AMOUNT_SCALE); + } + + /** + * Shipping's units-per-parcel allocation is a distinct concept from the + * batch's manufacturing yield (see perUnit()): it divides a parcel's cost by + * how many saleable units that one parcel carries, never by how many units + * the print batch itself produced. The two divisions must never be chained. + */ + public static function shippingUnitContribution(string $unitRate, int $unitsPerParcel): string + { + if ($unitsPerParcel < 1) { + throw new InvalidArgumentException('Units per parcel must be a positive number of saleable units.'); + } + + $rateScaled = FixedPoint::toScaled($unitRate, self::RATE_SCALE, self::MAX_RATE, allowNegative: false); + $amount = FixedPoint::rescale($rateScaled, self::RATE_SCALE, self::AMOUNT_SCALE); + self::assertWithinStorableAmount($amount); + + return FixedPoint::format(FixedPoint::divideByInt($amount, $unitsPerParcel), self::AMOUNT_SCALE); + } + + private static function assertWithinStorableAmount(int $amountScaled): void + { + if (abs($amountScaled) > self::MAX_AMOUNT_SCALED) { + throw new InvalidArgumentException('Cost amount exceeds the storable precision bound.'); + } + } + + /** + * @param list $lines + * @return array{filament_total: ?string, packaging_total: ?string, shipping_total: ?string, grand_total: ?string} + */ + public static function aggregate(array $lines): array + { + $totals = []; + foreach (ProjectCostLine::CATEGORIES as $category) { + $categoryLines = array_values(array_filter($lines, fn (array $line): bool => $line['category'] === $category)); + if ($categoryLines === []) { + $totals[$category] = null; + + continue; + } + + $sum = 0; + foreach ($categoryLines as $line) { + $sum = FixedPoint::add($sum, FixedPoint::toScaled((string) $line['line_total'], self::AMOUNT_SCALE)); + } + self::assertWithinStorableAmount($sum); + $totals[$category] = FixedPoint::format($sum, self::AMOUNT_SCALE); + } + + $grand = null; + foreach ($totals as $value) { + if ($value === null) { + continue; + } + $scaled = FixedPoint::toScaled($value, self::AMOUNT_SCALE); + $grand = $grand === null ? $scaled : FixedPoint::add($grand, $scaled); + } + if ($grand !== null) { + self::assertWithinStorableAmount($grand); + } + + return [ + 'filament_total' => $totals[ProjectCostLine::CATEGORY_FILAMENT], + 'packaging_total' => $totals[ProjectCostLine::CATEGORY_PACKAGING], + 'shipping_total' => $totals[ProjectCostLine::CATEGORY_SHIPPING], + 'grand_total' => $grand === null ? null : FixedPoint::format($grand, self::AMOUNT_SCALE), + ]; + } + + public static function perUnit(string $grandTotal, int $batchUnits): string + { + if ($batchUnits < 1) { + throw new InvalidArgumentException('Batch yield must be a positive number of saleable units.'); + } + + $scaled = FixedPoint::toScaled($grandTotal, self::AMOUNT_SCALE); + + return FixedPoint::format(FixedPoint::divideByInt($scaled, $batchUnits), self::AMOUNT_SCALE); + } +} diff --git a/app/Support/FixedPoint.php b/app/Support/FixedPoint.php new file mode 100644 index 0000000..d171174 --- /dev/null +++ b/app/Support/FixedPoint.php @@ -0,0 +1,153 @@ + $scale) { + throw new InvalidArgumentException("Value has more than {$scale} decimal places: {$decimal}"); + } + $fraction = str_pad($fraction, $scale, '0'); + $whole = $whole === '' ? '0' : $whole; + + $combined = ltrim($whole.$fraction, '0'); + $combined = $combined === '' ? '0' : $combined; + + if (strlen($combined) > 18) { + throw new InvalidArgumentException("Value out of bounds: {$decimal}"); + } + + $value = (int) $combined; + if ($value < 0 || $combined !== (string) $value) { + throw new InvalidArgumentException("Value out of bounds: {$decimal}"); + } + + if ($maxAbs !== null && $value > $maxAbs * (10 ** $scale)) { + throw new InvalidArgumentException("Value exceeds the allowed maximum: {$decimal}"); + } + + return $negative ? -$value : $value; + } + + public static function add(int $a, int $b): int + { + if (($b > 0 && $a > self::MAX_INT - $b) || ($b < 0 && $a < self::MIN_INT - $b)) { + throw new OverflowException('Fixed-point addition overflowed.'); + } + + return $a + $b; + } + + public static function subtract(int $a, int $b): int + { + return self::add($a, -$b); + } + + public static function multiply(int $a, int $b): int + { + if ($a === 0 || $b === 0) { + return 0; + } + $result = $a * $b; + if (! is_int($result) || intdiv($result, $b) !== $a) { + throw new OverflowException('Fixed-point multiplication overflowed.'); + } + + return $result; + } + + /** + * Divide a scaled value by a plain positive integer (e.g. batch units), rounding half away from zero. + */ + public static function divideByInt(int $value, int $divisor): int + { + if ($divisor === 0) { + throw new InvalidArgumentException('Cannot divide by zero.'); + } + + return self::roundDivide($value, $divisor); + } + + /** + * Rescale a value from one decimal scale to another, rounding half away from zero. + */ + public static function rescale(int $value, int $fromScale, int $toScale): int + { + if ($toScale === $fromScale) { + return $value; + } + if ($toScale > $fromScale) { + $factor = 10 ** ($toScale - $fromScale); + $result = $value * $factor; + if ($factor !== 0 && intdiv($result, $factor) !== $value) { + throw new OverflowException('Fixed-point rescale overflowed.'); + } + + return $result; + } + + return self::roundDivide($value, 10 ** ($fromScale - $toScale)); + } + + public static function format(int $scaled, int $scale): string + { + $negative = $scaled < 0; + $abs = (string) abs($scaled); + $abs = str_pad($abs, $scale + 1, '0', STR_PAD_LEFT); + $whole = substr($abs, 0, strlen($abs) - $scale); + $fraction = $scale > 0 ? substr($abs, strlen($abs) - $scale) : ''; + + $formatted = $scale > 0 ? "{$whole}.{$fraction}" : $whole; + + return ($negative && $scaled !== 0 ? '-' : '').$formatted; + } + + private static function roundDivide(int $value, int $divisor): int + { + $sign = ($value < 0) !== ($divisor < 0) ? -1 : 1; + $numerator = abs($value); + $denominator = abs($divisor); + $quotient = intdiv($numerator, $denominator); + $remainder = $numerator % $denominator; + + if ($remainder * 2 >= $denominator) { + $quotient++; + } + + return $sign * $quotient; + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php new file mode 100644 index 0000000..c6ccfa2 --- /dev/null +++ b/app/Support/Navigation.php @@ -0,0 +1,68 @@ +}>}> + */ + public static function groups(User $user): array + { + $isAdmin = $user->role === 'admin'; + + $groups = [ + [ + 'key' => 'workspace', + 'label' => 'Workspace', + 'items' => [ + ['route' => 'dashboard', 'label' => 'Overview', 'activePatterns' => ['dashboard'], 'admin' => false], + ['route' => 'projects.index', 'label' => 'Projects', 'activePatterns' => ['projects.*'], 'admin' => true], + ], + ], + [ + 'key' => 'workshop', + 'label' => 'Workshop', + 'items' => [ + ['route' => 'printers.index', 'label' => 'Print farm', 'activePatterns' => ['printers.*'], 'admin' => false], + ['route' => 'dryer', 'label' => 'Dryer', 'activePatterns' => ['dryer', 'dryer.*'], 'admin' => false], + ], + ], + [ + 'key' => 'commerce', + 'label' => 'Commerce', + 'items' => [ + ['route' => 'etsy.index', 'label' => 'Etsy', 'activePatterns' => ['etsy.*'], 'admin' => true], + ['route' => 'shipping', 'label' => 'Shipping', 'activePatterns' => ['shipping', 'shipping.*'], 'admin' => true], + ], + ], + [ + 'key' => 'administration', + 'label' => 'Administration', + 'items' => [ + ['route' => 'settings.index', 'label' => 'Settings', 'activePatterns' => ['settings.*'], 'admin' => true], + ['route' => 'users.index', 'label' => 'Users', 'activePatterns' => ['users.*'], 'admin' => true], + ], + ], + ]; + + return array_values(array_map( + fn (array $group): array => [ + 'key' => $group['key'], + 'label' => $group['label'], + 'items' => array_values(array_filter( + array_map(fn (array $item): array => [ + 'route' => $item['route'], + 'label' => $item['label'], + 'activePatterns' => $item['activePatterns'], + ], $group['items']), + fn (array $item, int $index) => ! $group['items'][$index]['admin'] || $isAdmin, + ARRAY_FILTER_USE_BOTH, + )), + ], + $groups, + )); + } +} diff --git a/app/Support/ProjectLifecycle.php b/app/Support/ProjectLifecycle.php new file mode 100644 index 0000000..c2e7b94 --- /dev/null +++ b/app/Support/ProjectLifecycle.php @@ -0,0 +1,23 @@ +listingLinks()->whereNull('detached_at'); + + return $relevant->exists() && $relevant->clone()->whereNull('live_release_id')->doesntExist(); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..1a19b44 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -38,6 +38,14 @@ return [ 'report' => false, ], + 'project_files' => [ + 'driver' => 'local', + 'root' => storage_path('app/project-files'), + 'serve' => false, + 'throw' => true, + 'report' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/database/factories/ProjectCostLineFactory.php b/database/factories/ProjectCostLineFactory.php new file mode 100644 index 0000000..706d902 --- /dev/null +++ b/database/factories/ProjectCostLineFactory.php @@ -0,0 +1,30 @@ + + */ +class ProjectCostLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'cost_snapshot_id' => ProjectCostSnapshot::factory(), + 'category' => ProjectCostLine::CATEGORY_FILAMENT, + 'description' => 'PLA black', + 'quantity' => '100.000', + 'quantity_unit' => 'g', + 'unit_rate' => '20.000000', + 'waste_quantity' => '0.000', + 'line_total' => '2.0000', + ]; + } +} diff --git a/database/factories/ProjectCostSnapshotFactory.php b/database/factories/ProjectCostSnapshotFactory.php new file mode 100644 index 0000000..b610b94 --- /dev/null +++ b/database/factories/ProjectCostSnapshotFactory.php @@ -0,0 +1,35 @@ + + */ +class ProjectCostSnapshotFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'revision_id' => ProjectRevision::factory(), + 'currency' => 'GBP', + 'basis' => ProjectCostSnapshot::BASIS_ESTIMATE, + 'batch_units' => 1, + 'filament_total' => null, + 'packaging_total' => null, + 'shipping_total' => null, + 'grand_total' => null, + 'recorded_by' => User::factory()->create(['role' => 'admin'])->id, + 'recorded_at' => now(), + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 0000000..1c3827b --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,28 @@ + + */ +class ProjectFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'reference' => 'PRJ-'.fake()->unique()->numerify('####'), + 'title' => fake()->words(3, true), + 'notes' => null, + 'stage' => 'design', + 'currency' => 'GBP', + 'created_by' => User::factory()->create(['role' => 'admin'])->id, + ]; + } +} diff --git a/database/factories/ProjectFileFactory.php b/database/factories/ProjectFileFactory.php new file mode 100644 index 0000000..93b6483 --- /dev/null +++ b/database/factories/ProjectFileFactory.php @@ -0,0 +1,32 @@ + + */ +class ProjectFileFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'revision_id' => ProjectRevision::factory(), + 'role' => 'body', + 'original_filename' => 'body.stl', + 'storage_key' => fake()->unique()->uuid().'.stl', + 'extension' => 'stl', + 'media_type' => 'model/stl', + 'byte_size' => 1024, + 'sha256' => hash('sha256', fake()->unique()->uuid()), + 'uploaded_by' => User::factory()->create(['role' => 'admin'])->id, + ]; + } +} diff --git a/database/factories/ProjectListingLinkFactory.php b/database/factories/ProjectListingLinkFactory.php new file mode 100644 index 0000000..078bc8f --- /dev/null +++ b/database/factories/ProjectListingLinkFactory.php @@ -0,0 +1,31 @@ + + */ +class ProjectListingLinkFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'shop_id' => 42, + 'listing_id' => fake()->unique()->numberBetween(1000, 999999), + 'variation_key' => ProjectListingLink::ALL_VARIATIONS, + 'title_snapshot' => fake()->words(3, true), + 'state_snapshot' => 'active', + 'synced_at' => now(), + 'created_by' => User::factory()->create(['role' => 'admin'])->id, + ]; + } +} diff --git a/database/factories/ProjectReleaseFactory.php b/database/factories/ProjectReleaseFactory.php new file mode 100644 index 0000000..d331cad --- /dev/null +++ b/database/factories/ProjectReleaseFactory.php @@ -0,0 +1,30 @@ + + */ +class ProjectReleaseFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'revision_id' => ProjectRevision::factory(), + 'cost_snapshot_id' => null, + 'action' => ProjectRelease::ACTION_RELEASE, + 'released_by' => User::factory()->create(['role' => 'admin'])->id, + 'released_at' => now(), + ]; + } +} diff --git a/database/factories/ProjectRevisionFactory.php b/database/factories/ProjectRevisionFactory.php new file mode 100644 index 0000000..51866e9 --- /dev/null +++ b/database/factories/ProjectRevisionFactory.php @@ -0,0 +1,37 @@ + + */ +class ProjectRevisionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'revision_number' => 1, + 'change_notes' => 'Initial revision.', + 'status' => ProjectRevision::STATUS_DRAFT, + 'created_by' => User::factory()->create(['role' => 'admin'])->id, + ]; + } + + public function approved(): static + { + return $this->state(fn (): array => [ + 'status' => ProjectRevision::STATUS_APPROVED, + 'approved_by' => User::factory()->create(['role' => 'admin'])->id, + 'approved_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_11_08_000100_create_projects_table.php b/database/migrations/2026_11_08_000100_create_projects_table.php new file mode 100644 index 0000000..0b531a0 --- /dev/null +++ b/database/migrations/2026_11_08_000100_create_projects_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('reference')->unique(); + $table->string('title'); + $table->text('notes')->nullable(); + $table->string('stage')->default('design'); + $table->string('currency', 3)->default('GBP'); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->timestamp('archived_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('projects'); + } +}; diff --git a/database/migrations/2026_11_08_000200_create_project_revisions_table.php b/database/migrations/2026_11_08_000200_create_project_revisions_table.php new file mode 100644 index 0000000..9094777 --- /dev/null +++ b/database/migrations/2026_11_08_000200_create_project_revisions_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->unsignedInteger('revision_number'); + $table->text('change_notes')->nullable(); + $table->string('status')->default('draft'); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->string('fusion_project_id')->nullable(); + $table->string('fusion_document_id')->nullable(); + $table->string('fusion_version')->nullable(); + $table->timestamps(); + $table->unique(['project_id', 'revision_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_revisions'); + } +}; diff --git a/database/migrations/2026_11_08_000300_create_project_files_table.php b/database/migrations/2026_11_08_000300_create_project_files_table.php new file mode 100644 index 0000000..479a468 --- /dev/null +++ b/database/migrations/2026_11_08_000300_create_project_files_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('revision_id')->constrained('project_revisions')->cascadeOnDelete(); + $table->string('role'); + $table->string('original_filename'); + $table->string('storage_key')->unique(); + $table->string('extension', 16); + $table->string('media_type'); + $table->unsignedBigInteger('byte_size'); + $table->string('sha256', 64); + $table->foreignId('uploaded_by')->constrained('users')->restrictOnDelete(); + $table->string('printer_profile')->nullable(); + $table->string('material')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_files'); + } +}; diff --git a/database/migrations/2026_11_08_000400_create_project_cost_snapshots_table.php b/database/migrations/2026_11_08_000400_create_project_cost_snapshots_table.php new file mode 100644 index 0000000..193e8bc --- /dev/null +++ b/database/migrations/2026_11_08_000400_create_project_cost_snapshots_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->foreignId('revision_id')->constrained('project_revisions')->cascadeOnDelete(); + $table->string('currency', 3); + $table->string('basis'); + $table->unsignedInteger('batch_units'); + $table->decimal('filament_total', 14, 4)->nullable(); + $table->decimal('packaging_total', 14, 4)->nullable(); + $table->decimal('shipping_total', 14, 4)->nullable(); + $table->decimal('grand_total', 14, 4)->nullable(); + $table->string('source_reference')->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('recorded_by')->constrained('users')->restrictOnDelete(); + $table->timestamp('recorded_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_cost_snapshots'); + } +}; diff --git a/database/migrations/2026_11_08_000500_create_project_cost_lines_table.php b/database/migrations/2026_11_08_000500_create_project_cost_lines_table.php new file mode 100644 index 0000000..05b13b0 --- /dev/null +++ b/database/migrations/2026_11_08_000500_create_project_cost_lines_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('cost_snapshot_id')->constrained('project_cost_snapshots')->cascadeOnDelete(); + $table->string('category'); + $table->string('description'); + $table->decimal('quantity', 12, 3); + $table->string('quantity_unit', 16); + $table->decimal('unit_rate', 14, 6); + $table->decimal('waste_quantity', 12, 3)->default(0); + $table->decimal('line_total', 14, 4); + $table->date('source_price_date')->nullable(); + $table->string('source_reference')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_cost_lines'); + } +}; diff --git a/database/migrations/2026_11_08_000600_create_project_releases_table.php b/database/migrations/2026_11_08_000600_create_project_releases_table.php new file mode 100644 index 0000000..4f63fc6 --- /dev/null +++ b/database/migrations/2026_11_08_000600_create_project_releases_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->foreignId('revision_id')->constrained('project_revisions')->restrictOnDelete(); + $table->foreignId('cost_snapshot_id')->nullable()->constrained('project_cost_snapshots')->restrictOnDelete(); + $table->string('action')->default('release'); + $table->foreignId('released_by')->constrained('users')->restrictOnDelete(); + $table->timestamp('released_at'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_releases'); + } +}; diff --git a/database/migrations/2026_11_08_000700_create_project_listing_links_table.php b/database/migrations/2026_11_08_000700_create_project_listing_links_table.php new file mode 100644 index 0000000..f494607 --- /dev/null +++ b/database/migrations/2026_11_08_000700_create_project_listing_links_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->unsignedBigInteger('shop_id'); + $table->unsignedBigInteger('listing_id'); + $table->string('variation_key')->default('all'); + $table->string('title_snapshot')->nullable(); + $table->string('state_snapshot')->nullable(); + $table->timestamp('remote_updated_at')->nullable(); + $table->timestamp('synced_at')->nullable(); + $table->foreignId('live_release_id')->nullable()->constrained('project_releases')->nullOnDelete(); + $table->unsignedInteger('lock_version')->default(0); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->timestamp('detached_at')->nullable(); + $table->timestamps(); + $table->unique(['shop_id', 'listing_id', 'variation_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_listing_links'); + } +}; diff --git a/database/migrations/2026_11_08_000800_create_project_events_table.php b/database/migrations/2026_11_08_000800_create_project_events_table.php new file mode 100644 index 0000000..c8a8ed2 --- /dev/null +++ b/database/migrations/2026_11_08_000800_create_project_events_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->string('event_type'); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('subject_type')->nullable(); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_events'); + } +}; diff --git a/database/migrations/2026_11_08_000900_add_units_per_parcel_to_project_cost_lines_table.php b/database/migrations/2026_11_08_000900_add_units_per_parcel_to_project_cost_lines_table.php new file mode 100644 index 0000000..894a775 --- /dev/null +++ b/database/migrations/2026_11_08_000900_add_units_per_parcel_to_project_cost_lines_table.php @@ -0,0 +1,22 @@ +unsignedInteger('units_per_parcel')->nullable()->after('quantity_unit'); + }); + } + + public function down(): void + { + Schema::table('project_cost_lines', function (Blueprint $table): void { + $table->dropColumn('units_per_parcel'); + }); + } +}; diff --git a/public/branding/hl3d-lockup-dark-background.svg b/public/branding/hl3d-lockup-dark-background.svg new file mode 100644 index 0000000..695f3f5 --- /dev/null +++ b/public/branding/hl3d-lockup-dark-background.svg @@ -0,0 +1,6 @@ + +HL3D refined lockup for dark backgrounds + + + + diff --git a/public/branding/hl3d-lockup-light-background.svg b/public/branding/hl3d-lockup-light-background.svg new file mode 100644 index 0000000..179e020 --- /dev/null +++ b/public/branding/hl3d-lockup-light-background.svg @@ -0,0 +1,6 @@ + +HL3D refined lockup for light backgrounds + + + + diff --git a/public/branding/hl3d-lockup-mono-black.svg b/public/branding/hl3d-lockup-mono-black.svg new file mode 100644 index 0000000..d600f8b --- /dev/null +++ b/public/branding/hl3d-lockup-mono-black.svg @@ -0,0 +1,6 @@ + +HL3D refined lockup for light backgrounds + + + + diff --git a/public/branding/hl3d-lockup-mono-white.svg b/public/branding/hl3d-lockup-mono-white.svg new file mode 100644 index 0000000..93f22aa --- /dev/null +++ b/public/branding/hl3d-lockup-mono-white.svg @@ -0,0 +1,6 @@ + +HL3D refined lockup for light backgrounds + + + + diff --git a/public/branding/hl3d-monogram-dark-background.svg b/public/branding/hl3d-monogram-dark-background.svg new file mode 100644 index 0000000..c34e052 --- /dev/null +++ b/public/branding/hl3d-monogram-dark-background.svg @@ -0,0 +1,4 @@ + +HL3D monogram for dark backgrounds + + diff --git a/public/branding/hl3d-monogram-light-background.svg b/public/branding/hl3d-monogram-light-background.svg new file mode 100644 index 0000000..6335d3b --- /dev/null +++ b/public/branding/hl3d-monogram-light-background.svg @@ -0,0 +1,4 @@ + +HL3D monogram for light backgrounds + + diff --git a/public/branding/hl3d-touchmark-white.svg b/public/branding/hl3d-touchmark-white.svg new file mode 100644 index 0000000..796ca53 --- /dev/null +++ b/public/branding/hl3d-touchmark-white.svg @@ -0,0 +1,4 @@ + +HL3D production touchmark + + diff --git a/public/branding/hl3d-touchmark.svg b/public/branding/hl3d-touchmark.svg new file mode 100644 index 0000000..b6f5f0c --- /dev/null +++ b/public/branding/hl3d-touchmark.svg @@ -0,0 +1,4 @@ + +HL3D production touchmark + + diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index e69de29..0000000 diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index df5899a..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,9 +0,0 @@ - - HickLab3D - - - - - - - diff --git a/public/hicklab3d-full.png b/public/hicklab3d-full.png deleted file mode 100644 index f3592b8..0000000 Binary files a/public/hicklab3d-full.png and /dev/null differ diff --git a/public/hicklab3d-monogram.png b/public/hicklab3d-monogram.png deleted file mode 100644 index 9c18e78..0000000 Binary files a/public/hicklab3d-monogram.png and /dev/null differ diff --git a/public/hicklab3d-touchmark.svg b/public/hicklab3d-touchmark.svg deleted file mode 100644 index 138cafe..0000000 --- a/public/hicklab3d-touchmark.svg +++ /dev/null @@ -1,12 +0,0 @@ - - HL3D HL Touchmark - Single-colour simplified HL monogram for 3D-print embossing, debossing and touchmarking. - - - - - - - - - diff --git a/resources/css/app.css b/resources/css/app.css index 6ce9bed..c47deef 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3,15 +3,45 @@ @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; +/* + * HL3D brand tokens (Brand Guidelines, HL3D_Brand_Identity.pdf): carbon #111316, + * alloy #A7ADB5, fluorescent yellow #E7FF00, ice #F4F6F8, slate #515963. + * No proprietary font files are bundled; "Avenir Next" falls back to the local + * system sans stack when the licensed font isn't installed, and Menlo is used + * for revision/reference labels with a monospace fallback. + */ @theme { - --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; - --color-brand: #e3f504; - --color-slate-950: #0b0c0d; - --color-slate-900: #17191b; + --font-sans: 'Avenir Next', 'Avenir', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: Menlo, ui-monospace, SFMono-Regular, Consolas, monospace; + --color-brand: #E7FF00; + --color-carbon: #111316; + --color-alloy: #A7ADB5; + --color-ice: #F4F6F8; + --color-hl-slate: #515963; + --color-slate-950: #111316; + --color-slate-900: #1a1d21; --color-slate-800: #292c30; --color-slate-700: #3c4146; --color-slate-500: #9a9fa3; - --color-slate-400: #b8bec4; + --color-slate-400: #A7ADB5; --color-slate-300: #d6dade; - --color-slate-100: #f4f5f5; + --color-slate-100: #F4F6F8; +} + +.nav-group summary::-webkit-details-marker { + display: none; +} + +.nav-group[open] > summary .nav-group-chevron { + transform: rotate(0deg); +} + +.nav-group:not([open]) > summary .nav-group-chevron { + transform: rotate(-90deg); +} + +@media (prefers-reduced-motion: reduce) { + .nav-group-chevron { + transition: none; + } } diff --git a/resources/js/app.js b/resources/js/app.js index e2857d4..dded48b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,8 +1,8 @@ const chartBase = { chart: { background: 'transparent', - foreColor: '#b8bec4', - fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif', + foreColor: '#A7ADB5', + fontFamily: "'Avenir Next', 'Avenir', ui-sans-serif, system-ui, sans-serif", toolbar: { show: false }, zoom: { enabled: false }, }, @@ -31,7 +31,7 @@ if (document.querySelector('[data-dashboard-chart]')) { options = { ...chartBase, chart: { ...chartBase.chart, type: 'area', height }, - colors: ['#e3f504'], + colors: ['#E7FF00'], series: [{ name: 'Awaiting dispatch', data: data.series }], xaxis: { categories: data.labels, labels: { rotate: -45, hideOverlappingLabels: true } }, yaxis: { min: 0, forceNiceScale: true, labels: { formatter: (value) => Number.isInteger(value) ? value.toString() : '' } }, @@ -47,11 +47,11 @@ if (document.querySelector('[data-dashboard-chart]')) { chart: { ...chartBase.chart, type: 'donut', height }, labels: data.map((item) => item.label), series: data.map((item) => item.count), - colors: ['#e3f504', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'], - legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } }, + colors: ['#E7FF00', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'], + legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#A7ADB5' } }, plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Printers' } } } } }, dataLabels: { enabled: false }, - stroke: { colors: ['#17191b'], width: 2 }, + stroke: { colors: ['#1a1d21'], width: 2 }, }; } @@ -63,3 +63,4 @@ if (document.querySelector('[data-dashboard-chart]')) { } import './notifications'; +import './navigation'; diff --git a/resources/js/navigation.js b/resources/js/navigation.js new file mode 100644 index 0000000..6b4bad0 --- /dev/null +++ b/resources/js/navigation.js @@ -0,0 +1,63 @@ +const costLines = document.querySelector('[data-cost-lines]'); +if (costLines) { + const template = costLines.firstElementChild.cloneNode(true); + let nextIndex = costLines.children.length; + document.querySelector('[data-add-cost-line]')?.addEventListener('click', () => { + if (costLines.children.length >= 50) { return; } + const line = template.cloneNode(true); + line.querySelector('legend').textContent = `Cost line ${nextIndex + 1}`; + line.querySelectorAll('[name]').forEach((input) => { + input.name = input.name.replace(/lines\[\d+\]/, `lines[${nextIndex}]`); + if (input.tagName === 'INPUT') { input.value = input.name.includes('units_per_parcel') ? '1' : input.name.includes('waste_quantity') ? '0' : ''; } + }); + nextIndex += 1; + costLines.append(line); + line.querySelector('select').focus(); + }); + costLines.addEventListener('click', (event) => { + if (event.target.matches('[data-remove-cost-line]') && costLines.children.length > 1) { event.target.closest('[data-cost-line]').remove(); } + }); +} + +const sidebar = document.querySelector('[data-sidebar]'); +const sidebarToggle = document.querySelector('[data-sidebar-toggle]'); + +if (sidebar && sidebarToggle) { + const collapsedKey = 'hl3d-sidebar-collapsed'; + + const applyCollapsed = (collapsed) => { + sidebar.classList.toggle('lg:w-20', collapsed); + sidebar.querySelectorAll('[data-sidebar-label]').forEach((label) => { + label.classList.toggle('hidden', collapsed); + }); + sidebar.querySelectorAll('[data-sidebar-expanded-mark]').forEach((mark) => { + mark.classList.toggle('hidden', collapsed); + }); + sidebar.querySelectorAll('[data-sidebar-collapsed-mark]').forEach((mark) => { + mark.classList.toggle('hidden', !collapsed); + }); + sidebarToggle.setAttribute('aria-pressed', collapsed ? 'true' : 'false'); + sidebarToggle.textContent = collapsed ? 'Expand' : 'Collapse'; + }; + + applyCollapsed(localStorage.getItem(collapsedKey) === '1'); + + sidebarToggle.addEventListener('click', () => { + const collapsed = !sidebar.classList.contains('lg:w-20'); + localStorage.setItem(collapsedKey, collapsed ? '1' : '0'); + applyCollapsed(collapsed); + }); +} + +document.querySelectorAll('[data-nav-group]').forEach((details) => { + const storageKey = `hl3d-nav-group-${details.dataset.navGroup}`; + const stored = localStorage.getItem(storageKey); + + if (stored !== null) { + details.open = stored === '1'; + } + + details.addEventListener('toggle', () => { + localStorage.setItem(storageKey, details.open ? '1' : '0'); + }); +}); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index a15ff61..38b9ad2 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -1,8 +1,8 @@ -Sign in · HickLab3D@vite(['resources/css/app.css', 'resources/js/app.js']) +Sign in · HL3D@vite(['resources/css/app.css', 'resources/js/app.js'])
-
HickLab3D
+
HickLab3D · Engineered Excellence

Welcome back

Sign in to your dashboard.

@csrf diff --git a/resources/views/etsy.blade.php b/resources/views/etsy.blade.php index 639ddd7..e966d92 100644 --- a/resources/views/etsy.blade.php +++ b/resources/views/etsy.blade.php @@ -1,12 +1,38 @@ @extends('layouts.app') @section('title', 'Etsy shop') @section('content') -

Sales

{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}

Paid orders awaiting dispatch and active listings.

@if($configured)@csrf@elseSet up Etsy@endif
+

Sales

{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}

Paid orders awaiting dispatch and listings by state.

@if($configured)
@csrf
@elseSet up Etsy@endif
@if(! $configured)@endif -@if($integrationError)@endif +@if($stateError)@endif +@if($pageError)@endif +@if($receiptsError)@endif +@if($listingsError)@endif @if($connection) -

Awaiting dispatch

{{ count($receipts) }}

Active listings shown

{{ count($listings) }}

+

Awaiting dispatch

{{ count($receipts) }}

{{ ucfirst($state === 'active' ? 'Live' : 'Draft') }} listings total

{{ $listingCount }}

Orders to dispatch

@forelse($receipts as $receipt)@empty@endforelse
ReceiptBuyerPlacedTotal
#{{ $receipt['receipt_id'] ?? '—' }}{{ $receipt['name'] ?? $receipt['buyer_email'] ?? 'Buyer' }}{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp($receipt['create_timestamp'])->format('d M Y') : '—' }}{{ isset($receipt['grandtotal']['amount']) ? ($receipt['grandtotal']['currency_code'] ?? 'GBP').' '.number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 100)), 2) : '—' }}
No paid orders awaiting dispatch.
-

Active listings

@forelse($listings as $listing)
{{ $listing['title'] ?? 'Untitled listing' }}{{ $listing['quantity'] ?? 0 }} available
@empty

No listings returned.

@endforelse
+
+ + @php + $snapshotAge = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['fetched_at']) + ? \Illuminate\Support\Carbon::createFromTimestamp($listingSnapshot['fetched_at'])->diffForHumans() + : null; + $snapshotStale = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['stale']); + @endphp +

Showing {{ count($listings) }} of {{ $listingCount }} {{ $state === 'active' ? 'live' : 'draft' }} listings.{{ $snapshotAge ? ' Snapshot '.$snapshotAge.($snapshotStale ? ' · cached after a failed refresh, not asserted live' : '').'.' : '' }}

+
@forelse($listings as $listing)
{{ $listing['title'] ?? 'Untitled listing' }}{{ $listing['quantity'] ?? 0 }} available
@empty

No {{ $state === 'active' ? 'live' : 'draft' }} listings returned.

@endforelse
+ @if($listingCount > $perPage || $page > 1) + + @endif +
@endif @endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index aeab5d0..2602d7b 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -3,39 +3,48 @@ - + - - @yield('title', 'Dashboard') · HickLab3D - - - + + @yield('title', 'Dashboard') · HL3D @vite(['resources/css/app.css', 'resources/js/app.js'])
-