Skip to content
Clone

feat(dir): make the dir tree the shared container for channels, repos and boards

- add the `dir` leaf crate: transactional create/update/archive with typed `DirKind`,
  parent validation, position locking and `dir.*` events
- extend `DirKind` with IssueBoard/GitRepo/ChatChannel/VoiceChannel and backfill legacy
  `Object` rows plus channel dir rows for existing space rooms
- create a channel dir inside the room transaction and mirror name/topic/parent/position
  updates; archive the dir when a channel is deactivated
- route git repo, issue board, space folder and chat channel dir creation through the crate
- expose `dir_id` on room DTOs, keep cleanup keyed by room id, regenerate openapi.json
ZhenYi committed
parent f495073 commit 9bd943a Browse files

25 files changed

+700 −202

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 25
Modified lib/chat/src/services/dms.rs View lib/chat/src/services/dms.rs
@@ -99,6 +99,7 @@
9999 rooms::ActiveModel {
100100 id: Set(room_id),
101101 space_id: Set(None),
102 dir_id: Set(None),
102103 kind: Set(RoomKind::DirectMessage),
103104 lower_name: Set(pair.clone()),
104105 name: Set(peer.username.clone()),
@@ -193,6 +194,7 @@
193194 rooms::ActiveModel {
194195 id: Set(room_id),
195196 space_id: Set(None),
197 dir_id: Set(None),
196198 kind: Set(RoomKind::GroupDm),
197199 lower_name: Set(key.clone()),
198200 name: Set(name),
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -1,7 +1,7 @@
11use export::users::UserDto;
22use models::chat::messages::{self, ChatMessageType};
33use models::chat::{read_states, room_seq, room_user_settings};
4use models::dir::dirs::{self, DirKind};
4use models::dir::dirs;
55use models::room::room_invites;
66use models::room::room_members::{self, RoomMemberRole};
77use models::room::rooms::{self, RoomKind};
@@ -31,6 +31,7 @@
3131pub struct RoomBriefDto {
3232 pub id: Uuid,
3333 pub space_id: Option<Uuid>,
34 pub dir_id: Option<Uuid>,
3435 pub owner_id: i64,
3536 pub owner: Option<UserDto>,
3637 pub kind: RoomKind,
@@ -246,6 +247,7 @@
246247 owner_id: room.owner_id,
247248 owner: owner_map.get(&room.owner_id).cloned(),
248249 space_id: room.space_id,
250 dir_id: room.dir_id,
249251 kind: room.kind,
250252 name: room.name,
251253 topic: room.topic,
@@ -290,6 +292,7 @@
290292 owner_id: room.owner_id,
291293 owner: owner_map.get(&room.owner_id).cloned(),
292294 space_id: room.space_id,
295 dir_id: room.dir_id,
293296 kind: room.kind,
294297 name: room.name,
295298 topic: room.topic,
@@ -350,6 +353,7 @@
350353 let room = ctx.room.clone();
351354 let mut am: rooms::ActiveModel = room.clone().into();
352355 let mut topic_changed: Option<(Option<String>, String)> = None;
356 let mut dir_update = dir::UpdateDir::default();
353357
354358 if let Some(name) = cmd.name {
355359 let name = name.trim().to_string();
@@ -363,8 +367,9 @@
363367 {
364368 return Err(ChatError::BadRequest("invalid room name".to_string()));
365369 }
366 am.name = Set(name);
370 am.name = Set(name.clone());
367371 am.lower_name = Set(lower);
372 dir_update.name = Some(name);
368373 }
369374 if let Some(topic) = cmd.topic {
370375 if topic.chars().count() > self.settings.chat.max_topic_len {
@@ -373,7 +378,9 @@
373378 if topic != room.topic.clone().unwrap_or_default() {
374379 topic_changed = Some((room.topic.clone(), topic.clone()));
375380 }
376 am.topic = Set(if topic.is_empty() { None } else { Some(topic) });
381 let stored = if topic.is_empty() { None } else { Some(topic) };
382 am.topic = Set(stored.clone());
383 dir_update.topic = Some(stored);
377384 }
378385 if let Some(is_private) = cmd.is_private {
379386 am.is_private = Set(is_private);
@@ -385,40 +392,71 @@
385392 am.rate_limit_per_user = Set(Some(rate));
386393 }
387394 if let Some(position) = cmd.position {
388 am.position = Set(position);
395 dir_update.position = Some(position);
389396 }
390 // The sidebar's drag-and-drop files channels into folders, so the target
391 // must be a real directory node of *this* room's space (never another
392 // space's, and never an object node such as a repo placeholder).
397 // The sidebar's drag-and-drop files channels into folders. Channels that
398 // own a dir row delegate the parent validation to the dir crate; legacy
399 // rooms without a dir row keep the local check.
393400 if let Some(parent) = cmd.parent_id {
394 let next = match parent {
395 None => None,
396 Some(dir_id) => {
397 let space_id = ctx
398 .space
399 .as_ref()
400 .map(|space| space.id())
401 .ok_or(ChatError::Forbidden)?;
402 let node = dirs::Entity::find_by_id(dir_id)
403 .filter(dirs::Column::SpaceId.eq(space_id))
404 .one(self.database.read())
405 .await?
406 .ok_or(ChatError::NotFound)?;
407 if node.kind != DirKind::Dir {
408 return Err(ChatError::BadRequest(
409 "room parent must be a directory node".to_string(),
410 ));
401 if room.dir_id.is_some() {
402 dir_update.parent_dir_id = Some(parent);
403 } else {
404 let next = match parent {
405 None => None,
406 Some(dir_id) => {
407 let space_id = ctx
408 .space
409 .as_ref()
410 .map(|space| space.id())
411 .ok_or(ChatError::Forbidden)?;
412 let node = dirs::Entity::find_by_id(dir_id)
413 .filter(dirs::Column::SpaceId.eq(space_id))
414 .one(self.database.read())
415 .await?
416 .ok_or(ChatError::NotFound)?;
417 if !node.kind.is_folder() {
418 return Err(ChatError::BadRequest(
419 "room parent must be a directory node".to_string(),
420 ));
421 }
422 Some(dir_id)
411423 }
412 Some(dir_id)
424 };
425 if next != ctx.room.parent_id {
426 am.parent_id = Set(next);
413427 }
414 };
415 if next != ctx.room.parent_id {
416 am.parent_id = Set(next);
417428 }
418429 }
419430 am.updated_at = Set(now);
420 am.update(self.database.write()).await?;
421431
432 let has_dir_update = dir_update.name.is_some()
433 || dir_update.topic.is_some()
434 || dir_update.icon.is_some()
435 || dir_update.position.is_some()
436 || dir_update.parent_dir_id.is_some();
437 match (room.dir_id, has_dir_update) {
438 (Some(dir_id), true) => {
439 self.database
440 .write()
441 .transaction::<_, (), ChatError>(move |tx| {
442 Box::pin(async move {
443 let node = dir::update_object(tx, caller, dir_id, dir_update).await?;
444 am.position = Set(node.position);
445 am.parent_id = Set(node.parent_dir_id);
446 am.update(tx).await?;
447 Ok(())
448 })
449 })
450 .await?;
451 }
452 _ => {
453 if let Some(position) = cmd.position {
454 am.position = Set(position);
455 }
456 am.update(self.database.write()).await?;
457 }
458 }
459
422460 if let Some((_old, new_topic)) = topic_changed {
423461 let body = format!("set the channel topic to: {new_topic}");
424462 let _ = self
@@ -574,10 +612,22 @@
574612 room_am.update(self.database.write()).await?;
575613 }
576614 None => {
577 let mut room_am: rooms::ActiveModel = room.clone().into();
578 room_am.is_active = Set(false);
579 room_am.updated_at = Set(now);
580 room_am.update(self.database.write()).await?;
615 let dir_id = room.dir_id;
616 self.database
617 .write()
618 .transaction::<_, (), ChatError>(move |tx| {
619 Box::pin(async move {
620 let mut room_am: rooms::ActiveModel = room.into();
621 room_am.is_active = Set(false);
622 room_am.updated_at = Set(now);
623 room_am.update(tx).await?;
624 if let Some(dir_id) = dir_id {
625 dir::archive(tx, caller, dir_id).await?;
626 }
627 Ok(())
628 })
629 })
630 .await?;
581631 }
582632 }
583633 }
Modified lib/chat/src/errors.rs View lib/chat/src/errors.rs
@@ -36,6 +36,16 @@
3636export::json_payload_conversion!(ChatError);
3737export::access_error_conversion!(ChatError, BadRequest);
3838
39impl From<dir::Error> for ChatError {
40 fn from(error: dir::Error) -> Self {
41 match error {
42 dir::Error::InvalidInput(message) => Self::BadRequest(message),
43 dir::Error::NotFound => Self::NotFound,
44 dir::Error::Database(error) => Self::from(error),
45 }
46 }
47}
48
3949export::api_error! {
4050 log: "chat",
4151 pub enum ApiError {
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77auth = { workspace = true }
8dir = { workspace = true }
89chat-room-store = { path = "../chat-room-store" }
910email = { workspace = true }
1011async-trait = { workspace = true }
Modified lib/chat-room-store/src/lib.rs View lib/chat-room-store/src/lib.rs
@@ -1,9 +1,7 @@
1use models::dir::dirs::DirKind;
12use models::room::room_members::{self, RoomMemberRole};
23use models::room::rooms::{self, RoomKind};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, ExprTrait, QueryFilter,
5 QuerySelect, Set,
6};
4use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
75use serde::{Deserialize, Serialize};
86use time::OffsetDateTime;
97use utoipa::ToSchema;
@@ -21,6 +19,16 @@
2119 Database(#[from] sea_orm::DbErr),
2220}
2321
22impl From<dir::Error> for Error {
23 fn from(error: dir::Error) -> Self {
24 match error {
25 dir::Error::InvalidInput(message) => Self::InvalidInput(message),
26 dir::Error::NotFound => Self::NotFound,
27 dir::Error::Database(error) => Self::Database(error),
28 }
29 }
30}
31
2432#[derive(Clone, Serialize)]
2533pub struct SpaceContext {
2634 pub id: Uuid,
@@ -52,6 +60,7 @@
5260pub struct RoomDto {
5361 pub id: Uuid,
5462 pub space_id: Option<Uuid>,
63 pub dir_id: Option<Uuid>,
5564 pub kind: RoomKind,
5665 pub name: String,
5766 pub topic: Option<String>,
@@ -72,6 +81,7 @@
7281 Self {
7382 id: r.id,
7483 space_id: r.space_id,
84 dir_id: r.dir_id,
7585 kind: r.kind,
7686 name: r.name,
7787 topic: r.topic,
@@ -143,27 +153,36 @@
143153 "room name {lower} already exists in this space"
144154 )));
145155 }
146 let max_pos = rooms::Entity::find()
147 .filter(rooms::Column::SpaceId.eq(space.id))
148 .select_only()
149 .column_as(
150 sea_orm::sea_query::Expr::col(rooms::Column::Position).max(),
151 "max_pos",
152 )
153 .into_tuple::<Option<i64>>()
154 .one(tx)
155 .await?
156 .flatten()
157 .unwrap_or(-1);
156 let dir_kind = match cmd.kind {
157 RoomKind::Voice | RoomKind::Stage => DirKind::VoiceChannel,
158 _ => DirKind::ChatChannel,
159 };
160 let node = dir::create_object(
161 tx,
162 actor,
163 dir::NewDir {
164 space_id: space.id,
165 kind: dir_kind,
166 name: name.to_owned(),
167 parent_dir_id: cmd.parent_id,
168 position: None,
169 topic: cmd.topic.clone(),
170 purpose: None,
171 icon: None,
172 color: None,
173 },
174 )
175 .await?;
158176 let now = OffsetDateTime::now_utc();
159177 let created = rooms::ActiveModel {
160178 id: Set(Uuid::now_v7()),
161179 space_id: Set(Some(space.id)),
180 dir_id: Set(Some(node.id)),
162181 kind: Set(cmd.kind),
163182 lower_name: Set(lower),
164183 name: Set(name.to_owned()),
165184 topic: Set(cmd.topic),
166 position: Set(max_pos + 1),
185 position: Set(node.position),
167186 parent_id: Set(cmd.parent_id),
168187 owner_id: Set(actor),
169188 is_private: Set(cmd.is_private),
@@ -203,10 +222,14 @@
203222 .filter(|row| row.is_active && row.space_id == Some(space.id))
204223 .ok_or(Error::NotFound)?;
205224 let detail = room_detail(&row);
225 let dir_id = row.dir_id;
206226 let mut active: rooms::ActiveModel = row.into();
207227 active.is_active = Set(false);
208228 active.updated_at = Set(OffsetDateTime::now_utc());
209229 active.update(tx).await?;
230 if let Some(dir_id) = dir_id {
231 dir::archive(tx, actor, dir_id).await?;
232 }
210233 events::emit(
211234 tx,
212235 "chat.room.update",
@@ -292,5 +315,5 @@
292315}
293316
294317fn room_detail(room: &rooms::Model) -> serde_json::Value {
295 serde_json::json!({ "id": room.id, "space_id": room.space_id, "kind": room.kind, "name": room.name, "topic": room.topic })
318 serde_json::json!({ "id": room.id, "space_id": room.space_id, "dir_id": room.dir_id, "kind": room.kind, "name": room.name, "topic": room.topic })
296319}
Modified lib/chat-room-store/Cargo.toml View lib/chat-room-store/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77events = { workspace = true }
8dir = { workspace = true }
89models = { workspace = true }
910sea-orm = { workspace = true }
1011serde = { workspace = true, features = ["derive"] }
Added lib/dir/src/lib.rs View lib/dir/src/lib.rs
@@ -0,0 +1,321 @@
1use models::dir::dirs::{self, DirKind};
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
4 QuerySelect, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::ToSchema;
9use uuid::Uuid;
10
11const MAX_NAME_LEN: usize = 255;
12
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15 #[error("{0}")]
16 InvalidInput(String),
17 #[error("dir not found")]
18 NotFound,
19 #[error(transparent)]
20 Database(#[from] sea_orm::DbErr),
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
24pub struct DirDto {
25 pub id: Uuid,
26 pub space_id: Option<Uuid>,
27 pub kind: DirKind,
28 pub name: String,
29 pub parent_dir_id: Option<Uuid>,
30 pub color: Option<String>,
31 pub position: i64,
32 pub icon: Option<String>,
33 pub topic: Option<String>,
34 pub purpose: Option<String>,
35 pub is_archived: bool,
36 pub archived_at: Option<OffsetDateTime>,
37 pub created_at: OffsetDateTime,
38 pub updated_at: OffsetDateTime,
39 pub created_by_uid: i64,
40}
41
42impl From<dirs::Model> for DirDto {
43 fn from(m: dirs::Model) -> Self {
44 Self {
45 id: m.id,
46 space_id: m.space_id,
47 kind: m.kind,
48 name: m.name,
49 parent_dir_id: m.parent_dir_id,
50 color: m.color,
51 position: m.position,
52 icon: m.icon,
53 topic: m.topic,
54 purpose: m.purpose,
55 is_archived: m.is_archived,
56 archived_at: m.archived_at,
57 created_at: m.created_at,
58 updated_at: m.updated_at,
59 created_by_uid: m.created_by_uid,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
65pub struct NewDir {
66 pub space_id: Uuid,
67 pub kind: DirKind,
68 pub name: String,
69 pub parent_dir_id: Option<Uuid>,
70 pub position: Option<i64>,
71 pub topic: Option<String>,
72 pub purpose: Option<String>,
73 pub icon: Option<String>,
74 pub color: Option<String>,
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct UpdateDir {
79 pub name: Option<String>,
80 pub topic: Option<Option<String>>,
81 pub icon: Option<Option<String>>,
82 pub position: Option<i64>,
83 pub parent_dir_id: Option<Option<Uuid>>,
84}
85
86pub async fn create_folder(
87 tx: &impl ConnectionTrait,
88 actor: i64,
89 mut cmd: NewDir,
90) -> Result<DirDto, Error> {
91 cmd.kind = DirKind::Dir;
92 create(tx, actor, cmd).await
93}
94
95pub async fn create_object(
96 tx: &impl ConnectionTrait,
97 actor: i64,
98 cmd: NewDir,
99) -> Result<DirDto, Error> {
100 if cmd.kind.is_folder() {
101 return Err(Error::InvalidInput(
102 "business dirs must declare a concrete kind".to_owned(),
103 ));
104 }
105 create(tx, actor, cmd).await
106}
107
108async fn create(tx: &impl ConnectionTrait, actor: i64, cmd: NewDir) -> Result<DirDto, Error> {
109 let name = validate_name(&cmd.name)?;
110 if let Some(parent) = cmd.parent_dir_id {
111 ensure_parent(tx, cmd.space_id, parent, None).await?;
112 }
113 let position = match cmd.position {
114 Some(position) => validate_position(position)?,
115 None => next_position(tx, cmd.space_id, cmd.parent_dir_id).await?,
116 };
117 let now = OffsetDateTime::now_utc();
118 let created = dirs::ActiveModel {
119 id: Set(Uuid::now_v7()),
120 space_id: Set(Some(cmd.space_id)),
121 name: Set(name),
122 kind: Set(cmd.kind),
123 parent_dir_id: Set(cmd.parent_dir_id),
124 color: Set(cmd.color),
125 position: Set(position),
126 icon: Set(cmd.icon),
127 topic: Set(cmd.topic),
128 purpose: Set(cmd.purpose),
129 is_archived: Set(false),
130 archived_at: Set(None),
131 archived_by: Set(None),
132 created_at: Set(now),
133 updated_at: Set(now),
134 created_by_uid: Set(actor),
135 }
136 .insert(tx)
137 .await?;
138 let dto = DirDto::from(created);
139 emit(tx, "dir.create", actor, &dto).await?;
140 Ok(dto)
141}
142
143pub async fn update_object(
144 tx: &impl ConnectionTrait,
145 actor: i64,
146 dir_id: Uuid,
147 cmd: UpdateDir,
148) -> Result<DirDto, Error> {
149 let existing = dirs::Entity::find_by_id(dir_id)
150 .one(tx)
151 .await?
152 .ok_or(Error::NotFound)?;
153 if existing.is_archived {
154 return Err(Error::InvalidInput("dir is archived".to_owned()));
155 }
156 let space_id = existing
157 .space_id
158 .ok_or_else(|| Error::InvalidInput("dir has no space".to_owned()))?;
159
160 let next_parent = match cmd.parent_dir_id {
161 Some(None) => None,
162 Some(Some(parent)) => {
163 ensure_parent(tx, space_id, parent, Some(dir_id)).await?;
164 Some(parent)
165 }
166 None => existing.parent_dir_id,
167 };
168 let parent_changed = next_parent != existing.parent_dir_id;
169 let position = match cmd.position {
170 Some(position) => validate_position(position)?,
171 None if parent_changed => next_position(tx, space_id, next_parent).await?,
172 None => existing.position,
173 };
174
175 let mut am: dirs::ActiveModel = existing.into();
176 if let Some(name) = cmd.name {
177 am.name = Set(validate_name(&name)?);
178 }
179 if let Some(topic) = cmd.topic {
180 am.topic = Set(normalize_optional(topic));
181 }
182 if let Some(icon) = cmd.icon {
183 am.icon = Set(normalize_optional(icon));
184 }
185 am.parent_dir_id = Set(next_parent);
186 am.position = Set(position);
187 am.updated_at = Set(OffsetDateTime::now_utc());
188 let updated = am.update(tx).await?;
189 let dto = DirDto::from(updated);
190 emit(tx, "dir.update", actor, &dto).await?;
191 Ok(dto)
192}
193
194pub async fn archive(tx: &impl ConnectionTrait, actor: i64, dir_id: Uuid) -> Result<(), Error> {
195 let existing = dirs::Entity::find_by_id(dir_id)
196 .one(tx)
197 .await?
198 .ok_or(Error::NotFound)?;
199 if existing.is_archived {
200 return Ok(());
201 }
202 let now = OffsetDateTime::now_utc();
203 let mut am: dirs::ActiveModel = existing.into();
204 am.is_archived = Set(true);
205 am.archived_at = Set(Some(now));
206 am.archived_by = Set(Some(actor));
207 am.updated_at = Set(now);
208 let updated = am.update(tx).await?;
209 let dto = DirDto::from(updated);
210 emit(tx, "dir.archive", actor, &dto).await?;
211 Ok(())
212}
213
214pub async fn dir(tx: &impl ConnectionTrait, id: Uuid) -> Result<DirDto, Error> {
215 dirs::Entity::find_by_id(id)
216 .one(tx)
217 .await?
218 .map(DirDto::from)
219 .ok_or(Error::NotFound)
220}
221
222fn validate_name(name: &str) -> Result<String, Error> {
223 let name = name.trim();
224 if name.is_empty() || name.chars().count() > MAX_NAME_LEN {
225 return Err(Error::InvalidInput(
226 "dir name is empty or too long".to_owned(),
227 ));
228 }
229 Ok(name.to_owned())
230}
231
232fn validate_position(position: i64) -> Result<i64, Error> {
233 if position < 0 {
234 return Err(Error::InvalidInput(
235 "position must be non-negative".to_owned(),
236 ));
237 }
238 Ok(position)
239}
240
241fn normalize_optional(value: Option<String>) -> Option<String> {
242 value
243 .map(|value| value.trim().to_owned())
244 .filter(|value| !value.is_empty())
245}
246
247async fn ensure_parent(
248 tx: &impl ConnectionTrait,
249 space_id: Uuid,
250 parent: Uuid,
251 moving: Option<Uuid>,
252) -> Result<(), Error> {
253 if moving == Some(parent) {
254 return Err(Error::InvalidInput(
255 "cannot move a dir into itself".to_owned(),
256 ));
257 }
258 let node = dirs::Entity::find_by_id(parent)
259 .filter(dirs::Column::SpaceId.eq(space_id))
260 .one(tx)
261 .await?
262 .ok_or(Error::NotFound)?;
263 if !node.kind.is_folder() || node.is_archived {
264 return Err(Error::InvalidInput(
265 "parent dir must be an active folder".to_owned(),
266 ));
267 }
268 if let Some(moving) = moving {
269 let mut cursor = node.parent_dir_id;
270 let mut guard = 0;
271 while let Some(current) = cursor {
272 if current == moving {
273 return Err(Error::InvalidInput(
274 "cannot move a dir under its own descendant".to_owned(),
275 ));
276 }
277 guard += 1;
278 if guard > 1024 {
279 break;
280 }
281 cursor = dirs::Entity::find_by_id(current)
282 .one(tx)
283 .await?
284 .and_then(|row| row.parent_dir_id);
285 }
286 }
287 Ok(())
288}
289
290async fn next_position(
291 tx: &impl ConnectionTrait,
292 space_id: Uuid,
293 parent_dir_id: Option<Uuid>,
294) -> Result<i64, Error> {
295 let last = dirs::Entity::find()
296 .filter(dirs::Column::SpaceId.eq(space_id))
297 .filter(dirs::Column::ParentDirId.eq(parent_dir_id))
298 .order_by_desc(dirs::Column::Position)
299 .lock(sea_orm::sea_query::LockType::Update)
300 .one(tx)
301 .await?;
302 Ok(last.map(|node| node.position + 1).unwrap_or(0))
303}
304
305async fn emit(
306 tx: &impl ConnectionTrait,
307 kind: &str,
308 actor: i64,
309 dir: &DirDto,
310) -> Result<(), Error> {
311 events::emit(
312 tx,
313 kind,
314 events::Labels::new()
315 .with(events::label::DIR_ID, dir.id)
316 .with(events::label::ACTOR_USER_ID, actor),
317 dir,
318 )
319 .await?;
320 Ok(())
321}
Added lib/dir/Cargo.toml View lib/dir/Cargo.toml
@@ -0,0 +1,15 @@
1[package]
2name = "dir"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7events = { workspace = true }
8models = { workspace = true }
9sea-orm = { workspace = true }
10serde = { workspace = true, features = ["derive"] }
11serde_json = { workspace = true }
12thiserror = { workspace = true }
13time = { workspace = true }
14utoipa = { workspace = true }
15uuid = { workspace = true }
Modified lib/events/src/label.rs View lib/events/src/label.rs
@@ -12,6 +12,7 @@
1212pub const BOARD_ID: &str = "board_id";
1313pub const CHANNEL_ID: &str = "channel_id";
1414pub const ROOM_ID: &str = "room_id";
15pub const DIR_ID: &str = "dir_id";
1516pub const ORDERING_KEY: &str = "ordering_key";
1617
1718pub const AUTH_REGISTER_CREATE: &str = "auth.register.create";
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -2,8 +2,8 @@
22use models::dir::dirs::{self, DirKind};
33use models::repo::{repo_collaborations, repo_license, repos};
44use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6 TransactionTrait, Unchanged,
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
6 Unchanged,
77};
88use serde::{Deserialize, Serialize};
99use space::Space;
@@ -310,19 +310,6 @@
310310 let name = validate_repo_name(&cmd.name)?;
311311 let default_branch =
312312 validate_branch_name(cmd.default_branch.as_deref().unwrap_or(DEFAULT_BRANCH))?;
313 if let Some(p) = cmd.parent_dir_id {
314 let parent = dirs::Entity::find_by_id(p)
315 .filter(dirs::Column::SpaceId.eq(space.id()))
316 .one(self.db_read())
317 .await?
318 .ok_or(GitError::NotFound)?;
319 if parent.kind != DirKind::Dir {
320 return Err(GitError::InvalidArgument(
321 "parent node must be a dir".to_string(),
322 ));
323 }
324 }
325
326313 if let Some(name) = cmd.license_name.as_deref() {
327314 let exists = repo_license::Entity::find_by_id(name.to_string())
328315 .one(self.db_read())
@@ -335,42 +322,29 @@
335322 }
336323 }
337324
338 let (dir, repo) = {
325 let (dir_row, repo) = {
339326 let txn = self.db_write().begin().await?;
340 let position = {
341 let max_node = dirs::Entity::find()
342 .filter(dirs::Column::SpaceId.eq(space.id()))
343 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
344 .order_by_desc(dirs::Column::Position)
345 .lock(sea_orm::sea_query::LockType::Update)
346 .one(&txn)
347 .await?;
348 max_node.map(|n| n.position + 1).unwrap_or(0)
349 };
350327 let now = OffsetDateTime::now_utc();
351 let dir_row = dirs::ActiveModel {
352 id: Set(Uuid::now_v7()),
353 space_id: Set(Some(space.id())),
354 name: Set(name.clone()),
355 kind: Set(DirKind::Object),
356 parent_dir_id: Set(cmd.parent_dir_id),
357 color: Set(None),
358 position: Set(position),
359 icon: Set(None),
360 topic: Set(None),
361 purpose: Set(None),
362 is_archived: Set(false),
363 archived_at: Set(None),
364 archived_by: Set(None),
365 created_at: Set(now),
366 updated_at: Set(now),
367 created_by_uid: Set(caller),
368 };
369 let dir = dir_row.insert(&txn).await?;
328 let dir_row = dir::create_object(
329 &txn,
330 caller,
331 dir::NewDir {
332 space_id: space.id(),
333 kind: DirKind::GitRepo,
334 name: name.clone(),
335 parent_dir_id: cmd.parent_dir_id,
336 position: None,
337 topic: None,
338 purpose: None,
339 icon: None,
340 color: None,
341 },
342 )
343 .await?;
370344
371345 let repo_row = repos::ActiveModel {
372346 id: Set(Uuid::now_v7()),
373 dir_id: Set(dir.id),
347 dir_id: Set(dir_row.id),
374348 lower_name: Set(name.to_lowercase()),
375349 name: Set(name),
376350 description: Set(cmd.description.clone()),
@@ -407,7 +381,7 @@
407381 };
408382 let repo = repo_row.insert(&txn).await?;
409383 txn.commit().await?;
410 (dir, repo)
384 (dir_row, repo)
411385 };
412386
413387 let ctx = self
@@ -429,7 +403,7 @@
429403 {
430404 tracing::warn!(error = %e, "failed to clean up repo row after init failure");
431405 }
432 if let Err(e) = dirs::Entity::delete_by_id(dir.id)
406 if let Err(e) = dirs::Entity::delete_by_id(dir_row.id)
433407 .exec(self.db_write())
434408 .await
435409 {
@@ -488,7 +462,7 @@
488462 let space = self.lookup_space(&space_name).await?;
489463 let dirs = dirs::Entity::find()
490464 .filter(dirs::Column::SpaceId.eq(space.id()))
491 .filter(dirs::Column::Kind.eq(DirKind::Object))
465 .filter(dirs::Column::Kind.eq(DirKind::GitRepo))
492466 .all(self.db_read())
493467 .await?;
494468 let dir_ids: Vec<Uuid> = dirs.iter().map(|d| d.id).collect();
Modified lib/git/src/errors.rs View lib/git/src/errors.rs
@@ -48,6 +48,16 @@
4848 }
4949}
5050
51impl From<dir::Error> for GitError {
52 fn from(error: dir::Error) -> Self {
53 match error {
54 dir::Error::InvalidInput(message) => Self::InvalidArgument(message),
55 dir::Error::NotFound => Self::NotFound,
56 dir::Error::Database(error) => Self::from(error),
57 }
58 }
59}
60
5161impl From<sea_orm::DbErr> for GitApiError {
5262 fn from(error: sea_orm::DbErr) -> Self {
5363 if is_unique_violation(&error) {
Modified lib/git/Cargo.toml View lib/git/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77auth = { workspace = true }
8dir = { workspace = true }
89proto = { workspace = true }
910tonic = { workspace = true }
1011prost-types = { workspace = true }
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -104,7 +104,7 @@
104104 ) -> Result<bool, IssueError> {
105105 let dir_ids: Vec<Uuid> = dirs::Entity::find()
106106 .filter(dirs::Column::SpaceId.eq(Some(space_id)))
107 .filter(dirs::Column::Kind.eq(DirKind::Object))
107 .filter(dirs::Column::Kind.eq(DirKind::IssueBoard))
108108 .all(self.database.read())
109109 .await?
110110 .into_iter()
@@ -217,52 +217,25 @@
217217 ));
218218 }
219219
220 if let Some(p) = cmd.parent_dir_id {
221 let parent = dirs::Entity::find_by_id(p)
222 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
223 .one(self.database.read())
224 .await?
225 .ok_or(IssueError::NotFound)?;
226
227 if parent.kind != DirKind::Dir {
228 return Err(IssueError::BadRequest(
229 "parent node must be a dir".to_string(),
230 ));
231 }
232 }
233
234220 let (_dir, board) = {
235221 let txn = self.database.write().begin().await?;
236 let position = {
237 let max_node = dirs::Entity::find()
238 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
239 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
240 .order_by_desc(dirs::Column::Position)
241 .lock(sea_orm::sea_query::LockType::Update)
242 .one(&txn)
243 .await?;
244 max_node.map(|n| n.position + 1).unwrap_or(0)
245 };
246222 let now = OffsetDateTime::now_utc();
247 let dir_row = dirs::ActiveModel {
248 id: Set(Uuid::now_v7()),
249 space_id: Set(Some(space.id())),
250 name: Set(name.to_string()),
251 kind: Set(DirKind::Object),
252 parent_dir_id: Set(cmd.parent_dir_id),
253 color: Set(None),
254 position: Set(position),
255 icon: Set(cmd.icon.clone()),
256 topic: Set(None),
257 purpose: Set(cmd.description.clone()),
258 is_archived: Set(false),
259 archived_at: Set(None),
260 archived_by: Set(None),
261 created_at: Set(now),
262 updated_at: Set(now),
263 created_by_uid: Set(caller),
264 };
265 let dir = dir_row.insert(&txn).await?;
223 let dir = dir::create_object(
224 &txn,
225 caller,
226 dir::NewDir {
227 space_id: space.id(),
228 kind: DirKind::IssueBoard,
229 name: name.to_string(),
230 parent_dir_id: cmd.parent_dir_id,
231 position: None,
232 topic: None,
233 purpose: cmd.description.clone(),
234 icon: cmd.icon.clone(),
235 color: None,
236 },
237 )
238 .await?;
266239
267240 let board_row = issues::ActiveModel {
268241 id: Set(Uuid::now_v7()),
@@ -305,7 +278,7 @@
305278
306279 let dirs_in_space = dirs::Entity::find()
307280 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
308 .filter(dirs::Column::Kind.eq(DirKind::Object))
281 .filter(dirs::Column::Kind.eq(DirKind::IssueBoard))
309282 .all(self.database.read())
310283 .await?;
311284
Modified lib/issues/src/errors.rs View lib/issues/src/errors.rs
@@ -28,6 +28,17 @@
2828impl std::error::Error for IssueError {}
2929
3030export::db_error_conversions!(IssueError);
31
32impl From<dir::Error> for IssueError {
33 fn from(error: dir::Error) -> Self {
34 match error {
35 dir::Error::InvalidInput(message) => Self::BadRequest(message),
36 dir::Error::NotFound => Self::NotFound,
37 dir::Error::Database(error) => Self::from(error),
38 }
39 }
40}
41
3142export::export_error_conversion!(IssueError);
3243export::json_payload_conversion!(IssueError);
3344export::access_error_conversion!(IssueError, BadRequest);
Modified lib/issues/Cargo.toml View lib/issues/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77storage = { workspace = true }
8dir = { workspace = true }
89auth = { workspace = true }
910email = { workspace = true }
1011bytes = { workspace = true }
Modified lib/models/src/dir/dirs.rs View lib/models/src/dir/dirs.rs
@@ -23,6 +23,24 @@
2323 Dir,
2424 #[sea_orm(num_value = 1)]
2525 Object,
26 #[sea_orm(num_value = 2)]
27 IssueBoard,
28 #[sea_orm(num_value = 3)]
29 GitRepo,
30 #[sea_orm(num_value = 4)]
31 ChatChannel,
32 #[sea_orm(num_value = 5)]
33 VoiceChannel,
34}
35
36impl DirKind {
37 pub fn is_folder(self) -> bool {
38 matches!(self, Self::Dir)
39 }
40
41 pub fn is_object(self) -> bool {
42 !self.is_folder()
43 }
2644}
2745
2846#[sea_orm::model]
Modified lib/models/src/room/rooms.rs View lib/models/src/room/rooms.rs
@@ -49,6 +49,10 @@
4949 pub id: Uuid,
5050 #[sea_orm(unique_key = "space_lower_name")]
5151 pub space_id: Option<Uuid>,
52 /// 频道在目录树里的 `dir` 行(空间频道必须有,DM 为 `NULL`)。名称、
53 /// 主题、排序与父节点以该 `dir` 行为准,`rooms` 上的同名字段是镜像。
54 #[sea_orm(indexed)]
55 pub dir_id: Option<Uuid>,
5256 #[sea_orm(default_value = 0)]
5357 pub kind: RoomKind,
5458 #[sea_orm(unique_key = "space_lower_name")]
@@ -74,10 +78,9 @@
7478 pub updated_at: OffsetDateTime,
7579 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
7680 pub owner: BelongsTo<super::super::users::user::Entity>,
77 /// 频道所在目录节点。`parent_id` 指向 `dir(id)` 而非本表:侧边栏的
78 /// 「拖进文件夹」把频道挂在目录节点下,写入前 `update_room` 会校验目标
79 /// 必须是同空间的 `DirKind::Dir` 节点。挂成 self_ref 会让这个外键在
80 /// 数据库里永远匹配不上(dir 的 id 不是 rooms 的 id),写入必然被拒。
81 /// 频道父目录的镜像。权威父节点在频道的 `dir` 行(`dir_id` 的
82 /// `parent_dir_id`)上;`parent_id` 仍指向 `dir(id)` 而非本表,挂成
83 /// self_ref 会让这个外键在数据库里永远匹配不上(dir 的 id 不是 rooms 的 id)。
8184 #[sea_orm(belongs_to, from = "parent_id", to = "id")]
8285 pub parent: BelongsTo<Option<super::super::dir::dirs::Entity>>,
8386 #[sea_orm(has_many)]
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -357,3 +357,29 @@
357357
358358 DROP TABLE IF EXISTS user_emails CASCADE;
359359END $$;
360
361-- dir.kind: legacy `Object` rows were created by the repo/issue-board domains.
362-- Split them into concrete kinds so callers read the type from the dir row.
363UPDATE dir SET kind = 3 WHERE kind = 1 AND id IN (SELECT dir_id FROM repos);
364UPDATE dir SET kind = 2 WHERE kind = 1 AND id IN (SELECT dir_id FROM issues);
365
366-- Channel dirs: space rooms created before the unified dir tree have no `dir`
367-- row. Reuse the room id as the dir id so the mapping stays deterministic and
368-- the insert can be retried safely.
369INSERT INTO dir (
370 id, space_id, name, kind, parent_dir_id, color, position, icon, topic,
371 purpose, is_archived, archived_at, archived_by, created_at, updated_at,
372 created_by_uid
373)
374SELECT
375 r.id, r.space_id, r.name,
376 CASE WHEN r.kind IN (11, 12) THEN 5 ELSE 4 END,
377 r.parent_id, NULL, r.position, NULL, r.topic,
378 NULL, NOT r.is_active, NULL, NULL, r.created_at, r.updated_at,
379 r.owner_id
380FROM rooms r
381WHERE r.space_id IS NOT NULL
382 AND r.dir_id IS NULL
383 AND NOT EXISTS (SELECT 1 FROM dir d WHERE d.id = r.id);
384
385UPDATE rooms SET dir_id = id WHERE dir_id IS NULL AND space_id IS NOT NULL;
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -21,6 +21,16 @@
2121};
2222use crate::share::lookup;
2323
24impl From<dir::Error> for SpaceError {
25 fn from(error: dir::Error) -> Self {
26 match error {
27 dir::Error::InvalidInput(message) => Self::BadRequest(message),
28 dir::Error::NotFound => Self::NotFound,
29 dir::Error::Database(error) => Self::from(error),
30 }
31 }
32}
33
2434pub const MAX_DIR_NODE_NAME: usize = 255;
2535pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
2636
@@ -66,6 +76,28 @@
6676 }
6777}
6878
79impl From<dir::DirDto> for DirNodeDto {
80 fn from(dto: dir::DirDto) -> Self {
81 Self {
82 id: dto.id,
83 name: dto.name,
84 kind: dto.kind,
85 parent_dir_id: dto.parent_dir_id,
86 color: dto.color,
87 position: dto.position,
88 icon: dto.icon,
89 topic: dto.topic,
90 purpose: dto.purpose,
91 is_archived: dto.is_archived,
92 archived_at: dto.archived_at,
93 created_at: dto.created_at,
94 updated_at: dto.updated_at,
95 created_by_id: dto.created_by_uid,
96 children: Vec::new(),
97 }
98 }
99}
100
69101#[derive(Debug, Clone, Deserialize, ToSchema)]
70102pub struct CreateDirCmd {
71103 pub name: String,
@@ -180,64 +212,40 @@
180212 let space = lookup::lookup_space_by_name(self, space_name).await?;
181213 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
182214
183 let name = cmd.name.trim();
215 let name = cmd.name.trim().to_string();
184216 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
185217 return Err(SpaceError::BadRequest(
186218 "dir name is empty or too long".to_string(),
187219 ));
188220 }
189221
190 if let Some(p) = cmd.parent_dir_id {
191 let parent_node = self.dir_in_space(space.id, p).await?;
192 if parent_node.kind != DirKind::Dir {
193 return Err(SpaceError::BadRequest(
194 "parent node must be a dir".to_string(),
195 ));
196 }
197 }
222 let created = self
223 .access
224 .database
225 .write()
226 .transaction::<_, dir::DirDto, SpaceError>(move |tx| {
227 Box::pin(async move {
228 Ok(dir::create_folder(
229 tx,
230 caller,
231 dir::NewDir {
232 space_id: space.id,
233 kind: DirKind::Dir,
234 name,
235 parent_dir_id: cmd.parent_dir_id,
236 position: cmd.position,
237 topic: None,
238 purpose: None,
239 icon: None,
240 color: None,
241 },
242 )
243 .await?)
244 })
245 })
246 .await?;
198247
199 let position = match cmd.position {
200 Some(pos) => {
201 if pos < 0 {
202 return Err(SpaceError::BadRequest(
203 "position must be non-negative".to_string(),
204 ));
205 }
206 pos
207 }
208 None => {
209 let max_node = dirs::Entity::find()
210 .filter(dirs::Column::SpaceId.eq(space.id))
211 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
212 .order_by_desc(dirs::Column::Position)
213 .one(self.access.database.read())
214 .await?;
215 max_node.map(|n| n.position + 1).unwrap_or(0)
216 }
217 };
218
219 let now = OffsetDateTime::now_utc();
220 let row = dirs::ActiveModel {
221 id: Set(Uuid::now_v7()),
222 space_id: Set(Some(space.id)),
223 name: Set(name.to_string()),
224 kind: Set(DirKind::Dir),
225 parent_dir_id: Set(cmd.parent_dir_id),
226 color: Set(None),
227 position: Set(position),
228 icon: Set(None),
229 topic: Set(None),
230 purpose: Set(None),
231 is_archived: Set(false),
232 archived_at: Set(None),
233 archived_by: Set(None),
234 created_at: Set(now),
235 updated_at: Set(now),
236 created_by_uid: Set(caller),
237 };
238
239 let inserted = row.insert(self.access.database.write()).await?;
240 Ok(DirNodeDto::from(inserted))
248 Ok(DirNodeDto::from(created))
241249 }
242250
243251 pub async fn get_space_dir_tree(
@@ -413,6 +421,11 @@
413421 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
414422
415423 let node: dirs::Model = self.dir_in_space(space.id, dir_id).await?;
424 if node.kind.is_object() {
425 return Err(SpaceError::BadRequest(
426 "object dirs are managed by their owning domain".to_string(),
427 ));
428 }
416429
417430 let name = cmd.name.trim();
418431 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
@@ -441,6 +454,11 @@
441454 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
442455
443456 let node = self.dir_in_space(space.id, dir_id).await?;
457 if node.kind.is_object() {
458 return Err(SpaceError::BadRequest(
459 "object dirs are managed by their owning domain".to_string(),
460 ));
461 }
444462
445463 let new_parent_id = match cmd.parent_dir_id {
446464 None => node.parent_dir_id,
Modified lib/space/src/access.rs View lib/space/src/access.rs
@@ -279,7 +279,7 @@
279279 else {
280280 return Ok(false);
281281 };
282 if node.kind != models::dir::dirs::DirKind::Object {
282 if !node.kind.is_object() {
283283 return Ok(false);
284284 }
285285 self.dir_visible(user, dir).await
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -6,6 +6,7 @@
66[dependencies]
77auth = { workspace = true }
88chat-room-store = { path = "../chat-room-store" }
9dir = { workspace = true }
910async-trait = { workspace = true }
1011base64 = { workspace = true }
1112cache = { workspace = true }
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -321,6 +321,15 @@
321321
322322 let mut objects = Vec::with_capacity(nodes.len() + room_rows.len());
323323 for node in &nodes {
324 // Space channels own a dir row for the sidebar tree, but cleanup keeps
325 // addressing them by room id below; skip the dir mirror to avoid listing
326 // the same channel twice.
327 if matches!(
328 node.kind,
329 dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
330 ) {
331 continue;
332 }
324333 let (kind, name, is_private, is_archived) = if let Some(repo) = repo_by_dir.get(&node.id) {
325334 (
326335 "repo",
@@ -330,7 +339,7 @@
330339 )
331340 } else if let Some(board) = board_by_dir.get(&node.id) {
332341 ("board", board.name.clone(), None, false)
333 } else if node.kind == dirs::DirKind::Object {
342 } else if node.kind.is_object() {
334343 ("object", node.name.clone(), None, node.is_archived)
335344 } else {
336345 ("dir", node.name.clone(), None, node.is_archived)
@@ -550,6 +559,14 @@
550559 .await
551560 .map_err(db_internal)?
552561 .ok_or_else(|| ApiError::not_found("directory not found"))?;
562 if matches!(
563 node.kind,
564 dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
565 ) {
566 return Err(ApiError::bad_request(
567 "channels are deleted from their room row instead",
568 ));
569 }
553570 let has_companion = repos::Entity::find()
554571 .filter(repos::Column::DirId.eq(node.id))
555572 .count(db.read())
Modified Cargo.lock View Cargo.lock
@@ -1288,6 +1288,7 @@
12881288 "bytes",
12891289 "chat-room-store",
12901290 "config 1.0.0",
1291 "dir",
12911292 "email",
12921293 "encoding_rs",
12931294 "events",
@@ -1326,6 +1327,7 @@
13261327name = "chat-room-store"
13271328version = "1.0.0"
13281329dependencies = [
1330 "dir",
13291331 "events",
13301332 "models",
13311333 "sea-orm",
@@ -2062,6 +2064,21 @@
20622064]
20632065
20642066[[package]]
2067name = "dir"
2068version = "1.0.0"
2069dependencies = [
2070 "events",
2071 "models",
2072 "sea-orm",
2073 "serde",
2074 "serde_json",
2075 "thiserror 2.0.21",
2076 "time 0.3.55",
2077 "utoipa 5.5.0",
2078 "uuid",
2079]
2080
2081[[package]]
20652082name = "dirs"
20662083version = "5.0.1"
20672084source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2758,6 +2775,7 @@
27582775 "bytes",
27592776 "cache",
27602777 "config 1.0.0",
2778 "dir",
27612779 "export",
27622780 "flate2",
27632781 "futures",
@@ -3614,6 +3632,7 @@
36143632 "auth",
36153633 "bytes",
36163634 "config 1.0.0",
3635 "dir",
36173636 "email",
36183637 "events",
36193638 "export",
@@ -7204,6 +7223,7 @@
72047223 "config 1.0.0",
72057224 "db",
72067225 "deletion",
7226 "dir",
72077227 "envelope",
72087228 "events",
72097229 "export",
Modified Cargo.toml View Cargo.toml
@@ -18,6 +18,7 @@
1818chat = { path = "lib/chat" }
1919config = { path = "lib/config" }
2020deletion = { path = "lib/deletion" }
21dir = { path = "lib/dir" }
2122export = { path = "lib/export" }
2223models = { path = "lib/models" }
2324push = { path = "lib/push" }
Modified openapi.json View openapi.json

Diff too large to display.