Skip to content

Commit 3e7c9d9

Browse files
feat: Channel management, messaging, threads, DMs, reactions, and NIP-29 support (#16)
1 parent 758049c commit 3e7c9d9

28 files changed

Lines changed: 5418 additions & 54 deletions

crates/sprout-core/src/kind.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
8686
pub const KIND_STREAM_REMINDER: u32 = 40007;
8787
/// Canvas (shared document) for a channel.
8888
pub const KIND_CANVAS: u32 = 40100;
89+
/// System message for channel state changes (join, leave, rename, etc.).
90+
pub const KIND_SYSTEM_MESSAGE: u32 = 40099;
8991

9092
// Direct messages (41000–41999)
9193
/// A new direct-message conversation was created.
@@ -225,6 +227,7 @@ pub const ALL_KINDS: &[u32] = &[
225227
KIND_STREAM_MESSAGE_SCHEDULED,
226228
KIND_STREAM_REMINDER,
227229
KIND_CANVAS,
230+
KIND_SYSTEM_MESSAGE,
228231
KIND_DM_CREATED,
229232
KIND_DM_MEMBER_ADDED,
230233
KIND_DM_MEMBER_REMOVED,

crates/sprout-db/src/channel.rs

Lines changed: 269 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ pub struct ChannelRecord {
166166
pub topic_required: bool,
167167
/// Optional cap on the number of members.
168168
pub max_members: Option<i32>,
169+
/// Current channel topic (short, visible in header).
170+
pub topic: Option<String>,
171+
/// Compressed public key bytes of the user who last set the topic.
172+
pub topic_set_by: Option<Vec<u8>>,
173+
/// When the topic was last set.
174+
pub topic_set_at: Option<DateTime<Utc>>,
175+
/// Channel purpose / description of intent.
176+
pub purpose: Option<String>,
177+
/// Compressed public key bytes of the user who last set the purpose.
178+
pub purpose_set_by: Option<Vec<u8>>,
179+
/// When the purpose was last set.
180+
pub purpose_set_at: Option<DateTime<Utc>>,
169181
}
170182

171183
/// A channel membership row as returned from the database.
@@ -241,7 +253,9 @@ pub async fn create_channel(
241253
r#"
242254
SELECT id, name, channel_type, visibility, description, canvas,
243255
created_by, created_at, updated_at, archived_at, deleted_at,
244-
nip29_group_id, topic_required, max_members
256+
nip29_group_id, topic_required, max_members,
257+
topic, topic_set_by, topic_set_at,
258+
purpose, purpose_set_by, purpose_set_at
245259
FROM channels WHERE id = ?
246260
"#,
247261
)
@@ -262,7 +276,9 @@ pub async fn get_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<ChannelRe
262276
r#"
263277
SELECT id, name, channel_type, visibility, description, canvas,
264278
created_by, created_at, updated_at, archived_at, deleted_at,
265-
nip29_group_id, topic_required, max_members
279+
nip29_group_id, topic_required, max_members,
280+
topic, topic_set_by, topic_set_at,
281+
purpose, purpose_set_by, purpose_set_at
266282
FROM channels WHERE id = ? AND deleted_at IS NULL
267283
"#,
268284
)
@@ -450,6 +466,26 @@ pub async fn remove_member(
450466
}
451467
}
452468

