Skip to content

Commit 110d944

Browse files
committed
api(personal): pin manual posts/upvotes for 14 d then keep elevated
User-action rows (manual Post button + upvote) now lead the personal page by their action timestamp for 14 d, then fall back to a +12 score boost so they stay "quite high" but a strong recent parsed doc can surpass them. Schema - documents.created_via_post BOOLEAN DEFAULT FALSE Distinguishes manual Post-button rows from background syncs and pipeline imports — both already share the documents table. Backend - BulkSaveRequest gains optional `via: String`. The /search compose dialog sends `via: "post"`; sync calls + MCP omit it. - INSERT stamps `created_via_post = ($12::bool)`. ON CONFLICT preserves a prior TRUE via `OR EXCLUDED.created_via_post`. - Personal-page ORDER BY (both VIP fast path and legacy path): Tier 1: hard pin within 14 d by GREATEST(fav.created_at, CASE WHEN created_via_post THEN created_at END). Tier 2: +12 score boost for any user-action row (any age). Older posts stay elevated; a high-scoring parsed doc still wins on its own merit. Tier 3: snapshot score, date, url (existing tiebreakers). Bonus - favorite_documents.sql was created out-of-band on prod and never in the boot migration list. A fresh local / DR-restored install would have booted without the table and every personal-page hit 500'd. Wired in now. Local verification: F (1 d upvote) → A (3 d post) → D (parsed score 40) → B (30 d post, 5+12=17) → C (90 d post) → E (parsed score 15) — matches the spec exactly.
1 parent 4cbe9a4 commit 110d944

5 files changed

Lines changed: 134 additions & 18 deletions

File tree

api/src/handlers/auth.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2747,6 +2747,14 @@ pub struct BulkSaveRequest {
27472747
/// some bookmarked URLs unstarred when the second call failed.
27482748
#[serde(default)]
27492749
pub favorite: bool,
2750+
/// Provenance flag. Set by the /search "Post" compose dialog
2751+
/// (`via = "post"`) to mark the row as a manual user action;
2752+
/// the personal-page sort pins these to the top by
2753+
/// `created_at`. Omitted by bulk sync calls and the MCP
2754+
/// upsert path so pipeline-style imports don't dominate the
2755+
/// page on every refresh.
2756+
#[serde(default)]
2757+
pub via: Option<String>,
27502758
}
27512759

