@@ -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