filled('ligand_smiles'); if ($isSmiles) { $ligandPath = null; } else { $ligandPath = $this->storeAsPdbqt($request->file('ligand_file'), true); } $proteinPath = $this->storeAsPdbqt($request->file('protein_file'), false); $job = DockingJob::create([ 'user_id' => $request->user()->id, 'input_type' => $isSmiles ? 'smiles' : 'file', 'smiles' => $isSmiles ? $request->ligand_smiles : null, 'protein_name' => $request->protein_name, 'ligand_name' => $request->ligand_name, 'protein_path' => $proteinPath, 'ligand_path' => $ligandPath, 'status' => 'pending', ]); $params = [ 'center_x' => $request->center_x, 'center_y' => $request->center_y, 'center_z' => $request->center_z, 'box_size_x' => $request->box_size_x, 'box_size_y' => $request->box_size_y, 'box_size_z' => $request->box_size_z, 'exhaustiveness' => $request->exhaustiveness ?? 8, 'n_poses' => $request->n_poses ?? 5, ]; if ($isSmiles) { ConvertSmilesJob::dispatch($job, $request->ligand_smiles, $params); } else { RunDockingJob::dispatch($job, $params); } return $this->successResponse('Docking Job Successfully Queued', [ 'job_id' => $job->id, 'status' => $job->status, ]); } public function history(Request $request) { $perPage = min((int) $request->query('per_page', 15), 100); $paginator = DockingJob::dockingOnly() ->where('user_id', $request->user()->id) ->orderBy('created_at', 'desc') ->paginate($perPage) ->through(function ($job) { return [ 'id' => $job->id, 'status' => $job->status, 'protein' => $job->protein_name, 'ligand' => $job->ligand_name, 'created_at' => $job->created_at->toIso8601String(), 'download_url' => url('/api/docking/download/'.$job->id), 'scores' => $job->vina_scores ?? [], 'error' => $job->status === 'failed' ? ($job->result_data['error'] ?? null) : null, ]; }); return $this->paginatedResponse('Docking history retrieved successfully', $paginator); } public function status(Request $request, $id) { $job = DockingJob::dockingOnly() ->where('id', $id) ->where('user_id', $request->user()->id) ->first(); if (! $job) { return $this->errorResponse('Docking job not found or unauthorized', 404); } return $this->successResponse('Job details retrieved successfully', [ 'id' => $job->id, 'status' => $job->status, 'protein' => $job->protein_name, 'ligand' => $job->ligand_name, 'created_at' => $job->created_at->toIso8601String(), 'download_url' => url('/api/docking/download/'.$job->id), 'scores' => $job->vina_scores ?? [], 'error' => $job->status === 'failed' ? ($job->result_data['error'] ?? null) : null, ]); } public function download(Request $request, $id) { $token = $request->bearerToken() ?? $request->query('token'); if (! $token) { return $this->errorResponse('Unauthenticated', 401); } $accessToken = PersonalAccessToken::findToken($token); if (! $accessToken || ! $accessToken->tokenable) { return $this->errorResponse('Invalid token', 401); } $job = DockingJob::dockingOnly() ->where('id', $id) ->where('user_id', $accessToken->tokenable->id) ->first(); if (! $job || $job->status !== 'completed') { return $this->errorResponse('Docking file not available or unauthorized', 404); } $filePath = $job->result_data['output_file'] ?? null; if (! $filePath || ! file_exists($filePath)) { return $this->errorResponse('File not found on server', 404); } return new StreamedResponse(function () use ($filePath) { $stream = fopen($filePath, 'rb'); if ($stream) { fpassthru($stream); fclose($stream); } }, 200, [ 'Content-Type' => 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="docking_result_'.$job->id.'.pdbqt"', 'Content-Length' => filesize($filePath), ]); } private function storeAsPdbqt(UploadedFile $file, bool $isLigand): string { $ext = strtolower($file->getClientOriginalExtension()); $filename = Str::random(40).'.pdbqt'; $destPath = storage_path('app/private/docking/'.$filename); if ($ext === 'pdbqt') { $file->storeAs('docking', $filename); } elseif ($ext === 'pdb') { $this->convertPdbToPdbqt($file->getRealPath(), $destPath, $isLigand); } else { throw new HttpResponseException( $this->errorResponse('Unsupported file format: .'.$ext.'. Only .pdb and .pdbqt files are accepted.', 422) ); } return $destPath; } private function convertPdbToPdbqt(string $source, string $dest, bool $isLigand): void { $pythonPath = env('DOCKING_PYTHON_PATH'); $obabel = $pythonPath ? dirname($pythonPath).'/obabel' : 'obabel'; if (! file_exists($obabel)) { $obabel = 'obabel'; } $result = Process::timeout(60)->run([ $obabel, '-ipdb', $source, '-opdbqt', '-O', $dest, ]); if (! $result->successful()) { Log::error('PDB-to-PDBQT conversion failed', [ 'source' => $source, 'dest' => $dest, 'error' => $result->errorOutput(), ]); throw new HttpResponseException( $this->errorResponse('Failed to convert PDB file to PDBQT format.', 500) ); } $content = file_get_contents($dest); $lines = explode("\n", $content); if ($isLigand) { $lines = array_map(fn ($line) => str_starts_with($line, 'ATOM') ? 'HETATM'.substr($line, 6) : $line, $lines); } else { $lines = array_filter($lines, fn ($line) => preg_match('/^(ATOM|HETATM)/', $line)); $lines = array_values($lines); } file_put_contents($dest, implode("\n", $lines)); } }