27522760
#[derive(Serialize)]
@@ -2892,10 +2900,15 @@ pub async fn bulk_save_documents(
28922900
// so pipeline syncs don't blow away user-curated tags.
28932901
// * deleted: clear — re-posting a soft-deleted URL resurrects it,
28942902
// same way the merge branch of update_document does.
2903+
// $12 = TRUE when this came from the /search compose dialog
2904+
// (`via = "post"` in the request). Pipeline-style imports leave it
2905+
// FALSE so they don't pin to the top of the personal page.
2906+
let via_post: bool = req.via.as_deref() == Some("post");
28952907
let sql = "
28962908
INSERT INTO documents (
28972909
user_id, url, title, summary, date, tags, extra_tags,
2898-
source, source_url, public, linked_urls, link_hosts
2910+
source, source_url, public, linked_urls, link_hosts,
2911+
created_via_post
28992912
)
29002913
SELECT $1, u.url, u.title, u.summary,
29012914
NULLIF(u.date, '')::date,
@@ -2905,7 +2918,8 @@ pub async fn bulk_save_documents(
29052918
u.source, u.source_url, u.public,
29062919
COALESCE(u.linked_urls::jsonb, '[]'::jsonb),
29072920
CASE WHEN u.link_hosts = '' THEN '{}'::text[]
2908-
ELSE string_to_array(u.link_hosts, ',') END
2921+
ELSE string_to_array(u.link_hosts, ',') END,
2922+
$12::bool
29092923
FROM UNNEST($2::text[], $3::text[], $4::text[], $5::text[],
29102924
$6::text[], $7::text[], $8::bool[], $9::text[],
29112925
$10::text[], $11::text[])
@@ -2941,6 +2955,13 @@ pub async fn bulk_save_documents(
29412955
-- away from the favorite-only lifecycle so a later
29422956
-- un-upvote no longer deletes the row.
29432957
created_via_favorite = FALSE,
2958+
-- Once a row has been authored by the user via
2959+
-- Post, keep that flag set on subsequent syncs of
2960+
-- the same URL (a later background sync of the same
2961+
-- URL shouldn't strip the manual-action provenance).
2962+
-- A direct re-post (`via=post` again) refreshes the
2963+
-- flag to TRUE just like the first time.
2964+
created_via_post = documents.created_via_post OR EXCLUDED.created_via_post,
29442965
deleted = FALSE,
29452966
updated_at = now()
29462967
";
@@ -2967,6 +2988,7 @@ pub async fn bulk_save_documents(
29672988
.bind(&tag_csvs)
29682989
.bind(&linked_urls_json)
29692990
.bind(&link_hosts_csv)
2991+
.bind(via_post)
29702992
.execute(&mut *tx)
29712993
.await;
29722994

api/src/handlers/users.rs

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ pub async fn list_documents(
424424
d.linked_urls,\n\
425425
d.link_hosts,\n\
426426
d.created_at,\n\
427+
d.created_via_post,\n\
427428
d.canonical_url,\n\
428429
d.canonical_referenced_urls\n\
429430
FROM documents d\n\
@@ -553,8 +554,33 @@ pub async fn list_documents(
553554
LEFT JOIN users uu ON uu.username = $1\n\
554555
LEFT JOIN favorite_documents fav\n\
555556
ON fav.user_id = uu.id AND fav.url = c.url\n\
556-
ORDER BY fav.created_at DESC NULLS LAST,\n\
557-
c.date DESC NULLS LAST, c.created_at DESC",
557+
ORDER BY CASE\n\
558+
WHEN GREATEST(\n\
559+
fav.created_at,\n\
560+
CASE WHEN c.created_via_post THEN c.created_at END\n\
561+
) > now() - interval '14 days'\n\
562+
THEN GREATEST(\n\
563+
fav.created_at,\n\
564+
CASE WHEN c.created_via_post THEN c.created_at END\n\
565+
)\n\
566+
END DESC NULLS LAST,\n\
567+
-- After the 14-d hard pin expires, user-action\n\
568+
-- rows keep a +12 bump on their date-based sort\n\
569+
-- so they stay elevated; a strong recent parsed\n\
570+
-- doc still surpasses them when its score wins.\n\
571+
-- The legacy path doesn't carry a per-doc score\n\
572+
-- column, so we approximate \"score\" via\n\
573+
-- `c.date` and add a virtual day-bump on the\n\
574+
-- date axis only for user-action rows.\n\
575+
(c.date + CASE\n\
576+
WHEN GREATEST(\n\
577+
fav.created_at,\n\
578+
CASE WHEN c.created_via_post THEN c.created_at END\n\
579+
) IS NOT NULL\n\
580+
THEN INTERVAL '30 days'\n\
581+
ELSE INTERVAL '0'\n\
582+
END) DESC NULLS LAST,\n\
583+
c.created_at DESC",
558584
);
559585
} else {
560586
sql.push_str(
@@ -593,7 +619,7 @@ pub async fn list_documents(
593619
SELECT DISTINCT ON (anchor_url)\n\
594620
url, title, summary, date, tags, extra_tags,\n\
595621
source, source_url, indexed, linked_urls, link_hosts,\n\
596-
created_at\n\
622+
created_at, created_via_post\n\
597623
FROM candidate_anchors\n\
598624
ORDER BY anchor_url, image_count DESC, url_count DESC,\n\
599625
date DESC NULLS LAST, created_at DESC\n\
@@ -607,8 +633,29 @@ pub async fn list_documents(
607633
LEFT JOIN users uu ON uu.username = $1\n\
608634
LEFT JOIN favorite_documents fav\n\
609635
ON fav.user_id = uu.id AND fav.url = dedup.url\n\
610-
ORDER BY fav.created_at DESC NULLS LAST,\n\
611-
dedup.date DESC NULLS LAST, dedup.created_at DESC",
636+
ORDER BY CASE\n\
637+
WHEN GREATEST(\n\
638+
fav.created_at,\n\
639+
CASE WHEN dedup.created_via_post THEN dedup.created_at END\n\
640+
) > now() - interval '14 days'\n\
641+
THEN GREATEST(\n\
642+
fav.created_at,\n\
643+
CASE WHEN dedup.created_via_post THEN dedup.created_at END\n\
644+
)\n\
645+
END DESC NULLS LAST,\n\
646+
-- Same date-shift as the skip_dedup branch:\n\
647+
-- after the 14-d pin expires, user-action rows\n\
648+
-- ride 30 days ahead of their actual date so\n\
649+
-- they stay elevated. Pipeline rows shift 0d.\n\
650+
(dedup.date + CASE\n\
651+
WHEN GREATEST(\n\
652+
fav.created_at,\n\
653+
CASE WHEN dedup.created_via_post THEN dedup.created_at END\n\
654+
) IS NOT NULL\n\
655+
THEN INTERVAL '30 days'\n\
656+
ELSE INTERVAL '0'\n\
657+
END) DESC NULLS LAST,\n\
658+
dedup.created_at DESC",
612659
);
613660
}
614661
// Server-side cap so the personal page doesn't have to ship the
@@ -786,12 +833,15 @@ async fn try_list_documents_from_snapshot(
786833
.unwrap_or_default();
787834

788835
// Build the SQL incrementally to keep $N placeholders aligned.
789-
// The LEFT JOIN on favorite_documents is what pins upvoted docs
790-
// to the top of the page (see the ORDER BY below) — every doc
791-
// the page-owner has favourited gets `fav.created_at` non-null,
792-
// and we sort that ASC NULLS LAST so the freshest upvote leads.
836+
// Two LEFT JOINs feed the ORDER BY's "user activity" sort key:
837+
// * favorite_documents → upvotes (fav.created_at)
838+
// * documents.created_via_post → manual Post-button rows
839+
// (d_post.created_at)
840+
// Whichever timestamp is more recent wins, so an upvote made
841+
// after a post still rises above it. Pipeline-imported rows
842+
// leave both slots NULL and fall back to feed-score order.
793843
let mut sql = String::from(
794-
"SELECT ps.url,\n ps.title,\n ps.summary,\n COALESCE(to_char(ps.date, 'YYYY-MM-DD'), '') AS date,\n ps.tags,\n ps.extra_tags,\n ps.source,\n ps.source_url,\n ps.indexed,\n ps.linked_urls,\n ps.link_hosts,\n COALESCE(to_char(ps.date AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), '') AS created_at,\n ps.sharers,\n ps.sharer_count\n FROM personal_snapshot ps\n JOIN users u ON u.id = ps.user_id\n LEFT JOIN favorite_documents fav\n ON fav.user_id = ps.user_id AND fav.url = ps.url\n WHERE u.username = $1",
844+
"SELECT ps.url,\n ps.title,\n ps.summary,\n COALESCE(to_char(ps.date, 'YYYY-MM-DD'), '') AS date,\n ps.tags,\n ps.extra_tags,\n ps.source,\n ps.source_url,\n ps.indexed,\n ps.linked_urls,\n ps.link_hosts,\n COALESCE(to_char(ps.date AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), '') AS created_at,\n ps.sharers,\n ps.sharer_count\n FROM personal_snapshot ps\n JOIN users u ON u.id = ps.user_id\n LEFT JOIN favorite_documents fav\n ON fav.user_id = ps.user_id AND fav.url = ps.url\n LEFT JOIN documents d_post\n ON d_post.user_id = ps.user_id AND d_post.url = ps.url\n AND d_post.deleted = FALSE\n WHERE u.username = $1",
795845
);
796846
let mut idx: usize = 2;
797847
if !sources_vec.is_empty() {
@@ -816,13 +866,26 @@ async fn try_list_documents_from_snapshot(
816866
sql.push_str(&format!(" AND ps.categories && ${idx}"));
817867
idx += 1;
818868
}
819-
// Upvoted docs ALWAYS lead, sorted by most-recent upvote first.
820-
// The rest of the library follows in feed-score order. Using
821-
// `NULLS LAST` on the upvote timestamp keeps non-favourited rows
822-
// out of the front block; sorting `fav.created_at DESC` puts
823-
// freshly-upvoted items at the very top.
869+
// User-activity boost (manual Post + upvote) on the personal
870+
// page — two-tier so fresh actions hard-pin and older ones still
871+
// stay elevated but yield to a strong recent parsed doc.
872+
//
873+
// * Tier 1 — hard pin (≤ 14 d): the sort uses
874+
// `GREATEST(fav.created_at, post.created_at)` as the first
875+
// key. Today's upvote beats yesterday's post; a Post lands
876+
// instantly at the top.
877+
// * Tier 2 — score boost (any age): every user-action row gets
878+
// a +12 bump on `ps.score` for the secondary sort key. After
879+
// the 14-d pin expires the row still ranks like a top-tier
880+
// doc, so it stays "quite high" — but a parsed doc that
881+
// genuinely scores higher (recent, broadly shared, sci-
882+
// anchored) can now surpass it.
883+
// * Pipeline-imported docs leave both inputs NULL (the
884+
// `created_via_post` guard masks them on the post side, no
885+
// favorite_documents row on the upvote side) so they sort
886+
// by raw `ps.score` without the bump.
824887
sql.push_str(
825-
"\n ORDER BY fav.created_at DESC NULLS LAST,\n ps.score DESC,\n ps.date DESC NULLS LAST,\n ps.url",
888+
"\n ORDER BY CASE\n WHEN GREATEST(\n fav.created_at,\n CASE WHEN d_post.created_via_post THEN d_post.created_at END\n ) > now() - interval '14 days'\n THEN GREATEST(\n fav.created_at,\n CASE WHEN d_post.created_via_post THEN d_post.created_at END\n )\n END DESC NULLS LAST,\n (ps.score + CASE\n WHEN GREATEST(\n fav.created_at,\n CASE WHEN d_post.created_via_post THEN d_post.created_at END\n ) IS NOT NULL\n THEN 12.0\n ELSE 0\n END) DESC,\n ps.date DESC NULLS LAST,\n ps.url",
826889
);
827890
let mut limit_val: Option<i64> = None;
828891
if let Some(n) = params.limit {

api/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,16 @@ async fn run_sql_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
367367
"favorites.sql",
368368
include_str!("../../sources/sql/favorites.sql"),
369369
),
370+
// Per-URL upvotes (the heart icon on every doc card). Distinct
371+
// from `favorites.sql` which tracks "I follow this person".
372+
// Was previously created out-of-band on prod and missing from
373+
// this list — a fresh local install (or any DR restore) would
374+
// boot without the table and every /api/users/<slug>/documents
375+
// request 500s.
376+
(
377+
"favorite_documents.sql",
378+
include_str!("../../sources/sql/favorite_documents.sql"),
379+
),
370380
("follows.sql", include_str!("../../sources/sql/follows.sql")),
371381
("events.sql", include_str!("../../sources/sql/events.sql")),
372382
(

sources/sql/documents.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ BEGIN
140140
ALTER TABLE documents
141141
ADD COLUMN created_via_favorite BOOLEAN NOT NULL DEFAULT FALSE;
142142
END IF;
143+
-- created_via_post: TRUE iff the row was inserted by the user
144+
-- clicking the "Post" button in /search compose (NOT by a
145+
-- background sync, an upvote-mirror, or the Python pipeline).
146+
-- The personal page uses this flag to pin manual posts to the
147+
-- top of the page by their `created_at`, sharing the same slot
148+
-- as upvotes (`favorite_documents.created_at`) — the most
149+
-- recent of the two wins, so an upvote made AFTER a post still
150+
-- rises above it. Pipeline-imported docs leave the flag FALSE
151+
-- so they slot into the natural feed-score order instead of
152+
-- consistently topping the page on every refresh cycle.
153+
IF NOT EXISTS (
154+
SELECT 1 FROM information_schema.columns
155+
WHERE table_name = 'documents' AND column_name = 'created_via_post'
156+
) THEN
157+
ALTER TABLE documents
158+
ADD COLUMN created_via_post BOOLEAN NOT NULL DEFAULT FALSE;
159+
END IF;
143160
-- Inline link previews. Replaces the old "create a companion
144161
-- document per URL embedded in a tweet" pattern: a tweet with N
145162
-- external links now stays a single row whose `linked_urls`

web/search/page.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8509,6 +8509,10 @@
85098509
public: true,
85108510
},
85118511
],
8512+
// Provenance — backend stamps `created_via_post = TRUE`
8513+
// so the personal page pins this row to the top by
8514+
// `created_at`. Background-sync calls omit this field.
8515+
via: "post",
85128516
}),
85138517
});
85148518
if (!r.ok) throw new Error(`HTTP ${r.status}`);

0 commit comments

Comments
 (0)