Skip to content
Merged
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
2 changes: 1 addition & 1 deletion rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ signed-cookie sessions for dev. `Curator` RBAC guard. The curator dashboard
| Variants | `/curator/variants` | CRUD; alias/coordinate JSONB editing |
| Genome regions | `/curator/regions` | CRUD (coordinates/properties JSONB) |
| Curation proposals | `/curator/proposals` | review/promote Navigator-submitted branch proposals → catalog |
| Publication candidates | `/curator/publications` | review OpenAlex discoveries → promote to references |
| Publication candidates | `/curator/publications` | review OpenAlex discoveries → promote to references; status/search/sort filters, retract an accept back to rejected |
| Change-sets | `/curator/change-sets` | tree-versioning lifecycle + diff + per-change review/apply |
| Merge review | `/curator/reviews` | resolve SNP-graft flags / merge ambiguities via the `wip_*` staging tables (accept-anchor / reparent / merge / defer) |
| Variant naming | `/curator/naming` | the **DU naming authority**: queue + mint `DUxxxxx` + lifecycle |
Expand Down
147 changes: 142 additions & 5 deletions rust/crates/du-db/src/publication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,27 +354,86 @@ const CAND_COLS: &str = "id, openalex_id, doi, title, abstract AS abstract_text,
journal_name, relevance_score::float8 AS relevance_score, cited_by_count, open_access_status, \
status, created_at";

/// Paginated candidate queue, optionally filtered by status, newest first.
/// Queue ordering. The default is discovery order (newest first); the others let
/// a curator reach a paper without paging through the whole backlog.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CandidateSort {
/// Discovery time, newest first.
#[default]
Newest,
/// Discovery time, oldest first (work the backlog from the bottom).
Oldest,
/// Publication date, newest first (undated last).
Published,
/// Title, A→Z.
Title,
}

impl CandidateSort {
/// Parse a UI/query-string value; anything unrecognised falls back to the default.
pub fn parse(s: &str) -> Self {
match s {
"oldest" => Self::Oldest,
"published" => Self::Published,
"title" => Self::Title,
_ => Self::Newest,
}
}

fn order_by(self) -> &'static str {
match self {
Self::Newest => "created_at DESC, id DESC",
Self::Oldest => "created_at ASC, id ASC",
Self::Published => "publication_date DESC NULLS LAST, id DESC",
Self::Title => "title ASC NULLS LAST, id DESC",
}
}
}

/// What narrows the candidate review queue.
#[derive(Debug, Default, Clone)]
pub struct CandidateFilter<'a> {
/// `pending`/`accepted`/`rejected`/`deferred`; `None` (or empty) = every status.
pub status: Option<&'a str>,
/// Case-insensitive substring over title, journal, DOI and OpenAlex id.
pub q: Option<&'a str>,
pub sort: CandidateSort,
}

/// Neutralise LIKE metacharacters so a curator's `%` searches for a literal `%`.
fn like_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
}

/// Paginated candidate queue.
pub async fn list_candidates(
pool: &PgPool,
status: Option<&str>,
f: &CandidateFilter<'_>,
page: i64,
page_size: i64,
) -> Result<Page<Candidate>, DbError> {
let offset = Page::<()>::offset(page, page_size);
let limit = page_size.clamp(1, 200);
let status = status.filter(|s| !s.is_empty());
let where_sql = "WHERE ($1::text IS NULL OR status = $1)";
let status = f.status.filter(|s| !s.is_empty());
let q = f.q.map(str::trim).filter(|s| !s.is_empty()).map(like_escape);
let where_sql = "WHERE ($1::text IS NULL OR status = $1) \
AND ($2::text IS NULL OR title ILIKE '%' || $2 || '%' \
OR journal_name ILIKE '%' || $2 || '%' \
OR doi ILIKE '%' || $2 || '%' \
OR openalex_id ILIKE '%' || $2 || '%')";
let total: i64 =
sqlx::query_scalar(&format!("SELECT count(*) FROM pubs.publication_candidate {where_sql}"))
.bind(status)
.bind(q.as_deref())
.fetch_one(pool)
.await?;
let items: Vec<Candidate> = sqlx::query_as(&format!(
"SELECT {CAND_COLS} FROM pubs.publication_candidate {where_sql} \
ORDER BY created_at DESC, id DESC LIMIT $2 OFFSET $3"
ORDER BY {} LIMIT $3 OFFSET $4",
f.sort.order_by()
))
.bind(status)
.bind(q.as_deref())
.bind(limit)
.bind(offset)
.fetch_all(pool)
Expand Down Expand Up @@ -485,6 +544,84 @@ pub async fn promote_candidate(pool: &PgPool, id: i64, by: Uuid) -> Result<Publi
Ok(PublicationId(pub_id))
}

