From 61f74306afb366e9cb99a7b46b0423d79d50feb7 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 6 Aug 2026 14:20:49 +0100 Subject: [PATCH 1/9] fix(bioschemas): match study NMRium spectra to datasets via fs names Selector files in study NMRium payloads often carry archive/S3 prefixes that never share the stored relative_url of the dataset, so path-based matching silently dropped spectra. Match on study/dataset folder names first (mirroring the study NMRium save path) and keep relative_url as a fallback needle. --- .../Schemas/Bioschemas/BioschemasHelper.php | 75 +++++++-- .../BioschemasHelperGetNMRiumInfoTest.php | 150 +++++++++++++++++- 2 files changed, 208 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/API/Schemas/Bioschemas/BioschemasHelper.php b/app/Http/Controllers/API/Schemas/Bioschemas/BioschemasHelper.php index a61242a1..8c05b468 100644 --- a/app/Http/Controllers/API/Schemas/Bioschemas/BioschemasHelper.php +++ b/app/Http/Controllers/API/Schemas/Bioschemas/BioschemasHelper.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\Schemas\Bioschemas; use App\Models\Dataset; +use App\Models\FileSystemObject; use App\Models\NMRium; use Illuminate\Support\Facades\DB; use Spatie\SchemaOrg\Schema; @@ -241,7 +242,9 @@ public static function getNMRiumInfo(Dataset $dataset): ?object /** * Collect spectra from a study-level NMRium JSON payload that belong to - * this dataset (robust path match on `FileSystemObject::relative_url`). + * this dataset. Matches on study/dataset folder names first (same as the + * study NMRium save path) because selector files often use archive/S3 + * prefixes that do not share `FileSystemObject::relative_url`. * * @param array $nmriumInfo * @return list> @@ -270,15 +273,13 @@ public static function collectStudySpectraMatchingDatasetFromPayload(Dataset $da return []; } - $datasetRelativeUrl = $datasetFSObject->relative_url; - if (! is_string($datasetRelativeUrl) || $datasetRelativeUrl === '') { - $datasetRelativeUrl = $isChemotion - ? '/'.$studyFSObject->name.'/'.$parentName.'/'.$datasetFSObject->name - : '/'.$studyFSObject->name.'/'.$datasetFSObject->name; - } - $path = rtrim($datasetRelativeUrl, '/'); + $matchPaths = self::datasetSpectrumMatchPaths( + $studyFSObject, + $datasetFSObject, + $isChemotion, + $parentName, + ); $isDatasetFile = $datasetFSObject->type === 'file'; - $needle = $isDatasetFile ? $path : $path.'/'; $matchedSpectra = []; foreach ($nmriumInfo['data']['spectra'] as $spectra) { @@ -295,12 +296,11 @@ public static function collectStudySpectraMatchingDatasetFromPayload(Dataset $da if (! is_string($file)) { continue; } - $pathsMatch = $isDatasetFile - ? str_ends_with($file, $needle) - : str_contains($file, $needle); - if ($pathsMatch) { - $hit = true; - break; + foreach ($matchPaths as $path) { + if (self::spectrumFileMatchesDatasetPath($file, $path, $isDatasetFile)) { + $hit = true; + break 2; + } } } if ($hit) { @@ -311,6 +311,51 @@ public static function collectStudySpectraMatchingDatasetFromPayload(Dataset $da return $matchedSpectra; } + /** + * Build path needles for matching NMRium selector files to a dataset. + * + * @return list + */ + private static function datasetSpectrumMatchPaths( + FileSystemObject $studyFSObject, + FileSystemObject $datasetFSObject, + bool $isChemotion, + ?string $chemotionParentName, + ): array { + $namePath = $isChemotion + ? '/'.$studyFSObject->name.'/'.$chemotionParentName.'/'.$datasetFSObject->name + : '/'.$studyFSObject->name.'/'.$datasetFSObject->name; + + $paths = [rtrim($namePath, '/')]; + + $relativeUrl = $datasetFSObject->relative_url; + if (is_string($relativeUrl) && $relativeUrl !== '') { + $trimmed = rtrim($relativeUrl, '/'); + if (! in_array($trimmed, $paths, true)) { + $paths[] = $trimmed; + } + } + + return $paths; + } + + private static function spectrumFileMatchesDatasetPath( + string $file, + string $path, + bool $isDatasetFile, + ): bool { + $path = rtrim($path, '/'); + if ($path === '') { + return false; + } + + if ($isDatasetFile) { + return str_contains($file, $path) || str_ends_with($file, $path); + } + + return str_contains($file, $path.'/') || str_contains($file, $path); + } + /** * Collect spectra entries from the parent study's stored NMRium JSON. * diff --git a/tests/Unit/Bioschemas/BioschemasHelperGetNMRiumInfoTest.php b/tests/Unit/Bioschemas/BioschemasHelperGetNMRiumInfoTest.php index 5a69a8a5..a89cabec 100644 --- a/tests/Unit/Bioschemas/BioschemasHelperGetNMRiumInfoTest.php +++ b/tests/Unit/Bioschemas/BioschemasHelperGetNMRiumInfoTest.php @@ -269,7 +269,153 @@ public function test_get_nmrium_info_returns_null_when_no_study_match(): void $this->assertFalse((bool) $dataset->fresh()->has_nmrium); } - public function test_partial_fs_object_eager_load_can_false_positive_study_match(): void + public function test_get_nmrium_info_matches_archive_selector_paths_via_fs_names(): void + { + $project = Project::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + ]); + + $studyRootFs = FileSystemObject::factory()->directory()->create([ + 'name' => 'compound_01', + 'relative_url' => '/uuid-folder/compound_01', + 'study_id' => null, + 'project_id' => $project->id, + ]); + + $study = Study::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'fs_id' => $studyRootFs->id, + ]); + + $studyRootFs->update(['study_id' => $study->id]); + + $datasetFs = FileSystemObject::factory()->file()->create([ + 'name' => '1f HC.jcamp', + 'relative_url' => '/uuid-folder/compound_01/1f HC.jcamp', + 'parent_id' => $studyRootFs->id, + 'study_id' => $study->id, + 'project_id' => $project->id, + ]); + + $dataset = Dataset::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'study_id' => $study->id, + 'fs_id' => $datasetFs->id, + 'has_nmrium' => false, + ]); + + NMRium::factory()->forStudy($study)->create([ + 'nmrium_info' => [ + 'data' => [ + 'spectra' => [ + [ + 'sourceSelector' => [ + 'files' => [ + '/nmrxiv-staging/archive/ff199cea/compound_01.zip/compound_01/1f HC.jcamp', + ], + ], + 'info' => [ + 'solvent' => 'CDCl3', + 'nucleus' => ['1H'], + ], + ], + ], + ], + ], + ]); + + $info = BioschemasHelper::getNMRiumInfo($dataset->fresh()); + + $this->assertNotNull($info); + $this->assertSame('CDCl3', $info->solvent); + } + + public function test_get_nmrium_info_prefers_dataset_fs_name_over_mismatched_relative_url(): void + { + $project = Project::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + ]); + + $studyRootFs = FileSystemObject::factory()->directory()->create([ + 'name' => 'StudyRoot', + 'relative_url' => '/StudyRoot', + 'study_id' => null, + 'project_id' => $project->id, + ]); + + $study = Study::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'fs_id' => $studyRootFs->id, + ]); + + $studyRootFs->update(['study_id' => $study->id]); + + $datasetFs = FileSystemObject::factory()->directory()->create([ + 'name' => 'proton', + 'relative_url' => '/StudyRoot/stored-alias', + 'parent_id' => $studyRootFs->id, + 'study_id' => $study->id, + 'project_id' => $project->id, + ]); + + $dataset = Dataset::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'study_id' => $study->id, + 'fs_id' => $datasetFs->id, + 'has_nmrium' => false, + ]); + + NMRium::factory()->forStudy($study)->create([ + 'nmrium_info' => [ + 'data' => [ + 'spectra' => [ + [ + 'sourceSelector' => [ + 'files' => [ + 'https://example.org/files/StudyRoot/proton/acqus', + ], + ], + 'info' => [ + 'solvent' => 'CDCl3', + 'nucleus' => ['1H'], + ], + ], + ], + ], + ], + ]); + + $dataset->load(['fsObject', 'study.fsObject', 'study.nmrium']); + + $info = BioschemasHelper::getNMRiumInfo($dataset); + + $this->assertNotNull($info); + $this->assertSame('CDCl3', $info->solvent); + } + + public function test_partial_fs_object_eager_load_still_matches_via_dataset_name(): void { $project = Project::factory()->create([ 'owner_id' => $this->user->id, @@ -337,7 +483,7 @@ public function test_partial_fs_object_eager_load_can_false_positive_study_match $dataset->load(['fsObject', 'study.fsObject', 'study.nmrium']); - $this->assertNull(BioschemasHelper::getNMRiumInfo($dataset)); + $this->assertNotNull(BioschemasHelper::getNMRiumInfo($dataset)); $dataset->unsetRelation('fsObject'); $dataset->setRelation( From e988c8d3d3233958f043e10a30c0e07d28bbc95c Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 6 Aug 2026 14:20:57 +0100 Subject: [PATCH 2/9] feat(console): add nmrxiv:sync-dataset-nmrium-from-study command Backfills datasets that lack their own NMRium row by copying matched spectra from the parent study payload. Supports scoping by dataset or study, dry runs, chunked processing with a limit, and optional refresh of the denormalized spectra columns. --- .../SyncDatasetNmriumFromStudyCommand.php | 230 +++++++++++++++++ .../SyncDatasetNmriumFromStudyCommandTest.php | 242 ++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 app/Console/Commands/SyncDatasetNmriumFromStudyCommand.php create mode 100644 tests/Feature/Commands/SyncDatasetNmriumFromStudyCommandTest.php diff --git a/app/Console/Commands/SyncDatasetNmriumFromStudyCommand.php b/app/Console/Commands/SyncDatasetNmriumFromStudyCommand.php new file mode 100644 index 00000000..6b120c82 --- /dev/null +++ b/app/Console/Commands/SyncDatasetNmriumFromStudyCommand.php @@ -0,0 +1,230 @@ +option('chunk')); + $dry = (bool) $this->option('dry'); + $extractSpectraInfo = (bool) $this->option('extract-spectra-info'); + $limit = $this->resolveLimit(); + + $processed = 0; + $synced = 0; + $noMatch = 0; + $errors = 0; + + $query = $this->buildQuery(); + $total = (int) $query->count(); + + if ($total === 0) { + $this->info('No datasets matched the selected scope.'); + + return self::SUCCESS; + } + + $targetTotal = $limit !== null ? min($total, $limit) : $total; + + $this->info(sprintf( + 'Syncing NMRium from study onto %d dataset%s.', + $targetTotal, + $targetTotal === 1 ? '' : 's', + )); + + if ($dry) { + $this->warn('Dry run: no database writes will be performed.'); + } + + $progressBar = $this->output->createProgressBar($targetTotal); + $progressBar->start(); + + $query->chunkById($chunkSize, function ($datasets) use ( + $dry, + $extractSpectraInfo, + $limit, + &$processed, + &$synced, + &$noMatch, + &$errors, + $progressBar, + ) { + foreach ($datasets as $dataset) { + if ($limit !== null && $processed >= $limit) { + return false; + } + + $processed++; + + try { + $result = $this->syncDataset($dataset, $dry, $extractSpectraInfo); + + if ($result === 'synced') { + $synced++; + } elseif ($result === 'no_match') { + $noMatch++; + } + } catch (Throwable $exception) { + $errors++; + $this->newLine(); + $this->warn(sprintf( + 'dataset_id=%d study_id=%s error=%s', + $dataset->id, + $dataset->study_id ?? 'null', + $exception->getMessage() + )); + } + + $progressBar->advance(); + } + }); + + $progressBar->finish(); + $this->newLine(2); + + $this->table( + ['Metric', 'Count'], + [ + ['Matched datasets', (string) $total], + ['Processed', (string) $processed], + ['Synced from study', (string) $synced], + ['No matching spectra', (string) $noMatch], + ['Errors', (string) $errors], + ] + ); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return 'synced'|'no_match'|null + */ + private function syncDataset(Dataset $dataset, bool $dry, bool $extractSpectraInfo): ?string + { + $study = $dataset->study; + $studyNmrium = $study?->nmrium; + if (! $study || ! $studyNmrium) { + return null; + } + + $payload = $studyNmrium->nmrium_info; + if (! is_array($payload) || $payload === []) { + return null; + } + + if ($dry) { + $matched = BioschemasHelper::collectStudySpectraMatchingDataset($dataset); + + return $matched === [] ? 'no_match' : 'synced'; + } + + $matched = BioschemasHelper::syncDatasetNmriumFromStudyPayload($dataset, $payload); + if ($matched === []) { + return 'no_match'; + } + + $this->updateDatasetType($dataset, $matched); + + if ($extractSpectraInfo) { + $this->spectraInfoExtractor->syncDataset($dataset->fresh()); + } + + return 'synced'; + } + + /** + * @param list> $matchedSpectra + */ + private function updateDatasetType(Dataset $dataset, array $matchedSpectra): void + { + $types = []; + + foreach ($matchedSpectra as $spectrum) { + $label = $this->spectrumTypeLabeler->label($spectrum); + if ($label !== null) { + $types[] = $label; + } + } + + $uniqueTypes = array_values(array_unique($types)); + if (count($uniqueTypes) === 1) { + $dataset->type = $uniqueTypes[0]; + $dataset->save(); + } + } + + private function buildQuery(): Builder + { + $publicOnly = ! (bool) $this->option('all'); + + $query = Dataset::query() + ->with([ + 'nmrium', + 'study.nmrium', + 'study.draft', + 'fsObject', + 'study.fsObject', + ]) + ->whereDoesntHave('nmrium') + ->whereHas('study', function (Builder $builder): void { + $builder->whereHas('nmrium'); + }) + ->orderBy('id'); + + if ($publicOnly) { + $query->where('is_public', true) + ->where(function (Builder $builder): void { + $builder->whereNull('is_archived') + ->orWhere('is_archived', false); + }); + } + + if ($datasetId = $this->option('dataset')) { + $query->whereKey($datasetId); + } + + if ($studyId = $this->option('study')) { + $query->where('study_id', $studyId); + } + + return $query; + } + + private function resolveLimit(): ?int + { + $limit = $this->option('limit'); + + if ($limit === null || $limit === '') { + return null; + } + + return max(1, (int) $limit); + } +} diff --git a/tests/Feature/Commands/SyncDatasetNmriumFromStudyCommandTest.php b/tests/Feature/Commands/SyncDatasetNmriumFromStudyCommandTest.php new file mode 100644 index 00000000..7ca13d43 --- /dev/null +++ b/tests/Feature/Commands/SyncDatasetNmriumFromStudyCommandTest.php @@ -0,0 +1,242 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(['user_id' => $this->user->id]); + $this->license = License::factory()->create(); + $this->validation = Validation::factory()->create(); + } + + public function test_command_syncs_nmrium_from_study_onto_public_dataset(): void + { + [$dataset, $study] = $this->makePublicDatasetWithStudyNmrium(); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--dataset' => (string) $dataset->id, + ])->assertSuccessful(); + + $dataset->refresh(); + + $this->assertTrue((bool) $dataset->has_nmrium); + $this->assertNotNull($dataset->nmrium); + $this->assertSame( + 'CDCl3', + $dataset->nmrium->nmrium_info['data']['spectra'][0]['info']['solvent'] ?? null + ); + $this->assertSame('1H NMR - proton', $dataset->type); + } + + public function test_command_matches_archive_selector_paths_via_fs_names(): void + { + [$dataset] = $this->makePublicDatasetWithStudyNmrium( + datasetFsAttributes: [ + 'name' => '1f HC.jcamp', + 'type' => 'file', + 'relative_url' => '/uuid-folder/compound_01/1f HC.jcamp', + ], + studyFsAttributes: [ + 'name' => 'compound_01', + 'relative_url' => '/uuid-folder/compound_01', + ], + selectorFiles: [ + '/nmrxiv-staging/archive/ff199cea/compound_01.zip/compound_01/1f HC.jcamp', + ], + ); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--dataset' => (string) $dataset->id, + ])->assertSuccessful(); + + $dataset->refresh(); + + $this->assertTrue((bool) $dataset->has_nmrium); + $this->assertNotNull($dataset->nmrium); + } + + public function test_command_skips_non_public_datasets_by_default(): void + { + [$dataset, $study] = $this->makePublicDatasetWithStudyNmrium([ + 'is_public' => false, + ]); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--dataset' => (string) $dataset->id, + ]) + ->expectsOutputToContain('No datasets matched the selected scope.') + ->assertSuccessful(); + + $dataset->refresh(); + + $this->assertFalse((bool) $dataset->has_nmrium); + $this->assertNull($dataset->nmrium); + } + + public function test_command_can_process_non_public_datasets_with_all_flag(): void + { + [$dataset] = $this->makePublicDatasetWithStudyNmrium([ + 'is_public' => false, + ]); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--all' => true, + '--dataset' => (string) $dataset->id, + ])->assertSuccessful(); + + $dataset->refresh(); + + $this->assertTrue((bool) $dataset->has_nmrium); + $this->assertNotNull($dataset->nmrium); + } + + public function test_command_extracts_spectra_info_when_requested(): void + { + [$dataset] = $this->makePublicDatasetWithStudyNmrium(); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--extract-spectra-info' => true, + '--dataset' => (string) $dataset->id, + ])->assertSuccessful(); + + $dataset->refresh(); + + $this->assertTrue((bool) $dataset->has_nmrium); + $this->assertSame('CDCl3', $dataset->spectra_solvent); + $this->assertSame('1H', $dataset->spectra_nucleus); + $this->assertSame('proton', $dataset->spectra_experiment); + $this->assertNotNull($dataset->spectra_info_extracted_at); + } + + public function test_dry_run_reports_without_writing(): void + { + [$dataset] = $this->makePublicDatasetWithStudyNmrium(); + + $this->artisan('nmrxiv:sync-dataset-nmrium-from-study', [ + '--dry' => true, + '--dataset' => (string) $dataset->id, + ]) + ->expectsOutputToContain('Dry run') + ->assertSuccessful(); + + $dataset->refresh(); + + $this->assertFalse((bool) $dataset->has_nmrium); + $this->assertNull($dataset->nmrium); + } + + /** + * @param array $datasetAttributes + * @param array $datasetFsAttributes + * @param array $studyFsAttributes + * @param list $selectorFiles + * @return array{0: Dataset, 1: Study} + */ + private function makePublicDatasetWithStudyNmrium( + array $datasetAttributes = [], + array $datasetFsAttributes = [], + array $studyFsAttributes = [], + array $selectorFiles = [], + ): array { + $project = Project::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + ]); + + $studyRootFs = FileSystemObject::factory()->directory()->create(array_merge([ + 'name' => 'StudyRoot', + 'relative_url' => '/StudyRoot', + 'study_id' => null, + 'project_id' => $project->id, + ], $studyFsAttributes)); + + $study = Study::factory()->create([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'fs_id' => $studyRootFs->id, + 'has_nmrium' => true, + ]); + + $studyRootFs->update(['study_id' => $study->id]); + + $datasetFs = FileSystemObject::factory()->directory()->create(array_merge([ + 'name' => 'proton', + 'relative_url' => '/StudyRoot/proton', + 'parent_id' => $studyRootFs->id, + 'study_id' => $study->id, + 'project_id' => $project->id, + ], $datasetFsAttributes)); + + $dataset = Dataset::factory()->create(array_merge([ + 'owner_id' => $this->user->id, + 'team_id' => $this->team->id, + 'license_id' => $this->license->id, + 'validation_id' => $this->validation->id, + 'project_id' => $project->id, + 'study_id' => $study->id, + 'fs_id' => $datasetFs->id, + 'has_nmrium' => false, + 'is_public' => true, + ], $datasetAttributes)); + + if ($selectorFiles === []) { + $selectorFiles = [ + 'https://example.org/files/StudyRoot/proton/acqus', + ]; + } + + NMRium::factory()->forStudy($study)->create([ + 'nmrium_info' => [ + 'data' => [ + 'molecules' => [], + 'spectra' => [ + [ + 'sourceSelector' => [ + 'files' => $selectorFiles, + ], + 'info' => [ + 'solvent' => 'CDCl3', + 'nucleus' => ['1H'], + 'experiment' => 'proton', + ], + ], + ], + ], + ], + ]); + + return [$dataset, $study]; + } +} From ef4278eb2002612d8b5822ceaaa474ef6bd19772 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 6 Aug 2026 14:21:05 +0100 Subject: [PATCH 3/9] fix(draft): allow shared project members to open the draft flow Accepting a project invitation attached the member, but clicking the shared project returned 403 because the dashboard routes private draft-stage projects into the draft editor and DraftPolicy::updateDraft only allowed the draft owner. Grant updateDraft to members with project update rights (creator, owner, collaborator) and route reviewers to the read-only project page instead of the draft editor. --- app/Policies/DraftPolicy.php | 8 +- resources/js/Pages/Project/Index.vue | 4 + .../Draft/SharedProjectDraftAccessTest.php | 115 ++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Draft/SharedProjectDraftAccessTest.php diff --git a/app/Policies/DraftPolicy.php b/app/Policies/DraftPolicy.php index 89a9d2d8..961aa7d5 100644 --- a/app/Policies/DraftPolicy.php +++ b/app/Policies/DraftPolicy.php @@ -14,6 +14,12 @@ public function updateDraft(User $user, Draft $draft): bool { [$user_id] = $user->getUserTeamData(); - return $draft->owner_id === $user_id; + if ($draft->owner_id === $user_id) { + return true; + } + + $project = $draft->project; + + return $project !== null && $user->canUpdateProject($project); } } diff --git a/resources/js/Pages/Project/Index.vue b/resources/js/Pages/Project/Index.vue index db6a2aaa..f628e0bc 100644 --- a/resources/js/Pages/Project/Index.vue +++ b/resources/js/Pages/Project/Index.vue @@ -707,6 +707,10 @@ export default { if (project.draft_id) { if (project.is_deleted) { return router.visit(this.projectHomeHref(project)); + } else if (project.viewer_role === "reviewer") { + // Reviewers cannot edit drafts, so send them to the + // read-only project page instead of the draft editor. + return router.visit(this.projectHomeHref(project)); } else { if ( project.draft.current_step && diff --git a/tests/Feature/Draft/SharedProjectDraftAccessTest.php b/tests/Feature/Draft/SharedProjectDraftAccessTest.php new file mode 100644 index 00000000..1d876b7c --- /dev/null +++ b/tests/Feature/Draft/SharedProjectDraftAccessTest.php @@ -0,0 +1,115 @@ +owner = User::factory()->withPersonalTeam()->create(); + $this->draft = Draft::factory()->create([ + 'owner_id' => $this->owner->id, + 'team_id' => $this->owner->currentTeam->id, + ]); + $this->project = Project::factory()->create([ + 'owner_id' => $this->owner->id, + 'team_id' => $this->owner->currentTeam->id, + 'draft_id' => $this->draft->id, + 'is_public' => false, + ]); + } + + protected function acceptInvitationAs(User $user, string $role): void + { + $invitation = ProjectInvitation::factory()->create([ + 'project_id' => $this->project->id, + 'email' => $user->email, + 'role' => $role, + 'invited_by' => $this->owner->id, + ]); + + $url = URL::signedRoute('project-invitations.accept', ['invitation' => $invitation]); + + $this->actingAs($user)->get($url)->assertRedirect(config('fortify.home')); + + $this->assertTrue($this->project->fresh()->hasUser($user)); + } + + public function test_draft_owner_can_access_draft(): void + { + $this->actingAs($this->owner) + ->get("/dashboard/drafts/{$this->draft->id}/show") + ->assertStatus(200); + } + + public function test_collaborator_can_access_draft_of_shared_project_after_accepting_invitation(): void + { + $collaborator = User::factory()->withPersonalTeam()->create(); + + $this->acceptInvitationAs($collaborator, 'collaborator'); + + $this->actingAs($collaborator) + ->get("/dashboard/drafts/{$this->draft->id}/show") + ->assertStatus(200); + } + + public function test_invited_owner_can_access_draft_of_shared_project_after_accepting_invitation(): void + { + $invitedOwner = User::factory()->withPersonalTeam()->create(); + + $this->acceptInvitationAs($invitedOwner, 'owner'); + + $this->actingAs($invitedOwner) + ->get("/dashboard/drafts/{$this->draft->id}/show") + ->assertStatus(200); + } + + public function test_reviewer_cannot_update_draft_but_can_view_shared_project(): void + { + $reviewer = User::factory()->withPersonalTeam()->create(); + + $this->acceptInvitationAs($reviewer, 'reviewer'); + + $this->actingAs($reviewer) + ->get("/dashboard/drafts/{$this->draft->id}/show") + ->assertStatus(403); + + $this->actingAs($reviewer) + ->get(route('dashboard.projects', ['project' => $this->project->id])) + ->assertStatus(200); + } + + public function test_unrelated_user_cannot_access_draft(): void + { + $stranger = User::factory()->withPersonalTeam()->create(); + + $this->actingAs($stranger) + ->get("/dashboard/drafts/{$this->draft->id}/show") + ->assertStatus(403); + } +} From fb81a89b44f45bb76e65ac83686e032540a47492 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 9 Aug 2026 10:47:21 +0100 Subject: [PATCH 4/9] fix: packages updates --- composer.lock | 28 ++++++++++++++-------------- package-lock.json | 24 ++++++++++++------------ package.json | 1 + yarn.lock | 26 +++++++++++++------------- 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/composer.lock b/composer.lock index 93b8f35f..b9a16b81 100644 --- a/composer.lock +++ b/composer.lock @@ -1374,16 +1374,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.1", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { @@ -1482,7 +1482,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -1498,7 +1498,7 @@ "type": "tidelift" } ], - "time": "2026-07-18T11:23:11+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", @@ -2979,16 +2979,16 @@ }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", "shasum": "" }, "require": { @@ -3010,8 +3010,8 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", @@ -3025,7 +3025,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -3082,7 +3082,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-08-03T13:42:31+00:00" }, { "name": "league/config", diff --git a/package-lock.json b/package-lock.json index 45b01925..1aa53970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2657,9 +2657,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3276,9 +3276,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4459,9 +4459,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5090,9 +5090,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 3783a37a..1e67c7ab 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "overrides": { "ajv": "^6.14.0", "brace-expansion": "^5.0.8", + "nanoid": "^3.3.17", "esbuild": "0.25.0", "form-data": ">=4.0.6", "minimatch": "^10.0.3", diff --git a/yarn.lock b/yarn.lock index a3eef7ce..f945bb50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1124,9 +1124,9 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^5.0.8: - version "5.0.8" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz" - integrity sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg== + version "5.0.9" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== dependencies: balanced-match "^4.0.2" @@ -1421,9 +1421,9 @@ doctrine@^3.0.0: esutils "^2.0.2" dompurify@^3.0.5: - version "3.4.12" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz" - integrity sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg== + version "3.4.13" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz" + integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -2110,9 +2110,9 @@ jiti@^1.21.7, jiti@>=1.21.0: integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== js-yaml@^4.1.0: - version "4.3.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz" - integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q== + version "4.3.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz" + integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ== dependencies: argparse "^2.0.1" @@ -2417,10 +2417,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== natural-compare@^1.4.0: version "1.4.0" From b4c001bdb01c56387a8f4a820a17c9987eca4abf Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Mon, 17 Aug 2026 22:20:55 +0100 Subject: [PATCH 5/9] fix(reviewer): allow NMRium spectra on reviewer-only project links Private studies returned 403 on /studies/{id}/nmriumInfo because the reviewer obfuscation URL was not accepted on that follow-up request. --- .../Controllers/ApplicationController.php | 2 + app/Support/Public/PublicEntityAccess.php | 68 +++++++++ resources/js/Shared/SpectraViewer.vue | 84 ++++++----- tests/Feature/ReviewerNmriumAccessTest.php | 136 ++++++++++++++++++ 4 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 tests/Feature/ReviewerNmriumAccessTest.php diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php index 56c67341..047fb680 100644 --- a/app/Http/Controllers/ApplicationController.php +++ b/app/Http/Controllers/ApplicationController.php @@ -372,6 +372,8 @@ private function renderPublicProject( $mergedProps = $props; if ($reviewerPreview && $project) { + PublicEntityAccess::rememberReviewerPreview($request, $project); + $mergedProps['reviewerPreview'] = [ 'obfuscationcode' => $project->obfuscationcode, 'samples_count' => $project->studies()->count(), diff --git a/app/Support/Public/PublicEntityAccess.php b/app/Support/Public/PublicEntityAccess.php index 9c53a12b..b28a39ef 100644 --- a/app/Support/Public/PublicEntityAccess.php +++ b/app/Support/Public/PublicEntityAccess.php @@ -3,6 +3,7 @@ namespace App\Support\Public; use App\Models\Dataset; +use App\Models\Project; use App\Models\Study; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; @@ -11,12 +12,19 @@ class PublicEntityAccess { + public const REVIEWER_PREVIEW_SESSION_KEY = 'reviewer_preview_obfuscationcode'; + public static function authorizeStudyAccess(Request $request, Study $study, bool $reviewerPreview = false): void { if ($reviewerPreview) { return; } + if (self::requestIncludesReviewerObfuscation($request) + && self::hasValidReviewerObfuscation($request, $study->project)) { + return; + } + if (! Gate::forUser($request->user())->check('viewStudy', $study)) { throw new AuthorizationException; } @@ -32,6 +40,13 @@ public static function authorizeDatasetAccess(Request $request, Dataset $dataset ? $dataset->study : $dataset->study()->first(); + if (self::requestIncludesReviewerObfuscation($request)) { + $project = $study?->project ?? $dataset->project; + if (self::hasValidReviewerObfuscation($request, $project)) { + return; + } + } + if ($study === null) { throw new AuthorizationException; } @@ -50,4 +65,57 @@ public static function authorizeDatasetAccess(Request $request, Dataset $dataset throw new AuthorizationException; } + + public static function rememberReviewerPreview(Request $request, Project $project): void + { + if (! $request->hasSession() || $project->is_archived) { + return; + } + + $code = $project->obfuscationcode; + if (! is_string($code) || $code === '') { + return; + } + + $request->session()->put(self::REVIEWER_PREVIEW_SESSION_KEY, $code); + } + + protected static function requestIncludesReviewerObfuscation(Request $request): bool + { + return $request->filled('obfuscationcode') + || self::reviewerObfuscationFromSession($request) !== null; + } + + protected static function hasValidReviewerObfuscation(Request $request, ?Project $project): bool + { + if ($project === null || $project->is_archived) { + return false; + } + + $expected = $project->obfuscationcode; + if (! is_string($expected) || $expected === '') { + return false; + } + + $provided = $request->filled('obfuscationcode') + ? (string) $request->query('obfuscationcode') + : self::reviewerObfuscationFromSession($request); + + if (! is_string($provided) || $provided === '') { + return false; + } + + return hash_equals($expected, $provided); + } + + protected static function reviewerObfuscationFromSession(Request $request): ?string + { + if (! $request->hasSession()) { + return null; + } + + $code = $request->session()->get(self::REVIEWER_PREVIEW_SESSION_KEY); + + return is_string($code) && $code !== '' ? $code : null; + } } diff --git a/resources/js/Shared/SpectraViewer.vue b/resources/js/Shared/SpectraViewer.vue index f584c8ba..238281ff 100644 --- a/resources/js/Shared/SpectraViewer.vue +++ b/resources/js/Shared/SpectraViewer.vue @@ -346,6 +346,9 @@ export default { url() { return String(this.$page.props.url); }, + reviewerObfuscationCode() { + return this.$page.props.reviewerPreview?.obfuscationcode ?? null; + }, nmriumURL() { const raw = this.$page.props.nmriumURL; if (!raw) { @@ -457,6 +460,45 @@ export default { getDefaultSpectrumTab(this.$page) ); }, + nmriumInfoUrl(path) { + if (!this.reviewerObfuscationCode) { + return path; + } + + const separator = path.includes("?") ? "&" : "?"; + + return ( + path + + separator + + "obfuscationcode=" + + encodeURIComponent(this.reviewerObfuscationCode) + ); + }, + loadNmriumInfo(iframe, path) { + this.infoLog("Loading Spectra from NMRium JSON.."); + axios + .get(this.nmriumInfoUrl(path)) + .then((response) => { + let nmrium_info = response.data; + if (nmrium_info) { + this.postLoadToIframe(iframe, { + data: nmrium_info, + type: "nmrium", + }); + } else if (this.study.download_url) { + this.loadFromURL([this.study.download_url]); + } else { + this.updateLoadingStatus(false); + } + }) + .catch((error) => { + this.updateLoadingStatus(false); + this.spectraError = + error?.response?.status === 403 + ? "You do not have permission to view these spectra." + : "Unable to load spectra."; + }); + }, loadSpectra() { if (this.study) { const iframe = window.frames.NMRiumIframe; @@ -467,42 +509,16 @@ export default { if (iframe) { if (this.dataset && this.dataset.has_nmrium) { - this.infoLog("Loading Spectra from NMRium JSON.."); - axios - .get("/datasets/" + this.dataset.id + "/nmriumInfo") - .then((response) => { - let nmrium_info = response.data; - if (nmrium_info) { - this.postLoadToIframe(iframe, { - data: nmrium_info, - type: "nmrium", - }); - } else { - let urls = []; - urls.push(this.study.download_url); - this.loadFromURL(urls); - } - }); + this.loadNmriumInfo( + iframe, + "/datasets/" + this.dataset.id + "/nmriumInfo" + ); } else { if (this.study.has_nmrium) { - this.infoLog("Loading Spectra from NMRium JSON.."); - axios - .get( - "/studies/" + this.study.id + "/nmriumInfo" - ) - .then((response) => { - let nmrium_info = response.data; - if (nmrium_info) { - this.postLoadToIframe(iframe, { - data: nmrium_info, - type: "nmrium", - }); - } else { - let urls = []; - urls.push(this.study.download_url); - this.loadFromURL(urls); - } - }); + this.loadNmriumInfo( + iframe, + "/studies/" + this.study.id + "/nmriumInfo" + ); } else { if (this.study.download_url) { let urls = []; diff --git a/tests/Feature/ReviewerNmriumAccessTest.php b/tests/Feature/ReviewerNmriumAccessTest.php new file mode 100644 index 00000000..94c4d6f0 --- /dev/null +++ b/tests/Feature/ReviewerNmriumAccessTest.php @@ -0,0 +1,136 @@ +withPersonalTeam()->create(); + $this->project = Project::factory()->create([ + 'owner_id' => $user->id, + 'team_id' => $user->personalTeam()->id, + 'is_public' => false, + 'obfuscationcode' => 'reviewer-access-token-abcdefghijklmnopqrstuvwxyz', + ]); + + $this->study = Study::factory()->create([ + 'project_id' => $this->project->id, + 'owner_id' => $user->id, + 'team_id' => $user->personalTeam()->id, + 'is_public' => false, + ]); + + $this->dataset = Dataset::factory()->create([ + 'project_id' => $this->project->id, + 'study_id' => $this->study->id, + 'owner_id' => $user->id, + 'team_id' => $user->personalTeam()->id, + 'is_public' => false, + ]); + + $this->nmriumInfo = [ + 'version' => '4', + 'data' => [ + 'spectra' => [ + ['id' => 'spectrum-1'], + ], + 'molecules' => [], + ], + ]; + + NMRium::factory()->forStudy($this->study)->create([ + 'nmrium_info' => $this->nmriumInfo, + ]); + + NMRium::factory()->forDataset($this->dataset)->create([ + 'nmrium_info' => $this->nmriumInfo, + ]); + } + + public function test_guest_cannot_fetch_private_study_nmrium_info(): void + { + $this->getJson('/studies/'.$this->study->id.'/nmriumInfo') + ->assertForbidden(); + } + + public function test_guest_cannot_fetch_private_dataset_nmrium_info(): void + { + $this->getJson('/datasets/'.$this->dataset->id.'/nmriumInfo') + ->assertForbidden(); + } + + public function test_reviewer_obfuscation_code_allows_private_study_nmrium_info(): void + { + $this->getJson('/studies/'.$this->study->id.'/nmriumInfo?obfuscationcode='.$this->project->obfuscationcode) + ->assertOk() + ->assertJsonPath('version', '4') + ->assertJsonPath('data.spectra.0.id', 'spectrum-1'); + } + + public function test_reviewer_obfuscation_code_allows_private_dataset_nmrium_info(): void + { + $this->getJson('/datasets/'.$this->dataset->id.'/nmriumInfo?obfuscationcode='.$this->project->obfuscationcode) + ->assertOk() + ->assertJsonPath('version', '4') + ->assertJsonPath('data.spectra.0.id', 'spectrum-1'); + } + + public function test_wrong_obfuscation_code_is_rejected_for_private_study_nmrium_info(): void + { + $this->getJson('/studies/'.$this->study->id.'/nmriumInfo?obfuscationcode=not-the-project-code') + ->assertForbidden(); + } + + public function test_other_project_obfuscation_code_is_rejected_for_private_study_nmrium_info(): void + { + $otherProject = Project::factory()->create([ + 'owner_id' => $this->project->owner_id, + 'is_public' => false, + 'obfuscationcode' => 'other-project-reviewer-token-abcdefghijklmnopqrstu', + ]); + + $this->getJson('/studies/'.$this->study->id.'/nmriumInfo?obfuscationcode='.$otherProject->obfuscationcode) + ->assertForbidden(); + } + + public function test_visiting_reviewer_link_allows_subsequent_nmrium_info_request(): void + { + $this->get('/project/'.$this->project->obfuscationcode.'?study='.$this->study->id.'&tab=study') + ->assertOk(); + + $this->getJson('/studies/'.$this->study->id.'/nmriumInfo') + ->assertOk() + ->assertJsonPath('data.spectra.0.id', 'spectrum-1'); + } + + public function test_visiting_reviewer_link_allows_subsequent_dataset_nmrium_info_request(): void + { + $this->get('/project/'.$this->project->obfuscationcode.'?study='.$this->study->id.'&tab=dataset&dataset='.$this->dataset->id) + ->assertOk(); + + $this->getJson('/datasets/'.$this->dataset->id.'/nmriumInfo') + ->assertOk() + ->assertJsonPath('data.spectra.0.id', 'spectrum-1'); + } +} From 273db36e1bca64083af1710025ed555b9593b6bd Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Mon, 17 Aug 2026 22:21:02 +0100 Subject: [PATCH 6/9] fix(public): skip bioschemas fetch when identifier is missing Unpublished reviewer preview pages have no public identifier, so the schema request must not run until one exists. --- resources/js/Pages/Public/Project/Dataset.vue | 12 +++++++----- resources/js/Pages/Public/Project/Show.vue | 12 +++++++----- resources/js/Pages/Public/Sample/Dataset.vue | 12 +++++++----- resources/js/Pages/Public/Sample/Show.vue | 12 +++++++----- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/resources/js/Pages/Public/Project/Dataset.vue b/resources/js/Pages/Public/Project/Dataset.vue index 682531d5..0daeee7e 100644 --- a/resources/js/Pages/Public/Project/Dataset.vue +++ b/resources/js/Pages/Public/Project/Dataset.vue @@ -38,11 +38,13 @@ export default { }; }, mounted() { - axios - .get(route("bioschemas.id", this.dataset.data.identifier)) - .then((response) => { - this.schema = response.data; - }); + if (this.dataset?.data?.identifier) { + axios + .get(route("bioschemas.id", this.dataset.data.identifier)) + .then((response) => { + this.schema = response.data; + }); + } }, }; diff --git a/resources/js/Pages/Public/Project/Show.vue b/resources/js/Pages/Public/Project/Show.vue index 6e6d8863..32efe0f2 100644 --- a/resources/js/Pages/Public/Project/Show.vue +++ b/resources/js/Pages/Public/Project/Show.vue @@ -628,11 +628,13 @@ export default { }, mounted() { - axios - .get(route("bioschemas.id", this.project.data.identifier)) - .then((response) => { - this.schema = response.data; - }); + if (this.project?.data?.identifier) { + axios + .get(route("bioschemas.id", this.project.data.identifier)) + .then((response) => { + this.schema = response.data; + }); + } this.handleEditQueryParam(); }, diff --git a/resources/js/Pages/Public/Sample/Dataset.vue b/resources/js/Pages/Public/Sample/Dataset.vue index 16348e22..c72ce81c 100644 --- a/resources/js/Pages/Public/Sample/Dataset.vue +++ b/resources/js/Pages/Public/Sample/Dataset.vue @@ -32,11 +32,13 @@ export default { }; }, mounted() { - axios - .get(route("bioschemas.id", this.dataset.data.identifier)) - .then((response) => { - this.schema = response.data; - }); + if (this.dataset?.data?.identifier) { + axios + .get(route("bioschemas.id", this.dataset.data.identifier)) + .then((response) => { + this.schema = response.data; + }); + } }, }; diff --git a/resources/js/Pages/Public/Sample/Show.vue b/resources/js/Pages/Public/Sample/Show.vue index 69ad17eb..1c6b37c1 100644 --- a/resources/js/Pages/Public/Sample/Show.vue +++ b/resources/js/Pages/Public/Sample/Show.vue @@ -723,11 +723,13 @@ export default { }, }, mounted() { - axios - .get(route("bioschemas.id", this.study.data.identifier)) - .then((response) => { - this.schema = response.data; - }); + if (this.study?.data?.identifier) { + axios + .get(route("bioschemas.id", this.study.data.identifier)) + .then((response) => { + this.schema = response.data; + }); + } }, methods: { datasetHref(dataset) { From 9db035a6c197dd8456189fa53a997ad279c21b8a Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Tue, 18 Aug 2026 11:05:31 +0200 Subject: [PATCH 7/9] fix: backup failure overlap issue (#1514) --- app/Jobs/DataBackupJob.php | 13 +++++++++++++ config/database.php | 3 ++- routes/console.php | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/Jobs/DataBackupJob.php b/app/Jobs/DataBackupJob.php index be9a7f6b..8819ad6e 100644 --- a/app/Jobs/DataBackupJob.php +++ b/app/Jobs/DataBackupJob.php @@ -17,6 +17,19 @@ class DataBackupJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Number of times the job may be attempted. + */ + public int $tries = 3; + + /** + * Delay (in seconds) between retry attempts, to ride out transient + * pg_dump failures caused by concurrent schema changes (e.g. deployments). + * + * @var array + */ + public array $backoff = [60, 300]; + /** * Execute the job. */ diff --git a/config/database.php b/config/database.php index aecbf46a..156e9044 100644 --- a/config/database.php +++ b/config/database.php @@ -19,7 +19,8 @@ 'dump' => [ 'use_single_transaction', 'timeout' => 620 * 100, // 51 minute timeout - 'exclude_tables' => ['versions'], + // mols/fps are ephemeral RDKit search caches dropped & rebuilt daily by nmrxiv:index-molecules, excluded to avoid pg_dump racing that DDL + 'exclude_tables' => ['versions', 'mols', 'fps'], ], 'search_path' => 'public', ], diff --git a/routes/console.php b/routes/console.php index 6ce04d5e..f8ff664e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -20,13 +20,14 @@ Schedule::command('nmrxiv:publish-embargo-projects')->daily(); Schedule::command('nmrxiv:delete-projects')->daily(); -Schedule::command('nmrxiv:index-molecules')->daily(); +// Staggered away from the backup dump (both defaulted to ->daily(), i.e. midnight) since this rebuilds the mols/fps RDKit tables via DROP/CREATE DDL +Schedule::command('nmrxiv:index-molecules')->dailyAt('02:00'); Schedule::command('nmrxiv:index-spectra-metadata-stats')->daily(); Schedule::command('nmrxiv:delete-citations')->weekly(); Schedule::command('nmrxiv:delete-authors')->weekly(); if (App::environment('production')) { Schedule::command('nmrxiv:backup-postgres-dump') - ->daily() + ->dailyAt('04:00') ->onOneServer() ->withoutOverlapping(); } From 6b80de19ec5fff99c3ec7dfcdc60f8c6a7264fa6 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Tue, 18 Aug 2026 11:05:57 +0200 Subject: [PATCH 8/9] fix: increase memory limit for node container to avoid out of memory errors (#1516) --- deployment/docker-compose.dev.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deployment/docker-compose.dev.yml b/deployment/docker-compose.dev.yml index 8bde2267..8fcfa033 100644 --- a/deployment/docker-compose.dev.yml +++ b/deployment/docker-compose.dev.yml @@ -128,6 +128,9 @@ services: entrypoint: /bin/sh stdin_open: true tty: true + environment: + NODE_OPTIONS: "--max-old-space-size=12288" + mem_limit: 16g networks: - nmrxiv_net volumes: From d75be041396d25ee65cc9ce8b4feae0af60196a6 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Tue, 18 Aug 2026 11:06:25 +0200 Subject: [PATCH 9/9] fix: redirect spectrum preference saves to user profile (#1519) --- app/Http/Controllers/UserPreferencesController.php | 2 +- tests/Feature/UserPreferencesTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/UserPreferencesController.php b/app/Http/Controllers/UserPreferencesController.php index 463b2db4..fb903ff5 100644 --- a/app/Http/Controllers/UserPreferencesController.php +++ b/app/Http/Controllers/UserPreferencesController.php @@ -23,6 +23,6 @@ public function update(UpdateUserPreferencesRequest $request): RedirectResponse 'preferences' => $preferences === [] ? null : $preferences, ])->save(); - return back(); + return to_route('profile.show'); } } diff --git a/tests/Feature/UserPreferencesTest.php b/tests/Feature/UserPreferencesTest.php index d7f9f39c..a85c1dc1 100644 --- a/tests/Feature/UserPreferencesTest.php +++ b/tests/Feature/UserPreferencesTest.php @@ -44,7 +44,7 @@ public function test_verified_user_can_set_default_spectrum_tab(): void 'default_spectrum_tab' => DefaultSpectrumTab::C13->value, ]); - $response->assertRedirect(); + $response->assertRedirect(route('profile.show')); $this->assertSame( DefaultSpectrumTab::C13->value, $user->fresh()->preferences['default_spectrum_tab']