469+
// Defense-in-depth: prevent removing the last owner regardless of caller.
470+
// Callers (REST handlers, NIP-29 handlers) also check this, but the DB
471+
// layer enforces it as the final safety net.
472+
let target_role = get_active_role_tx(&mut tx, channel_id, pubkey).await?;
473+
if target_role.as_deref() == Some("owner") {
474+
let row = sqlx::query(
475+
"SELECT COUNT(*) as cnt FROM channel_members \
476+
WHERE channel_id = ? AND role = 'owner' AND removed_at IS NULL",
477+
)
478+
.bind(&channel_id_bytes)
479+
.fetch_one(&mut *tx)
480+
.await?;
481+
let owner_count: i64 = row.try_get("cnt")?;
482+
if owner_count <= 1 {
483+
return Err(DbError::AccessDenied(
484+
"cannot remove the last owner — transfer ownership first".to_string(),
485+
));
486+
}
487+
}
488+
453489
let result = sqlx::query(
454490
r#"
455491
UPDATE channel_members
@@ -543,7 +579,9 @@ pub async fn list_channels(
543579
r#"
544580
SELECT id, name, channel_type, visibility, description, canvas,
545581
created_by, created_at, updated_at, archived_at, deleted_at,
546-
nip29_group_id, topic_required, max_members
582+
nip29_group_id, topic_required, max_members,
583+
topic, topic_set_by, topic_set_at,
584+
purpose, purpose_set_by, purpose_set_at
547585
FROM channels
548586
WHERE deleted_at IS NULL AND visibility = ?
549587
ORDER BY created_at DESC
@@ -558,7 +596,9 @@ pub async fn list_channels(
558596
r#"
559597
SELECT id, name, channel_type, visibility, description, canvas,
560598
created_by, created_at, updated_at, archived_at, deleted_at,
561-
nip29_group_id, topic_required, max_members
599+
nip29_group_id, topic_required, max_members,
600+
topic, topic_set_by, topic_set_at,
601+
purpose, purpose_set_by, purpose_set_at
562602
FROM channels
563603
WHERE deleted_at IS NULL
564604
ORDER BY created_at DESC
@@ -600,7 +640,9 @@ async fn get_channel_tx(
600640
r#"
601641
SELECT id, name, channel_type, visibility, description, canvas,
602642
created_by, created_at, updated_at, archived_at, deleted_at,
603-
nip29_group_id, topic_required, max_members
643+
nip29_group_id, topic_required, max_members,
644+
topic, topic_set_by, topic_set_at,
645+
purpose, purpose_set_by, purpose_set_at
604646
FROM channels WHERE id = ? AND deleted_at IS NULL
605647
"#,
606648
)
@@ -650,7 +692,9 @@ pub async fn get_accessible_channels(
650692
r#"
651693
SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas,
652694
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
653-
c.nip29_group_id, c.topic_required, c.max_members
695+
c.nip29_group_id, c.topic_required, c.max_members,
696+
c.topic, c.topic_set_by, c.topic_set_at,
697+
c.purpose, c.purpose_set_by, c.purpose_set_at
654698
FROM channels c
655699
LEFT JOIN channel_members cm
656700
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
@@ -744,6 +788,15 @@ fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
744788
let id = uuid_from_bytes(&id_bytes)?;
745789
let topic_required: bool = row.try_get("topic_required")?;
746790

791+
// topic/purpose fields are new — use try_get and fall back to None if the
792+
// column is absent (e.g. queries that don't SELECT these columns yet).
793+
let topic: Option<String> = row.try_get("topic").unwrap_or(None);
794+
let topic_set_by: Option<Vec<u8>> = row.try_get("topic_set_by").unwrap_or(None);
795+
let topic_set_at: Option<DateTime<Utc>> = row.try_get("topic_set_at").unwrap_or(None);
796+
let purpose: Option<String> = row.try_get("purpose").unwrap_or(None);
797+
let purpose_set_by: Option<Vec<u8>> = row.try_get("purpose_set_by").unwrap_or(None);
798+
let purpose_set_at: Option<DateTime<Utc>> = row.try_get("purpose_set_at").unwrap_or(None);
799+
747800
Ok(ChannelRecord {
748801
id,
749802
name: row.try_get("name")?,
@@ -759,6 +812,12 @@ fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
759812
nip29_group_id: row.try_get("nip29_group_id")?,
760813
topic_required,
761814
max_members: row.try_get("max_members")?,
815+
topic,
816+
topic_set_by,
817+
topic_set_at,
818+
purpose,
819+
purpose_set_by,
820+
purpose_set_at,
762821
})
763822
}
764823

@@ -775,3 +834,207 @@ fn row_to_member_record(row: sqlx::mysql::MySqlRow) -> Result<MemberRecord> {
775834
removed_at: row.try_get("removed_at")?,
776835
})
777836
}
837+
838+
// ── Phase 2: Channel Metadata ─────────────────────────────────────────────────
839+
840+
/// Partial update for channel name/description.
841+
pub struct ChannelUpdate {
842+
/// New channel name, or `None` to leave unchanged.
843+
pub name: Option<String>,
844+
/// New channel description, or `None` to leave unchanged.
845+
pub description: Option<String>,
846+
}
847+
848+
/// Updates channel name and/or description dynamically.
849+
///
850+
/// At least one field must be `Some`; returns `InvalidData` otherwise.
851+
/// Returns the updated `ChannelRecord` on success.
852+
pub async fn update_channel(
853+
pool: &MySqlPool,
854+
channel_id: Uuid,
855+
updates: ChannelUpdate,
856+
) -> Result<ChannelRecord> {
857+
if updates.name.is_none() && updates.description.is_none() {
858+
return Err(DbError::InvalidData(
859+
"at least one field must be provided for update".to_string(),
860+
));
861+
}
862+
863+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
864+
865+
// Build SET clause dynamically — only include fields that are Some.
866+
let mut set_parts: Vec<&str> = Vec::new();
867+
if updates.name.is_some() {
868+
set_parts.push("name = ?");
869+
}
870+
if updates.description.is_some() {
871+
set_parts.push("description = ?");
872+
}
873+
let sql = format!(
874+
"UPDATE channels SET {}, updated_at = NOW(6) WHERE id = ? AND deleted_at IS NULL",
875+
set_parts.join(", ")
876+
);
877+
878+
let mut q = sqlx::query(&sql);
879+
if let Some(ref name) = updates.name {
880+
q = q.bind(name);
881+
}
882+
if let Some(ref desc) = updates.description {
883+
q = q.bind(desc);
884+
}
885+
q = q.bind(&id_bytes);
886+
887+
let result = q.execute(pool).await?;
888+
if result.rows_affected() == 0 {
889+
return Err(DbError::ChannelNotFound(channel_id));
890+
}
891+
892+
get_channel(pool, channel_id).await
893+
}
894+
895+
/// Sets the topic for a channel, recording who set it and when.
896+
pub async fn set_topic(
897+
pool: &MySqlPool,
898+
channel_id: Uuid,
899+
topic: &str,
900+
set_by: &[u8],
901+
) -> Result<()> {
902+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
903+
let result = sqlx::query(
904+
"UPDATE channels SET topic = ?, topic_set_by = ?, topic_set_at = NOW(6) \
905+
WHERE id = ? AND deleted_at IS NULL",
906+
)
907+
.bind(topic)
908+
.bind(set_by)
909+
.bind(&id_bytes)
910+
.execute(pool)
911+
.await?;
912+
if result.rows_affected() == 0 {
913+
return Err(DbError::ChannelNotFound(channel_id));
914+
}
915+
Ok(())
916+
}
917+
918+
/// Sets the purpose for a channel, recording who set it and when.
919+
pub async fn set_purpose(
920+
pool: &MySqlPool,
921+
channel_id: Uuid,
922+
purpose: &str,
923+
set_by: &[u8],
924+
) -> Result<()> {
925+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
926+
let result = sqlx::query(
927+
"UPDATE channels SET purpose = ?, purpose_set_by = ?, purpose_set_at = NOW(6) \
928+
WHERE id = ? AND deleted_at IS NULL",
929+
)
930+
.bind(purpose)
931+
.bind(set_by)
932+
.bind(&id_bytes)
933+
.execute(pool)
934+
.await?;
935+
if result.rows_affected() == 0 {
936+
return Err(DbError::ChannelNotFound(channel_id));
937+
}
938+
Ok(())
939+
}
940+
941+
/// Archives a channel.
942+
///
943+
/// Returns `AccessDenied` if the channel is already archived.
944+
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
945+
pub async fn archive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
946+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
947+
948+
// First check: does the channel exist and what is its state?
949+
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = ? AND deleted_at IS NULL")
950+
.bind(&id_bytes)
951+
.fetch_optional(pool)
952+
.await?;
953+
954+
match row {
955+
None => return Err(DbError::ChannelNotFound(channel_id)),
956+
Some(r) => {
957+
let archived_at: Option<DateTime<Utc>> = r.try_get("archived_at")?;
958+
if archived_at.is_some() {
959+
return Err(DbError::AccessDenied(
960+
"channel is already archived".to_string(),
961+
));
962+
}
963+
}
964+
}
965+
966+
sqlx::query(
967+
"UPDATE channels SET archived_at = NOW(6) \
968+
WHERE id = ? AND deleted_at IS NULL AND archived_at IS NULL",
969+
)
970+
.bind(&id_bytes)
971+
.execute(pool)
972+
.await?;
973+
974+
Ok(())
975+
}
976+
977+
/// Unarchives a channel.
978+
///
979+
/// Returns `AccessDenied` if the channel is not currently archived.
980+
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
981+
pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
982+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
983+
984+
// First check: does the channel exist and what is its state?
985+
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = ? AND deleted_at IS NULL")
986+
.bind(&id_bytes)
987+
.fetch_optional(pool)
988+
.await?;
989+
990+
match row {
991+
None => return Err(DbError::ChannelNotFound(channel_id)),
992+
Some(r) => {
993+
let archived_at: Option<DateTime<Utc>> = r.try_get("archived_at")?;
994+
if archived_at.is_none() {
995+
return Err(DbError::AccessDenied("channel is not archived".to_string()));
996+
}
997+
}
998+
}
999+
1000+
sqlx::query(
1001+
"UPDATE channels SET archived_at = NULL \
1002+
WHERE id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL",
1003+
)
1004+
.bind(&id_bytes)
1005+
.execute(pool)
1006+
.await?;
1007+
1008+
Ok(())
1009+
}
1010+
1011+
/// Returns the count of active (non-removed) members in a channel.
1012+
pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64> {
1013+
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
1014+
let row = sqlx::query(
1015+
"SELECT COUNT(*) as cnt FROM channel_members WHERE channel_id = ? AND removed_at IS NULL",
1016+
)
1017+
.bind(&id_bytes)
1018+
.fetch_one(pool)
1019+
.await?;
1020+
Ok(row.try_get("cnt")?)
1021+
}
1022+
1023+
/// Get the active role of a pubkey in a channel.
1024+
///
1025+
/// Returns `None` if the pubkey is not an active member.
1026+
pub async fn get_member_role(
1027+
pool: &MySqlPool,
1028+
channel_id: Uuid,
1029+
pubkey: &[u8],
1030+
) -> Result<Option<String>> {
1031+
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
1032+
let row = sqlx::query(
1033+
"SELECT role FROM channel_members WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
1034+
)
1035+
.bind(&channel_id_bytes)
1036+
.bind(pubkey)
1037+
.fetch_optional(pool)
1038+
.await?;
1039+
Ok(row.map(|r| r.try_get("role")).transpose()?)
1040+
}

0 commit comments

Comments
 (0)