/// Samples + studies linked to a publication — what a retraction must not orphan.
const ATTACH_COUNT_SQL: &str =
"SELECT (SELECT count(*) FROM pubs.publication_biosample WHERE publication_id = $1) \
+ (SELECT count(*) FROM pubs.publication_study WHERE publication_id = $1)";

/// How many samples/studies hang off a publication (0 = nothing references it).
pub async fn attachment_count(pool: &PgPool, id: PublicationId) -> Result<i64, DbError> {
Ok(sqlx::query_scalar(ATTACH_COUNT_SQL).bind(id.0).fetch_one(pool).await?)
}

/// Outcome of walking an accepted candidate back to `rejected`.
#[derive(Debug, Clone, Default)]
pub struct Retraction {
/// The paper the accept had promoted to, if it still resolves.
pub publication_id: Option<PublicationId>,
/// The paper was removed from the catalog as part of the retraction.
pub publication_deleted: bool,
/// Samples/studies hanging off that paper. Non-zero means deletion was
/// refused: real curated data references it, so only the candidate flips.
pub attached: i64,
}

/// **Retract** an accepted candidate: flip it back to `rejected`, and optionally
/// remove the publication the accept promoted it to.
///
/// The promotion may have *reused* a paper the curators already owned rather than
/// creating one, and samples or studies may have been attached since — so the
/// delete is opt-in (`delete_publication`) and is refused outright once anything
/// links to the paper. The candidate always ends up `rejected`; the returned
/// [`Retraction`] says what happened to the paper.
pub async fn retract_candidate(
pool: &PgPool,
id: i64,
by: Uuid,
delete_publication: bool,
) -> Result<Retraction, DbError> {
let mut tx = pool.begin().await?;
let c: Candidate = sqlx::query_as(&format!(
"SELECT {CAND_COLS} FROM pubs.publication_candidate WHERE id = $1 FOR UPDATE"
))
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| DbError::Conflict(format!("candidate {id} not found")))?;

// Same match promote_candidate used to find/create the paper.
let pub_id: Option<i64> = sqlx::query_scalar(
"SELECT id FROM pubs.publication WHERE open_alex_id = $1 OR ($2::text IS NOT NULL AND doi = $2) \
LIMIT 1 FOR UPDATE",
)
.bind(&c.openalex_id)
.bind(c.doi.as_deref())
.fetch_optional(&mut *tx)
.await?;

let mut out = Retraction { publication_id: pub_id.map(PublicationId), ..Default::default() };
if let Some(pid) = pub_id {
out.attached =
sqlx::query_scalar(ATTACH_COUNT_SQL).bind(pid).fetch_one(&mut *tx).await?;
if delete_publication && out.attached == 0 {
sqlx::query("DELETE FROM pubs.publication WHERE id = $1")
.bind(pid)
.execute(&mut *tx)
.await?;
out.publication_deleted = true;
out.publication_id = None;
}
}

sqlx::query("UPDATE pubs.publication_candidate SET status = 'rejected', reviewed_by = $2 WHERE id = $1")
.bind(id)
.bind(by)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(out)
}

#[derive(sqlx::FromRow)]
struct PublicationRow {
id: i64,
Expand Down
137 changes: 132 additions & 5 deletions rust/crates/du-db/tests/publication_candidate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ fn database_url() -> Option<String> {
}


/// Queue filter that only narrows by status — what most of these assertions want.
fn status_filter(status: Option<&str>) -> du_db::publication::CandidateFilter<'_> {
du_db::publication::CandidateFilter { status, ..Default::default() }
}

async fn test_user(pool: &PgPool) -> Uuid {
sqlx::query_scalar(
"INSERT INTO ident.users (handle, display_name) VALUES ('testpc-curator', 'Test Curator') \
Expand Down Expand Up @@ -57,11 +62,11 @@ async fn candidate_review_and_promote() {
).await.expect("upsert 2");

// Both are pending.
let pending = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50).await.expect("list");
let pending = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50).await.expect("list");
let mine: Vec<_> = pending.items.iter().filter(|c| c.openalex_id.starts_with("TESTPC-")).collect();
assert_eq!(mine.len(), 2, "two pending candidates");

