diff --git a/app/Actions/Citation/SyncCitations.php b/app/Actions/Citation/SyncCitations.php index f33a96eb..5ba82c26 100644 --- a/app/Actions/Citation/SyncCitations.php +++ b/app/Actions/Citation/SyncCitations.php @@ -8,6 +8,7 @@ use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; class SyncCitations { @@ -73,7 +74,7 @@ private function findOrCreateCitation(Project $project, array $citationData): Ci { $doi = $this->normalizeDoi($citationData['doi'] ?? null); $title = $this->normalizeText($citationData['title'] ?? null); - $authors = $this->normalizeText($citationData['authors'] ?? null); + $titleSlug = $this->normalizeTitleSlug($citationData['title'] ?? null); $existingCitation = null; @@ -87,11 +88,12 @@ private function findOrCreateCitation(Project $project, array $citationData): Ci $existingCitation = $project->citations->firstWhere('doi', $doi); } - // 3. Try to match by title + authors (content-based matching for missing DOI) - if (! $existingCitation && ! is_null($title) && ! is_null($authors)) { - $existingCitation = $project->citations->first(function ($citation) use ($title, $authors): bool { - return $this->normalizeText($citation->title) === $title - && $this->normalizeText($citation->authors) === $authors; + // 3. Try to match by title slug (content-based matching for missing DOI) + if (! $existingCitation && ! is_null($titleSlug)) { + $existingCitation = $project->citations->first(function ($citation) use ($titleSlug): bool { + $citationSlug = $this->normalizeTitleSlug($citation->title_slug ?? $citation->title); + + return $citationSlug === $titleSlug; }); } @@ -115,11 +117,29 @@ private function prepareCitationAttributes(array $citationData): array return [ 'doi' => $this->normalizeDoi($citationData['doi'] ?? null), 'title' => $this->normalizeText($citationData['title'] ?? null), + 'title_slug' => $this->normalizeTitleSlug($citationData['title'] ?? null), 'authors' => $this->normalizeText($citationData['authors'] ?? null), 'citation_text' => $this->normalizeText($citationData['citation_text'] ?? null), ]; } + private function normalizeTitleSlug(mixed $title): ?string + { + $normalizedTitle = $this->normalizeText($title); + + if (is_null($normalizedTitle)) { + return null; + } + + $slug = Str::slug($normalizedTitle); + + if ($slug === '') { + return null; + } + + return $slug; + } + private function rememberCitation(Project $project, Citation $citation): void { $citations = $project->citations; diff --git a/app/Models/Citation.php b/app/Models/Citation.php index efa96c31..c24edefd 100644 --- a/app/Models/Citation.php +++ b/app/Models/Citation.php @@ -13,6 +13,7 @@ class Citation extends Model protected $fillable = [ 'doi', 'title', + 'title_slug', 'authors', 'citation_text', ]; diff --git a/app/Models/Validation.php b/app/Models/Validation.php index 2b59efb8..2a6a407e 100644 --- a/app/Models/Validation.php +++ b/app/Models/Validation.php @@ -2,6 +2,7 @@ namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -246,6 +247,11 @@ public function process() $citations = $project->citations; $citationsValidation = []; $citationsStatus = true; + $shouldValidateCitationDoi = true; + + if ($project->release_date) { + $shouldValidateCitationDoi = Carbon::parse($project->release_date)->lessThanOrEqualTo(now()); + } foreach ($citations as $citation) { $citationReport = [ @@ -253,17 +259,23 @@ public function process() 'id' => $citation->id, ]; - // Check if DOI is present - $hasDoi = is_string($citation->doi) && trim($citation->doi) !== ''; + if ($shouldValidateCitationDoi) { + // Check if DOI is present only for current/past release date projects. + $hasDoi = is_string($citation->doi) && trim($citation->doi) !== ''; + + if ($hasDoi) { + $citationReport['doi'] = 'true|required'; + } else { + $citationReport['doi'] = 'false|required'; + $citationsStatus = false; // Citation validation failed + } - if ($hasDoi) { - $citationReport['doi'] = 'true|required'; + $citationReport['status'] = $hasDoi; } else { - $citationReport['doi'] = 'false|required'; - $citationsStatus = false; // Citation validation failed + $citationReport['doi'] = 'true|skipped-future-release'; + $citationReport['status'] = true; } - $citationReport['status'] = $hasDoi; array_push($citationsValidation, $citationReport); } diff --git a/database/migrations/2026_04_01_060726_add_title_slug_to_citations_table.php b/database/migrations/2026_04_01_060726_add_title_slug_to_citations_table.php new file mode 100644 index 00000000..5a9028c0 --- /dev/null +++ b/database/migrations/2026_04_01_060726_add_title_slug_to_citations_table.php @@ -0,0 +1,44 @@ +string('title_slug')->nullable()->after('title')->index(); + }); + + DB::table('citations') + ->select('id', 'title') + ->orderBy('id') + ->chunkById(500, function ($citations): void { + foreach ($citations as $citation) { + DB::table('citations') + ->where('id', $citation->id) + ->update([ + 'title_slug' => Str::slug((string) $citation->title), + ]); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('citations', function (Blueprint $table) { + $table->dropIndex(['title_slug']); + $table->dropColumn('title_slug'); + }); + } +}; diff --git a/tests/Feature/ManageCitationsTest.php b/tests/Feature/ManageCitationsTest.php index e849b576..9d616652 100644 --- a/tests/Feature/ManageCitationsTest.php +++ b/tests/Feature/ManageCitationsTest.php @@ -6,6 +6,7 @@ use App\Models\Project; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Response; use Tests\TestCase; class ManageCitationsTest extends TestCase @@ -268,6 +269,53 @@ public function test_citation_duplicate_prevention_by_doi() $this->assertEquals('Updated Author', $citation->authors); } + /** + * Test citation update uses the existing citation ID when it is already attached. + * + * @return void + */ + public function test_citation_update_prevention_by_id() + { + $this->actingAs($user = User::factory()->withPersonalTeam()->create()); + + $project = Project::factory()->create([ + 'owner_id' => $user->id, + ]); + + $citation = Citation::factory()->create([ + 'title' => 'Original Title', + 'doi' => '10.1000/original', + 'authors' => 'Original Author', + 'citation_text' => 'Original citation text', + ]); + + $project->citations()->sync([$citation->id => ['user' => $user->id]]); + + $body = [ + 'citations' => [[ + 'id' => $citation->id, + 'title' => 'Updated Title', + 'doi' => '10.1000/updated', + 'authors' => 'Updated Author', + 'citation_text' => 'Updated citation text', + ]], + ]; + + $response = $this->updateCitation($body, $project->id); + $response->assertStatus(200); + + $project = $project->refresh(); + $this->assertEquals(1, $project->citations()->count()); + + $updatedCitation = $project->citations()->first(); + $this->assertNotNull($updatedCitation); + $this->assertEquals($citation->id, $updatedCitation->id); + $this->assertEquals('Updated Title', $updatedCitation->title); + $this->assertEquals('10.1000/updated', $updatedCitation->doi); + $this->assertEquals('Updated Author', $updatedCitation->authors); + $this->assertEquals('Updated citation text', $updatedCitation->citation_text); + } + /** * Test duplicate citation prevention when DOI is missing. * @@ -360,6 +408,53 @@ public function test_citation_duplicate_prevention_without_doi_with_normalized_c $this->assertEquals('Updated text', $citation->citation_text); } + /** + * Test duplicate citation prevention without DOI by slugified title. + * + * @return void + */ + public function test_citation_duplicate_prevention_without_doi_by_title_slug() + { + $this->actingAs($user = User::factory()->withPersonalTeam()->create()); + + $project = Project::factory()->create([ + 'owner_id' => $user->id, + ]); + + $firstPayload = [ + 'citations' => [[ + 'title' => 'NMR Study: Alpha/Beta', + 'doi' => null, + 'authors' => 'Author One', + 'citation_text' => 'Initial text', + ]], + ]; + + $response = $this->updateCitation($firstPayload, $project->id); + $response->assertStatus(200); + + $secondPayload = [ + 'citations' => [[ + 'title' => 'nmr study alpha beta', + 'doi' => '', + 'authors' => 'Completely Different Author', + 'citation_text' => 'Updated text', + ]], + ]; + + $response = $this->updateCitation($secondPayload, $project->id); + $response->assertStatus(200); + + $project = $project->refresh(); + $this->assertEquals(1, $project->citations()->count()); + + $citation = $project->citations()->first(); + $this->assertNotNull($citation); + $this->assertEquals('nmr-study-alpha-beta', $citation->title_slug); + $this->assertEquals('Completely Different Author', $citation->authors); + $this->assertEquals('Updated text', $citation->citation_text); + } + /** * Test empty citations array handling * @@ -883,7 +978,7 @@ public function test_citation_destroy_requires_authentication() /** * Prepare request body for citation * - * @param \App\Models\Citation $citation + * @param Citation $citation * @return array $body */ public function prepareBody($citation) @@ -907,9 +1002,9 @@ public function prepareBody($citation) /** * Make Request to update citation * - * @param \App\Models\Citation $citation + * @param Citation $citation * @param int $projectId - * @return \Illuminate\Http\Response + * @return Response */ public function updateCitation($body, $projectId) { @@ -921,9 +1016,9 @@ public function updateCitation($body, $projectId) /** * Make Request to detach citation * - * @param \App\Models\Citation $citation + * @param Citation $citation * @param int $projectId - * @return \Illuminate\Http\Response + * @return Response */ public function detachCitation($body, $projectId) { diff --git a/tests/Feature/Project/PublishProjectTest.php b/tests/Feature/Project/PublishProjectTest.php index 1d771d1c..4b4ffea4 100644 --- a/tests/Feature/Project/PublishProjectTest.php +++ b/tests/Feature/Project/PublishProjectTest.php @@ -120,6 +120,8 @@ public function test_project_publication_updates_validation_status() #[Test] public function citations_without_doi_fail_validation(): void { + $this->project->update(['release_date' => now()->subMinute()]); + $citation = \App\Models\Citation::factory()->create([ 'doi' => null, ]); @@ -145,6 +147,8 @@ public function citations_without_doi_fail_validation(): void #[Test] public function citations_with_doi_pass_validation(): void { + $this->project->update(['release_date' => now()->subMinute()]); + $citation = \App\Models\Citation::factory()->create([ 'doi' => '10.1234/test.doi', ]); @@ -166,6 +170,31 @@ public function citations_with_doi_pass_validation(): void $this->assertEquals('true|required', $validation->report['project']['citations_detail'][0]['doi']); } + #[Test] + public function citations_without_doi_are_skipped_for_future_release_date(): void + { + $this->project->update(['release_date' => now()->addDay()]); + + $citation = \App\Models\Citation::factory()->create([ + 'doi' => null, + ]); + + $this->project->citations()->attach($citation->id, [ + 'user' => $this->user->id, + ]); + + $validation = Validation::factory()->create(); + $this->project->validation_id = $validation->id; + $this->project->save(); + + $validation->process(); + + $this->assertEquals('true|required', $validation->report['project']['citations']); + $this->assertNotEmpty($validation->report['project']['citations_detail']); + $this->assertEquals(true, $validation->report['project']['citations_detail'][0]['status']); + $this->assertEquals('true|skipped-future-release', $validation->report['project']['citations_detail'][0]['doi']); + } + public function test_unauthorized_user_cannot_publish_project() { $unauthorizedUser = User::factory()->create(); diff --git a/tests/Unit/Models/CitationModelTest.php b/tests/Unit/Models/CitationModelTest.php index bf5607b5..783bff7c 100644 --- a/tests/Unit/Models/CitationModelTest.php +++ b/tests/Unit/Models/CitationModelTest.php @@ -4,6 +4,8 @@ use App\Models\Citation; use App\Models\Project; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -19,7 +21,7 @@ public function test_it_belongs_to_many_projects() $citation->projects()->attach([$project1->id, $project2->id]); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Collection::class, $citation->projects); + $this->assertInstanceOf(Collection::class, $citation->projects); $this->assertCount(2, $citation->projects); $this->assertTrue($citation->projects->contains($project1)); $this->assertTrue($citation->projects->contains($project2)); @@ -27,7 +29,7 @@ public function test_it_belongs_to_many_projects() public function test_it_has_correct_fillable_attributes() { - $fillable = ['doi', 'title', 'authors', 'citation_text']; + $fillable = ['doi', 'title', 'title_slug', 'authors', 'citation_text']; $citation = new Citation; $this->assertEquals($fillable, $citation->getFillable()); @@ -71,7 +73,7 @@ public function test_projects_relationship_is_many_to_many() $citation = Citation::factory()->create(); $relationship = $citation->projects(); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class, $relationship); + $this->assertInstanceOf(BelongsToMany::class, $relationship); } public function test_citation_can_be_attached_to_project()