@@ -9,7 +9,9 @@ use nostr::Event;
99use sqlx:: { PgPool , QueryBuilder , Row } ;
1010use uuid:: Uuid ;
1111
12- use buzz_core:: kind:: { event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH } ;
12+ use buzz_core:: kind:: {
13+ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH , KIND_EVENT_REMINDER ,
14+ } ;
1315use buzz_core:: StoredEvent ;
1416
1517use crate :: error:: { DbError , Result } ;
@@ -96,6 +98,26 @@ pub fn extract_d_tag(event: &Event) -> Option<String> {
9698 Some ( val)
9799}
98100
101+ /// Extract the `not_before` timestamp for materialization in the `events` table.
102+ ///
103+ /// Only applies to `kind:30300` (NIP-ER event reminders). Returns the first
104+ /// valid `not_before` tag value as an `i64` Unix timestamp, or `None` if the
105+ /// event is not a reminder or has no `not_before` tag.
106+ pub fn extract_not_before ( event : & Event ) -> Option < i64 > {
107+ let kind_u32 = event. kind . as_u16 ( ) as u32 ;
108+ if kind_u32 != KIND_EVENT_REMINDER {
109+ return None ;
110+ }
111+ event. tags . iter ( ) . find_map ( |tag| {
112+ let parts = tag. as_slice ( ) ;
113+ if parts. len ( ) >= 2 && parts[ 0 ] == "not_before" {
114+ parts[ 1 ] . parse :: < i64 > ( ) . ok ( )
115+ } else {
116+ None
117+ }
118+ } )
119+ }
120+
99121/// Insert a Nostr event. Rejects AUTH and ephemeral kinds.
100122///
101123/// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate.
@@ -125,10 +147,11 @@ pub async fn insert_event(
125147 . ok_or ( DbError :: InvalidTimestamp ( created_at_secs) ) ?;
126148 let received_at = Utc :: now ( ) ;
127149 let d_tag = extract_d_tag ( event) ;
150+ let not_before = extract_not_before ( event) ;
128151 let result = sqlx:: query (
129152 r#"
130- INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
131- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
153+ INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before )
154+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 )
132155 ON CONFLICT DO NOTHING
133156 "# ,
134157 )
@@ -142,6 +165,7 @@ pub async fn insert_event(
142165 . bind ( received_at)
143166 . bind ( channel_id)
144167 . bind ( d_tag. as_deref ( ) )
168+ . bind ( not_before)
145169 . execute ( pool)
146170 . await ?;
147171
@@ -842,13 +866,14 @@ pub async fn insert_event_with_thread_metadata(
842866 . ok_or ( DbError :: InvalidTimestamp ( created_at_secs) ) ?;
843867 let received_at = Utc :: now ( ) ;
844868 let d_tag = extract_d_tag ( event) ;
869+ let not_before = extract_not_before ( event) ;
845870 let mut tx = pool. begin ( ) . await ?;
846871
847872 // ── Insert event ──────────────────────────────────────────────────────────
848873 let result = sqlx:: query (
849874 r#"
850- INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag)
851- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
875+ INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before )
876+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 )
852877 ON CONFLICT DO NOTHING
853878 "# ,
854879 )
@@ -862,6 +887,7 @@ pub async fn insert_event_with_thread_metadata(
862887 . bind ( received_at)
863888 . bind ( channel_id)
864889 . bind ( d_tag. as_deref ( ) )
890+ . bind ( not_before)
865891 . execute ( & mut * tx)
866892 . await ?;
867893
@@ -981,6 +1007,101 @@ pub async fn insert_event_with_thread_metadata(
9811007 ) )
9821008}
9831009
1010+ /// A due reminder row returned by [`query_due_reminders`].
1011+ #[ derive( Debug ) ]
1012+ pub struct DueReminder {
1013+ /// The event's raw ID bytes.
1014+ pub id : Vec < u8 > ,
1015+ /// The event's pubkey bytes.
1016+ pub pubkey : Vec < u8 > ,
1017+ /// The event's `created_at` timestamp.
1018+ pub created_at : DateTime < Utc > ,
1019+ /// The event's kind (always 30300).
1020+ pub kind : i32 ,
1021+ /// The event's JSONB tags.
1022+ pub tags : serde_json:: Value ,
1023+ /// The event's encrypted content.
1024+ pub content : String ,
1025+ /// The event's signature bytes.
1026+ pub sig : Vec < u8 > ,
1027+ /// The channel ID (always None for reminders — global events).
1028+ pub channel_id : Option < Uuid > ,
1029+ }
1030+
1031+ /// Query due reminders: latest-per-address `kind:30300` rows where
1032+ /// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`.
1033+ ///
1034+ /// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16
1035+ /// ordering (`created_at DESC, id ASC`).
1036+ pub async fn query_due_reminders (
1037+ pool : & PgPool ,
1038+ now_secs : i64 ,
1039+ batch_limit : i64 ,
1040+ ) -> Result < Vec < DueReminder > > {
1041+ let kind_i32 = KIND_EVENT_REMINDER as i32 ;
1042+ let rows = sqlx:: query (
1043+ r#"
1044+ SELECT DISTINCT ON (pubkey, d_tag)
1045+ id, pubkey, created_at, kind, tags, content, sig, channel_id
1046+ FROM events
1047+ WHERE kind = $1
1048+ AND not_before IS NOT NULL
1049+ AND not_before <= $2
1050+ AND deleted_at IS NULL
1051+ AND delivered_at IS NULL
1052+ ORDER BY pubkey, d_tag, created_at DESC, id ASC
1053+ LIMIT $3
1054+ "# ,
1055+ )
1056+ . bind ( kind_i32)
1057+ . bind ( now_secs)
1058+ . bind ( batch_limit)
1059+ . fetch_all ( pool)
1060+ . await ?;
1061+
1062+ let results = rows
1063+ . into_iter ( )
1064+ . map ( |row| DueReminder {
1065+ id : row. get ( "id" ) ,
1066+ pubkey : row. get ( "pubkey" ) ,
1067+ created_at : row. get ( "created_at" ) ,
1068+ kind : row. get ( "kind" ) ,
1069+ tags : row. get ( "tags" ) ,
1070+ content : row. get ( "content" ) ,
1071+ sig : row. get ( "sig" ) ,
1072+ channel_id : row. get ( "channel_id" ) ,
1073+ } )
1074+ . collect ( ) ;
1075+
1076+ Ok ( results)
1077+ }
1078+
1079+ /// Atomically claim a due reminder for delivery. Returns `Some(id)` if this
1080+ /// caller won the claim (set `delivered_at`), or `None` if another pod already
1081+ /// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod
1082+ /// idempotency.
1083+ pub async fn claim_due_reminder (
1084+ pool : & PgPool ,
1085+ event_id : & [ u8 ] ,
1086+ event_created_at : DateTime < Utc > ,
1087+ ) -> Result < bool > {
1088+ let now_epoch = Utc :: now ( ) . timestamp ( ) ;
1089+ let result = sqlx:: query (
1090+ r#"
1091+ UPDATE events
1092+ SET delivered_at = $1
1093+ WHERE created_at = $2 AND id = $3 AND delivered_at IS NULL
1094+ "# ,
1095+ )
1096+ . bind ( now_epoch)
1097+ . bind ( event_created_at)
1098+ . bind ( event_id)
1099+ . execute ( pool)
1100+ . await ?;
1101+
1102+ Ok ( result. rows_affected ( ) > 0 )
1103+ }
1104+
9841105#[ cfg( test) ]
9851106mod tests {
9861107 use super :: * ;
@@ -1082,4 +1203,43 @@ mod tests {
10821203 assert_eq ! ( result. len( ) , 2048 ) ;
10831204 assert_eq ! ( result, long_val) ;
10841205 }
1206+
1207+ #[ test]
1208+ fn extract_not_before_from_reminder ( ) {
1209+ let event = make_event_with_kind_and_tags (
1210+ KIND_EVENT_REMINDER as u16 ,
1211+ vec ! [ Tag :: parse( [ "not_before" , "1717000000" ] ) . unwrap( ) ] ,
1212+ ) ;
1213+ assert_eq ! ( extract_not_before( & event) , Some ( 1_717_000_000 ) ) ;
1214+ }
1215+
1216+ #[ test]
1217+ fn extract_not_before_absent_returns_none ( ) {
1218+ // A bookmark/terminal reminder carries no `not_before` tag.
1219+ let event = make_event_with_kind_and_tags (
1220+ KIND_EVENT_REMINDER as u16 ,
1221+ vec ! [ Tag :: parse( [ "d" , "abc" ] ) . unwrap( ) ] ,
1222+ ) ;
1223+ assert_eq ! ( extract_not_before( & event) , None ) ;
1224+ }
1225+
1226+ #[ test]
1227+ fn extract_not_before_non_reminder_returns_none ( ) {
1228+ // Only kind:30300 materializes `not_before`; other kinds stay NULL.
1229+ let event = make_event_with_kind_and_tags (
1230+ 30023 ,
1231+ vec ! [ Tag :: parse( [ "not_before" , "1717000000" ] ) . unwrap( ) ] ,
1232+ ) ;
1233+ assert_eq ! ( extract_not_before( & event) , None ) ;
1234+ }
1235+
1236+ #[ test]
1237+ fn extract_not_before_non_numeric_returns_none ( ) {
1238+ // Malformed values are rejected by ingest; materialization just skips them.
1239+ let event = make_event_with_kind_and_tags (
1240+ KIND_EVENT_REMINDER as u16 ,
1241+ vec ! [ Tag :: parse( [ "not_before" , "not-a-number" ] ) . unwrap( ) ] ,
1242+ ) ;
1243+ assert_eq ! ( extract_not_before( & event) , None ) ;
1244+ }
10851245}
0 commit comments