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, ]); } }