feat(appview): Notifications, Follower-/Following-Listen, Thread-Route
Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.
Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
umgebende Like-Transaktion abbrechen, also das Like wegen eines
Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.
Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.
Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.
/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.
Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d009bfcba
commit
c4ca218d97
@@ -348,6 +348,31 @@ pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
|
||||
.bind(&row.avatar_cid)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// Reply notification. A post with a `parent_uri` is a reply, so the
|
||||
// parent's author gets a "someone replied to you" row.
|
||||
//
|
||||
// `subject_uri` is the REPLY's own URI, not the parent's: the
|
||||
// notification list hydrates `subject_uri`'s text, and what the
|
||||
// recipient wants to read is what the replier wrote — they already
|
||||
// know the content of their own post. It also makes the row a
|
||||
// direct link target for "open this reply in the thread view".
|
||||
//
|
||||
// The dedupe index keys on the reply URI, so re-indexing (or an
|
||||
// edit that re-runs the upsert) can't produce a second row.
|
||||
if let Some(parent_uri) = row.parent_uri.as_deref() {
|
||||
if let Some(recipient) = post_author_did(db, parent_uri).await? {
|
||||
record_notification(
|
||||
db,
|
||||
&recipient,
|
||||
&row.did,
|
||||
NOTIF_REPLY,
|
||||
Some(&row.uri),
|
||||
row.created_at,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -414,6 +439,23 @@ pub async fn upsert_like(
|
||||
.bind(&post_uri)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Notify the post's author. Runs inside the same transaction as
|
||||
// the counter bump so a crash can't leave a like counted but
|
||||
// un-notified (or vice versa). `post_author_did` returns None
|
||||
// for a post we haven't indexed — then there's nobody to
|
||||
// notify and we quietly skip.
|
||||
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
|
||||
record_notification(
|
||||
&mut *tx,
|
||||
&recipient,
|
||||
did,
|
||||
NOTIF_LIKE,
|
||||
Some(&post_uri),
|
||||
created_at,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
@@ -489,6 +531,20 @@ pub async fn upsert_repost(
|
||||
.bind(&post_uri)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Same transactional notification write as the like path — see
|
||||
// `upsert_like` for the rationale.
|
||||
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
|
||||
record_notification(
|
||||
&mut *tx,
|
||||
&recipient,
|
||||
did,
|
||||
NOTIF_REPOST,
|
||||
Some(&post_uri),
|
||||
created_at,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
@@ -517,6 +573,124 @@ pub async fn delete_repost(db: &PgPool, did: &str, rkey: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- notifications ---------------------------------------------------------
|
||||
|
||||
/// The four notification kinds the AppView produces. Kept as `&str`
|
||||
/// constants rather than an enum because the value is a plain `TEXT`
|
||||
/// column guarded by a CHECK constraint (migration 0008) and every
|
||||
/// call site is a literal — an enum would only add a `to_string()`.
|
||||
pub const NOTIF_LIKE: &str = "like";
|
||||
pub const NOTIF_REPOST: &str = "repost";
|
||||
pub const NOTIF_FOLLOW: &str = "follow";
|
||||
pub const NOTIF_REPLY: &str = "reply";
|
||||
|
||||
/// Should an interaction by `author_did` aimed at `recipient_did`
|
||||
/// produce a notification row?
|
||||
///
|
||||
/// Pure so it can be unit-tested without a database. Two rules:
|
||||
///
|
||||
/// 1. **No self-interactions.** Liking your own post, reposting
|
||||
/// yourself, self-following or replying to yourself must stay
|
||||
/// silent — the user already knows they did it, and a "you liked
|
||||
/// your own post" row is pure noise.
|
||||
/// 2. **No empty DIDs.** An empty recipient means we failed to resolve
|
||||
/// the target (e.g. a like against a post that isn't in our index),
|
||||
/// and an empty author means the event was malformed. Either way
|
||||
/// the row would be unattributable in the UI.
|
||||
///
|
||||
/// The same two rules are ALSO enforced in SQL inside
|
||||
/// [`record_notification`], so a caller that forgets this guard still
|
||||
/// can't write a bad row — this function exists to skip the round trip
|
||||
/// in the common self-interaction case and to make the rule testable.
|
||||
pub fn should_notify(recipient_did: &str, author_did: &str) -> bool {
|
||||
!recipient_did.is_empty() && !author_did.is_empty() && recipient_did != author_did
|
||||
}
|
||||
|
||||
/// Insert one notification row, if and only if it is warranted.
|
||||
///
|
||||
/// Returns `Ok(true)` when a row was actually written, `Ok(false)` when
|
||||
/// the write was skipped — either because the interaction failed
|
||||
/// [`should_notify`], because the recipient isn't a user this AppView
|
||||
/// serves, or because the same notification already exists.
|
||||
///
|
||||
/// **"Local user"**: the AppView deliberately has no `users` table —
|
||||
/// the PDS owns account state. The closest thing we have is "a DID this
|
||||
/// AppView knows about", i.e. one with a row in the `profiles` cache
|
||||
/// (written by the PDS profile push / the Jetstream `app.bsky.actor.profile`
|
||||
/// arm) or at least one indexed post. That is exactly the set of users
|
||||
/// a client could ever poll notifications for, so restricting writes to
|
||||
/// it keeps us from materialising a notification row for every like on
|
||||
/// the entire public firehose while never dropping one a local user
|
||||
/// would actually see.
|
||||
///
|
||||
/// **Idempotency**: the `ON CONFLICT` target is the expression index
|
||||
/// `notifications_dedupe_idx` from migration 0008, keyed on
|
||||
/// `(recipient_did, author_did, kind, COALESCE(subject_uri, ''))`. The
|
||||
/// `COALESCE` is load-bearing: a plain unique index would let two
|
||||
/// `follow` rows (whose `subject_uri` is NULL) coexist, because SQL
|
||||
/// NULLs never collide. Re-indexing the same Jetstream event — on a
|
||||
/// reconnect replay, or via the PDS `/internal/ingest-commit` push that
|
||||
/// races the firehose — is therefore a no-op.
|
||||
///
|
||||
/// The executor is generic so this can run either on the pool (the
|
||||
/// reply path) or inside the caller's transaction (the like / repost
|
||||
/// paths, where the notification must commit atomically with the
|
||||
/// counter bump).
|
||||
pub async fn record_notification<'e, E>(
|
||||
exec: E,
|
||||
recipient_did: &str,
|
||||
author_did: &str,
|
||||
kind: &str,
|
||||
subject_uri: Option<&str>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
if !should_notify(recipient_did, author_did) {
|
||||
return Ok(false);
|
||||
}
|
||||
// One statement, so there's no TOCTOU window between "is the
|
||||
// recipient local?" and "insert". Every parameter is explicitly
|
||||
// cast because `INSERT ... SELECT $1, $2, ...` gives Postgres no
|
||||
// column context to infer the placeholder types from.
|
||||
let res = sqlx::query(
|
||||
r#"INSERT INTO notifications
|
||||
(recipient_did, author_did, kind, subject_uri, created_at)
|
||||
SELECT $1::text, $2::text, $3::text, $4::text, $5::timestamptz
|
||||
WHERE $1::text <> $2::text
|
||||
AND $1::text <> ''
|
||||
AND $2::text <> ''
|
||||
AND (EXISTS (SELECT 1 FROM profiles WHERE did = $1::text)
|
||||
OR EXISTS (SELECT 1 FROM posts WHERE did = $1::text))
|
||||
ON CONFLICT (recipient_did, author_did, kind, COALESCE(subject_uri, ''))
|
||||
DO NOTHING"#,
|
||||
)
|
||||
.bind(recipient_did)
|
||||
.bind(author_did)
|
||||
.bind(kind)
|
||||
.bind(subject_uri)
|
||||
.bind(created_at)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Look up the author DID of an indexed post. `None` when the post
|
||||
/// isn't in our index — which is the normal case for a like/reply
|
||||
/// aimed at a post hosted somewhere we don't follow. The caller then
|
||||
/// simply skips the notification rather than guessing a recipient.
|
||||
async fn post_author_did<'e, E>(exec: E, uri: &str) -> Result<Option<String>>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let did: Option<String> = sqlx::query_scalar("SELECT did FROM posts WHERE uri = $1")
|
||||
.bind(uri)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
Ok(did)
|
||||
}
|
||||
|
||||
// -- follows ---------------------------------------------------------------
|
||||
|
||||
pub async fn upsert_follow(
|
||||
@@ -540,6 +714,22 @@ pub async fn upsert_follow(
|
||||
.bind(created_at)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// Notify the followed user. `subject_uri` is NULL — a follow isn't
|
||||
// about a post — which is exactly the case the dedupe index's
|
||||
// `COALESCE(subject_uri, '')` exists for. Not wrapped in a
|
||||
// transaction with the follow upsert: the follow row is the source
|
||||
// of truth and a missed notification is recoverable noise, whereas
|
||||
// taking a transaction here would serialise every follow write.
|
||||
record_notification(
|
||||
db,
|
||||
subject_did,
|
||||
follower_did,
|
||||
NOTIF_FOLLOW,
|
||||
None,
|
||||
created_at,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1181,6 +1371,373 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notification_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn did(tag: &str) -> String {
|
||||
format!("did:plc:notif_{}_{}", tag, uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
/// Seed one post so `did` counts as a user this AppView knows
|
||||
/// about (see [`record_notification`]'s "local user" note) and so
|
||||
/// there's something to like / reply to.
|
||||
///
|
||||
/// The `handle` is deliberately non-empty. `handle_sync`'s tests
|
||||
/// assert exact counts over a *global* scan of empty-handle rows,
|
||||
/// so a fixture that left the column blank would silently break
|
||||
/// them whenever both suites share a database.
|
||||
async fn seed_post(db: &PgPool, author: &str, rkey: &str) -> String {
|
||||
let uri = format!("at://{author}/app.twi.post/{rkey}");
|
||||
sqlx::query(
|
||||
r#"INSERT INTO posts
|
||||
(uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, langs, created_at)
|
||||
VALUES ($1,$2,'notif-fixture.test','x','app.twi.post','seed','bafy',
|
||||
NULL,NULL,NULL, now())
|
||||
ON CONFLICT (uri) DO NOTHING"#,
|
||||
)
|
||||
.bind(&uri)
|
||||
.bind(author)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
uri
|
||||
}
|
||||
|
||||
async fn count_notifications(db: &PgPool, recipient: &str, kind: &str) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM notifications \
|
||||
WHERE recipient_did = $1 AND kind = $2",
|
||||
)
|
||||
.bind(recipient)
|
||||
.bind(kind)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn cleanup(db: &PgPool, dids: &[&str]) {
|
||||
for d in dids {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM notifications WHERE recipient_did = $1 OR author_did = $1",
|
||||
)
|
||||
.bind(d)
|
||||
.execute(db)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM likes WHERE did = $1")
|
||||
.bind(d)
|
||||
.execute(db)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM reposts WHERE did = $1")
|
||||
.bind(d)
|
||||
.execute(db)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM follows WHERE follower_did = $1 OR subject_did = $1")
|
||||
.bind(d)
|
||||
.execute(db)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
|
||||
.bind(d)
|
||||
.execute(db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_notify_rejects_self_and_empty() {
|
||||
assert!(should_notify("did:plc:a", "did:plc:b"));
|
||||
// Self-interaction: liking / replying to / following yourself.
|
||||
assert!(!should_notify("did:plc:a", "did:plc:a"));
|
||||
// Unresolvable ends of the edge.
|
||||
assert!(!should_notify("", "did:plc:b"));
|
||||
assert!(!should_notify("did:plc:a", ""));
|
||||
assert!(!should_notify("", ""));
|
||||
// Case matters — DIDs are compared verbatim, never folded.
|
||||
assert!(should_notify("did:plc:A", "did:plc:a"));
|
||||
}
|
||||
|
||||
/// A like by someone else must produce exactly one notification,
|
||||
/// and re-indexing the same event (Jetstream replay racing the PDS
|
||||
/// push) must not produce a second.
|
||||
#[tokio::test]
|
||||
async fn like_notifies_author_once() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let author = did("author");
|
||||
let liker = did("liker");
|
||||
// The liker also needs to exist for nothing in particular —
|
||||
// only the *recipient* is checked — but seeding both keeps the
|
||||
// fixture symmetric with reality.
|
||||
let post_uri = seed_post(&db, &author, "p1").await;
|
||||
seed_post(&db, &liker, "p1").await;
|
||||
|
||||
let record = json!({
|
||||
"subject": { "uri": post_uri, "cid": "bafysubject" },
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
});
|
||||
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count_notifications(&db, &author, NOTIF_LIKE).await, 1);
|
||||
|
||||
// Replay the exact same event.
|
||||
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_notifications(&db, &author, NOTIF_LIKE).await,
|
||||
1,
|
||||
"replayed like must not duplicate the notification"
|
||||
);
|
||||
|
||||
// Unlike + re-like under a NEW rkey. The like row is recreated
|
||||
// but the notification tuple is unchanged, so the dedupe index
|
||||
// suppresses it — see migration 0008's "Idempotency" note.
|
||||
delete_like(&db, &liker, "lrk1").await.unwrap();
|
||||
upsert_like(&db, &liker, "lrk2", Some("bafylike2"), Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_notifications(&db, &author, NOTIF_LIKE).await,
|
||||
1,
|
||||
"toggling a like must not be a notification-spam vector"
|
||||
);
|
||||
|
||||
// The stored row must point at the liked post and name the
|
||||
// liker as the author.
|
||||
let row: (String, Option<String>) = sqlx::query_as(
|
||||
"SELECT author_did, subject_uri FROM notifications \
|
||||
WHERE recipient_did = $1 AND kind = $2",
|
||||
)
|
||||
.bind(&author)
|
||||
.bind(NOTIF_LIKE)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0, liker);
|
||||
assert_eq!(row.1.as_deref(), Some(post_uri.as_str()));
|
||||
// Unread by default — that's what /api/notifications/count sees.
|
||||
let unread: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM notifications \
|
||||
WHERE recipient_did = $1 AND read_at IS NULL",
|
||||
)
|
||||
.bind(&author)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unread, 1);
|
||||
|
||||
cleanup(&db, &[&author, &liker]).await;
|
||||
}
|
||||
|
||||
/// Liking / reposting your own post is silent.
|
||||
#[tokio::test]
|
||||
async fn self_interaction_writes_no_notification() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let author = did("selfie");
|
||||
let post_uri = seed_post(&db, &author, "p1").await;
|
||||
let record = json!({
|
||||
"subject": { "uri": post_uri, "cid": "bafysubject" },
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
});
|
||||
|
||||
upsert_like(&db, &author, "lrk1", None, Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_repost(&db, &author, "rrk1", None, Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
// Self-follow is legal in the protocol; it must stay silent too.
|
||||
upsert_follow(&db, &author, &author, None).await.unwrap();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM notifications WHERE recipient_did = $1",
|
||||
)
|
||||
.bind(&author)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 0, "self-interactions must not notify");
|
||||
|
||||
// The like/repost themselves still landed — the notification
|
||||
// suppression must not swallow the interaction.
|
||||
let likes: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM likes WHERE did = $1")
|
||||
.bind(&author)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(likes, 1);
|
||||
|
||||
cleanup(&db, &[&author]).await;
|
||||
}
|
||||
|
||||
/// A repost notifies, and a reply notifies the *parent's* author
|
||||
/// with the reply's own URI as the subject.
|
||||
#[tokio::test]
|
||||
async fn repost_and_reply_notify() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let author = did("parent");
|
||||
let other = did("replier");
|
||||
let parent_uri = seed_post(&db, &author, "p1").await;
|
||||
seed_post(&db, &other, "p1").await;
|
||||
|
||||
let record = json!({
|
||||
"subject": { "uri": parent_uri, "cid": "bafysubject" },
|
||||
"createdAt": "2026-07-01T12:00:00Z"
|
||||
});
|
||||
upsert_repost(&db, &other, "rrk1", None, Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count_notifications(&db, &author, NOTIF_REPOST).await, 1);
|
||||
|
||||
// Now a reply from `other` to `author`'s post.
|
||||
let reply_record = json!({
|
||||
"text": "nice one",
|
||||
"createdAt": "2026-07-01T12:05:00Z",
|
||||
"reply": {
|
||||
"parent": { "uri": parent_uri, "cid": "bafyparent" },
|
||||
"root": { "uri": parent_uri, "cid": "bafyparent" }
|
||||
}
|
||||
});
|
||||
let mut row = PostRow::from_record(
|
||||
&other,
|
||||
"replykey",
|
||||
"app.twi.post",
|
||||
"bafyreply",
|
||||
&reply_record,
|
||||
None,
|
||||
);
|
||||
let reply_uri = row.uri.clone();
|
||||
upsert_post(&db, &mut row).await.unwrap();
|
||||
|
||||
let subject: Option<String> = sqlx::query_scalar(
|
||||
"SELECT subject_uri FROM notifications \
|
||||
WHERE recipient_did = $1 AND kind = $2",
|
||||
)
|
||||
.bind(&author)
|
||||
.bind(NOTIF_REPLY)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
subject.as_deref(),
|
||||
Some(reply_uri.as_str()),
|
||||
"a reply notification's subject is the REPLY, so the list can \
|
||||
preview what was written"
|
||||
);
|
||||
|
||||
// Re-indexing the reply must not duplicate.
|
||||
upsert_post(&db, &mut row).await.unwrap();
|
||||
assert_eq!(count_notifications(&db, &author, NOTIF_REPLY).await, 1);
|
||||
|
||||
cleanup(&db, &[&author, &other]).await;
|
||||
}
|
||||
|
||||
/// A follow notifies the followed user with a NULL subject_uri —
|
||||
/// the case the dedupe index's `COALESCE(subject_uri, '')` exists
|
||||
/// for, since plain SQL NULLs never collide.
|
||||
#[tokio::test]
|
||||
async fn follow_notifies_subject_and_dedupes_on_null_subject() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let subject = did("followee");
|
||||
let follower = did("follower");
|
||||
seed_post(&db, &subject, "p1").await;
|
||||
|
||||
let record = json!({ "subject": subject, "createdAt": "2026-01-01T00:00:00Z" });
|
||||
upsert_follow(&db, &follower, &subject, Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_follow(&db, &follower, &subject, Some(&record))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_notifications(&db, &subject, NOTIF_FOLLOW).await,
|
||||
1,
|
||||
"two NULL-subject follow rows must collide, not coexist"
|
||||
);
|
||||
|
||||
let subject_uri: Option<String> = sqlx::query_scalar(
|
||||
"SELECT subject_uri FROM notifications \
|
||||
WHERE recipient_did = $1 AND kind = $2",
|
||||
)
|
||||
.bind(&subject)
|
||||
.bind(NOTIF_FOLLOW)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(subject_uri.is_none(), "a follow is not about a post");
|
||||
|
||||
cleanup(&db, &[&subject, &follower]).await;
|
||||
}
|
||||
|
||||
/// A recipient the AppView has never seen (no profile row, no
|
||||
/// posts) gets nothing — this is what keeps us from materialising
|
||||
/// a row for every like on the public firehose.
|
||||
#[tokio::test]
|
||||
async fn unknown_recipient_is_skipped() {
|
||||
let Some(db) = try_test_db().await else {
|
||||
eprintln!("appview DB unavailable; skipping");
|
||||
return;
|
||||
};
|
||||
let stranger = did("stranger");
|
||||
let author = did("author");
|
||||
|
||||
let wrote = record_notification(
|
||||
&db,
|
||||
&stranger,
|
||||
&author,
|
||||
NOTIF_FOLLOW,
|
||||
None,
|
||||
chrono::Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wrote, "unknown recipient must not get a notification row");
|
||||
|
||||
// Give the recipient a post — now they're a user we serve.
|
||||
seed_post(&db, &stranger, "p1").await;
|
||||
let wrote = record_notification(
|
||||
&db,
|
||||
&stranger,
|
||||
&author,
|
||||
NOTIF_FOLLOW,
|
||||
None,
|
||||
chrono::Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wrote, "known recipient must get the row");
|
||||
|
||||
// And the second call is a no-op thanks to ON CONFLICT.
|
||||
let wrote = record_notification(
|
||||
&db,
|
||||
&stranger,
|
||||
&author,
|
||||
NOTIF_FOLLOW,
|
||||
None,
|
||||
chrono::Utc::now(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wrote, "duplicate must report false, not error");
|
||||
|
||||
cleanup(&db, &[&stranger, &author]).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Backfill the `posts.handle` column for every row belonging to
|
||||
/// `did`. Used by the Jetstream `identity` handler when Jetstream
|
||||
/// tells us a DID's handle has changed — every existing post row
|
||||
|
||||
+886
-107
File diff suppressed because it is too large
Load Diff
@@ -199,6 +199,165 @@ pub struct SearchResponse {
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
// -- thread -----------------------------------------------------------------
|
||||
|
||||
/// `GET /api/thread` response.
|
||||
///
|
||||
/// This is the *full* thread shape: the ancestor chain above the post
|
||||
/// and the direct replies below it. `/api/post/{uri}` keeps its own,
|
||||
/// narrower `{ post, thread: { parent, root } }` shape for backwards
|
||||
/// compatibility — both are built from the same
|
||||
/// `routes::load_thread_context` call, so they can never disagree
|
||||
/// about what the parent or root is.
|
||||
///
|
||||
/// `post` is `None` when the URI isn't in our index. In that case
|
||||
/// `parents` / `replies` are empty and the counters are omitted, so a
|
||||
/// client can render "post not found" from a single field check.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ThreadFullResponse {
|
||||
pub post: Option<PostRow>,
|
||||
/// Ancestor chain ordered **root first, immediate parent last**.
|
||||
/// Empty for a top-level post, and truncated (from the top) when
|
||||
/// the chain is longer than the walk limit or when an ancestor
|
||||
/// isn't in our index — the client should treat a `parents[0]`
|
||||
/// whose `parent_uri` is non-null as "chain continues above,
|
||||
/// not loaded".
|
||||
pub parents: Vec<PostRow>,
|
||||
/// The thread root as named by the post's own `root_uri`. May be
|
||||
/// the same row as `parents[0]`, and is `None` for a top-level
|
||||
/// post (which is its own root).
|
||||
pub root: Option<PostRow>,
|
||||
/// Direct replies to `post`, oldest first. Only direct children —
|
||||
/// the client re-requests `/api/thread` for a nested branch.
|
||||
pub replies: Vec<PostRow>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub like_count: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repost_count: Option<i64>,
|
||||
/// Same semantics as on `/api/post/{uri}`: `None` means "no
|
||||
/// viewer_did was supplied, state unknown" — not "not liked".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub viewer_liked: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub viewer_reposted: Option<bool>,
|
||||
}
|
||||
|
||||
// -- notifications ----------------------------------------------------------
|
||||
|
||||
/// One hydrated row of `GET /api/notifications`.
|
||||
///
|
||||
/// The DB row only stores DIDs and a subject URI; the read query joins
|
||||
/// the `profiles` cache (falling back to the newest non-empty
|
||||
/// `posts.handle` for authors we've seen post but never had a profile
|
||||
/// record for) and the `posts` table so the client can render a full
|
||||
/// notification line without any follow-up fetch.
|
||||
///
|
||||
/// `read_at` is serialised even when `None` — unlike the optional
|
||||
/// fields around it — because "unread" is the state the client's badge
|
||||
/// keys off, and a *missing* key would be indistinguishable from a
|
||||
/// field the client forgot to read.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NotificationItem {
|
||||
/// Row id. Also the tiebreaker inside the opaque cursor.
|
||||
pub id: i64,
|
||||
/// `"like"` | `"repost"` | `"follow"` | `"reply"`.
|
||||
pub kind: String,
|
||||
pub author_did: String,
|
||||
/// Best known handle for the author, WITHOUT a leading `@` (the UI
|
||||
/// renders `@{handle}`). Falls back to a truncated DID, and is
|
||||
/// never null.
|
||||
pub author_handle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author_display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author_avatar_cid: Option<String>,
|
||||
/// The post this notification is about. `null` for `"follow"`. For
|
||||
/// `"like"`/`"repost"` it's the recipient's own post; for
|
||||
/// `"reply"` it's the reply itself.
|
||||
pub subject_uri: Option<String>,
|
||||
/// Text of `subject_uri`'s post. Omitted when the subject is not
|
||||
/// (or no longer) in our index, and always for `"follow"`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject_text: Option<String>,
|
||||
/// The interaction's own `createdAt` from the AT record.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the AppView indexed it. This is the list's sort key, and
|
||||
/// the value a client should echo back as `seenAt`.
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
/// `null` while unread.
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for NotificationItem {
|
||||
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
|
||||
Ok(NotificationItem {
|
||||
id: row.try_get("id")?,
|
||||
kind: row.try_get("kind")?,
|
||||
author_did: row.try_get("author_did")?,
|
||||
author_handle: row.try_get("author_handle")?,
|
||||
author_display_name: row.try_get("author_display_name")?,
|
||||
author_avatar_cid: row.try_get("author_avatar_cid")?,
|
||||
subject_uri: row.try_get("subject_uri")?,
|
||||
subject_text: row.try_get("subject_text")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
indexed_at: row.try_get("indexed_at")?,
|
||||
read_at: row.try_get("read_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/notifications` response. `cursor` is `None` at the end of
|
||||
/// the list — same contract as [`TimelineResponse`].
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NotificationsResponse {
|
||||
pub notifications: Vec<NotificationItem>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /api/notifications/count` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NotificationCountResponse {
|
||||
/// Number of rows with `read_at IS NULL` for this DID.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/notifications/seen` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NotificationSeenResponse {
|
||||
pub ok: bool,
|
||||
/// How many previously-unread rows this call flipped to read.
|
||||
/// Zero is a normal, successful outcome (nothing was unread).
|
||||
pub updated: i64,
|
||||
}
|
||||
|
||||
// -- actor lists (followers / following) ------------------------------------
|
||||
|
||||
/// A minimal profile card, as returned by `GET /api/followers` and
|
||||
/// `GET /api/following`.
|
||||
///
|
||||
/// Deliberately *not* [`ProfileResponse`]: those endpoints return a
|
||||
/// list, and shipping each entry's posts + counts would turn one page
|
||||
/// of 30 followers into 30 post queries. The client renders a row with
|
||||
/// avatar + name + handle and navigates to `/api/profile` on click.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActorProfile {
|
||||
pub did: String,
|
||||
/// Without a leading `@`; falls back to a truncated DID and is
|
||||
/// never null.
|
||||
pub handle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /api/followers` / `GET /api/following` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActorListResponse {
|
||||
pub profiles: Vec<ActorProfile>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
// -- Langs newtype ----------------------------------------------------------
|
||||
|
||||
/// A list of language tags. Always serialises as `Vec<String>`, never
|
||||
|
||||
Reference in New Issue
Block a user