Skip to content

Commit 906d1a7

Browse files
theosanderson-agenttheosandersonclaudetheosanderson-agent
authored
feat(website): add SeqSet citations section to admin dashboard (#6769)
## Summary Adds a **SeqSet citations** section to the admin dashboard that lists every publication citing a SeqSet in the instance. This gives super users a single place to see all external citations across all SeqSets, complementing the existing per-SeqSet (`get-seqset-citations`) and per-sequence (`get-sequence-citations`) views, which previously had no aggregate/admin-level counterpart. It also lets super users **manually register a citation** ("curated", as opposed to "crossref"-discovered) — useful when a publication hasn't been indexed by CrossRef yet, and as a way to seed citation data for e2e testing without depending on an external CrossRef call. ## Backend - New endpoint `GET /admin/get-all-seqset-citations` on `SeqSetCitationsController`. - Placing it under `/admin/*` means it is automatically gated to **super users** by the existing `SecurityConfig` matcher (`auth.requestMatchers("/admin/*").hasAuthority(SUPER_USER)`), so no new security wiring is needed. - It stays on `SeqSetCitationsController`, which is `@ConditionalOnProperty(ENABLE_SEQSETS)`, so the endpoint only exists when SeqSets are enabled. - `SeqSetCitationsDatabaseService.getAllSeqSetCitations()` joins the citation source, join, and SeqSets tables and returns each citation source together with the list of SeqSets it references (accession version, name and DOI). Results are grouped per citation source and ordered by year (descending), mirroring the existing `getSequenceCitations` query shape. - New API types `AdminSeqSetCitation` and `CitedSeqSet`. - New `POST /admin/add-seqset-citation` and `DELETE /admin/delete-seqset-citation` endpoints (also under `/admin/*`, so also super-user gated): - Adding sets `origin = CURATED` on `seqset_citation_source` (the schema already had a `CROSSREF`/`CURATED` enum, but nothing ever wrote `CURATED` before this). Re-submitting the same `sourceDOI` updates its metadata and links any newly listed SeqSets without dropping existing links. - Deleting only works for `CURATED` citations — `CROSSREF`-discovered citations can't be removed through this admin path. - `AdminSeqSetCitation` now also returns `origin`, so the frontend knows which rows are deletable. ## Website - `BackendClient.getAllSeqSetCitations(token)` calls the new endpoint (parsed with the new `adminSeqSetCitations` zod schema). - New `AdminSeqSetCitationsTable` component (renamed from `SeqSetCitationsTable` for clarity, since it's specifically the admin aggregate view) renders, per citation: the title (linking to the source DOI), contributors, year, and the cited SeqSets (each linking to its SeqSet page, with accession version and DOI), plus a "Remove" button on `CURATED` rows. - New `AddSeqSetCitationForm` renders a form (source DOI, title, year, contributors, cited SeqSet accession-versions) that posts to the new add endpoint. - It also has a "Fetch from DOI" button that calls CrossRef's public REST API (`api.crossref.org/works/{doi}`) directly from the browser to autopopulate title, year, and contributors. That endpoint sends `Access-Control-Allow-Origin: *`, so no backend proxy is needed for this lookup (unlike the institutional CrossRef deposit/cited-by API the backend already talks to, which needs credentials). - New `AdminSeqSetCitationsSection` is a small client island that owns the citation list state so add/remove feel immediate, without a full page reload. - `admin/dashboard.astro` fetches and renders the new section below the pipeline statistics. The fetch is only issued when `seqSetsAreEnabled()`, and the section degrades gracefully on error. ## Tests - Backend endpoint tests in `CitationEndpointsTest`: - returns **401**/**403** without auth / for a non-super user (for get, add, and delete), - returns an empty list when there are no citations, - returns each citation with its cited SeqSet metadata (accession version, name, DOI) after a simulated CrossRef run, - adding a citation for an existing SeqSet sets `origin = CURATED` and shows up in both the admin and per-SeqSet citation views, - adding a citation for a non-existent SeqSet returns 404, - re-adding the same `sourceDOI` with an additional SeqSet updates metadata and links both SeqSets, - deleting a curated citation removes it; deleting a `CROSSREF`-origin citation is rejected; deleting an unknown DOI returns 404. - New Playwright spec `admin-citations.dependent.spec.ts`: logs in as the super user, creates a SeqSet, adds a citation through the admin UI, asserts it renders, deletes it via the UI, asserts it disappears. ## Verification - `./gradlew ktlintFormat` and the full backend test suite (`./gradlew test`, 72 test classes) pass, real Postgres via Testcontainers. - Website `npm run check-types`, `npm run format`, and `CI=1 npm run test` pass (683 unit tests). - Manually exercised the full flow on the preview deployment as the seeded super user, including after the review-feedback fixes: created a SeqSet, added a curated citation (via "Fetch from DOI" autofill against a real CrossRef DOI), confirmed it appears in the admin table (now above the form), the SeqSet's own citations dialog, and the cited sequence's "Cited in" section, then removed it via the admin UI and confirmed it disappears everywhere. Also confirmed the Year-validation fix rejects a blank year instead of silently submitting `0`. Screenshots below. ### Admin dashboard: citations table above the add-citation form ![Admin dashboard, empty citations table above the form](https://raw.githubusercontent.com/loculus-project/agent_store/main/pr-6769/admin-dashboard-table-above-form.png) ### Autofilling from a DOI via CrossRef Typing a DOI and clicking "Fetch from DOI" populates title, year, and contributors from CrossRef's public API: ![DOI autofill populated the form](https://raw.githubusercontent.com/loculus-project/agent_store/main/pr-6769/doi-autofill-populated.png) ### Citation appears in the table (above the form), with real CrossRef metadata and a Remove button ![Citation added, table above form](https://raw.githubusercontent.com/loculus-project/agent_store/main/pr-6769/citation-added-table-above-form.png) ### Citation also appears on the SeqSet's own citations dialog, and on the cited sequence's page ![SeqSet page citations dialog](https://raw.githubusercontent.com/loculus-project/agent_store/main/pr-6769/seqset-page-citations-dialog.png) ![Sequence page Cited in section](https://raw.githubusercontent.com/loculus-project/agent_store/main/pr-6769/sequence-page-cited-in.png) 🤖 Generated with [Claude Code](https://claude.com/claude-code) 🚀 Preview: https://feat-admin-seqset-citatio.loculus.org --------- Co-authored-by: theosanderson-agent <theo@theo.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: theosanderson-agent <sndrsn@gmail.com>
1 parent 32562de commit 906d1a7

17 files changed

Lines changed: 1171 additions & 58 deletions

File tree

backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,20 @@ data class SeqSetCitingSequence(
115115
@Schema(description = "A citation of a sequence.")
116116
data class SequenceCitation(val source: CitationSource, val seqSets: List<SeqSetCitingSequence>)
117117

118+
@Schema(description = "A citation of one or more SeqSets, with the SeqSets it references.")
119+
data class AdminSeqSetCitation(val source: CitationSource, val seqSets: List<SeqSet>, val origin: CitationOrigin)
120+
121+
@Schema(description = "A request to manually register a publication or other source as citing one or more SeqSets.")
122+
data class AddSeqSetCitationRequest(
123+
val source: CitationSource,
124+
@Schema(
125+
description = "Accession versions of the SeqSets that this source cites.",
126+
type = "array",
127+
example = "[\"PP_SS_1.1\"]",
128+
)
129+
val seqSetAccessionVersions: List<String>,
130+
)
131+
118132
data class ResponseSeqSet(val seqSetId: String, val seqSetVersion: Long)
119133

120134
data class AuthorProfile(

backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package org.loculus.backend.controller
33
import io.swagger.v3.oas.annotations.Operation
44
import io.swagger.v3.oas.annotations.security.SecurityRequirement
55
import org.loculus.backend.api.AccessionVersion
6+
import org.loculus.backend.api.AddSeqSetCitationRequest
7+
import org.loculus.backend.api.AdminSeqSetCitation
68
import org.loculus.backend.api.AuthorProfile
79
import org.loculus.backend.api.ResponseSeqSet
810
import org.loculus.backend.api.SeqSet
@@ -101,6 +103,24 @@ class SeqSetCitationsController(
101103
fun getSeqSetCitations(@RequestParam seqSetId: String, @RequestParam version: Long): List<SeqSetCitation> =
102104
seqSetCitationsService.getSeqSetCitations(seqSetId, version)
103105

106+
@Operation(description = "Get all citations to all SeqSets in this instance. Restricted to super users.")
107+
@GetMapping("/admin/get-all-seqset-citations")
108+
fun getAllSeqSetCitations(): List<AdminSeqSetCitation> = seqSetCitationsService.getAllSeqSetCitations()
109+
110+
@Operation(
111+
description = "Manually register a publication or other source as citing one or more SeqSets. " +
112+
"Restricted to super users.",
113+
)
114+
@PostMapping("/admin/add-seqset-citation")
115+
fun addSeqSetCitation(@RequestBody body: AddSeqSetCitationRequest): AdminSeqSetCitation =
116+
seqSetCitationsService.addCuratedCitation(body)
117+
118+
@Operation(
119+
description = "Delete a manually registered SeqSet citation by its source DOI. Restricted to super users.",
120+
)
121+
@DeleteMapping("/admin/delete-seqset-citation")
122+
fun deleteSeqSetCitation(@RequestParam sourceDOI: String) = seqSetCitationsService.deleteCuratedCitation(sourceDOI)
123+
104124
@Operation(description = "Get sequence citations from publications or other sources")
105125
@GetMapping("/get-sequence-citations")
106126
fun getSequenceCitations(@RequestParam accession: String, @RequestParam version: Long?): List<SequenceCitation> =

backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt

Lines changed: 131 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import mu.KotlinLogging
88
import org.jetbrains.exposed.sql.Database
99
import org.jetbrains.exposed.sql.LikePattern
1010
import org.jetbrains.exposed.sql.Op
11+
import org.jetbrains.exposed.sql.ResultRow
1112
import org.jetbrains.exposed.sql.SortOrder
1213
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
1314
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
@@ -24,6 +25,8 @@ import org.jetbrains.exposed.sql.selectAll
2425
import org.jetbrains.exposed.sql.update
2526
import org.keycloak.representations.idm.UserRepresentation
2627
import org.loculus.backend.api.AccessionVersion
28+
import org.loculus.backend.api.AddSeqSetCitationRequest
29+
import org.loculus.backend.api.AdminSeqSetCitation
2730
import org.loculus.backend.api.AuthorProfile
2831
import org.loculus.backend.api.CitationOrigin
2932
import org.loculus.backend.api.CitationSource
@@ -59,6 +62,16 @@ data class CitationSourcesUpdateResult(val updatedCitationSourceDOIs: Set<String
5962

6063
data class SeqSetToCitationSourceEntry(val citationSourceId: Long, val seqSetId: String, val seqSetVersion: Long)
6164

65+
private fun ResultRow.toSeqSet() = SeqSet(
66+
seqSetId = this[SeqSetsTable.seqSetId],
67+
seqSetVersion = this[SeqSetsTable.seqSetVersion],
68+
name = this[SeqSetsTable.name],
69+
createdAt = Timestamp.valueOf(this[SeqSetsTable.createdAt].toJavaLocalDateTime()),
70+
createdBy = this[SeqSetsTable.createdBy],
71+
description = this[SeqSetsTable.description],
72+
seqSetDOI = this[SeqSetsTable.seqSetDOI],
73+
)
74+
6275
@Service
6376
@Transactional
6477
class SeqSetCitationsDatabaseService(
@@ -211,17 +224,7 @@ class SeqSetCitationsDatabaseService(
211224
throw NotFoundException("SeqSet $seqSetId, version $version does not exist")
212225
}
213226

214-
return query.map { row ->
215-
SeqSet(
216-
row[SeqSetsTable.seqSetId],
217-
row[SeqSetsTable.seqSetVersion],
218-
row[SeqSetsTable.name],
219-
Timestamp.valueOf(row[SeqSetsTable.createdAt].toJavaLocalDateTime()),
220-
row[SeqSetsTable.createdBy],
221-
row[SeqSetsTable.description],
222-
row[SeqSetsTable.seqSetDOI],
223-
)
224-
}
227+
return query.map { it.toSeqSet() }
225228
}
226229

227230
fun getSeqSetRecords(seqSetId: String, version: Long?): List<SeqSetRecord> {
@@ -276,17 +279,7 @@ class SeqSetCitationsDatabaseService(
276279
.selectAll()
277280
.where { SeqSetsTable.createdBy eq username }
278281

279-
return selectedSeqSets.map {
280-
SeqSet(
281-
it[SeqSetsTable.seqSetId],
282-
it[SeqSetsTable.seqSetVersion],
283-
it[SeqSetsTable.name],
284-
Timestamp.valueOf(it[SeqSetsTable.createdAt].toJavaLocalDateTime()),
285-
it[SeqSetsTable.createdBy],
286-
it[SeqSetsTable.description],
287-
it[SeqSetsTable.seqSetDOI],
288-
)
289-
}
282+
return selectedSeqSets.map { it.toSeqSet() }
290283
}
291284

292285
fun deleteSeqSet(authenticatedUser: AuthenticatedUser, seqSetId: String, version: Long) {
@@ -515,6 +508,122 @@ class SeqSetCitationsDatabaseService(
515508
}
516509
}
517510

511+
fun getAllSeqSetCitations(): List<AdminSeqSetCitation> {
512+
log.info { "Get all seqSet citations" }
513+
514+
return SeqSetCitationSourceTable.innerJoin(
515+
SeqSetToCitationSourceTable,
516+
).innerJoin(
517+
SeqSetsTable,
518+
).selectAll()
519+
.orderBy(
520+
SeqSetCitationSourceTable.year to SortOrder.DESC,
521+
SeqSetCitationSourceTable.citationSourceId to SortOrder.DESC,
522+
)
523+
.groupBy { it[SeqSetCitationSourceTable.citationSourceId] }
524+
.map { (_, rows) ->
525+
val first = rows.first()
526+
AdminSeqSetCitation(
527+
source = CitationSource(
528+
sourceDOI = first[SeqSetCitationSourceTable.sourceDOI],
529+
title = first[SeqSetCitationSourceTable.title],
530+
year = first[SeqSetCitationSourceTable.year],
531+
contributors = first[SeqSetCitationSourceTable.contributors],
532+
),
533+
seqSets = rows.map { it.toSeqSet() },
534+
origin = first[SeqSetCitationSourceTable.origin],
535+
)
536+
}
537+
}
538+
539+
fun addCuratedCitation(request: AddSeqSetCitationRequest): AdminSeqSetCitation {
540+
log.info { "Add curated seqSet citation ${request.source.sourceDOI}" }
541+
542+
if (request.source.sourceDOI.isBlank()) {
543+
throw UnprocessableEntityException("Citation source DOI must not be empty")
544+
}
545+
if (request.source.title.isBlank()) {
546+
throw UnprocessableEntityException("Citation source title must not be empty")
547+
}
548+
if (request.seqSetAccessionVersions.isEmpty()) {
549+
throw UnprocessableEntityException("At least one cited SeqSet must be provided")
550+
}
551+
552+
val existingOrigin = SeqSetCitationSourceTable
553+
.select(SeqSetCitationSourceTable.origin)
554+
.where { SeqSetCitationSourceTable.sourceDOI eq request.source.sourceDOI }
555+
.singleOrNull()
556+
?.get(SeqSetCitationSourceTable.origin)
557+
if (existingOrigin == CitationOrigin.CROSSREF) {
558+
throw UnprocessableEntityException(
559+
"Citation source ${request.source.sourceDOI} was discovered via CrossRef and cannot be modified manually",
560+
)
561+
}
562+
563+
val accessionVersions = request.seqSetAccessionVersions.map { AccessionVersion.fromString(it) }
564+
565+
val existingSeqSets = SeqSetsTable
566+
.select(SeqSetsTable.seqSetId, SeqSetsTable.seqSetVersion, SeqSetsTable.name, SeqSetsTable.seqSetDOI)
567+
.where {
568+
Pair(SeqSetsTable.seqSetId, SeqSetsTable.seqSetVersion) inList
569+
accessionVersions.map { it.accession to it.version }
570+
}
571+
.associateBy { AccessionVersion(it[SeqSetsTable.seqSetId], it[SeqSetsTable.seqSetVersion]) }
572+
573+
val missing = accessionVersions.filterNot { it in existingSeqSets }
574+
if (missing.isNotEmpty()) {
575+
throw NotFoundException(
576+
"The following SeqSets do not exist: ${missing.joinToString(", ") { it.displayAccessionVersion() }}",
577+
)
578+
}
579+
580+
val citationSourceId = SeqSetCitationSourceTable
581+
.batchUpsert(listOf(request.source), SeqSetCitationSourceTable.sourceDOI) {
582+
this[SeqSetCitationSourceTable.sourceDOI] = it.sourceDOI
583+
this[SeqSetCitationSourceTable.origin] = CitationOrigin.CURATED
584+
this[SeqSetCitationSourceTable.title] = it.title
585+
this[SeqSetCitationSourceTable.year] = it.year
586+
this[SeqSetCitationSourceTable.contributors] = it.contributors
587+
}
588+
.single()[SeqSetCitationSourceTable.citationSourceId]
589+
590+
SeqSetToCitationSourceTable.batchInsert(accessionVersions, ignore = true) { accessionVersion ->
591+
this[SeqSetToCitationSourceTable.citationSourceId] = citationSourceId
592+
this[SeqSetToCitationSourceTable.seqSetId] = accessionVersion.accession
593+
this[SeqSetToCitationSourceTable.seqSetVersion] = accessionVersion.version
594+
}
595+
596+
val linkedSeqSets = SeqSetToCitationSourceTable
597+
.innerJoin(SeqSetsTable)
598+
.selectAll()
599+
.where { SeqSetToCitationSourceTable.citationSourceId eq citationSourceId }
600+
.map { it.toSeqSet() }
601+
602+
return AdminSeqSetCitation(
603+
source = request.source,
604+
seqSets = linkedSeqSets,
605+
origin = CitationOrigin.CURATED,
606+
)
607+
}
608+
609+
fun deleteCuratedCitation(sourceDOI: String) {
610+
log.info { "Delete curated seqSet citation $sourceDOI" }
611+
612+
val citationSource = SeqSetCitationSourceTable
613+
.selectAll()
614+
.where { SeqSetCitationSourceTable.sourceDOI eq sourceDOI }
615+
.singleOrNull()
616+
?: throw NotFoundException("Citation source $sourceDOI does not exist")
617+
618+
if (citationSource[SeqSetCitationSourceTable.origin] != CitationOrigin.CURATED) {
619+
throw UnprocessableEntityException(
620+
"Citation source $sourceDOI was not manually curated and cannot be deleted",
621+
)
622+
}
623+
624+
SeqSetCitationSourceTable.deleteWhere { SeqSetCitationSourceTable.sourceDOI eq sourceDOI }
625+
}
626+
518627
fun validateSeqSetRecords(seqSetRecords: List<SubmittedSeqSetRecord>) {
519628
if (seqSetRecords.isEmpty()) {
520629
throw UnprocessableEntityException(

backend/src/test/kotlin/org/loculus/backend/controller/EndpointTestExtension.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ private fun clearDatabaseStatement(): String = """
244244
seqsets,
245245
seqset_records,
246246
seqset_to_records,
247+
seqset_citation_source,
248+
seqset_to_citation_source,
247249
audit_log,
248250
table_update_tracker,
249251
task_lock
@@ -253,6 +255,7 @@ private fun clearDatabaseStatement(): String = """
253255
alter sequence seqset_id_sequence restart with 1;
254256
alter sequence seqset_records_seqset_record_id_seq restart with 1;
255257
alter sequence seqset_to_records_seqset_record_id_seq restart with 1;
258+
alter sequence seqset_citation_source_citation_source_id_seq restart with 1;
256259
alter sequence user_groups_table_id_seq restart with 1;
257260
alter sequence audit_log_id_seq restart with 1;
258261
insert into $CURRENT_PROCESSING_PIPELINE_TABLE_NAME values

0 commit comments

Comments
 (0)