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
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/chat/src/services/dms.rs lib/chat/src/services/dms.rs +2 −0 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 lib/chat/src/services/rooms.rs +83 −33 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 lib/chat/src/errors.rs +10 −0 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 lib/chat/Cargo.toml +1 −0 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 lib/chat-room-store/src/lib.rs +41 −18 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 lib/chat-room-store/Cargo.toml +1 −0 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 lib/dir/src/lib.rs +321 −0 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 lib/dir/Cargo.toml +15 −0 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 lib/events/src/label.rs +1 −0 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 lib/git/src/service/repos.rs +23 −49 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 lib/git/src/errors.rs +10 −0 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 lib/git/Cargo.toml +1 −0 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 lib/issues/src/services/boards.rs +18 −45 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 lib/issues/src/errors.rs +11 −0 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 lib/issues/Cargo.toml +1 −0 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 lib/models/src/dir/dirs.rs +18 −0 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 lib/models/src/room/rooms.rs +7 −4 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 lib/models/schema_extras.sql +26 −0 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 lib/space/src/services/dir.rs +69 −51 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 lib/space/src/access.rs +1 −1 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 lib/space/Cargo.toml +1 −0 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 lib/webapi/src/cleanup.rs +18 −1 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 Cargo.lock +20 −0 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 Cargo.toml +1 −0 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 openapi.json View openapi.json
Diff too large to display.