From b9e25db0a57667eb4bc38c8c60567563673fcc4d Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:40 +0200 Subject: [PATCH 01/10] feat(collections): add versioning schema and models Introduce lineage columns on collections, archived entries flag, revocation audit table, and SUPERSEDED status for audit-only prior releases. --- app/Helper.php | 1 + app/Models/Collection.php | 126 +++++++++++++++--- app/Models/CollectionVersionRevocation.php | 50 +++++++ app/Models/Entry.php | 6 + ...0001_add_collection_versioning_columns.php | 91 +++++++++++++ 5 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 app/Models/CollectionVersionRevocation.php create mode 100644 database/migrations/2026_06_08_000001_add_collection_versioning_columns.php diff --git a/app/Helper.php b/app/Helper.php index 61bb07cb..c121a096 100644 --- a/app/Helper.php +++ b/app/Helper.php @@ -75,6 +75,7 @@ function getCollectionStatuses() 'EMBARGO' => 'Embargo', 'PUBLISHED' => 'Published', 'REJECTED' => 'Rejected', + 'SUPERSEDED' => 'Superseded', ]; } diff --git a/app/Models/Collection.php b/app/Models/Collection.php index e34a2c06..b1027e00 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Filament\Traits\MutatesCollectionFormData; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -17,7 +18,12 @@ /** * @property string|null $doi + * @property string|null $doi_base + * @property string|null $doi_suffix * @property array|null $datacite_schema + * @property int $version + * @property bool $is_latest + * @property string|null $version_migration_status */ class Collection extends Model implements Auditable, HasMedia { @@ -28,14 +34,18 @@ class Collection extends Model implements Auditable, HasMedia use MutatesCollectionFormData; use \OwenIt\Auditing\Auditable; + public const VERSION_MIGRATION_PENDING = 'pending'; + + public const VERSION_MIGRATION_PROCESSING = 'processing'; + + public const VERSION_MIGRATION_COMPLETE = 'complete'; + protected static function booted() { static::creating(fn ($collection) => $collection->uuid = Str::uuid()); } /** - * The attributes that are mass assignable. - * * @var array */ protected $fillable = [ @@ -50,50 +60,126 @@ protected static function booted() 'status', 'release_date', 'datacite_schema', + 'parent_collection_id', + 'version', + 'is_latest', + 'superseded_by_collection_id', + 'superseded_at', + 'version_migration_status', + 'archived_entries_count', + 'archived_molecules_count', + 'doi_base', + 'doi_suffix', ]; - /** - * Get the license of the project. - * - * @return BelongsTo - */ - public function license() + protected $casts = [ + 'is_latest' => 'boolean', + 'superseded_at' => 'datetime', + 'release_date' => 'datetime', + ]; + + public function license(): BelongsTo { return $this->belongsTo(License::class, 'license_id'); } - /** - * Get all of the citations for the collection. - */ public function citations(): MorphToMany { return $this->morphToMany(Citation::class, 'citable'); } - /** - * Get all of the entries for the collection. - */ public function entries(): HasMany { return $this->hasMany(Entry::class); } - /** - * Get the molecules associated with the collection. - */ public function molecules(): BelongsToMany { return $this->belongsToMany(Molecule::class)->withPivot('url', 'reference', 'mol_filename', 'structural_comments')->withTimestamps(); } - /** - * Get all of the reports for the collection. - */ public function reports(): MorphToMany { return $this->morphToMany(Report::class, 'reportable'); } + public function parentCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'parent_collection_id'); + } + + public function supersededBy(): BelongsTo + { + return $this->belongsTo(Collection::class, 'superseded_by_collection_id'); + } + + public function versionRevocations(): HasMany + { + return $this->hasMany(CollectionVersionRevocation::class, 'lineage_root_id'); + } + + public function lineageRoot(): self + { + return $this->parent_collection_id + ? self::query()->findOrFail($this->parent_collection_id) + : $this; + } + + public function lineageRootId(): int + { + return $this->parent_collection_id ?? $this->id; + } + + /** + * @return Builder + */ + public function lineageVersionsQuery(): Builder + { + $rootId = $this->lineageRootId(); + + return self::query() + ->where(function (Builder $q) use ($rootId) { + $q->where('id', $rootId)->orWhere('parent_collection_id', $rootId); + }) + ->orderByDesc('version'); + } + + public function isVersionMigrationActive(): bool + { + return in_array($this->version_migration_status, [ + self::VERSION_MIGRATION_PENDING, + self::VERSION_MIGRATION_PROCESSING, + ], true); + } + + public function scopeEligibleForLegacyPipeline(Builder $query): Builder + { + return $query->where(function (Builder $q) { + $q->whereNull('version_migration_status') + ->orWhere('version_migration_status', self::VERSION_MIGRATION_COMPLETE); + }); + } + + public function isSuperseded(): bool + { + return $this->status === 'SUPERSEDED' || ! $this->is_latest; + } + + public static function lineageDoiKey(string $identifier): string + { + return 'coconut.'.strtolower($identifier); + } + + public function versionDoiSuffix(): string + { + return self::lineageDoiKey((string) $this->identifier).'.v'.$this->version; + } + + public function baseDoiSuffix(): string + { + return self::lineageDoiKey((string) $this->identifier); + } + public function transformAudit(array $data): array { return changeAudit($data); diff --git a/app/Models/CollectionVersionRevocation.php b/app/Models/CollectionVersionRevocation.php new file mode 100644 index 00000000..3499c739 --- /dev/null +++ b/app/Models/CollectionVersionRevocation.php @@ -0,0 +1,50 @@ + 'datetime', + ]; + + public function lineageRoot(): BelongsTo + { + return $this->belongsTo(Collection::class, 'lineage_root_id'); + } + + public function fromCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'from_collection_id'); + } + + public function toCollection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'to_collection_id'); + } + + public function entry(): BelongsTo + { + return $this->belongsTo(Entry::class); + } + + public function molecule(): BelongsTo + { + return $this->belongsTo(Molecule::class); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 85791a32..3a630ea7 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -19,6 +19,9 @@ class Entry extends Model implements Auditable 'meta_data' => 'array', 'synonyms' => 'array', 'cm_data' => 'array', + 'has_rdkit_smiles_change' => 'boolean', + 'rdkit_restandardized_at' => 'datetime', + 'is_archived' => 'boolean', ]; /** @@ -44,8 +47,11 @@ class Entry extends Model implements Auditable 'has_stereocenters', 'is_invalid', 'cm_data', + 'has_rdkit_smiles_change', + 'rdkit_restandardized_at', 'submission_type', 'meta_data', + 'is_archived', ]; /** diff --git a/database/migrations/2026_06_08_000001_add_collection_versioning_columns.php b/database/migrations/2026_06_08_000001_add_collection_versioning_columns.php new file mode 100644 index 00000000..d80f16c1 --- /dev/null +++ b/database/migrations/2026_06_08_000001_add_collection_versioning_columns.php @@ -0,0 +1,91 @@ +foreignId('parent_collection_id')->nullable()->after('license_id')->constrained('collections')->nullOnDelete(); + $table->unsignedSmallInteger('version')->default(1)->after('parent_collection_id'); + $table->boolean('is_latest')->default(true)->after('version'); + $table->foreignId('superseded_by_collection_id')->nullable()->after('is_latest')->constrained('collections')->nullOnDelete(); + $table->timestamp('superseded_at')->nullable()->after('superseded_by_collection_id'); + $table->string('version_migration_status', 32)->nullable()->after('superseded_at'); + $table->unsignedInteger('archived_entries_count')->nullable()->after('version_migration_status'); + $table->unsignedInteger('archived_molecules_count')->nullable()->after('archived_entries_count'); + $table->string('doi_base')->nullable()->after('doi'); + $table->string('doi_suffix')->nullable()->after('doi_base'); + + $table->unique(['identifier', 'version']); + $table->index(['parent_collection_id', 'version']); + $table->index('is_latest'); + }); + + Schema::table('entries', function (Blueprint $table) { + $table->boolean('is_archived')->default(false)->after('status'); + }); + + Schema::create('collection_version_revocations', function (Blueprint $table) { + $table->id(); + $table->foreignId('lineage_root_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('from_collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('to_collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('entry_id')->nullable()->constrained('entries')->nullOnDelete(); + $table->foreignId('molecule_id')->nullable()->constrained('molecules')->nullOnDelete(); + $table->longText('reference_id')->nullable(); + $table->longText('standardized_canonical_smiles')->nullable(); + $table->timestamp('revoked_at'); + $table->string('reason')->nullable(); + $table->timestamps(); + + $table->index('lineage_root_id'); + }); + + if (DB::getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_status_check'); + DB::statement("ALTER TABLE collections ADD CONSTRAINT collections_status_check CHECK (status::text = ANY (ARRAY['DRAFT'::text, 'REVIEW'::text, 'EMBARGO'::text, 'PUBLISHED'::text, 'REJECTED'::text, 'SUPERSEDED'::text]))"); + } + + DB::table('collections')->update([ + 'version' => 1, + 'is_latest' => true, + ]); + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_status_check'); + DB::statement("ALTER TABLE collections ADD CONSTRAINT collections_status_check CHECK (status::text = ANY (ARRAY['DRAFT'::text, 'REVIEW'::text, 'EMBARGO'::text, 'PUBLISHED'::text, 'REJECTED'::text]))"); + } + + Schema::dropIfExists('collection_version_revocations'); + + Schema::table('entries', function (Blueprint $table) { + $table->dropColumn('is_archived'); + }); + + Schema::table('collections', function (Blueprint $table) { + $table->dropUnique(['identifier', 'version']); + $table->dropIndex(['parent_collection_id', 'version']); + $table->dropIndex(['is_latest']); + $table->dropConstrainedForeignId('parent_collection_id'); + $table->dropConstrainedForeignId('superseded_by_collection_id'); + $table->dropColumn([ + 'version', + 'is_latest', + 'superseded_at', + 'version_migration_status', + 'archived_entries_count', + 'archived_molecules_count', + 'doi_base', + 'doi_suffix', + ]); + }); + } +}; From 593fc0461a23b0ca9f9416fdc4e04bb6f9155459 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:43 +0200 Subject: [PATCH 02/10] feat(collections): add version migration orchestration services Add diff-by-SMILES, provenance stripping, exclusive molecule revocation, batch validation waiter, version creator, and import orchestrator. --- .../CollectionVersionCreator.php | 84 +++++++++ .../CollectionVersionDiff.php | 92 ++++++++++ .../CollectionVersionImporter.php | 166 ++++++++++++++++++ .../RevokeDroppedMolecules.php | 109 ++++++++++++ .../StripCollectionProvenance.php | 89 ++++++++++ .../ValidateMoleculesBatchWaiter.php | 77 ++++++++ 6 files changed, 617 insertions(+) create mode 100644 app/Services/CollectionVersioning/CollectionVersionCreator.php create mode 100644 app/Services/CollectionVersioning/CollectionVersionDiff.php create mode 100644 app/Services/CollectionVersioning/CollectionVersionImporter.php create mode 100644 app/Services/CollectionVersioning/RevokeDroppedMolecules.php create mode 100644 app/Services/CollectionVersioning/StripCollectionProvenance.php create mode 100644 app/Services/CollectionVersioning/ValidateMoleculesBatchWaiter.php diff --git a/app/Services/CollectionVersioning/CollectionVersionCreator.php b/app/Services/CollectionVersioning/CollectionVersionCreator.php new file mode 100644 index 00000000..52043263 --- /dev/null +++ b/app/Services/CollectionVersioning/CollectionVersionCreator.php @@ -0,0 +1,84 @@ +is_latest || $source->status !== 'PUBLISHED') { + throw new RuntimeException('Only the latest published collection can spawn a new version.'); + } + + if ($source->jobs_status === 'PROCESSING' || $source->jobs_status === 'QUEUED') { + throw new RuntimeException('Collection is still processing. Wait until jobs complete.'); + } + + $root = $source->lineageRoot(); + $rootId = $root->id; + + $activeSibling = Collection::query() + ->where(function ($q) use ($rootId) { + $q->where('id', $rootId)->orWhere('parent_collection_id', $rootId); + }) + ->whereIn('version_migration_status', [ + Collection::VERSION_MIGRATION_PENDING, + Collection::VERSION_MIGRATION_PROCESSING, + ]) + ->exists(); + + if ($activeSibling) { + throw new RuntimeException('A version import is already in progress for this collection lineage.'); + } + + $nextVersion = (int) Collection::query() + ->where(function ($q) use ($rootId) { + $q->where('id', $rootId)->orWhere('parent_collection_id', $rootId); + }) + ->max('version') + 1; + + $new = $source->replicate([ + 'uuid', + 'doi', + 'doi_base', + 'doi_suffix', + 'datacite_schema', + 'jobs_status', + 'job_info', + 'successful_entries', + 'failed_entries', + 'molecules_count', + 'citations_count', + 'organisms_count', + 'geo_count', + 'total_entries', + 'release_date', + 'superseded_by_collection_id', + 'superseded_at', + 'archived_entries_count', + 'archived_molecules_count', + ]); + + $new->parent_collection_id = $rootId; + $new->version = $nextVersion; + $new->is_latest = false; + $new->status = 'DRAFT'; + $new->identifier = $source->identifier; + $new->slug = Str::slug($root->slug.'-v'.$nextVersion); + $new->version_migration_status = Collection::VERSION_MIGRATION_PENDING; + $new->jobs_status = 'INCURATION'; + $new->job_info = ''; + $new->save(); + + $tags = $source->tags()->get(); + if ($tags->isNotEmpty()) { + $new->syncTags($tags); + } + + return $new->fresh(); + } +} diff --git a/app/Services/CollectionVersioning/CollectionVersionDiff.php b/app/Services/CollectionVersioning/CollectionVersionDiff.php new file mode 100644 index 00000000..6a28b46b --- /dev/null +++ b/app/Services/CollectionVersioning/CollectionVersionDiff.php @@ -0,0 +1,92 @@ + $oldOnlySmilesToMoleculeId + * @param SupportCollection $retainedSmilesToMoleculeId + * @param SupportCollection $newOnlySmiles + */ + public function __construct( + public SupportCollection $oldOnlySmilesToMoleculeId, + public SupportCollection $retainedSmilesToMoleculeId, + public SupportCollection $newOnlySmiles, + ) {} + + public function oldOnlyMoleculeIds(): array + { + return $this->oldOnlySmilesToMoleculeId->values()->unique()->values()->all(); + } + + public function retainedMoleculeIds(): array + { + return $this->retainedSmilesToMoleculeId->values()->unique()->values()->all(); + } +} + +class CollectionVersionDiff +{ + public function compare(Collection $oldCollection, Collection $newCollection): CollectionVersionDiffResult + { + $oldSmilesToMoleculeId = $this->oldSmilesToMoleculeIds($oldCollection); + $newSmiles = $this->newPassedSmiles($newCollection); + + $oldOnly = $oldSmilesToMoleculeId->keys()->diff($newSmiles); + $retained = $oldSmilesToMoleculeId->keys()->intersect($newSmiles); + $newOnly = $newSmiles->diff($oldSmilesToMoleculeId->keys())->values(); + + return new CollectionVersionDiffResult( + $oldSmilesToMoleculeId->only($oldOnly->all()), + $oldSmilesToMoleculeId->only($retained->all()), + $newOnly, + ); + } + + public function preview(Collection $oldCollection, Collection $newCollection): array + { + $diff = $this->compare($oldCollection, $newCollection); + + return [ + 'old_only_count' => $diff->oldOnlySmilesToMoleculeId->count(), + 'retained_count' => $diff->retainedSmilesToMoleculeId->count(), + 'new_only_count' => $diff->newOnlySmiles->count(), + 'revoke_candidate_count' => count($diff->oldOnlyMoleculeIds()), + 'old_only_sample' => $diff->oldOnlySmilesToMoleculeId->keys()->take(10)->values()->all(), + 'new_only_sample' => $diff->newOnlySmiles->take(10)->values()->all(), + ]; + } + + /** + * @return SupportCollection + */ + protected function oldSmilesToMoleculeIds(Collection $oldCollection): SupportCollection + { + return Entry::query() + ->where('collection_id', $oldCollection->id) + ->whereNotNull('molecule_id') + ->whereNotNull('standardized_canonical_smiles') + ->get(['molecule_id', 'standardized_canonical_smiles']) + ->groupBy('standardized_canonical_smiles') + ->map(fn ($group) => (int) $group->first()->molecule_id); + } + + /** + * @return SupportCollection + */ + protected function newPassedSmiles(Collection $newCollection): SupportCollection + { + return Entry::query() + ->where('collection_id', $newCollection->id) + ->where('status', 'PASSED') + ->whereNotNull('standardized_canonical_smiles') + ->pluck('standardized_canonical_smiles') + ->unique() + ->values(); + } +} diff --git a/app/Services/CollectionVersioning/CollectionVersionImporter.php b/app/Services/CollectionVersioning/CollectionVersionImporter.php new file mode 100644 index 00000000..5bde7fc0 --- /dev/null +++ b/app/Services/CollectionVersioning/CollectionVersionImporter.php @@ -0,0 +1,166 @@ +version_migration_status !== Collection::VERSION_MIGRATION_PENDING + && $newCollection->version_migration_status !== Collection::VERSION_MIGRATION_PROCESSING) { + throw new RuntimeException('Collection is not in a version migration state.'); + } + + $oldCollection = $this->resolveOldCollection($newCollection); + if (! $oldCollection) { + throw new RuntimeException('Could not resolve previous latest collection in lineage.'); + } + + $newCollection->update(['version_migration_status' => Collection::VERSION_MIGRATION_PROCESSING]); + + try { + $this->validateWaiter->runAndWait($newCollection); + + $diffResult = $this->diff->compare($oldCollection, $newCollection); + + Artisan::call('coconut:import-molecules', ['collection_id' => $newCollection->id]); + + $this->revokeDropped->revoke( + $diffResult->oldOnlyMoleculeIds(), + $oldCollection, + $newCollection, + $diffResult, + ); + + $this->stripProvenance->stripAllForCollection($oldCollection->id); + + Artisan::call('coconut:enrich-molecules', ['collection_id' => $newCollection->id]); + Artisan::call('coconut:publish-molecules', ['collection_id' => $newCollection->id]); + $this->runPostPublishChain($newCollection->id); + + $this->archiveOldCollection($oldCollection, $newCollection); + $this->publishNewCollection($newCollection, $oldCollection); + + Cache::forget('collections.'.$newCollection->identifier); + if (Artisan::all()['coconut:cache'] ?? null) { + Artisan::call('coconut:cache'); + } + + return [ + 'old_collection_id' => $oldCollection->id, + 'new_collection_id' => $newCollection->id, + 'revoked' => count($diffResult->oldOnlyMoleculeIds()), + 'retained' => count($diffResult->retainedMoleculeIds()), + 'new_only' => $diffResult->newOnlySmiles->count(), + ]; + } catch (\Throwable $e) { + $newCollection->update(['version_migration_status' => Collection::VERSION_MIGRATION_PENDING]); + throw $e; + } + } + + public function preview(Collection $newCollection): array + { + $oldCollection = $this->resolveOldCollection($newCollection); + if (! $oldCollection) { + throw new RuntimeException('Could not resolve previous latest collection in lineage.'); + } + + return $this->diff->preview($oldCollection, $newCollection); + } + + protected function resolveOldCollection(Collection $newCollection): ?Collection + { + $rootId = $newCollection->lineageRootId(); + + return Collection::query() + ->where(function ($q) use ($rootId) { + $q->where('id', $rootId)->orWhere('parent_collection_id', $rootId); + }) + ->where('id', '!=', $newCollection->id) + ->where('is_latest', true) + ->first() + ?? Collection::query() + ->where(function ($q) use ($rootId) { + $q->where('id', $rootId)->orWhere('parent_collection_id', $rootId); + }) + ->where('id', '!=', $newCollection->id) + ->where('version', $newCollection->version - 1) + ->first(); + } + + protected function runPostPublishChain(int $collectionId): void + { + $commands = [ + 'coconut:generate-properties-auto', + 'coconut:generate-coordinates-auto', + 'coconut:npclassify', + 'coconut:import-pubchem-data', + 'coconut:fetch-cas-numbers', + ]; + + foreach ($commands as $command) { + if (Artisan::all()[$command] ?? null) { + Artisan::call($command, ['collection_id' => $collectionId]); + } + } + } + + protected function archiveOldCollection(Collection $oldCollection, Collection $newCollection): void + { + $entriesCount = Entry::query()->where('collection_id', $oldCollection->id)->count(); + $moleculesCount = DB::table('collection_molecule')->where('collection_id', $oldCollection->id)->count(); + + Entry::query() + ->where('collection_id', $oldCollection->id) + ->update(['is_archived' => true]); + + $oldCollection->update([ + 'status' => 'SUPERSEDED', + 'is_latest' => false, + 'superseded_by_collection_id' => $newCollection->id, + 'superseded_at' => now(), + 'archived_entries_count' => $entriesCount, + 'archived_molecules_count' => $moleculesCount, + 'molecules_count' => 0, + ]); + } + + protected function publishNewCollection(Collection $newCollection, Collection $oldCollection): void + { + $root = $newCollection->lineageRoot(); + + Collection::query() + ->where(function ($q) use ($root) { + $q->where('id', $root->id)->orWhere('parent_collection_id', $root->id); + }) + ->where('id', '!=', $newCollection->id) + ->update(['is_latest' => false]); + + $newCollection->update([ + 'status' => 'PUBLISHED', + 'is_latest' => true, + 'version_migration_status' => Collection::VERSION_MIGRATION_COMPLETE, + 'release_date' => now(), + ]); + + $this->assignDoi->assign($newCollection->fresh()); + $newCollection->fresh()->updateBaseDoiLanding(app(\App\Services\DOI\DOIService::class), $root->fresh()); + } +} diff --git a/app/Services/CollectionVersioning/RevokeDroppedMolecules.php b/app/Services/CollectionVersioning/RevokeDroppedMolecules.php new file mode 100644 index 00000000..58fbc178 --- /dev/null +++ b/app/Services/CollectionVersioning/RevokeDroppedMolecules.php @@ -0,0 +1,109 @@ + $moleculeIds + * @return array Revoked molecule ids + */ + public function revoke( + array $moleculeIds, + Collection $oldCollection, + Collection $newCollection, + CollectionVersionDiffResult $diff, + ): array { + $revoked = []; + $lineageRootId = $oldCollection->lineageRootId(); + $lineageCollectionIds = $this->lineageCollectionIds($lineageRootId); + + foreach ($moleculeIds as $moleculeId) { + if (! $this->isExclusiveToLineage($moleculeId, $lineageCollectionIds)) { + continue; + } + + $molecule = Molecule::query()->find($moleculeId); + if (! $molecule) { + continue; + } + + $molecule->status = 'REVOKED'; + $molecule->active = false; + $molecule->comment = trim(($molecule->comment ?? '').' Revoked: dropped in collection version '.$newCollection->version.'.'); + $molecule->save(); + + $entry = Entry::query() + ->where('collection_id', $oldCollection->id) + ->where('molecule_id', $moleculeId) + ->first(); + + $smiles = $diff->oldOnlySmilesToMoleculeId->search($moleculeId) ?: $entry?->standardized_canonical_smiles; + + CollectionVersionRevocation::query()->create([ + 'lineage_root_id' => $lineageRootId, + 'from_collection_id' => $oldCollection->id, + 'to_collection_id' => $newCollection->id, + 'entry_id' => $entry?->id, + 'molecule_id' => $moleculeId, + 'reference_id' => $entry?->reference_id, + 'standardized_canonical_smiles' => is_string($smiles) ? $smiles : null, + 'revoked_at' => now(), + 'reason' => 'dropped_in_version_'.$newCollection->version, + ]); + + $revoked[] = $moleculeId; + } + + return $revoked; + } + + /** + * @return array + */ + protected function lineageCollectionIds(int $lineageRootId): array + { + return Collection::query() + ->where(function ($q) use ($lineageRootId) { + $q->where('id', $lineageRootId)->orWhere('parent_collection_id', $lineageRootId); + }) + ->pluck('id') + ->all(); + } + + /** + * @param array $lineageCollectionIds + */ + protected function isExclusiveToLineage(int $moleculeId, array $lineageCollectionIds): bool + { + $otherCollectionLinks = DB::table('collection_molecule') + ->where('molecule_id', $moleculeId) + ->whereNotIn('collection_id', $lineageCollectionIds) + ->exists(); + + if ($otherCollectionLinks) { + return false; + } + + $organismRows = DB::table('molecule_organism') + ->where('molecule_id', $moleculeId) + ->get(['collection_ids']); + + foreach ($organismRows as $row) { + $ids = json_decode($row->collection_ids ?? '[]', true) ?: []; + foreach ($ids as $collectionId) { + if (! in_array((int) $collectionId, $lineageCollectionIds, true)) { + return false; + } + } + } + + return true; + } +} diff --git a/app/Services/CollectionVersioning/StripCollectionProvenance.php b/app/Services/CollectionVersioning/StripCollectionProvenance.php new file mode 100644 index 00000000..bb5353b1 --- /dev/null +++ b/app/Services/CollectionVersioning/StripCollectionProvenance.php @@ -0,0 +1,89 @@ +where('collection_id', $oldCollectionId) + ->pluck('molecule_id') + ->unique() + ->all(); + + foreach ($moleculeIds as $moleculeId) { + $this->stripForMolecule((int) $moleculeId, $oldCollectionId); + } + + DB::table('citables') + ->where('citable_type', 'App\\Models\\Collection') + ->where('citable_id', $oldCollectionId) + ->delete(); + } + + public function stripForMolecule(int $moleculeId, int $oldCollectionId): void + { + DB::table('collection_molecule') + ->where('collection_id', $oldCollectionId) + ->where('molecule_id', $moleculeId) + ->delete(); + + $rows = DB::table('molecule_organism') + ->where('molecule_id', $moleculeId) + ->get(); + + foreach ($rows as $row) { + $metadata = json_decode($row->metadata ?? '{}', true) ?: []; + $cols = array_values(array_filter( + $metadata['cols'] ?? [], + fn ($col) => (int) ($col['id'] ?? 0) !== $oldCollectionId + )); + + $collectionIds = array_values(array_filter( + json_decode($row->collection_ids ?? '[]', true) ?: [], + fn ($id) => (int) $id !== $oldCollectionId + )); + + if (empty($cols) && empty($collectionIds)) { + DB::table('molecule_organism')->where('id', $row->id)->delete(); + + continue; + } + + $metadata['cols'] = $cols; + $metadata['col_ids'] = array_values(array_filter( + $metadata['col_ids'] ?? [], + fn ($id) => (int) $id !== $oldCollectionId + )); + + DB::table('molecule_organism')->where('id', $row->id)->update([ + 'metadata' => json_encode($metadata), + 'collection_ids' => json_encode($collectionIds), + 'updated_at' => now(), + ]); + } + + $entryDois = DB::table('entries') + ->where('collection_id', $oldCollectionId) + ->where('molecule_id', $moleculeId) + ->whereNotNull('doi') + ->pluck('doi') + ->flatMap(fn ($doi) => array_filter(array_map('trim', preg_split('/[|,]/', (string) $doi)))) + ->unique() + ->all(); + + if (! empty($entryDois)) { + $citationIds = DB::table('citations')->whereIn('doi', $entryDois)->pluck('id'); + if ($citationIds->isNotEmpty()) { + DB::table('citables') + ->where('citable_type', 'App\\Models\\Molecule') + ->where('citable_id', $moleculeId) + ->whereIn('citation_id', $citationIds) + ->delete(); + } + } + } +} diff --git a/app/Services/CollectionVersioning/ValidateMoleculesBatchWaiter.php b/app/Services/CollectionVersioning/ValidateMoleculesBatchWaiter.php new file mode 100644 index 00000000..9a5be5af --- /dev/null +++ b/app/Services/CollectionVersioning/ValidateMoleculesBatchWaiter.php @@ -0,0 +1,77 @@ + $collection->id]); + + $batch = $this->findLatestBatchForCollection($collection->id); + if (! $batch) { + $this->assertGateConditions($collection); + + return; + } + + $start = time(); + while (! $batch->finished()) { + if ((time() - $start) > $maxWaitSeconds) { + throw new RuntimeException("Validation batch timed out for collection {$collection->id}."); + } + sleep(15); + $batch = Bus::findBatch($batch->id) ?? $batch; + } + + if ($batch->hasFailures()) { + throw new RuntimeException("Validation batch failed for collection {$collection->id}: {$batch->failedJobs} jobs failed."); + } + + $this->assertGateConditions($collection->fresh()); + } + + protected function findLatestBatchForCollection(int $collectionId): ?Batch + { + $record = DB::table('job_batches') + ->where('name', 'Validate Molecules '.$collectionId) + ->orderByDesc('created_at') + ->first(); + + if (! $record) { + return null; + } + + return Bus::findBatch($record->id); + } + + protected function assertGateConditions(Collection $collection): void + { + $submitted = Entry::query() + ->where('collection_id', $collection->id) + ->where('status', 'SUBMITTED') + ->count(); + + if ($submitted > 0) { + throw new RuntimeException("Collection {$collection->id} still has {$submitted} SUBMITTED entries after validation."); + } + + $missingSmiles = Entry::query() + ->where('collection_id', $collection->id) + ->where('status', 'PASSED') + ->whereNull('standardized_canonical_smiles') + ->count(); + + if ($missingSmiles > 0) { + throw new RuntimeException("Collection {$collection->id} has {$missingSmiles} PASSED entries without standardized_canonical_smiles."); + } + } +} From 9b1068ac561ca55249ea9ab1cbc2466956f5f347 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:43 +0200 Subject: [PATCH 03/10] feat(collections): add version import and preview artisan commands Expose coconut:import-collection-version and coconut:preview-collection-version for curator-driven release migrations. --- .../Commands/ImportCollectionVersion.php | 36 +++++++++++++++++++ .../Commands/PreviewCollectionVersion.php | 35 ++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 app/Console/Commands/ImportCollectionVersion.php create mode 100644 app/Console/Commands/PreviewCollectionVersion.php diff --git a/app/Console/Commands/ImportCollectionVersion.php b/app/Console/Commands/ImportCollectionVersion.php new file mode 100644 index 00000000..009f636d --- /dev/null +++ b/app/Console/Commands/ImportCollectionVersion.php @@ -0,0 +1,36 @@ +find($this->argument('new_collection_id')); + if (! $collection) { + $this->error('Collection not found.'); + + return self::FAILURE; + } + + try { + $result = $importer->import($collection); + $this->info('Collection version migration completed.'); + $this->table(array_keys($result), [array_values($result)]); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/PreviewCollectionVersion.php b/app/Console/Commands/PreviewCollectionVersion.php new file mode 100644 index 00000000..93700e5c --- /dev/null +++ b/app/Console/Commands/PreviewCollectionVersion.php @@ -0,0 +1,35 @@ +find($this->argument('new_collection_id')); + if (! $collection) { + $this->error('Collection not found.'); + + return self::FAILURE; + } + + try { + $preview = $importer->preview($collection); + $this->table(array_keys($preview), [array_values($preview)]); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + } +} From 95519e479148a73f19ab08dfcd2110780448a3b3 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:43 +0200 Subject: [PATCH 04/10] feat(doi): support versioned and base DataCite DOI suffixes Mint per-version DOIs (e.g. .v2) and a base DOI that resolves to the latest release within a collection lineage. --- app/Models/HasDOI.php | 120 +++++++++++++++++++++++++++++--- app/Services/DOI/DOIService.php | 4 +- app/Services/DOI/DataCite.php | 60 ++++++++-------- 3 files changed, 143 insertions(+), 41 deletions(-) diff --git a/app/Models/HasDOI.php b/app/Models/HasDOI.php index 8190c251..08ecd3c4 100644 --- a/app/Models/HasDOI.php +++ b/app/Models/HasDOI.php @@ -7,20 +7,118 @@ trait HasDOI public function generateDOI($doiService) { $doi_host = config('doi.datacite.host'); - if (! is_null($doi_host)) { - $identifier = $this->getIdentifier($this, 'identifier'); - if ($this->doi == null) { - $url = 'https://coconut.naturalproducts.net/collections/'.$identifier; - $attributes = $this->getMetadata(); - $attributes['url'] = $url; - $doiResponse = $doiService->createDOI($identifier, $attributes); - $this->doi = $doiResponse['data']['id']; - $this->datacite_schema = $doiResponse; - $this->save(); + if (is_null($doi_host)) { + return; + } + + if ($this->doi !== null) { + return; + } + + if ($this instanceof Collection && $this->identifier) { + $this->generateVersionedCollectionDois($doiService); + + return; + } + + $identifier = $this->getIdentifier($this, 'identifier'); + $url = $this->collectionLandingUrl($identifier); + $attributes = $this->getMetadata(); + $attributes['url'] = $url; + $doiResponse = $doiService->createDOI($identifier, $attributes); + $this->doi = $doiResponse['data']['id']; + $this->datacite_schema = $doiResponse; + $this->save(); + } + + public function generateVersionedCollectionDois($doiService): void + { + if (! ($this instanceof Collection)) { + return; + } + + $doi_host = config('doi.datacite.host'); + if (is_null($doi_host)) { + return; + } + + $identifier = (string) $this->identifier; + $versionSuffix = $this->versionDoiSuffix(); + $baseSuffix = $this->baseDoiSuffix(); + $root = $this->lineageRoot(); + + if ($this->doi === null) { + $versionUrl = $this->collectionLandingUrl($identifier, $this->version); + $attributes = $this->getMetadata(); + $attributes['url'] = $versionUrl; + if ($root->doi_base) { + $attributes['relatedIdentifiers'] = array_merge($attributes['relatedIdentifiers'] ?? [], [ + [ + 'relatedIdentifier' => $root->doi_base, + 'relatedIdentifierType' => 'DOI', + 'relationType' => 'IsVersionOf', + ], + ]); } + $doiResponse = $doiService->createDoiWithSuffix($versionSuffix, $attributes); + $this->doi = $doiResponse['data']['id']; + $this->doi_suffix = $versionSuffix; + $this->datacite_schema = $doiResponse; + $this->save(); + } + + if ($root->doi_base === null) { + $baseUrl = $this->collectionLandingUrl($identifier); + $baseAttributes = $this->getMetadata(); + $baseAttributes['url'] = $baseUrl; + $baseAttributes['titles'] = [ + ['title' => $this->title.' (latest)'], + ]; + $baseResponse = $doiService->createDoiWithSuffix($baseSuffix, $baseAttributes); + $root->doi_base = $baseResponse['data']['id']; + $root->save(); + } else { + $this->updateBaseDoiLanding($doiService, $root); + } + } + + public function updateBaseDoiLanding($doiService, ?Collection $root = null): void + { + if (! ($this instanceof Collection)) { + return; + } + + $root = $root ?? $this->lineageRoot(); + if (! $root->doi_base) { + return; + } + + $identifier = (string) $this->identifier; + $baseUrl = $this->collectionLandingUrl($identifier); + $related = [ + [ + 'relatedIdentifier' => $this->doi, + 'relatedIdentifierType' => 'DOI', + 'relationType' => 'HasVersion', + ], + ]; + + $doiService->updateDOI($root->doi_base, [ + 'url' => $baseUrl, + 'relatedIdentifiers' => $related, + ]); + } + + protected function collectionLandingUrl(string $identifier, ?int $version = null): string + { + $base = rtrim(config('app.url', 'https://coconut.naturalproducts.net'), '/'); + $url = $base.'/collections/'.$identifier; + if ($version !== null && $version > 1) { + $url .= '?version='.$version; } + return $url; } public function getIdentifier($model, $key) @@ -70,7 +168,7 @@ public function getMetadata() 'schemeUri' => 'https://spdx.org/licenses/', ], ]; - $publicationYear = explode('-', $this->created_at)[0]; + $publicationYear = explode('-', (string) $this->created_at)[0]; $subjects = [ ['subject' => 'Natural Product', 'subjectScheme' => 'NCI Thesaurus OBO Edition', diff --git a/app/Services/DOI/DOIService.php b/app/Services/DOI/DOIService.php index 27a37b7f..7a750faa 100644 --- a/app/Services/DOI/DOIService.php +++ b/app/Services/DOI/DOIService.php @@ -8,9 +8,11 @@ public function getDOIs(); public function createDOI($identifier, $attributes = []); + public function createDoiWithSuffix(string $suffix, array $metadata = []): array; + public function getDOI($doi); - public function updateDOI($doi); + public function updateDOI($doi, $metadata = []); public function deleteDOI($doi); diff --git a/app/Services/DOI/DataCite.php b/app/Services/DOI/DataCite.php index f195e787..4b1d245d 100644 --- a/app/Services/DOI/DataCite.php +++ b/app/Services/DOI/DataCite.php @@ -8,6 +8,10 @@ class DataCite implements DOIService { + protected Client $client; + + protected string $prefix; + public function __construct() { $this->client = new Client([ @@ -20,9 +24,6 @@ public function __construct() $this->prefix = Config::get('doi.'.Config::get('doi.default').'.prefix'); } - /** - * Returns a list of DOIs - */ public function getDOIs() { $prefix = Config::get('doi.'.Config::get('doi.default').'.prefix'); @@ -38,15 +39,23 @@ public function getDOI($doi) return $response->getBody(); } + /** + * @param string $identifier Legacy: CNPC id or full suffix e.g. coconut.cnpc0070.v2 + */ public function createDOI($identifier, $metadata = []) { - $doi = $this->prefix.'/'.Config::get('app.name').'.'.$identifier; - $url = 'https://coconut.naturalproducts.net/collections/'.$identifier; - $suffix = Config::get('app.name').'.'.$identifier; + $suffix = $this->resolveSuffix($identifier); + + return $this->createDoiWithSuffix($suffix, $metadata); + } + + public function createDoiWithSuffix(string $suffix, array $metadata = []): array + { + $doi = $this->prefix.'/'.$suffix; $attributes = [ - 'doi' => $this->prefix.'/'.Config::get('app.name').'.'.$identifier, + 'doi' => $doi, 'prefix' => $this->prefix, - 'suffix' => Config::get('app.name').'.'.$identifier, + 'suffix' => $suffix, 'publisher' => Config::get('app.name'), 'publicationYear' => now()->format('Y'), 'language' => 'en', @@ -63,23 +72,12 @@ public function createDOI($identifier, $metadata = []) ], ]; - $response = $this->client->post('/dois', - [RequestOptions::JSON => $body] - ); - - $stream = $response->getBody(); - $contents = $stream->getContents(); + $response = $this->client->post('/dois', [RequestOptions::JSON => $body]); + $contents = $response->getBody()->getContents(); return json_decode($contents, true); } - /** - * Update DataCite metadata based on DOI - * - * @param string $doi - * @param array $metadata - * @return array $contents - */ public function updateDOI($doi, $metadata = []) { $attributes = []; @@ -94,15 +92,10 @@ public function updateDOI($doi, $metadata = []) ], ]; - $response = $this->client->put('/dois/'.urlencode($doi), - [RequestOptions::JSON => $body] - ); + $response = $this->client->put('/dois/'.urlencode($doi), [RequestOptions::JSON => $body]); + $contents = $response->getBody()->getContents(); - $stream = $response->getBody(); - $contents = $stream->getContents(); - $contents = json_decode($contents, true); - - return $contents; + return json_decode($contents, true); } public function deleteDOI($doi) @@ -118,4 +111,13 @@ public function getDOIActivity($doi) return $response->getBody(); } + + protected function resolveSuffix(string $identifier): string + { + if (str_contains($identifier, '.')) { + return $identifier; + } + + return Config::get('app.name').'.'.$identifier; + } } From 3d65e6056e0a60a11875c8c66bc83558a639fe2c Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:45 +0200 Subject: [PATCH 05/10] fix(scheduler): skip collections during active version migration Prevent legacy entries-process and entries-import jobs from touching collections with pending or processing version_migration_status. --- app/Console/Commands/ImportEntries.php | 7 +++++-- app/Console/Commands/ProcessEntries.php | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/ImportEntries.php b/app/Console/Commands/ImportEntries.php index 50c4e031..0c0e51c8 100644 --- a/app/Console/Commands/ImportEntries.php +++ b/app/Console/Commands/ImportEntries.php @@ -34,9 +34,12 @@ public function handle() $collection_id = $this->argument('collection_id'); if (! is_null($collection_id)) { - $collections = Collection::where('id', $collection_id)->get(); + $collections = Collection::query()->where('id', $collection_id)->eligibleForLegacyPipeline()->get(); } else { - $collections = Collection::where('status', 'DRAFT')->get(); + $collections = Collection::query() + ->where('status', 'DRAFT') + ->eligibleForLegacyPipeline() + ->get(); } foreach ($collections as $collection) { diff --git a/app/Console/Commands/ProcessEntries.php b/app/Console/Commands/ProcessEntries.php index 414a5b67..adc222da 100644 --- a/app/Console/Commands/ProcessEntries.php +++ b/app/Console/Commands/ProcessEntries.php @@ -38,6 +38,9 @@ public function handle() $i = 0; $collection = Collection::whereId($collectionId['collection_id'])->first(); + if (! $collection || $collection->isVersionMigrationActive()) { + continue; + } $collection->jobs_status = 'PROCESSING'; $collection->job_info = 'Processing entries using ChEMBL Pipeline.'; $collection->save(); From 351952342387babd1cdd0f6935d942dfbe5bbef4 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:45 +0200 Subject: [PATCH 06/10] feat(filament): add collection version management actions Add create-version, preview migration, and process migration actions on the collection view page with superseded archive UI in relation managers. --- .../Resources/CollectionResource.php | 13 +++ .../Pages/ViewCollection.php | 81 +++++++++++++++++++ .../EntriesRelationManager.php | 15 +++- .../MoleculesRelationManager.php | 6 ++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/app/Filament/Dashboard/Resources/CollectionResource.php b/app/Filament/Dashboard/Resources/CollectionResource.php index 600f095a..c635bbf0 100644 --- a/app/Filament/Dashboard/Resources/CollectionResource.php +++ b/app/Filament/Dashboard/Resources/CollectionResource.php @@ -166,6 +166,18 @@ public static function infolist(Schema $schema): Schema TextEntry::make('identifier') ->label('Identifier') ->placeholder('No identifier'), + TextEntry::make('version') + ->label('Version'), + TextEntry::make('is_latest') + ->label('Latest') + ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No'), + TextEntry::make('doi') + ->label('Version DOI') + ->placeholder('Not minted'), + TextEntry::make('doi_base') + ->label('Base DOI (latest)') + ->state(fn (Collection $record) => $record->lineageRoot()->doi_base) + ->placeholder('Not minted'), ]) ->columns(2), Section::make('Display Image') @@ -217,6 +229,7 @@ public static function table(Table $table): Table 'EMBARGO' => 'warning', 'PUBLISHED' => 'success', 'REJECTED' => 'danger', + 'SUPERSEDED' => 'gray', default => 'gray', }), ]) diff --git a/app/Filament/Dashboard/Resources/CollectionResource/Pages/ViewCollection.php b/app/Filament/Dashboard/Resources/CollectionResource/Pages/ViewCollection.php index b07ea8b9..cd05856d 100644 --- a/app/Filament/Dashboard/Resources/CollectionResource/Pages/ViewCollection.php +++ b/app/Filament/Dashboard/Resources/CollectionResource/Pages/ViewCollection.php @@ -4,6 +4,11 @@ use App\Filament\Dashboard\Resources\CollectionResource; use App\Filament\Dashboard\Resources\CollectionResource\Widgets\CollectionStats; +use App\Models\Collection; +use App\Services\CollectionVersioning\CollectionVersionCreator; +use App\Services\CollectionVersioning\CollectionVersionImporter; +use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; class ViewCollection extends ViewRecord @@ -16,4 +21,80 @@ protected function getHeaderWidgets(): array CollectionStats::class, ]; } + + protected function getHeaderActions(): array + { + return [ + Action::make('createNewVersion') + ->label('Create new version') + ->icon('heroicon-o-document-duplicate') + ->visible(fn (Collection $record) => $record->is_latest + && $record->status === 'PUBLISHED' + && ! $record->isVersionMigrationActive()) + ->requiresConfirmation() + ->modalHeading('Create new collection version') + ->modalDescription('This clones metadata into a new DRAFT version with the same CNPC identifier. Import CSV entries, then use Preview and Process new version.') + ->action(function (Collection $record, CollectionVersionCreator $creator) { + $new = $creator->createFrom($record); + Notification::make() + ->title('New version created') + ->body("Version {$new->version} is ready for CSV import.") + ->success() + ->send(); + + $this->redirect(CollectionResource::getUrl('view', ['record' => $new])); + }), + Action::make('previewVersionMigration') + ->label('Preview migration') + ->icon('heroicon-o-eye') + ->visible(fn (Collection $record) => $record->version_migration_status === Collection::VERSION_MIGRATION_PENDING) + ->action(function (Collection $record, CollectionVersionImporter $importer) { + try { + $preview = $importer->preview($record); + Notification::make() + ->title('Migration preview') + ->body(sprintf( + 'Dropped: %d | Retained: %d | New: %d | Revoke candidates: %d', + $preview['old_only_count'], + $preview['retained_count'], + $preview['new_only_count'], + $preview['revoke_candidate_count'], + )) + ->info() + ->send(); + } catch (\Throwable $e) { + Notification::make()->title('Preview failed')->body($e->getMessage())->danger()->send(); + } + }), + Action::make('processNewVersion') + ->label('Process new version') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (Collection $record) => in_array($record->version_migration_status, [ + Collection::VERSION_MIGRATION_PENDING, + Collection::VERSION_MIGRATION_PROCESSING, + ], true)) + ->requiresConfirmation() + ->modalHeading('Process collection version migration') + ->modalDescription('This validates entries, diffs by standardized SMILES, migrates live data, revokes dropped exclusive molecules, and archives the previous version.') + ->action(function (Collection $record, CollectionVersionImporter $importer) { + try { + $result = $importer->import($record); + Notification::make() + ->title('Version migration completed') + ->body(sprintf( + 'Revoked: %d | Retained: %d | New: %d', + $result['revoked'], + $result['retained'], + $result['new_only'], + )) + ->success() + ->send(); + $this->redirect(CollectionResource::getUrl('view', ['record' => $record->fresh()])); + } catch (\Throwable $e) { + Notification::make()->title('Migration failed')->body($e->getMessage())->danger()->send(); + } + }), + ]; + } } diff --git a/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/EntriesRelationManager.php b/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/EntriesRelationManager.php index 575505a3..37d3b392 100644 --- a/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/EntriesRelationManager.php +++ b/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/EntriesRelationManager.php @@ -151,6 +151,10 @@ public function table(Table $table): Table ->label('Reference ID') ->searchable(), TextColumn::make('status'), + TextColumn::make('is_archived') + ->label('Archived') + ->badge() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('status') @@ -165,11 +169,16 @@ public function table(Table $table): Table ->headerActions([ ImportAction::make() ->importer(EntryImporter::class) + ->hidden(fn () => $this->ownerRecord->status === 'SUPERSEDED') ->options([ 'collection_id' => $this->ownerRecord->id, ]), Action::make('process') ->hidden(function () { + if ($this->ownerRecord->isVersionMigrationActive()) { + return true; + } + return $this->ownerRecord->entries()->where('status', 'SUBMITTED')->count() < 1; }) ->action(function () { @@ -190,9 +199,11 @@ public function table(Table $table): Table ViewAction::make() ->iconButton(), EditAction::make() - ->iconButton(), + ->iconButton() + ->hidden(fn () => $this->ownerRecord->status === 'SUPERSEDED'), DeleteAction::make() - ->iconButton(), + ->iconButton() + ->hidden(fn () => $this->ownerRecord->status === 'SUPERSEDED'), ]) ->toolbarActions([ BulkActionGroup::make([ diff --git a/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/MoleculesRelationManager.php b/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/MoleculesRelationManager.php index 57621e83..054e1a94 100644 --- a/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/MoleculesRelationManager.php +++ b/app/Filament/Dashboard/Resources/CollectionResource/RelationManagers/MoleculesRelationManager.php @@ -14,11 +14,17 @@ use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; class MoleculesRelationManager extends RelationManager { protected static string $relationship = 'molecules'; + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + return $ownerRecord->status !== 'SUPERSEDED'; + } + public function form(Schema $schema): Schema { return $schema From a7a2ea2eefc21e2c4ebf81b7f1f1d22e149d5e34 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:47 +0200 Subject: [PATCH 07/10] feat(collections): add public version history and revoked compounds UI Show lineage version picker and dropped exclusive compounds on the collection data source page with optional ?version= query support. --- app/Http/Controllers/CollectionController.php | 18 ++++- app/Livewire/CollectionRevokedCompounds.php | 45 +++++++++++++ app/Livewire/CollectionVersionHistory.php | 39 +++++++++++ app/Livewire/Search.php | 3 + .../collection-revoked-compounds.blade.php | 39 +++++++++++ .../collection-version-history.blade.php | 67 +++++++++++++++++++ resources/views/livewire/search.blade.php | 24 +++++-- 7 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 app/Livewire/CollectionRevokedCompounds.php create mode 100644 app/Livewire/CollectionVersionHistory.php create mode 100644 resources/views/livewire/collection-revoked-compounds.blade.php create mode 100644 resources/views/livewire/collection-version-history.blade.php diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index 51d0289c..1d58a8d7 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -18,19 +18,33 @@ public function __invoke(Request $request, $id) } $collection = Cache::flexible('collections.'.$id, [172800, 259200], function () use ($id) { - return Collection::where('identifier', $id)->first(); + return Collection::query() + ->where('identifier', $id) + ->orderByDesc('is_latest') + ->orderByDesc('version') + ->first(); }); if (! $collection) { abort(404); } + $latest = $collection->is_latest + ? $collection + : ($collection->lineageVersionsQuery()->where('is_latest', true)->first() ?? $collection); + $query = [ 'type' => 'tags', - 'q' => str_replace(' ', '+', $collection->title), + 'q' => str_replace(' ', '+', $latest->title), 'tagType' => 'dataSource', ]; + if ($request->has('version')) { + $query['version'] = (int) $request->query('version'); + } elseif (! $collection->is_latest) { + $query['version'] = $collection->version; + } + $baseUrl = config('app.url'); return redirect()->to($baseUrl.'/search?'.http_build_query($query)); diff --git a/app/Livewire/CollectionRevokedCompounds.php b/app/Livewire/CollectionRevokedCompounds.php new file mode 100644 index 00000000..74a76048 --- /dev/null +++ b/app/Livewire/CollectionRevokedCompounds.php @@ -0,0 +1,45 @@ +lineageRootId = $lineageRootId; + $this->filterVersion = $filterVersion; + } + + public function toggle(): void + { + $this->expanded = ! $this->expanded; + } + + public function render() + { + $query = CollectionVersionRevocation::query() + ->where('lineage_root_id', $this->lineageRootId) + ->with(['molecule', 'fromCollection']) + ->orderByDesc('revoked_at'); + + if ($this->filterVersion) { + $query->whereHas('fromCollection', fn ($q) => $q->where('version', $this->filterVersion)); + } + + $revocations = $query->get(); + + return view('livewire.collection-revoked-compounds', [ + 'revocations' => $revocations, + 'count' => $revocations->count(), + ]); + } +} diff --git a/app/Livewire/CollectionVersionHistory.php b/app/Livewire/CollectionVersionHistory.php new file mode 100644 index 00000000..a5fd1635 --- /dev/null +++ b/app/Livewire/CollectionVersionHistory.php @@ -0,0 +1,39 @@ +lineageRootId = $lineageRootId; + $this->selectedVersion = $selectedVersion; + } + + public function selectVersion(int $version): void + { + $this->selectedVersion = $version; + } + + public function render() + { + $root = Collection::query()->findOrFail($this->lineageRootId); + $versions = $root->lineageVersionsQuery()->get(); + $selected = $this->selectedVersion + ? $versions->firstWhere('version', $this->selectedVersion) + : $versions->firstWhere('is_latest', true); + + return view('livewire.collection-version-history', [ + 'versions' => $versions, + 'selected' => $selected, + 'baseDoi' => $root->doi_base, + ]); + } +} diff --git a/app/Livewire/Search.php b/app/Livewire/Search.php index 38cf71ae..46f4cad5 100644 --- a/app/Livewire/Search.php +++ b/app/Livewire/Search.php @@ -46,6 +46,9 @@ class Search extends Component #[Url(as: 'status')] public $status = 'all'; + #[Url(as: 'version')] + public ?int $version = null; + public function placeholder() { return <<<'HTML' diff --git a/resources/views/livewire/collection-revoked-compounds.blade.php b/resources/views/livewire/collection-revoked-compounds.blade.php new file mode 100644 index 00000000..19455041 --- /dev/null +++ b/resources/views/livewire/collection-revoked-compounds.blade.php @@ -0,0 +1,39 @@ +@if ($count > 0) +
+ +

+ Compounds removed during a collection version update. They are no longer active for this source in the current release. +

+ + @if ($expanded) +
    + @foreach ($revocations as $revocation) +
  • +
    + @if ($revocation->standardized_canonical_smiles) + Structure + @endif +
    +

    Reference: {{ $revocation->reference_id ?? '—' }}

    +

    Dropped in: v{{ $revocation->fromCollection?->version }} → newer version

    +

    Revoked: {{ $revocation->revoked_at?->format('Y-m-d') }}

    + @if ($revocation->molecule?->identifier) +

    + View molecule +

    + @endif +
    +
    +
  • + @endforeach +
+ @endif +
+@endif diff --git a/resources/views/livewire/collection-version-history.blade.php b/resources/views/livewire/collection-version-history.blade.php new file mode 100644 index 00000000..33a0e0e6 --- /dev/null +++ b/resources/views/livewire/collection-version-history.blade.php @@ -0,0 +1,67 @@ +
+

Version history

+ @if ($baseDoi) +

+ Latest DOI: + {{ $baseDoi }} +

+ @endif + +
+ + + + + + + + + + + + @foreach ($versions as $version) + + + + + + + + @endforeach + +
VersionStatusDOIReleasedEntries
+ + + @if ($version->is_latest) + Current + @else + Superseded + @endif + + @if ($version->doi) + {{ $version->doi }} + @else + — + @endif + {{ $version->release_date?->format('Y-m-d') ?? $version->superseded_at?->format('Y-m-d') ?? '—' }}{{ $version->is_latest ? $version->total_entries : ($version->archived_entries_count ?? $version->total_entries) }}
+
+ + @if ($selected && ! $selected->is_latest) +

+ You are viewing metadata for v{{ $selected->version }}. + Jump to current version +

+ @endif + + @if ($selected) +
+

{{ $selected->title }} (v{{ $selected->version }})

+

{{ $selected->description }}

+ @if ($selected->url) +

Source URL

+ @endif +
+ @endif +
diff --git a/resources/views/livewire/search.blade.php b/resources/views/livewire/search.blade.php index f4802bea..b556668d 100644 --- a/resources/views/livewire/search.blade.php +++ b/resources/views/livewire/search.blade.php @@ -30,17 +30,31 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/20 backdr $currentMonthYear = now()->format('m-Y'); @endphp - @if ($collection->doi) - DOI: is_latest + ? ($collection->lineageRoot()->doi_base ?? $collection->doi) + : $collection->doi; + @endphp + @if ($displayDoi) + DOI: - {{ $collection->doi }} + {{ $displayDoi }}
@endif - - Download Collection (SDF) + + + @elseif ($tagType == 'organisms' && $organisms)
From aedf6de940e95044d7f544d0900604c3c305e653 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:47 +0200 Subject: [PATCH 08/10] feat(search): scope stats, search, and exports to latest collections Filter public listings, dashboard widgets, and export queries to is_latest published collections so superseded releases remain audit-only. --- app/Actions/Coconut/SearchMolecule.php | 18 ++++++++++++++---- app/Console/Commands/DashWidgetsRefresh.php | 1 + .../Dashboard/Widgets/DashboardStats.php | 2 +- app/Livewire/CollectionList.php | 1 + app/Livewire/DataSources.php | 8 +++++++- app/Livewire/Stats.php | 2 +- app/Livewire/Welcome.php | 2 +- .../python/exports/query_with_collection.sql | 2 +- 8 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/Actions/Coconut/SearchMolecule.php b/app/Actions/Coconut/SearchMolecule.php index e6964b49..e7284ca6 100644 --- a/app/Actions/Coconut/SearchMolecule.php +++ b/app/Actions/Coconut/SearchMolecule.php @@ -285,13 +285,23 @@ private function applyStatusFilterToQuery($query) private function buildTagsStatement($offset) { if ($this->tagType == 'dataSource') { - $this->collection = Collection::where('title', $this->query)->first(); - if ($this->collection) { - $query = $this->collection->molecules() + $matched = Collection::query() + ->where('title', $this->query) + ->where('is_latest', true) + ->first() + ?? Collection::where('title', $this->query)->first(); + if ($matched) { + $latest = $matched->is_latest + ? $matched + : ($matched->lineageVersionsQuery()->where('is_latest', true)->first() ?? $matched); + + $this->collection = $latest; + + $query = $latest->molecules() ->whereIn('molecules.id', function ($query) { $query->select('molecule_id') ->from('entries') - ->where('collection_id', $this->collection->id) + ->where('collection_id', $latest->id) ->distinct(); }); diff --git a/app/Console/Commands/DashWidgetsRefresh.php b/app/Console/Commands/DashWidgetsRefresh.php index ba44fe04..9b2d0a02 100644 --- a/app/Console/Commands/DashWidgetsRefresh.php +++ b/app/Console/Commands/DashWidgetsRefresh.php @@ -36,6 +36,7 @@ public function handle() return DB::table('collections') ->selectRaw('count(*) as count') ->where('status', 'PUBLISHED') + ->where('is_latest', true) ->get()[0]->count; }); $this->info('Cache for collections refreshed.'); diff --git a/app/Filament/Dashboard/Widgets/DashboardStats.php b/app/Filament/Dashboard/Widgets/DashboardStats.php index 7cde466c..446b1ef6 100644 --- a/app/Filament/Dashboard/Widgets/DashboardStats.php +++ b/app/Filament/Dashboard/Widgets/DashboardStats.php @@ -24,7 +24,7 @@ protected function getStats(): array }); $totalCollections = Cache::flexible('stats.collections', [172800, 259200], function () { - return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->get()[0]->count; + return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->where('is_latest', true)->get()[0]->count; }); $uniqueOrganisms = Cache::flexible('stats.organisms', [172800, 259200], function () { diff --git a/app/Livewire/CollectionList.php b/app/Livewire/CollectionList.php index 2f250cfa..c5777c9e 100644 --- a/app/Livewire/CollectionList.php +++ b/app/Livewire/CollectionList.php @@ -41,6 +41,7 @@ public function render() // $search = strtolower($this->query ?? ''); $query = Collection::query() ->where('status', 'PUBLISHED') + ->where('is_latest', true) ->where(function ($query) use ($search) { $query->whereRaw('LOWER(title) ILIKE ?', ['%'.$search.'%']) ->orWhereRaw('LOWER(description) ILIKE ?', ['%'.$search.'%']); diff --git a/app/Livewire/DataSources.php b/app/Livewire/DataSources.php index 4829c9be..296f47a5 100644 --- a/app/Livewire/DataSources.php +++ b/app/Livewire/DataSources.php @@ -13,7 +13,13 @@ class DataSources extends Component public function mount() { $this->collections = Cache::flexible('collections', [172800, 259200], function () { - return Collection::where('promote', true)->orderBy('sort_order')->take(10)->get(['title', 'image'])->toArray(); + return Collection::query() + ->where('promote', true) + ->where('is_latest', true) + ->orderBy('sort_order') + ->take(10) + ->get(['title', 'image']) + ->toArray(); }); } } diff --git a/app/Livewire/Stats.php b/app/Livewire/Stats.php index 5d6d0f32..e41d0e8f 100644 --- a/app/Livewire/Stats.php +++ b/app/Livewire/Stats.php @@ -84,7 +84,7 @@ public function render() return DB::table('molecules')->selectRaw('count(*)')->whereRaw('active=true and NOT (is_parent=true AND has_variants=true)')->get()[0]->count; }); $this->totalCollections = Cache::flexible('stats.collections', [172800, 259200], function () { - return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->get()[0]->count; + return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->where('is_latest', true)->get()[0]->count; }); $this->uniqueOrganisms = Cache::flexible('stats.organisms', [172800, 259200], function () { return DB::table('organisms')->selectRaw('count(*)')->get()[0]->count; diff --git a/app/Livewire/Welcome.php b/app/Livewire/Welcome.php index 0b153aa8..28f3abbd 100644 --- a/app/Livewire/Welcome.php +++ b/app/Livewire/Welcome.php @@ -56,7 +56,7 @@ public function render() return DB::table('molecules')->selectRaw('count(*)')->whereRaw('active=true and NOT (is_parent=true AND has_variants=true)')->get()[0]->count; }); $this->totalCollections = Cache::flexible('stats.collections', [172800, 259200], function () { - return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->get()[0]->count; + return DB::table('collections')->selectRaw('count(*)')->whereRaw("status = 'PUBLISHED'")->where('is_latest', true)->get()[0]->count; }); $this->uniqueOrganisms = Cache::flexible('stats.organisms', [172800, 259200], function () { return DB::table('organisms')->selectRaw('count(*)')->get()[0]->count; diff --git a/resources/scripts/python/exports/query_with_collection.sql b/resources/scripts/python/exports/query_with_collection.sql index a92ec061..9a6f37f3 100644 --- a/resources/scripts/python/exports/query_with_collection.sql +++ b/resources/scripts/python/exports/query_with_collection.sql @@ -77,7 +77,7 @@ molecule_collections AS ( INNER JOIN collection_molecule cm ON m.id = cm.molecule_id INNER JOIN - collections c ON cm.collection_id = c.id + collections c ON cm.collection_id = c.id AND c.is_latest = TRUE WHERE m.identifier IS NOT NULL AND m.active = TRUE From 638bd5f5e34ba32f64f2c98aa914f73d67afae24 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:49 +0200 Subject: [PATCH 09/10] test(collections): add version diff and scheduler guard coverage Verify SMILES-based diff logic and legacy scheduler exclusion for collections in version migration states. --- tests/Unit/CollectionVersionDiffTest.php | 56 +++++++++++++++++++ .../Unit/LegacySchedulerVersionGuardTest.php | 34 +++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/Unit/CollectionVersionDiffTest.php create mode 100644 tests/Unit/LegacySchedulerVersionGuardTest.php diff --git a/tests/Unit/CollectionVersionDiffTest.php b/tests/Unit/CollectionVersionDiffTest.php new file mode 100644 index 00000000..09fd332d --- /dev/null +++ b/tests/Unit/CollectionVersionDiffTest.php @@ -0,0 +1,56 @@ +create([ + 'version' => 1, + 'is_latest' => true, + 'identifier' => 'CNPC_TEST_1', + ]); + $new = Collection::factory()->create([ + 'parent_collection_id' => $old->id, + 'version' => 2, + 'is_latest' => false, + 'identifier' => 'CNPC_TEST_1', + ]); + + $molA = Molecule::query()->create(['canonical_smiles' => 'AAA', 'standard_inchi' => 'InChI-A']); + $molB = Molecule::query()->create(['canonical_smiles' => 'BBB', 'standard_inchi' => 'InChI-B']); + + foreach ([ + ['collection_id' => $old->id, 'molecule_id' => $molA->id, 'smiles' => 'AAA'], + ['collection_id' => $old->id, 'molecule_id' => $molB->id, 'smiles' => 'BBB'], + ['collection_id' => $new->id, 'molecule_id' => null, 'smiles' => 'BBB', 'status' => 'PASSED'], + ['collection_id' => $new->id, 'molecule_id' => null, 'smiles' => 'CCC', 'status' => 'PASSED'], + ] as $row) { + Entry::query()->create([ + 'collection_id' => $row['collection_id'], + 'molecule_id' => $row['molecule_id'], + 'standardized_canonical_smiles' => $row['smiles'], + 'canonical_smiles' => $row['smiles'], + 'status' => $row['status'] ?? 'IMPORTED', + 'uuid' => (string) Str::uuid(), + ]); + } + + $diff = app(CollectionVersionDiff::class)->compare($old, $new); + + $this->assertTrue($diff->oldOnlySmilesToMoleculeId->has('AAA')); + $this->assertTrue($diff->retainedSmilesToMoleculeId->has('BBB')); + $this->assertTrue($diff->newOnlySmiles->contains('CCC')); + } +} diff --git a/tests/Unit/LegacySchedulerVersionGuardTest.php b/tests/Unit/LegacySchedulerVersionGuardTest.php new file mode 100644 index 00000000..8d7692db --- /dev/null +++ b/tests/Unit/LegacySchedulerVersionGuardTest.php @@ -0,0 +1,34 @@ +create([ + 'version_migration_status' => Collection::VERSION_MIGRATION_PENDING, + 'identifier' => 'CNPC_PENDING', + 'version' => 2, + ]); + + $normal = Collection::factory()->create([ + 'version_migration_status' => null, + 'identifier' => 'CNPC_NORMAL', + 'version' => 1, + ]); + + $eligible = Collection::query()->eligibleForLegacyPipeline()->pluck('id')->all(); + + $this->assertContains($normal->id, $eligible); + $this->assertNotContains($pending->id, $eligible); + } +} From 31aee2d7ebe88c5983ef923c08efbd2f08d1cf12 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Sun, 7 Jun 2026 21:36:49 +0200 Subject: [PATCH 10/10] docs(collections): document version migration curator workflow Describe create-version, CSV import, preview, and process steps for releasing new collection versions with audit retention. --- docs/collection-submission.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/collection-submission.md b/docs/collection-submission.md index ce9749fb..a8324473 100644 --- a/docs/collection-submission.md +++ b/docs/collection-submission.md @@ -82,3 +82,13 @@ After creating an empty collection, an **"Entries"** pane becomes available at t 9. **Publish the Compounds:** - If at least one compound passed the process, you will see the **"Publish"** button. Clicking this will publish the compounds that passed processing. > **Info:** After publishing, the collection status changes from **"DRAFT"** to **"PUBLISHED."** Only the compounds with status **"PASSED"** will be visible to everyone on the COCONUT platform. + +## Importing a new version of an existing collection + +For published collections, curators can release an updated dataset without losing audit history: + +1. Open the latest published collection and choose **Create new version**. +2. Import the new CSV into the new DRAFT version (same CNPC identifier; new internal version row). +3. Use **Preview migration** to review dropped, retained, and new compound counts (comparison uses standardized isomeric canonical SMILES after CM pre-processing). +4. Run **Process new version** to validate entries, migrate live data to the new version, revoke compounds dropped exclusively from this source, and archive the previous version (entries kept read-only for audit). +5. Version DOIs follow `10.71606/coconut.cnpc####.vN`; the base DOI `10.71606/coconut.cnpc####` always resolves to the latest release on the public collection page.