From 64fec9bfd590d219061c778118afeb1349eb30bb Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Tue, 18 Aug 2026 12:37:03 +0200 Subject: [PATCH 01/19] build: update release-please config and manifest files to point to v2.3.0 --- .github/workflows/release-please.yml | 7 ++++--- .release-please-manifest.json | 3 +++ release-please-config.json | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 223b17685..254653eba 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,4 +1,4 @@ -name: Release Please +name: Release Please on: push: @@ -15,6 +15,7 @@ jobs: steps: - uses: googleapis/release-please-action@v4.2.0 with: - release-type: php + config-file: release-please-config.json + manifest-file: .release-please-manifest.json target-branch: main - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..9965a3410 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.3.0" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..29655ab2a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "php", + "changelog-path": "CHANGELOG.md" + } + } +} From 74808274aa7acd40be39b36b7cb97a41a16f5bc4 Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Wed, 19 Aug 2026 13:20:44 +0200 Subject: [PATCH 02/19] build: add env for max-old-space in prod docker file --- deployment/docker-compose.prod.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deployment/docker-compose.prod.yml b/deployment/docker-compose.prod.yml index 90831ce77..f06d3a834 100644 --- a/deployment/docker-compose.prod.yml +++ b/deployment/docker-compose.prod.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 5f6cdc6ea3d7f39aefaf015f72d1a06aa167bf8d Mon Sep 17 00:00:00 2001 From: Nisha Sharma Date: Thu, 20 Aug 2026 13:13:49 +0200 Subject: [PATCH 03/19] feat: trigger Bagit Metadata Extraction Job on Project Publication (#1526) * feat: trigger Bagit Metadata Extraction Job on Project Publication with Downloadable Archive * test: fix failing tests * fix: copilot review changes * fix: update the email template for BagIt generation success notification --- app/Actions/Project/PublishProject.php | 4 + app/Actions/Study/PublishStudy.php | 5 + app/Http/Resources/StudyResource.php | 2 + ...ssMetadataExtractionBagitGenerationJob.php | 92 ++++++++++- app/Mail/BagitGenerationFailed.php | 32 ++++ app/Mail/BagitGenerationSucceeded.php | 28 ++++ app/Models/Study.php | 1 + .../BagitGenerationFailedNotification.php | 48 ++++++ .../BagitGenerationSucceededNotification.php | 42 +++++ ...0001_add_archive_link_to_studies_table.php | 28 ++++ public/build/manifest.json | 4 + resources/js/Pages/Public/Project/Study.vue | 141 ++++++++++++++++- resources/js/Pages/Public/Sample/Show.vue | 143 ++++++++++++++++-- resources/js/Shared/SpectraViewer.vue | 5 +- .../mail/bagit-generation-failed.blade.php | 32 ++++ .../mail/bagit-generation-succeeded.blade.php | 29 ++++ tests/Feature/Study/PublishStudyTest.php | 23 +++ .../Actions/Project/PublishProjectTest.php | 29 ++++ ...tadataExtractionBagitGenerationJobTest.php | 35 +++++ tests/Unit/Models/StudyModelTest.php | 1 + 20 files changed, 705 insertions(+), 19 deletions(-) create mode 100644 app/Mail/BagitGenerationFailed.php create mode 100644 app/Mail/BagitGenerationSucceeded.php create mode 100644 app/Notifications/BagitGenerationFailedNotification.php create mode 100644 app/Notifications/BagitGenerationSucceededNotification.php create mode 100644 database/migrations/2026_08_17_000001_add_archive_link_to_studies_table.php create mode 100644 resources/views/vendor/mail/bagit-generation-failed.blade.php create mode 100644 resources/views/vendor/mail/bagit-generation-succeeded.blade.php diff --git a/app/Actions/Project/PublishProject.php b/app/Actions/Project/PublishProject.php index 24f1519ae..88c72a75d 100644 --- a/app/Actions/Project/PublishProject.php +++ b/app/Actions/Project/PublishProject.php @@ -2,6 +2,7 @@ namespace App\Actions\Project; +use App\Jobs\ProcessMetadataExtractionBagitGenerationJob; use App\Models\Project; use App\Support\Public\PublicMoleculeAggregates; @@ -21,6 +22,9 @@ public function publish($project) foreach ($studies as $study) { $study->is_public = true; $study->save(); + if ($study->has_nmrium && filled($study->download_url)) { + ProcessMetadataExtractionBagitGenerationJob::dispatch($study->id); + } $datasets = $study->datasets; foreach ($datasets as $dataset) { $dataset->is_public = true; diff --git a/app/Actions/Study/PublishStudy.php b/app/Actions/Study/PublishStudy.php index 2e255bc69..edc4def61 100644 --- a/app/Actions/Study/PublishStudy.php +++ b/app/Actions/Study/PublishStudy.php @@ -2,6 +2,7 @@ namespace App\Actions\Study; +use App\Jobs\ProcessMetadataExtractionBagitGenerationJob; use App\Models\Study; use App\Services\ChemotionRepositoryTrackerService; use App\Support\Public\PublicMoleculeAggregates; @@ -25,6 +26,10 @@ public function publish($study) $dataset->save(); } + if ($study->is_public && $study->has_nmrium && filled($study->download_url)) { + ProcessMetadataExtractionBagitGenerationJob::dispatch($study->id); + } + PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); // Track publication if this is an ELN submission diff --git a/app/Http/Resources/StudyResource.php b/app/Http/Resources/StudyResource.php index 8efa40bd4..ef8d20f3b 100644 --- a/app/Http/Resources/StudyResource.php +++ b/app/Http/Resources/StudyResource.php @@ -49,6 +49,8 @@ public function toArray($request): array 'study_preview_urls' => $this->study_preview_urls, 'experiment_types' => $this->study_experiment_types, 'download_url' => $this->download_url, + 'metadata_bagit_generation_status' => $this->metadata_bagit_generation_status, + 'bagit_archive_link' => $this->bagit_archive_link, 'has_nmrium' => $this->has_nmrium, 'submitted_through' => $this->submitted_through, 'external_id' => $this->external_id, diff --git a/app/Jobs/ProcessMetadataExtractionBagitGenerationJob.php b/app/Jobs/ProcessMetadataExtractionBagitGenerationJob.php index 10afc758e..b11042813 100644 --- a/app/Jobs/ProcessMetadataExtractionBagitGenerationJob.php +++ b/app/Jobs/ProcessMetadataExtractionBagitGenerationJob.php @@ -3,6 +3,9 @@ namespace App\Jobs; use App\Models\Study; +use App\Models\User; +use App\Notifications\BagitGenerationFailedNotification; +use App\Notifications\BagitGenerationSucceededNotification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -10,7 +13,10 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Throwable; use whikloj\BagItTools\Bag; use ZipArchive; @@ -98,6 +104,13 @@ public function handle(): void ]); Log::info("Successfully processed study {$study->id} ({$study->identifier}): {$result['imageCount']} images saved to {$result['location']}"); + + if ($study->owner) { + Notification::send($study->owner, new BagitGenerationSucceededNotification( + $study->fresh(), + $study->fresh()->bagit_archive_link, + )); + } } catch (Throwable $e) { Log::warning("Attempt {$this->attempts()} failed for study {$this->studyId}: {$e->getMessage()}"); @@ -134,6 +147,17 @@ public function failed(Throwable $exception): void 'attempts' => $this->attempts(), ]), ]); + + $admins = User::role(['super-admin'])->get(); + + if ($admins->isNotEmpty()) { + Notification::send($admins, new BagitGenerationFailedNotification( + $study, + $exception->getMessage(), + $exception, + $this->attempts(), + )); + } } /** @@ -235,9 +259,34 @@ protected function processStudy(Study $study): array Log::info('Step 6/7: Generating BagIt manifests...'); $this->generateBagItManifests($disk->path($baseDir)); + $archiveZipPath = $this->createBagItArchive($disk->path($baseDir), $studyIdentifier); + $archiveKey = 'archive/'.$studyIdentifier.'/'.$studyIdentifier.'.zip'; + $archiveDisk = Storage::disk(config('filesystems.default_public', 'local')); + $archiveStream = fopen($archiveZipPath, 'rb'); + if ($archiveStream === false) { + throw new \RuntimeException("Failed to open archive for upload: {$archiveZipPath}"); + } + + $archiveDisk->put($archiveKey, $archiveStream, 'public'); + fclose($archiveStream); + + $archiveUrl = $archiveDisk->url($archiveKey); + $study->update([ + 'bagit_archive_link' => $archiveUrl, + 'metadata_bagit_generation_logs' => array_merge((array) ($study->metadata_bagit_generation_logs ?: []), [ + 'bagit_archive_link' => $archiveUrl, + 'archive_path' => $archiveKey, + ]), + ]); + + if (file_exists($archiveZipPath)) { + @unlink($archiveZipPath); + } + return [ 'imageCount' => $imageCount, 'location' => $disk->path($baseDir), + 'archiveUrl' => $archiveUrl, ]; } finally { // Step 7: Cleanup temporary files (always runs, even on exception) @@ -548,9 +597,9 @@ protected function generateBagItManually(string $bagPath): void $dataPath = $bagPath.'/data'; if (is_dir($dataPath)) { - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dataPath), - \RecursiveIteratorIterator::LEAVES_ONLY + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dataPath), + RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($files as $file) { @@ -593,9 +642,9 @@ protected function calculatePayloadOxum(string $dataPath): string $totalFiles = 0; if (is_dir($dataPath)) { - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dataPath), - \RecursiveIteratorIterator::LEAVES_ONLY + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dataPath), + RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($files as $file) { @@ -608,4 +657,35 @@ protected function calculatePayloadOxum(string $dataPath): string return "{$totalBytes}.{$totalFiles}"; } + + /** + * Create a ZIP archive for the generated BagIt directory and return the local path. + */ + protected function createBagItArchive(string $bagPath, string $studyIdentifier): string + { + $archivePath = storage_path('app/temp_'.uniqid().'_'.$studyIdentifier.'.zip'); + $zip = new ZipArchive; + + if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException("Failed to create archive for study {$studyIdentifier}: {$archivePath}"); + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($bagPath, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (! $file->isFile()) { + continue; + } + + $relativePath = ltrim(str_replace($bagPath.'/', '', $file->getPathname()), '/'); + $zip->addFile($file->getPathname(), $relativePath); + } + + $zip->close(); + + return $archivePath; + } } diff --git a/app/Mail/BagitGenerationFailed.php b/app/Mail/BagitGenerationFailed.php new file mode 100644 index 000000000..72647a28c --- /dev/null +++ b/app/Mail/BagitGenerationFailed.php @@ -0,0 +1,32 @@ +markdown('vendor.mail.bagit-generation-failed', [ + 'study' => $this->study, + 'reason' => $this->reason, + 'exception' => $this->exception, + 'attempts' => $this->attempts, + 'url' => url(config('app.url').'/dashboard/studies/'.$this->study->id), + ])->subject(__('BagIt metadata generation failed for '.$this->study->name)); + } +} diff --git a/app/Mail/BagitGenerationSucceeded.php b/app/Mail/BagitGenerationSucceeded.php new file mode 100644 index 000000000..2bd3af20c --- /dev/null +++ b/app/Mail/BagitGenerationSucceeded.php @@ -0,0 +1,28 @@ +markdown('vendor.mail.bagit-generation-succeeded', [ + 'study' => $this->study, + 'archiveUrl' => $this->archiveUrl ?? $this->study->bagit_archive_link, + 'publicUrl' => $this->study->public_url, + 'sampleUrl' => url(config('app.url').'/sample/S'.$this->study->getRawOriginal('identifier')), + ])->subject(__('BagIt archive is ready for '.$this->study->name)); + } +} diff --git a/app/Models/Study.php b/app/Models/Study.php index 1a31eead1..609694b1c 100644 --- a/app/Models/Study.php +++ b/app/Models/Study.php @@ -68,6 +68,7 @@ class Study extends Model implements Auditable 'validation_id', 'metadata_bagit_generation_status', 'metadata_bagit_generation_logs', + 'bagit_archive_link', 'has_nmrium', 'has_nmredata', 'hifsa_data', diff --git a/app/Notifications/BagitGenerationFailedNotification.php b/app/Notifications/BagitGenerationFailedNotification.php new file mode 100644 index 000000000..c6abc652e --- /dev/null +++ b/app/Notifications/BagitGenerationFailedNotification.php @@ -0,0 +1,48 @@ +study, + $this->reason, + $this->exception, + $this->attempts, + ))->to($notifiable->email); + } + + public function toArray($notifiable): array + { + return [ + 'study_id' => $this->study->id, + 'study_identifier' => $this->study->identifier, + 'reason' => $this->reason, + 'attempts' => $this->attempts, + ]; + } +} diff --git a/app/Notifications/BagitGenerationSucceededNotification.php b/app/Notifications/BagitGenerationSucceededNotification.php new file mode 100644 index 000000000..09e681479 --- /dev/null +++ b/app/Notifications/BagitGenerationSucceededNotification.php @@ -0,0 +1,42 @@ +study, + $this->archiveUrl, + ))->to($notifiable->email); + } + + public function toArray($notifiable): array + { + return [ + 'study_id' => $this->study->id, + 'study_identifier' => $this->study->identifier, + 'archive_url' => $this->archiveUrl, + ]; + } +} diff --git a/database/migrations/2026_08_17_000001_add_archive_link_to_studies_table.php b/database/migrations/2026_08_17_000001_add_archive_link_to_studies_table.php new file mode 100644 index 000000000..6c9d90d2b --- /dev/null +++ b/database/migrations/2026_08_17_000001_add_archive_link_to_studies_table.php @@ -0,0 +1,28 @@ +string('bagit_archive_link', 2048)->nullable()->after('metadata_bagit_generation_logs'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('studies', function (Blueprint $table) { + $table->dropColumn('bagit_archive_link'); + }); + } +}; diff --git a/public/build/manifest.json b/public/build/manifest.json index e5853f291..1a76d827e 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -935,6 +935,10 @@ "assets/main-CITaNMI7.css" ] }, + "_main-CITaNMI7.css": { + "file": "assets/main-CITaNMI7.css", + "src": "_main-CITaNMI7.css" + }, "_mixtureComposition-zq-QRxdV.js": { "file": "assets/mixtureComposition-zq-QRxdV.js", "name": "mixtureComposition" diff --git a/resources/js/Pages/Public/Project/Study.vue b/resources/js/Pages/Public/Project/Study.vue index 92afae390..946682ee5 100644 --- a/resources/js/Pages/Public/Project/Study.vue +++ b/resources/js/Pages/Public/Project/Study.vue @@ -35,6 +35,48 @@ v-if="study.data.is_public" class="flex shrink-0 flex-wrap items-center gap-3" > +
+ + {{ bagitStatusLabel }} + + + + +
{ this.checkDescriptionLength(); }); }, + beforeUnmount() { + this.stopBagitStatusPolling(); + }, + methods: { + startBagitStatusPolling() { + if (!["pending", "processing"].includes(this.bagitJobStatus)) { + this.stopBagitStatusPolling(); + + return; + } + + if (this.bagitStatusPolling) { + return; + } + + this.bagitStatusPolling = window.setInterval(() => { + router.reload({ only: ["study"] }); + }, 15000); + }, + + stopBagitStatusPolling() { + if (this.bagitStatusPolling) { + window.clearInterval(this.bagitStatusPolling); + this.bagitStatusPolling = null; + } + }, + datasetHref(dataset) { if ( this.reviewerPreview?.obfuscationcode && diff --git a/resources/js/Pages/Public/Sample/Show.vue b/resources/js/Pages/Public/Sample/Show.vue index 1c6b37c17..52aec1b33 100644 --- a/resources/js/Pages/Public/Sample/Show.vue +++ b/resources/js/Pages/Public/Sample/Show.vue @@ -23,6 +23,47 @@ > +
+
+ {{ bagitStatusLabel }} +
+ + + +
+ - +
- + + + + + + + + + + + +
@@ -1167,6 +1236,45 @@ export default { url() { return String(this.$page.props.url); }, + currentStudyData() { + return this.currentStudy?.data ?? this.currentStudy ?? null; + }, + sampleBagitJobStatus() { + const study = this.currentStudyData; + + return ( + study?.metadata_bagit_generation_status || + (study?.bagit_archive_link ? "completed" : "pending") + ); + }, + sampleBagitArchiveReady() { + return ( + this.sampleBagitJobStatus === "completed" && + Boolean(this.currentStudyData?.bagit_archive_link) + ); + }, + sampleBagitStatusLabel() { + switch (this.sampleBagitJobStatus) { + case "pending": + return "Queued"; + case "processing": + return "Processing"; + case "failed": + return "Failed"; + case "completed": + return "Ready"; + default: + return "Queued"; + } + }, + showSampleBagitDownloadOption() { + const path = + typeof window === "undefined" ? "" : window.location.pathname; + + return Boolean( + this.currentStudyData && path.startsWith("/sample/") + ); + }, workspace() { return this.$page.props.workspace ?? null; }, @@ -1340,6 +1448,16 @@ export default { pageUrl.includes(String(dataset.identifier)) ); }, + requestSampleBagitDownload() { + if (!this.sampleBagitArchiveReady) { + return; + } + + this.requestDownload( + this.currentStudyData.bagit_archive_link, + this.trackingIdentifier(this.currentStudyData) + ); + }, projectStudiesFetchUrl() { if (this.reviewerPreview?.obfuscationcode) { return route("studies.preview", [ diff --git a/resources/js/Pages/Public/Project/Study.vue b/resources/js/Pages/Public/Project/Study.vue index 946682ee5..b9c47ba03 100644 --- a/resources/js/Pages/Public/Project/Study.vue +++ b/resources/js/Pages/Public/Project/Study.vue @@ -35,48 +35,6 @@ v-if="study.data.is_public" class="flex shrink-0 flex-wrap items-center gap-3" > -
- - {{ bagitStatusLabel }} - - - - -
- +
- + + + + + + + + + + + +
@@ -213,6 +276,8 @@ diff --git a/resources/js/Pages/Public/Sample/Show.vue b/resources/js/Pages/Public/Sample/Show.vue index 52aec1b33..8c67bc18a 100644 --- a/resources/js/Pages/Public/Sample/Show.vue +++ b/resources/js/Pages/Public/Sample/Show.vue @@ -23,47 +23,6 @@ > -
-
- {{ bagitStatusLabel }} -
- - - -
-