feat: soft-delete for events/channels, enriched API responses, NIP-29 group management (#17)

This commit is contained in:
tlongwell-block
2026-03-10 16:01:32 -04:00
committed by GitHub
parent 3e7c9d900a
commit 69b36d11dd
12 changed files with 866 additions and 59 deletions
+1
View File
@@ -218,6 +218,7 @@ Sprout is designed as a complete platform, not a collection of independent micro
| ✅ | Nostr proxy (`sprout-proxy`) — guest client compatibility |
| ✅ | Huddle (`sprout-huddle`) — LiveKit integration |
| ✅ | Admin CLI (`sprout-admin`) |
| ✅ | Channel features — messaging, threads, DMs, reactions, NIP-29 group management, soft-delete |
| 🚧 | Web client (Tauri) — Stream, Forum, DM, Search |
| ✅ | Workflow engine (`sprout-workflow`) — YAML-as-code, 4 trigger types, 7 action types, approval gates, execution traces |
| ✅ | Home Feed (`/api/feed`) — @mentions, needs-action, channel activity, agent activity |
+68 -10
View File
@@ -511,8 +511,9 @@ pub async fn remove_member(
pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Result<bool> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM channel_members \
WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
"SELECT COUNT(*) as cnt FROM channel_members cm \
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
)
.bind(&channel_id_bytes)
.bind(pubkey)
@@ -523,14 +524,17 @@ pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Res
}
/// Returns all active members of the given channel.
///
/// Returns an empty list if the channel has been soft-deleted.
pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<MemberRecord>> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let rows = sqlx::query(
r#"
SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at
FROM channel_members
WHERE channel_id = ? AND removed_at IS NULL
ORDER BY joined_at ASC
SELECT cm.channel_id, cm.pubkey, cm.role, cm.joined_at, cm.invited_by, cm.removed_at
FROM channel_members cm
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.channel_id = ? AND cm.removed_at IS NULL
ORDER BY cm.joined_at ASC
LIMIT 1000
"#,
)
@@ -547,9 +551,10 @@ pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<Membe
pub async fn get_accessible_channel_ids(pool: &MySqlPool, pubkey: &[u8]) -> Result<Vec<Uuid>> {
let rows = sqlx::query(
r#"
SELECT channel_id
FROM channel_members
WHERE pubkey = ? AND removed_at IS NULL
SELECT cm.channel_id
FROM channel_members cm
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.pubkey = ? AND cm.removed_at IS NULL
UNION
SELECT id AS channel_id
FROM channels
@@ -1008,6 +1013,21 @@ pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()>
Ok(())
}
/// Soft-delete a channel by setting `deleted_at = NOW(6)`.
///
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
/// deleted or not found.
pub async fn soft_delete_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<bool> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let result =
sqlx::query("UPDATE channels SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
.bind(&id_bytes)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Returns the count of active (non-removed) members in a channel.
pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
@@ -1020,6 +1040,42 @@ pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64>
Ok(row.try_get("cnt")?)
}
/// Bulk-fetch member counts for a set of channel IDs.
///
/// Returns a map of `channel_id → count`. Channels with zero members are omitted.
/// Single query regardless of input size.
pub async fn get_member_counts_bulk(
pool: &MySqlPool,
channel_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, i64>> {
use crate::event::uuid_from_bytes;
if channel_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let mut qb: sqlx::QueryBuilder<sqlx::MySql> = sqlx::QueryBuilder::new(
"SELECT channel_id, COUNT(*) as cnt FROM channel_members \
WHERE removed_at IS NULL AND channel_id IN (",
);
let mut sep = qb.separated(", ");
for id in channel_ids {
sep.push_bind(id.as_bytes().to_vec());
}
qb.push(") GROUP BY channel_id");
let rows = qb.build().fetch_all(pool).await?;
let mut map = std::collections::HashMap::with_capacity(rows.len());
for row in rows {
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
let id = uuid_from_bytes(&id_bytes)?;
let cnt: i64 = row.try_get("cnt")?;
map.insert(id, cnt);
}
Ok(map)
}
/// Get the active role of a pubkey in a channel.
///
/// Returns `None` if the pubkey is not an active member.
@@ -1030,7 +1086,9 @@ pub async fn get_member_role(
) -> Result<Option<String>> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT role FROM channel_members WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
"SELECT cm.role FROM channel_members cm \
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
)
.bind(&channel_id_bytes)
.bind(pubkey)
+296 -2
View File
@@ -99,7 +99,7 @@ pub async fn query_events(pool: &MySqlPool, q: &EventQuery) -> Result<Vec<Stored
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE 1=1",
FROM events WHERE deleted_at IS NULL",
);
if let Some(ch) = q.channel_id {
@@ -185,8 +185,154 @@ pub(crate) fn row_to_stored_event(row: sqlx::mysql::MySqlRow) -> Result<Option<S
)))
}
/// Fetches a single event by its raw 32-byte ID. Returns `None` if not found.
/// Soft-delete an event by setting `deleted_at = NOW(6)`.
///
/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted
/// or not found. Callers are responsible for decrementing thread reply counts
/// when the deleted event is a thread reply.
pub async fn soft_delete_event(pool: &MySqlPool, event_id: &[u8]) -> Result<bool> {
let result =
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
.bind(event_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Atomically soft-delete an event and decrement thread reply counters.
///
/// Wraps the delete + counter update in a single transaction so a crash between
/// them cannot leave counters permanently inflated. Returns `Ok(true)` if the
/// event was deleted this call.
pub async fn soft_delete_event_and_update_thread(
pool: &MySqlPool,
event_id: &[u8],
parent_event_id: Option<&[u8]>,
root_event_id: Option<&[u8]>,
) -> Result<bool> {
let mut tx = pool.begin().await?;
let result =
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
.bind(event_id)
.execute(&mut *tx)
.await?;
let deleted = result.rows_affected() > 0;
if deleted {
if let Some(pid) = parent_event_id {
sqlx::query(
"UPDATE thread_metadata \
SET reply_count = GREATEST(reply_count - 1, 0) \
WHERE event_id = ?",
)
.bind(pid)
.execute(&mut *tx)
.await?;
if let Some(root_id) = root_event_id {
sqlx::query(
"UPDATE thread_metadata \
SET descendant_count = GREATEST(descendant_count - 1, 0) \
WHERE event_id = ?",
)
.bind(root_id)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
Ok(deleted)
}
/// Returns the `created_at` timestamp of the most recent non-deleted event in a channel.
pub async fn get_last_message_at(
pool: &MySqlPool,
channel_id: uuid::Uuid,
) -> Result<Option<DateTime<Utc>>> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT created_at FROM events \
WHERE channel_id = ? AND deleted_at IS NULL \
ORDER BY created_at DESC LIMIT 1",
)
.bind(&id_bytes)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some(r.try_get("created_at")?)),
None => Ok(None),
}
}
/// Bulk-fetch the most recent `created_at` for a set of channel IDs.
///
/// Returns a map of `channel_id → last_message_at`. Channels with no events are omitted.
/// Single query regardless of input size.
pub async fn get_last_message_at_bulk(
pool: &MySqlPool,
channel_ids: &[uuid::Uuid],
) -> Result<std::collections::HashMap<uuid::Uuid, DateTime<Utc>>> {
if channel_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
"SELECT channel_id, MAX(created_at) as last_at FROM events \
WHERE deleted_at IS NULL AND channel_id IN (",
);
let mut sep = qb.separated(", ");
for id in channel_ids {
sep.push_bind(id.as_bytes().to_vec());
}
qb.push(") GROUP BY channel_id");
let rows = qb.build().fetch_all(pool).await?;
let mut map = std::collections::HashMap::with_capacity(rows.len());
for row in rows {
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
let id = uuid_from_bytes(&id_bytes)?;
let last_at: DateTime<Utc> = row.try_get("last_at")?;
map.insert(id, last_at);
}
Ok(map)
}
/// Fetches a single non-deleted event by its raw 32-byte ID.
///
/// Returns `None` if the event does not exist or has been soft-deleted.
/// Use [`get_event_by_id_including_deleted`] when you need to inspect
/// tombstoned rows (e.g. audit, undelete).
pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
let row = sqlx::query(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
)
.bind(id_bytes)
.fetch_optional(pool)
.await?;
match row {
Some(r) => row_to_stored_event(r),
None => Ok(None),
}
}
/// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**.
///
/// Most callers should use [`get_event_by_id`] instead. This variant is needed
/// when the caller must distinguish "never existed" from "was deleted" (e.g.
/// audit trails, compliance queries).
pub async fn get_event_by_id_including_deleted(
pool: &MySqlPool,
id_bytes: &[u8],
) -> Result<Option<StoredEvent>> {
let row = sqlx::query(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE id = ? ORDER BY created_at DESC LIMIT 1",
@@ -201,6 +347,154 @@ pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option
}
}
/// Parameters for [`insert_event_with_thread_metadata`].
#[derive(Debug)]
pub struct ThreadMetadataParams<'a> {
/// The Nostr event ID of this message.
pub event_id: &'a [u8],
/// When the event was created.
pub event_created_at: DateTime<Utc>,
/// The channel this event belongs to.
pub channel_id: Uuid,
/// Event ID of the direct parent, if this is a reply.
pub parent_event_id: Option<&'a [u8]>,
/// When the parent event was created.
pub parent_event_created_at: Option<DateTime<Utc>>,
/// Event ID of the thread root, if this is a nested reply.
pub root_event_id: Option<&'a [u8]>,
/// When the root event was created.
pub root_event_created_at: Option<DateTime<Utc>>,
/// Nesting depth (root = 0).
pub depth: i32,
/// Whether this reply is broadcast to the channel timeline.
pub broadcast: bool,
}
/// Atomically insert an event AND its thread metadata in a single transaction.
///
/// This prevents the race condition where a concurrent delete between separate
/// `insert_event` and `insert_thread_metadata` calls could leave reply counters
/// permanently inflated (the metadata insert increments counters for an event
/// that was already soft-deleted).
///
/// Returns `(StoredEvent, was_inserted)`.
pub async fn insert_event_with_thread_metadata(
pool: &MySqlPool,
event: &Event,
channel_id: Option<Uuid>,
thread_meta: Option<ThreadMetadataParams<'_>>,
) -> Result<(StoredEvent, bool)> {
let kind_u16 = event.kind.as_u16();
let kind_u32 = u32::from(kind_u16);
if kind_u32 == KIND_AUTH {
return Err(DbError::AuthEventRejected);
}
if is_ephemeral(kind_u32) {
return Err(DbError::EphemeralEventRejected(kind_u16));
}
let id_bytes = event.id.as_bytes();
let pubkey_bytes = event.pubkey.to_bytes();
let sig_bytes = event.sig.serialize();
let tags_json = serde_json::to_value(&event.tags)?;
let kind_i32 = event_kind_i32(event);
let created_at_secs = event.created_at.as_u64() as i64;
let created_at = DateTime::from_timestamp(created_at_secs, 0)
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
let received_at = Utc::now();
let channel_id_bytes: Option<[u8; 16]> = channel_id.map(|u| *u.as_bytes());
let mut tx = pool.begin().await?;
// ── Insert event ──────────────────────────────────────────────────────────
let result = sqlx::query(
r#"
INSERT IGNORE INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(id_bytes.as_slice())
.bind(pubkey_bytes.as_slice())
.bind(created_at)
.bind(kind_i32)
.bind(&tags_json)
.bind(&event.content)
.bind(sig_bytes.as_slice())
.bind(received_at)
.bind(channel_id_bytes.as_ref().map(|b| b.as_slice()))
.execute(&mut *tx)
.await?;
let was_inserted = result.rows_affected() > 0;
// ── Insert thread metadata (if provided and event was actually inserted) ──
if was_inserted {
if let Some(ref meta) = thread_meta {
let ch_bytes = meta.channel_id.as_bytes().as_slice().to_vec();
let broadcast_val: i8 = if meta.broadcast { 1 } else { 0 };
let tm_result = sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(meta.event_created_at)
.bind(meta.event_id)
.bind(ch_bytes.as_slice())
.bind(meta.parent_event_id)
.bind(meta.parent_event_created_at)
.bind(meta.root_event_id)
.bind(meta.root_event_created_at)
.bind(meta.depth)
.bind(broadcast_val)
.execute(&mut *tx)
.await?;
// Only bump reply counts if the metadata row was actually inserted.
if tm_result.rows_affected() > 0 {
if let Some(pid) = meta.parent_event_id {
sqlx::query(
r#"
UPDATE thread_metadata
SET reply_count = reply_count + 1, last_reply_at = NOW(6)
WHERE event_id = ?
"#,
)
.bind(pid)
.execute(&mut *tx)
.await?;
if let Some(root_id) = meta.root_event_id {
sqlx::query(
r#"
UPDATE thread_metadata
SET descendant_count = descendant_count + 1
WHERE event_id = ?
"#,
)
.bind(root_id)
.execute(&mut *tx)
.await?;
}
}
}
}
}
tx.commit().await?;
Ok((
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
was_inserted,
))
}
/// Convert raw BINARY(16) bytes to a [`Uuid`].
pub(crate) fn uuid_from_bytes(bytes: &[u8]) -> Result<Uuid> {
Uuid::from_slice(bytes).map_err(|e| DbError::InvalidData(format!("invalid UUID: {e}")))
+85 -1
View File
@@ -139,11 +139,70 @@ impl Db {
event::query_events(&self.pool, q).await
}
/// Fetches a single event by its raw ID bytes. Returns `None` if not found.
/// Fetches a single non-deleted event by its raw ID bytes.
///
/// Returns `None` if the event does not exist or has been soft-deleted.
pub async fn get_event_by_id(&self, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
event::get_event_by_id(&self.pool, id_bytes).await
}
/// Fetches a single event by ID, **including soft-deleted rows**.
///
/// Most callers should use [`get_event_by_id`] instead.
pub async fn get_event_by_id_including_deleted(
&self,
id_bytes: &[u8],
) -> Result<Option<StoredEvent>> {
event::get_event_by_id_including_deleted(&self.pool, id_bytes).await
}
/// Atomically insert an event and its thread metadata in one transaction.
///
/// Prevents the race where a concurrent delete between separate insert calls
/// could leave reply counters permanently inflated.
pub async fn insert_event_with_thread_metadata(
&self,
ev: &nostr::Event,
channel_id: Option<Uuid>,
thread_meta: Option<event::ThreadMetadataParams<'_>>,
) -> Result<(StoredEvent, bool)> {
event::insert_event_with_thread_metadata(&self.pool, ev, channel_id, thread_meta).await
}
/// Soft-delete an event. Returns `true` if the event was deleted.
pub async fn soft_delete_event(&self, event_id: &[u8]) -> Result<bool> {
event::soft_delete_event(&self.pool, event_id).await
}
/// Atomically soft-delete an event and decrement thread counters in one transaction.
pub async fn soft_delete_event_and_update_thread(
&self,
event_id: &[u8],
parent_event_id: Option<&[u8]>,
root_event_id: Option<&[u8]>,
) -> Result<bool> {
event::soft_delete_event_and_update_thread(
&self.pool,
event_id,
parent_event_id,
root_event_id,
)
.await
}
/// Returns the timestamp of the most recent non-deleted event in a channel.
pub async fn get_last_message_at(&self, channel_id: Uuid) -> Result<Option<DateTime<Utc>>> {
event::get_last_message_at(&self.pool, channel_id).await
}
/// Bulk-fetch last message timestamps for multiple channels in one query.
pub async fn get_last_message_at_bulk(
&self,
channel_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, DateTime<Utc>>> {
event::get_last_message_at_bulk(&self.pool, channel_ids).await
}
// ── Feed ─────────────────────────────────────────────────────────────────
/// Returns events that mention `pubkey` in the given channels.
@@ -309,11 +368,24 @@ impl Db {
channel::unarchive_channel(&self.pool, channel_id).await
}
/// Soft-delete a channel. Returns `true` if the channel was deleted.
pub async fn soft_delete_channel(&self, channel_id: Uuid) -> Result<bool> {
channel::soft_delete_channel(&self.pool, channel_id).await
}
/// Returns the count of active members in a channel.
pub async fn get_member_count(&self, channel_id: Uuid) -> Result<i64> {
channel::get_member_count(&self.pool, channel_id).await
}
/// Bulk-fetch member counts for multiple channels in one query.
pub async fn get_member_counts_bulk(
&self,
channel_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, i64>> {
channel::get_member_counts_bulk(&self.pool, channel_ids).await
}
/// Returns the active role of a pubkey in a channel.
pub async fn get_member_role(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<Option<String>> {
channel::get_member_role(&self.pool, channel_id, pubkey).await
@@ -379,6 +451,18 @@ impl Db {
thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before).await
}
/// Decrement reply counts when a thread reply is deleted.
///
/// Decrements `reply_count` on the parent and `descendant_count` on the root
/// (floor at 0). Mirrors [`thread::increment_reply_count`] exactly.
pub async fn decrement_reply_count(
&self,
parent_event_id: &[u8],
root_event_id: Option<&[u8]>,
) -> Result<()> {
thread::decrement_reply_count(&self.pool, parent_event_id, root_event_id).await
}
/// Fetch a raw thread_metadata row by event ID.
pub async fn get_thread_metadata_by_event(
&self,
+5
View File
@@ -186,6 +186,11 @@ pub async fn insert_thread_metadata(
/// root — even when root == parent (direct reply to root). This is correct
/// because `reply_count` tracks direct children only, while `descendant_count`
/// tracks ALL descendants at every nesting level.
///
/// NOTE: The primary increment path is inlined inside [`insert_thread_metadata`]'s
/// transaction. This standalone version exists for future use cases where
/// incrementing outside of insert is needed (e.g., event re-parenting).
#[allow(dead_code)]
pub async fn increment_reply_count(
pool: &MySqlPool,
parent_event_id: &[u8],
+31 -1
View File
@@ -36,6 +36,20 @@ pub async fn channels_handler(
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
// Bulk-fetch member counts and last-message timestamps in two queries
// instead of 2N queries (one per channel per metric).
let channel_ids: Vec<uuid::Uuid> = channels.iter().map(|ch| ch.id).collect();
let member_counts = state
.db
.get_member_counts_bulk(&channel_ids)
.await
.unwrap_or_default();
let last_messages = state
.db
.get_last_message_at_bulk(&channel_ids)
.await
.unwrap_or_default();
let mut result = Vec::with_capacity(channels.len());
for ch in &channels {
@@ -45,10 +59,15 @@ pub async fn channels_handler(
(vec![], vec![])
};
let member_count = member_counts.get(&ch.id).copied().unwrap_or(0);
let last_message_at = last_messages.get(&ch.id).copied();
result.push(channel_record_to_json(
ch,
participants,
participant_pubkeys,
member_count,
last_message_at,
));
}
@@ -120,7 +139,7 @@ pub async fn create_channel(
Ok((
StatusCode::CREATED,
Json(channel_record_to_json(&channel, vec![], vec![])),
Json(channel_record_to_json(&channel, vec![], vec![], 1, None)),
))
}
@@ -128,12 +147,23 @@ fn channel_record_to_json(
channel: &ChannelRecord,
participants: Vec<String>,
participant_pubkeys: Vec<String>,
member_count: i64,
last_message_at: Option<chrono::DateTime<chrono::Utc>>,
) -> serde_json::Value {
serde_json::json!({
"id": channel.id.to_string(),
"name": &channel.name,
"channel_type": &channel.channel_type,
"visibility": &channel.visibility,
"description": channel.description.clone().unwrap_or_default(),
"topic": channel.topic,
"purpose": channel.purpose,
"created_by": nostr_hex::encode(&channel.created_by),
"created_at": channel.created_at.to_rfc3339(),
"updated_at": channel.updated_at.to_rfc3339(),
"archived_at": channel.archived_at.map(|t| t.to_rfc3339()),
"member_count": member_count,
"last_message_at": last_message_at.map(|t| t.to_rfc3339()),
"participants": participants,
"participant_pubkeys": participant_pubkeys,
})
@@ -428,3 +428,54 @@ pub async fn unarchive_channel_handler(
Ok(Json(serde_json::json!({ "ok": true })))
}
/// DELETE /api/channels/{channel_id} — Soft-delete a channel.
///
/// Requires owner role. Sets `deleted_at` on the channel record; the channel
/// becomes invisible to all queries that filter on `deleted_at IS NULL`.
pub async fn delete_channel_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let channel_id = parse_channel_id(&channel_id_str)?;
// Only channel owners may delete.
let role = state
.db
.get_member_role(channel_id, &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
match role.as_deref() {
Some("owner") => {}
Some(_) => return Err(forbidden("only channel owner can delete")),
None => return Err(forbidden("not a member of this channel")),
}
let deleted = state
.db
.soft_delete_channel(channel_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if !deleted {
return Err(api_error(StatusCode::CONFLICT, "channel already deleted"));
}
let actor_hex = nostr_hex::encode(&pubkey_bytes);
if let Err(e) = emit_system_message(
&state,
channel_id,
serde_json::json!({
"type": "channel_deleted",
"actor": actor_hex,
}),
)
.await
{
tracing::warn!("Failed to emit system message: {e}");
}
Ok(Json(serde_json::json!({ "ok": true, "deleted": true })))
}
+192 -30
View File
@@ -29,7 +29,44 @@ use serde::Deserialize;
use crate::state::AppState;
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
use super::{
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
};
/// Extract the effective message author from a stored event.
///
/// REST-created messages are signed by the relay keypair and attribute the real
/// sender via a `p` tag. For user-signed events (WebSocket), `event.pubkey` is
/// the author. This helper returns the correct author bytes in both cases.
fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
if event.pubkey == *relay_pubkey {
// Relay-signed: real author is in the first p tag.
for tag in event.tags.iter() {
if tag.kind().to_string() == "p" {
if let Some(hex) = tag.content() {
if let Ok(bytes) = nostr_hex::decode(hex) {
if bytes.len() == 32 {
return bytes;
}
}
}
}
}
}
// User-signed or no p tag found: pubkey is the author.
event.pubkey.serialize().to_vec()
}
/// Serialize a slice of reaction summaries to JSON.
fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value {
serde_json::json!(reactions
.iter()
.map(|r| serde_json::json!({
"emoji": r.emoji,
"count": r.count,
}))
.collect::<Vec<_>>())
}
// ── POST /api/channels/:channel_id/messages ───────────────────────────────────
@@ -215,32 +252,26 @@ pub async fn send_message(
chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
};
// ── Persist event ─────────────────────────────────────────────────────────
// ── Persist event + thread metadata atomically ──────────────────────────
let thread_meta = Some(sprout_db::event::ThreadMetadataParams {
event_id: &event_id_bytes,
event_created_at,
channel_id,
parent_event_id: parent_id_bytes.as_deref(),
parent_event_created_at: parent_created_at,
root_event_id: root_id_bytes.as_deref(),
root_event_created_at: root_created_at,
depth,
broadcast: body.broadcast_to_channel,
});
state
.db
.insert_event(&event, Some(channel_id))
.insert_event_with_thread_metadata(&event, Some(channel_id), thread_meta)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
// ── Persist thread metadata ───────────────────────────────────────────────
state
.db
.insert_thread_metadata(
&event_id_bytes,
event_created_at,
channel_id,
parent_id_bytes.as_deref(),
parent_created_at,
root_id_bytes.as_deref(),
root_created_at,
depth,
body.broadcast_to_channel,
)
.await
.map_err(|e| internal_error(&format!("thread metadata error: {e}")))?;
// ── Response ──────────────────────────────────────────────────────────────
Ok(Json(serde_json::json!({
@@ -252,6 +283,82 @@ pub async fn send_message(
})))
}
// ── DELETE /api/messages/:event_id ─────────────────────────────────────────────
/// Soft-delete a message by event ID.
///
/// Authorization: the caller must be the message author, or an owner/admin of
/// the channel the message belongs to. If the deleted event is a thread reply,
/// parent/root reply counts are decremented.
pub async fn delete_message(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(event_id_hex): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let event_id_bytes = nostr_hex::decode(&event_id_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
// Look up the event to check ownership and channel.
let stored = state
.db
.get_event_by_id(&event_id_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.ok_or_else(|| not_found("event not found"))?;
let channel_id = stored
.channel_id
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
// Auth: must be the message author OR an owner/admin of the channel.
// For relay-signed REST messages, the real author is in the p tag.
let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
let is_author = author_bytes == pubkey_bytes;
if !is_author {
let role = state
.db
.get_member_role(channel_id, &pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
match role.as_deref() {
Some("owner") | Some("admin") => {} // authorized
_ => return Err(forbidden("must be message author or channel owner/admin")),
}
}
// Look up thread metadata before deleting so we can pass parent/root IDs
// to the transactional delete function. Fail hard on DB errors to avoid
// deleting the event without decrementing counters.
let meta = state
.db
.get_thread_metadata_by_event(&event_id_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone());
let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone());
// Atomically soft-delete the event and decrement thread counters in one transaction.
let deleted = state
.db
.soft_delete_event_and_update_thread(
&event_id_bytes,
parent_id.as_deref(),
root_id.as_deref(),
)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if !deleted {
return Err(api_error(StatusCode::CONFLICT, "event already deleted"));
}
Ok(Json(serde_json::json!({ "ok": true, "deleted": true })))
}
// ── GET /api/channels/:channel_id/messages ────────────────────────────────────
/// Query parameters for listing top-level channel messages.
@@ -297,15 +404,33 @@ pub async fn list_messages(
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
// Optionally enrich with thread summaries.
if params.with_threads {
for msg in &mut messages {
if let Ok(summary) = state.db.get_thread_summary(&msg.event_id).await {
msg.thread_summary = summary;
}
// Always enrich with thread summaries for messages that have replies.
// The `with_threads` param is kept for backward compatibility but summaries
// are now included by default.
for msg in &mut messages {
if let Ok(summary) = state.db.get_thread_summary(&msg.event_id).await {
msg.thread_summary = summary;
}
}
// Bulk-fetch reaction counts for all messages in this page.
let event_pairs: Vec<(&[u8], chrono::DateTime<Utc>)> = messages
.iter()
.map(|m| (m.event_id.as_slice(), m.created_at))
.collect();
let bulk_reactions = state
.db
.get_reactions_bulk(&event_pairs)
.await
.unwrap_or_default();
// Index reactions by event_id for O(1) lookup during serialization.
let reaction_map: std::collections::HashMap<Vec<u8>, &[sprout_db::reaction::ReactionSummary]> =
bulk_reactions
.iter()
.map(|entry| (entry.event_id.clone(), entry.reactions.as_slice()))
.collect();
// Determine next_cursor from the oldest message in this page.
let next_cursor = messages.last().map(|m| m.created_at.timestamp());
@@ -332,6 +457,11 @@ pub async fn list_messages(
});
}
// Embed reaction counts if any exist for this message.
if let Some(reactions) = reaction_map.get(&m.event_id) {
obj["reactions"] = reactions_to_json(reactions);
}
obj
})
.collect();
@@ -443,7 +573,7 @@ pub async fn get_thread(
// Serialize root event.
let root_created_at = root_event.event.created_at.as_u64() as i64;
let root_obj = serde_json::json!({
let mut root_obj = serde_json::json!({
"event_id": root_event.event.id.to_hex(),
"pubkey": root_event.event.pubkey.to_hex(),
"content": root_event.event.content,
@@ -460,11 +590,37 @@ pub async fn get_thread(
})),
});
// Bulk-fetch reaction counts for root + all replies.
let root_created_at_dt =
chrono::DateTime::from_timestamp(root_created_at, 0).unwrap_or_else(Utc::now);
let mut thread_event_pairs: Vec<(&[u8], chrono::DateTime<Utc>)> =
vec![(root_id_bytes.as_slice(), root_created_at_dt)];
for r in &replies {
thread_event_pairs.push((r.event_id.as_slice(), r.created_at));
}
let thread_bulk_reactions = state
.db
.get_reactions_bulk(&thread_event_pairs)
.await
.unwrap_or_default();
let thread_reaction_map: std::collections::HashMap<
Vec<u8>,
&[sprout_db::reaction::ReactionSummary],
> = thread_bulk_reactions
.iter()
.map(|entry| (entry.event_id.clone(), entry.reactions.as_slice()))
.collect();
// Attach reactions to root event.
if let Some(reactions) = thread_reaction_map.get(&root_id_bytes) {
root_obj["reactions"] = reactions_to_json(reactions);
}
// Serialize replies.
let reply_objs: Vec<serde_json::Value> = replies
.iter()
.map(|r| {
serde_json::json!({
let mut obj = serde_json::json!({
"event_id": nostr_hex::encode(&r.event_id),
"parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode),
"root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode),
@@ -475,7 +631,13 @@ pub async fn get_thread(
"depth": r.depth,
"created_at": r.created_at.timestamp(),
"broadcast": r.broadcast,
})
});
if let Some(reactions) = thread_reaction_map.get(&r.event_id) {
obj["reactions"] = reactions_to_json(reactions);
}
obj
})
.collect();
+3 -3
View File
@@ -43,13 +43,13 @@ pub use agents::agents_handler;
pub use approvals::{deny_approval, grant_approval};
pub use channels::{channels_handler, create_channel};
pub use channels_metadata::{
archive_channel_handler, get_channel_handler, set_purpose_handler, set_topic_handler,
unarchive_channel_handler, update_channel_handler,
archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler,
set_topic_handler, unarchive_channel_handler, update_channel_handler,
};
pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
pub use feed::feed_handler;
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
pub use messages::{get_thread, list_messages, send_message};
pub use messages::{delete_message, get_thread, list_messages, send_message};
pub use presence::presence_handler;
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
pub use search::search_handler;
+128 -10
View File
@@ -145,13 +145,55 @@ pub async fn validate_admin_event(
}
}
9005 => {
// DELETE_EVENT: owner/admin or event author
// For now, just check membership
let is_member = state.db.is_member(channel_id, &actor_bytes).await?;
if is_member {
Ok(())
} else {
Err(anyhow::anyhow!("not a member"))
// DELETE_EVENT: event author OR channel owner/admin.
// Extract target event from e tag to check authorship.
let target_id = event
.tags
.iter()
.find_map(|tag| {
if tag.kind().to_string() == "e" {
tag.content().and_then(|v| hex::decode(v).ok())
} else {
None
}
})
.ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?;
// Verify the target event belongs to the h-tag channel BEFORE storage.
// Without this, an admin of channel A could craft h=A, e=<event-in-B>
// and the relay would store the invalid admin event.
if let Ok(Some(target_event)) = state.db.get_event_by_id(&target_id).await {
match target_event.channel_id {
Some(target_ch) if target_ch != channel_id => {
return Err(anyhow::anyhow!(
"target event belongs to a different channel"
));
}
None => {
return Err(anyhow::anyhow!("target event has no channel"));
}
_ => {} // Same channel — OK
}
// Check if actor is the event author.
// For relay-signed REST messages, the real author is in the p tag.
let author = effective_message_author(
&target_event.event,
&state.relay_keypair.public_key(),
);
if author == actor_bytes {
return Ok(()); // Author can always delete their own messages
}
}
// Not the author — must be owner/admin.
let members = state.db.get_members(channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
_ => Err(anyhow::anyhow!(
"must be event author or channel owner/admin"
)),
}
}
9008 => {
@@ -378,8 +420,50 @@ async fn handle_delete_event_side_effect(
})
.ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?;
// TODO: Add soft_delete_event to Db for full implementation
tracing::info!(target_event = %hex::encode(&target_id), "Would soft-delete event");
// Verify the target event belongs to the same channel as the h-tag.
// Without this check, an admin of channel A could delete events in channel B
// by sending h=A, e=<event-in-B>.
if let Some(target_event) = state
.db
.get_event_by_id_including_deleted(&target_id)
.await
.map_err(|e| anyhow::anyhow!("get_event_by_id failed: {e}"))?
{
match target_event.channel_id {
Some(target_ch) if target_ch != channel_id => {
return Err(anyhow::anyhow!(
"target event belongs to a different channel"
));
}
None => {
return Err(anyhow::anyhow!("target event has no channel"));
}
_ => {} // Same channel — OK
}
}
// Look up thread metadata so we can pass parent/root IDs to the
// transactional delete function.
let meta = state
.db
.get_thread_metadata_by_event(&target_id)
.await
.map_err(|e| anyhow::anyhow!("get_thread_metadata failed: {e}"))?;
let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone());
let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone());
// Atomically soft-delete the event and decrement thread counters in one transaction.
let deleted = state
.db
.soft_delete_event_and_update_thread(&target_id, parent_id.as_deref(), root_id.as_deref())
.await
.map_err(|e| anyhow::anyhow!("soft_delete_event failed: {e}"))?;
if !deleted {
warn!(target_event = %hex::encode(&target_id), "event already deleted or not found");
return Ok(()); // No-op: skip system message to avoid false audit records.
}
let actor_hex = nostr::util::hex::encode(event.pubkey.serialize());
emit_system_message(
@@ -393,6 +477,7 @@ async fn handle_delete_event_side_effect(
)
.await?;
info!(target_event = %hex::encode(&target_id), "NIP-29 DELETE_EVENT processed");
Ok(())
}
@@ -436,7 +521,17 @@ async fn handle_delete_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?;
let actor_bytes = event.pubkey.serialize().to_vec();
// TODO: Add soft_delete_channel to Db for full implementation
// Soft-delete the channel.
let deleted = state
.db
.soft_delete_channel(channel_id)
.await
.map_err(|e| anyhow::anyhow!("soft_delete_channel failed: {e}"))?;
if !deleted {
warn!(channel = %channel_id, "channel already deleted or not found");
}
let actor_hex = nostr::util::hex::encode(&actor_bytes);
emit_system_message(
state,
@@ -447,6 +542,7 @@ async fn handle_delete_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
)
.await?;
info!(channel = %channel_id, "NIP-29 DELETE_GROUP processed");
Ok(())
}
@@ -581,6 +677,28 @@ fn extract_p_tag(event: &Event) -> Option<Vec<u8>> {
None
}
/// Extract the effective message author from a stored event.
///
/// REST-created messages are signed by the relay keypair and attribute the real
/// sender via a `p` tag. For user-signed events (WebSocket), `event.pubkey` is
/// the author. Returns the correct author bytes in both cases.
fn effective_message_author(event: &Event, relay_pubkey: &nostr::PublicKey) -> Vec<u8> {
if event.pubkey == *relay_pubkey {
for tag in event.tags.iter() {
if tag.kind().to_string() == "p" {
if let Some(hex) = tag.content() {
if let Ok(bytes) = hex::decode(hex) {
if bytes.len() == 32 {
return bytes;
}
}
}
}
}
}
event.pubkey.serialize().to_vec()
}
/// Extract value of a named tag.
fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
for tag in event.tags.iter() {
+1 -1
View File
@@ -56,7 +56,7 @@ impl RelayInfo {
description: "Sprout — private team communication relay".to_string(),
pubkey: None,
contact: None,
supported_nips: vec![1, 11, 42],
supported_nips: vec![1, 11, 25, 29, 42],
software: "https://github.com/sprout-rs/sprout".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(RelayLimitation {
+5 -1
View File
@@ -62,7 +62,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
// Channel detail + metadata routes
.route(
"/api/channels/{channel_id}",
get(api::get_channel_handler).put(api::update_channel_handler),
get(api::get_channel_handler)
.put(api::update_channel_handler)
.delete(api::delete_channel_handler),
)
.route(
"/api/channels/{channel_id}/topic",
@@ -98,6 +100,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/dms/{channel_id}/members",
post(api::add_dm_member_handler),
)
// Message delete route
.route("/api/messages/{event_id}", delete(api::delete_message))
// Reaction routes
.route(
"/api/messages/{event_id}/reactions",