Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions app/Actions/Citation/SyncCitations.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

class SyncCitations
{
Expand Down Expand Up @@ -73,7 +74,7 @@ private function findOrCreateCitation(Project $project, array $citationData): Ci
{
$doi = $this->normalizeDoi($citationData['doi'] ?? null);
$title = $this->normalizeText($citationData['title'] ?? null);
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$title is normalized but never used in this method. This is dead code and makes it harder to follow the matching logic; remove the unused variable (or use it if it’s intended for a future match condition).

Suggested change
$title = $this->normalizeText($citationData['title'] ?? null);

Copilot uses AI. Check for mistakes.
$authors = $this->normalizeText($citationData['authors'] ?? null);
$titleSlug = $this->normalizeTitleSlug($citationData['title'] ?? null);

$existingCitation = null;

Expand All @@ -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;
});
}

Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions app/Models/Citation.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Citation extends Model
protected $fillable = [
'doi',
'title',
'title_slug',
'authors',
'citation_text',
];
Expand Down
26 changes: 19 additions & 7 deletions app/Models/Validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,24 +247,35 @@ 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 = [
'name' => $citation->title ?? 'Untitled',
'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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('citations', function (Blueprint $table) {
$table->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');
});
}
};
105 changes: 100 additions & 5 deletions tests/Feature/ManageCitationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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)
Expand All @@ -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)
{
Expand All @@ -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)
{
Expand Down
29 changes: 29 additions & 0 deletions tests/Feature/Project/PublishProjectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
Expand All @@ -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',
]);
Expand All @@ -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();
Expand Down
Loading
Loading