let c1 = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50)
let c1 = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50)
.await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-W1").unwrap();

// Promote W1 → a real publication; candidate flips to accepted.
Expand All @@ -84,19 +89,141 @@ async fn candidate_review_and_promote() {
assert_eq!(dup, 1, "no duplicate publication");

// Reject W2.
let c2 = du_db::publication::list_candidates(&pool, None, 1, 50)
let c2 = du_db::publication::list_candidates(&pool, &status_filter(None), 1, 50)
.await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-W2").unwrap();
assert!(du_db::publication::review_candidate(&pool, c2.id, "rejected", curator).await.expect("reject"));
let c2_after = du_db::publication::get_candidate(&pool, c2.id).await.unwrap().unwrap();
assert_eq!(c2_after.status, "rejected");

// Filter now shows one accepted, one rejected, zero pending (of ours).
let still_pending = du_db::publication::list_candidates(&pool, Some("pending"), 1, 50)
let still_pending = du_db::publication::list_candidates(&pool, &status_filter(Some("pending")), 1, 50)
.await.unwrap().items.into_iter().filter(|c| c.openalex_id.starts_with("TESTPC-")).count();
assert_eq!(still_pending, 0, "no TESTPC pending left");

}

#[tokio::test]
async fn queue_search_and_sort() {
let Some(url) = database_url() else {
eprintln!("DATABASE_URL unset — skipping queue_search_and_sort test");
return;
};
let db = du_db::testing::ephemeral_db(&url).await.expect("ephemeral db");
let pool = db.pool().clone();
use du_db::publication::{CandidateFilter, CandidateSort};

for (oa, title, journal, date) in [
("TESTPC-S1", "Zebra haplogroups of the steppe", "J. Phylogenetics", "2025-01-02"),
("TESTPC-S2", "Ancient mitochondrial lineages", "Nature 100% Genetics", "2026-03-04"),
("TESTPC-S3", "Survey of Y-chromosome markers", "Cell", "2024-05-06"),
] {
du_db::publication::upsert_candidate(
&pool,
&du_db::publication::NewCandidate {
openalex_id: oa,
title: Some(title),
journal_name: Some(journal),
publication_date: Some(date.parse().unwrap()),
..Default::default()
},
).await.expect("upsert");
}

let search = |q: &'static str| CandidateFilter { q: Some(q), ..Default::default() };
let ids = |p: du_db::Page<du_db::publication::Candidate>| -> Vec<String> {
p.items.into_iter().map(|c| c.openalex_id).collect()
};

// Title, journal and OpenAlex id all match, case-insensitively.
let r = du_db::publication::list_candidates(&pool, &search("zebra"), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S1"], "title match, case-insensitive");
let r = du_db::publication::list_candidates(&pool, &search("cell"), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S3"], "journal match");
let r = du_db::publication::list_candidates(&pool, &search("testpc-s2"), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S2"], "openalex id match");

// The count is filtered too, so the pager doesn't advertise phantom pages.
let r = du_db::publication::list_candidates(&pool, &search("TESTPC-S"), 1, 2).await.unwrap();
assert_eq!(r.total, 3);
assert_eq!(r.total_pages(), 2);

// `%` is a literal, not a wildcard: it only matches the journal that has one.
let r = du_db::publication::list_candidates(&pool, &search("100%"), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S2"], "LIKE metacharacters are escaped");

// Sorts. All three rows share a created_at, so sort on their own fields.
let sorted = |sort| CandidateFilter { q: Some("TESTPC-S"), sort, ..Default::default() };
let r = du_db::publication::list_candidates(&pool, &sorted(CandidateSort::Published), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S2", "TESTPC-S1", "TESTPC-S3"], "publication date, newest first");
let r = du_db::publication::list_candidates(&pool, &sorted(CandidateSort::Title), 1, 50).await.unwrap();
assert_eq!(ids(r), vec!["TESTPC-S2", "TESTPC-S3", "TESTPC-S1"], "title A→Z");

assert_eq!(CandidateSort::parse("published"), CandidateSort::Published);
assert_eq!(CandidateSort::parse("nonsense"), CandidateSort::Newest, "unknown falls back");
}

#[tokio::test]
async fn retract_moves_accepted_back_to_rejected() {
let Some(url) = database_url() else {
eprintln!("DATABASE_URL unset — skipping retract test");
return;
};
let db = du_db::testing::ephemeral_db(&url).await.expect("ephemeral db");
let pool = db.pool().clone();
let curator = test_user(&pool).await;

let new = |oa: &'static str| du_db::publication::NewCandidate {
openalex_id: oa,
title: Some("Retract me"),
..Default::default()
};
for oa in ["TESTPC-T1", "TESTPC-T2", "TESTPC-T3"] {
du_db::publication::upsert_candidate(&pool, &new(oa)).await.expect("upsert");
}
async fn by_oa(pool: &PgPool, oa: &str) -> du_db::publication::Candidate {
du_db::publication::list_candidates(pool, &status_filter(None), 1, 200)
.await.unwrap().items.into_iter().find(|c| c.openalex_id == oa).unwrap()
}

// 1. Retract without deleting: the paper stays in the catalog.
let t1 = by_oa(&pool, "TESTPC-T1").await;
let p1 = du_db::publication::promote_candidate(&pool, t1.id, curator).await.expect("promote");
let r = du_db::publication::retract_candidate(&pool, t1.id, curator, false).await.expect("retract");
assert_eq!(r.publication_id, Some(p1));
assert!(!r.publication_deleted);
assert_eq!(du_db::publication::get_candidate(&pool, t1.id).await.unwrap().unwrap().status, "rejected");
assert!(du_db::publication::get_by_id(&pool, p1).await.unwrap().is_some(), "paper kept");

// 2. Retract with delete: an untouched promoted paper goes away.
let t2 = by_oa(&pool, "TESTPC-T2").await;
let p2 = du_db::publication::promote_candidate(&pool, t2.id, curator).await.expect("promote");
let r = du_db::publication::retract_candidate(&pool, t2.id, curator, true).await.expect("retract");
assert!(r.publication_deleted, "unattached paper removed");
assert_eq!(r.publication_id, None);
assert!(du_db::publication::get_by_id(&pool, p2).await.unwrap().is_none(), "paper gone");
assert_eq!(du_db::publication::get_candidate(&pool, t2.id).await.unwrap().unwrap().status, "rejected");

// 3. Delete is refused once a study hangs off the paper — the candidate still
// flips, but the curated links survive.
let t3 = by_oa(&pool, "TESTPC-T3").await;
let p3 = du_db::publication::promote_candidate(&pool, t3.id, curator).await.expect("promote");
let study = du_db::study::upsert_by_accession(&pool, "TESTPC-PRJEB1", du_db::study::source_for_accession("PRJEB1"))
.await.expect("study");
du_db::study::link_publication(&pool, p3.0, study).await.expect("link");
assert_eq!(du_db::publication::attachment_count(&pool, p3).await.unwrap(), 1);
let r = du_db::publication::retract_candidate(&pool, t3.id, curator, true).await.expect("retract");
assert!(!r.publication_deleted, "attached paper is not deleted");
assert_eq!(r.attached, 1);
assert!(du_db::publication::get_by_id(&pool, p3).await.unwrap().is_some(), "paper kept");
assert_eq!(du_db::publication::get_candidate(&pool, t3.id).await.unwrap().unwrap().status, "rejected");

// Retracting again is idempotent — and the paper kept in step 1 is now free of
// attachments, so a second pass with the box ticked does remove it.
let r = du_db::publication::retract_candidate(&pool, t1.id, curator, true).await.expect("re-retract");
assert!(r.publication_deleted, "second pass removes the paper left behind");
assert!(du_db::publication::get_by_id(&pool, p1).await.unwrap().is_none());
}

#[tokio::test]
async fn bulk_system_reject_only_touches_pending() {
let Some(url) = database_url() else {
Expand All @@ -114,7 +241,7 @@ async fn bulk_system_reject_only_touches_pending() {
&du_db::publication::NewCandidate { openalex_id: oa, title: Some("t"), ..Default::default() },
).await.expect("upsert");
}
let r4 = du_db::publication::list_candidates(&pool, None, 1, 100)
let r4 = du_db::publication::list_candidates(&pool, &status_filter(None), 1, 100)
.await.unwrap().items.into_iter().find(|c| c.openalex_id == "TESTPC-R4").unwrap();
du_db::publication::review_candidate(&pool, r4.id, "accepted", curator).await.unwrap();

Expand Down
Loading
Loading