Skip to content
Clone

refactor: replace lucide-react icons with tabler icons and remove unused modules

ZhenYi committed
parent 5c7d438 commit eeff292 Browse files

214 files changed

+1272 −7898

File tree 214
Modified config/default/space.json View config/default/space.json
@@ -8,6 +8,5 @@
88 "max_invite_email_len": 254,
99 "allow_self_join_groups": false,
1010 "join_request_cooldown_hours": 24,
11 "max_dir_nodes": 5000,
1211 "deletion_grace_days": 14
1312}
Modified lib/infra/config/src/space.rs View lib/infra/config/src/space.rs
@@ -12,7 +12,6 @@
1212 pub max_invite_email_len: usize,
1313 pub allow_self_join_groups: bool,
1414 pub join_request_cooldown_hours: i64,
15 pub max_dir_nodes: u64,
1615 pub deletion_grace_days: u32,
1716}
1817
@@ -28,7 +27,6 @@
2827 max_invite_email_len: 254,
2928 allow_self_join_groups: false,
3029 join_request_cooldown_hours: 24,
31 max_dir_nodes: 5000,
3230 deletion_grace_days: 14,
3331 }
3432 }
Modified lib/service/auth/src/rpc/access_key.rs View lib/service/auth/src/rpc/access_key.rs
@@ -16,8 +16,8 @@
1616};
1717use crate::protos::auth::v1::{
1818 CreateAccessKeyRequest, CreateAccessKeyResponse, ListAccessKeysRequest, ListAccessKeysResponse,
19 RevokeAccessKeyRequest, RevokeAccessKeyResponse, RotateAccessKeyRequest, RotateAccessKeyResponse,
20 VerifyAccessKeyRequest, VerifyAccessKeyResponse,
19 RevokeAccessKeyRequest, RevokeAccessKeyResponse, RotateAccessKeyRequest,
20 RotateAccessKeyResponse, VerifyAccessKeyRequest, VerifyAccessKeyResponse,
2121};
2222use crate::services::AuthService;
2323use crate::share::crypto::sha256_hex;
@@ -191,7 +191,9 @@
191191
192192/// 调用方未声明范围时放行;令牌本身不限范围时也放行。
193193fn scopes_cover(granted: &[i32], required: &[i32]) -> bool {
194 required.is_empty() || granted.is_empty() || required.iter().all(|scope| granted.contains(scope))
194 required.is_empty()
195 || granted.is_empty()
196 || required.iter().all(|scope| granted.contains(scope))
195197}
196198
197199fn timestamp(value: DateTime<Utc>) -> prost_types::Timestamp {
Modified lib/service/auth/src/rpc/ssh_key.rs View lib/service/auth/src/rpc/ssh_key.rs
@@ -8,8 +8,8 @@
88use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
99use tonic::{Request, Response, Status};
1010
11use crate::models::users::user_ssh_keys::PublicKeyType;
1112use crate::models::users::{user_ssh_keys, users};
12use crate::models::users::user_ssh_keys::PublicKeyType;
1313use crate::protos::auth::common::v1::{AuthMethod, Principal, SshKeyAlgorithm, SshKeyInfo};
1414use crate::protos::auth::v1::ssh_key_service_server::{
1515 SshKeyService as SshKeyTrait, SshKeyServiceServer,
@@ -66,7 +66,8 @@
6666 return Ok(Response::new(missed()));
6767 };
6868 if request.require_active
69 && (key.revoked_at.is_some() || key.expires_at.is_some_and(|expiry| expiry <= Utc::now()))
69 && (key.revoked_at.is_some()
70 || key.expires_at.is_some_and(|expiry| expiry <= Utc::now()))
7071 {
7172 return Ok(Response::new(missed()));
7273 }
Modified lib/service/chat/src/services/dms.rs View lib/service/chat/src/services/dms.rs
@@ -99,13 +99,13 @@
9999 rooms::ActiveModel {
100100 id: Set(room_id),
101101 space_id: Set(None),
102 dir_id: Set(None),
103102 kind: Set(RoomKind::DirectMessage),
104103 lower_name: Set(pair.clone()),
105104 name: Set(peer.username.clone()),
106105 topic: Set(None),
106 icon: Set(None),
107 color: Set(None),
107108 position: Set(0),
108 parent_id: Set(None),
109109 owner_id: Set(caller),
110110 is_private: Set(true),
111111 nsfw: Set(false),
@@ -194,13 +194,13 @@
194194 rooms::ActiveModel {
195195 id: Set(room_id),
196196 space_id: Set(None),
197 dir_id: Set(None),
198197 kind: Set(RoomKind::GroupDm),
199198 lower_name: Set(key.clone()),
200199 name: Set(name),
201200 topic: Set(None),
201 icon: Set(None),
202 color: Set(None),
202203 position: Set(0),
203 parent_id: Set(None),
204204 owner_id: Set(caller),
205205 is_private: Set(true),
206206 nsfw: Set(false),
Modified lib/service/chat/src/services/read_state.rs View lib/service/chat/src/services/read_state.rs
@@ -160,18 +160,20 @@
160160 .filter(rooms::Column::IsActive.eq(true))
161161 .all(self.database.read())
162162 .await?;
163 let dir_ids: Vec<Uuid> = space_rooms.iter().filter_map(|room| room.dir_id).collect();
164 let visible: HashSet<Uuid> = if dir_ids.is_empty() {
165 HashSet::new()
166 } else {
167 space.visible_dirs(caller, &dir_ids).await?
168 };
163 let room_ids: Vec<Uuid> = space_rooms.iter().map(|room| room.id).collect();
164 let my_rooms: HashSet<Uuid> = models::room::room_members::Entity::find()
165 .filter(models::room::room_members::Column::UserId.eq(caller))
166 .filter(models::room::room_members::Column::RoomId.is_in(room_ids))
167 .filter(models::room::room_members::Column::LeftAt.is_null())
168 .all(self.database.read())
169 .await?
170 .into_iter()
171 .map(|row| row.room_id)
172 .collect();
169173 let mut items = Vec::new();
170174 for room in space_rooms {
171 let is_visible = match room.dir_id {
172 Some(dir_id) => visible.contains(&dir_id),
173 None => !room.is_private || room.owner_id == caller,
174 };
175 let is_visible =
176 !room.is_private || room.owner_id == caller || my_rooms.contains(&room.id);
175177 if !is_visible {
176178 continue;
177179 }
Modified lib/service/chat/src/services/rooms.rs View lib/service/chat/src/services/rooms.rs
@@ -1,7 +1,6 @@
11use api::users::UserDto;
22use models::chat::messages::{self, ChatMessageType};
33use models::chat::{read_states, room_seq, room_user_settings};
4use models::dir::dirs::{self, DirKind};
54use models::room::rooms::{self, RoomKind};
65use sea_orm::sea_query::Expr;
76use sea_orm::{
@@ -24,14 +23,14 @@
2423pub struct RoomBriefDto {
2524 pub id: Uuid,
2625 pub space_id: Option<Uuid>,
27 pub dir_id: Option<Uuid>,
2826 pub owner_id: i64,
2927 pub owner: Option<UserDto>,
3028 pub kind: RoomKind,
3129 pub name: String,
3230 pub topic: Option<String>,
31 pub icon: Option<String>,
32 pub color: Option<String>,
3333 pub position: i64,
34 pub parent_id: Option<Uuid>,
3534 pub is_private: bool,
3635 pub nsfw: bool,
3736 pub rate_limit_per_user: Option<i32>,
@@ -59,10 +58,10 @@
5958 pub rate_limit_per_user: Option<i32>,
6059 #[serde(default)]
6160 pub position: Option<i64>,
62 /// Move the room under another directory node of the same space; `null` puts
63 /// it back at the space root. Absent means "leave the parent alone".
6461 #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
65 pub parent_id: Option<Option<Uuid>>,
62 pub icon: Option<Option<String>>,
63 #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
64 pub color: Option<Option<String>>,
6665}
6766
6867impl ChatService {
@@ -82,12 +81,15 @@
8281 .await?;
8382
8483 let room_ids: Vec<Uuid> = rooms.iter().map(|r| r.id).collect();
85 let dir_ids: Vec<Uuid> = rooms.iter().filter_map(|r| r.dir_id).collect();
86 let visible_dirs: std::collections::HashSet<Uuid> = if dir_ids.is_empty() {
87 std::collections::HashSet::new()
88 } else {
89 space.visible_dirs(caller, &dir_ids).await?
90 };
84 let my_rooms: std::collections::HashSet<Uuid> = models::room::room_members::Entity::find()
85 .filter(models::room::room_members::Column::UserId.eq(caller))
86 .filter(models::room::room_members::Column::RoomId.is_in(room_ids.clone()))
87 .filter(models::room::room_members::Column::LeftAt.is_null())
88 .all(self.database.read())
89 .await?
90 .into_iter()
91 .map(|row| row.room_id)
92 .collect();
9193
9294 let settings = room_user_settings::Entity::find()
9395 .filter(room_user_settings::Column::UserId.eq(caller))
@@ -143,10 +145,8 @@
143145 let owner_map = self.audience.load_user_briefs(&owner_ids).await?;
144146 let mut result = Vec::new();
145147 for room in rooms {
146 let visible = match room.dir_id {
147 Some(dir_id) => visible_dirs.contains(&dir_id),
148 None => !room.is_private || room.owner_id == caller,
149 };
148 let visible =
149 !room.is_private || room.owner_id == caller || my_rooms.contains(&room.id);
150150 if !visible {
151151 continue;
152152 }
@@ -171,12 +171,12 @@
171171 owner_id: room.owner_id,
172172 owner: owner_map.get(&room.owner_id).cloned(),
173173 space_id: room.space_id,
174 dir_id: room.dir_id,
175174 kind: room.kind,
176175 name: room.name,
177176 topic: room.topic,
177 icon: room.icon,
178 color: room.color,
178179 position: room.position,
179 parent_id: room.parent_id,
180180 is_private: room.is_private,
181181 nsfw: room.nsfw,
182182 rate_limit_per_user: room.rate_limit_per_user,
@@ -219,12 +219,12 @@
219219 owner_id: room.owner_id,
220220 owner: owner_map.get(&room.owner_id).cloned(),
221221 space_id: room.space_id,
222 dir_id: room.dir_id,
223222 kind: room.kind,
224223 name: room.name,
225224 topic: room.topic,
225 icon: room.icon,
226 color: room.color,
226227 position: room.position,
227 parent_id: room.parent_id,
228228 is_private: room.is_private,
229229 nsfw: room.nsfw,
230230 rate_limit_per_user: room.rate_limit_per_user,
@@ -253,8 +253,7 @@
253253 || cmd.position.is_some()
254254 || cmd.topic.is_some()
255255 || cmd.nsfw.is_some()
256 || cmd.rate_limit_per_user.is_some()
257 || cmd.parent_id.is_some());
256 || cmd.rate_limit_per_user.is_some());
258257 if needs_manage {
259258 match &ctx.space {
260259 Some(space) => {
@@ -280,7 +279,6 @@
280279 let room = ctx.room.clone();
281280 let mut am: rooms::ActiveModel = room.clone().into();
282281 let mut topic_changed: Option<(Option<String>, String)> = None;
283 let mut dir_update = dir::UpdateDir::default();
284282
285283 if let Some(name) = cmd.name {
286284 let name = name.trim().to_string();
@@ -296,7 +294,6 @@
296294 }
297295 am.name = Set(name.clone());
298296 am.lower_name = Set(lower);
299 dir_update.name = Some(name);
300297 }
301298 if let Some(topic) = cmd.topic {
302299 if topic.chars().count() > self.settings.chat.max_topic_len {
@@ -306,12 +303,9 @@
306303 topic_changed = Some((room.topic.clone(), topic.clone()));
307304 }
308305 let stored = if topic.is_empty() { None } else { Some(topic) };
309 am.topic = Set(stored.clone());
310 dir_update.topic = Some(stored);
306 am.topic = Set(stored);
311307 }
312 let mut privacy_change: Option<bool> = None;
313308 if let Some(is_private) = cmd.is_private {
314 privacy_change = Some(is_private);
315309 am.is_private = Set(is_private);
316310 }
317311 if let Some(nsfw) = cmd.nsfw {
@@ -321,78 +315,22 @@
321315 am.rate_limit_per_user = Set(Some(rate));
322316 }
323317 if let Some(position) = cmd.position {
324 dir_update.position = Some(position);
325 }
326 // The sidebar's drag-and-drop files channels into folders. Channels that
327 // own a dir row delegate the parent validation to the dir crate; legacy
328 // rooms without a dir row keep the local check.
329 if let Some(parent) = cmd.parent_id {
330 if room.dir_id.is_some() {
331 dir_update.parent_dir_id = Some(parent);
332 } else {
333 let next = match parent {
334 None => None,
335 Some(dir_id) => {
336 let space_id = ctx
337 .space
338 .as_ref()
339 .map(|space| space.id())
340 .ok_or(ChatError::Forbidden)?;
341 let node = dirs::Entity::find_by_id(dir_id)
342 .filter(dirs::Column::SpaceId.eq(space_id))
343 .one(self.database.read())
344 .await?
345 .ok_or(ChatError::NotFound)?;
346 if !node.kind.is_folder() {
347 return Err(ChatError::BadRequest(
348 "room parent must be a directory node".to_string(),
349 ));
350 }
351 Some(dir_id)
352 }
353 };
354 if next != ctx.room.parent_id {
355 am.parent_id = Set(next);
356 }
318 if position < 0 {
319 return Err(ChatError::BadRequest(
320 "position must be non-negative".to_string(),
321 ));
357322 }
323 am.position = Set(position);
324 }
325 if let Some(icon) = cmd.icon {
326 am.icon = Set(icon);
327 }
328 if let Some(color) = cmd.color {
329 am.color = Set(color);
358330 }
359331 am.updated_at = Set(now);
360332
361 let has_dir_update = dir_update.name.is_some()
362 || dir_update.topic.is_some()
363 || dir_update.icon.is_some()
364 || dir_update.position.is_some()
365 || dir_update.parent_dir_id.is_some();
366 let owner_id = room.owner_id;
367 let needs_privacy_sync = privacy_change.is_some();
368 match (room.dir_id, has_dir_update || needs_privacy_sync) {
369 (Some(dir_id), true) => {
370 self.database
371 .write()
372 .transaction::<_, (), ChatError>(move |tx| {
373 Box::pin(async move {
374 if has_dir_update {
375 let node =
376 dir::update_object(tx, caller, dir_id, dir_update).await?;
377 am.position = Set(node.position);
378 am.parent_id = Set(node.parent_dir_id);
379 }
380 if let Some(is_private) = privacy_change {
381 sync_channel_privacy(tx, dir_id, owner_id, is_private).await?;
382 }
383 am.update(tx).await?;
384 Ok(())
385 })
386 })
387 .await?;
388 }
389 _ => {
390 if let Some(position) = cmd.position {
391 am.position = Set(position);
392 }
393 am.update(self.database.write()).await?;
394 }
395 }
333 am.update(self.database.write()).await?;
396334
397335 if let Some((_old, new_topic)) = topic_changed {
398336 let body = format!("set the channel topic to: {new_topic}");
@@ -475,20 +413,6 @@
475413 }
476414}
477415
478async fn sync_channel_privacy(
479 tx: &impl sea_orm::ConnectionTrait,
480 dir_id: Uuid,
481 owner_id: i64,
482 is_private: bool,
483) -> Result<(), ChatError> {
484 if !is_private {
485 dir::clear_user_overwrites(tx, dir_id).await?;
486 return Ok(());
487 }
488 dir::set_user_overwrite(tx, dir_id, owner_id, dir::PERM_VISIBLE, 0, None).await?;
489 Ok(())
490}
491
492416#[derive(Clone, Serialize)]
493417pub struct SpaceContext {
494418 pub id: Uuid,
@@ -512,20 +436,18 @@
512436 pub user_limit: Option<i32>,
513437 #[serde(default)]
514438 pub rate_limit_per_user: Option<i32>,
515 #[serde(default)]
516 pub parent_id: Option<Uuid>,
517439}
518440
519441#[derive(Debug, Clone, Serialize, ToSchema)]
520442pub struct RoomDto {
521443 pub id: Uuid,
522444 pub space_id: Option<Uuid>,
523 pub dir_id: Option<Uuid>,
524445 pub kind: RoomKind,
525446 pub name: String,
526447 pub topic: Option<String>,
448 pub icon: Option<String>,
449 pub color: Option<String>,
527450 pub position: i64,
528 pub parent_id: Option<Uuid>,
529451 pub owner_id: i64,
530452 pub is_private: bool,
531453 pub nsfw: bool,
@@ -541,12 +463,12 @@
541463 Self {
542464 id: r.id,
543465 space_id: r.space_id,
544 dir_id: r.dir_id,
545466 kind: r.kind,
546467 name: r.name,
547468 topic: r.topic,
469 icon: r.icon,
470 color: r.color,
548471 position: r.position,
549 parent_id: r.parent_id,
550472 owner_id: r.owner_id,
551473 is_private: r.is_private,
552474 nsfw: r.nsfw,
@@ -612,37 +534,24 @@
612534 "room name {lower} already exists in this space"
613535 )));
614536 }
615 let dir_kind = match cmd.kind {
616 RoomKind::Voice | RoomKind::Stage => DirKind::VoiceChannel,
617 _ => DirKind::ChatChannel,
618 };
619 let node = dir::create_object(
620 tx,
621 actor,
622 dir::NewDir {
623 space_id: space.id,
624 kind: dir_kind,
625 name: name.to_owned(),
626 parent_dir_id: cmd.parent_id,
627 position: None,
628 topic: cmd.topic.clone(),
629 purpose: None,
630 icon: None,
631 color: None,
632 },
633 )
634 .await?;
537 let position = rooms::Entity::find()
538 .filter(rooms::Column::SpaceId.eq(space.id))
539 .order_by_desc(rooms::Column::Position)
540 .one(tx)
541 .await?
542 .map(|row| row.position + 1)
543 .unwrap_or(0);
635544 let now = OffsetDateTime::now_utc();
636545 let created = rooms::ActiveModel {
637546 id: Set(Uuid::now_v7()),
638547 space_id: Set(Some(space.id)),
639 dir_id: Set(Some(node.id)),
640548 kind: Set(cmd.kind),
641549 lower_name: Set(lower),
642550 name: Set(name.to_owned()),
643551 topic: Set(cmd.topic),
644 position: Set(node.position),
645 parent_id: Set(cmd.parent_id),
552 icon: Set(None),
553 color: Set(None),
554 position: Set(position),
646555 owner_id: Set(actor),
647556 is_private: Set(cmd.is_private),
648557 nsfw: Set(cmd.nsfw),
@@ -656,11 +565,6 @@
656565 }
657566 .insert(tx)
658567 .await?;
659 if created.is_private
660 && let Some(dir_id) = created.dir_id
661 {
662 dir::set_user_overwrite(tx, dir_id, actor, dir::PERM_VISIBLE, 0, None).await?;
663 }
664568 Ok(created.into())
665569}
666570
@@ -676,14 +580,10 @@
676580 .filter(|row| row.is_active && row.space_id == Some(space.id))
677581 .ok_or(ChatError::NotFound)?;
678582 let detail = room_detail(&row);
679 let dir_id = row.dir_id;
680583 let mut active: rooms::ActiveModel = row.into();
681584 active.is_active = Set(false);
682585 active.updated_at = Set(OffsetDateTime::now_utc());
683586 active.update(tx).await?;
684 if let Some(dir_id) = dir_id {
685 dir::archive(tx, actor, dir_id).await?;
686 }
687587 events::emit(
688588 tx,
689589 events::EventKind::ChatRoomUpdate,
@@ -699,7 +599,7 @@
699599}
700600
701601fn room_detail(room: &rooms::Model) -> serde_json::Value {
702 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 })
602 serde_json::json!({ "id": room.id, "space_id": room.space_id, "kind": room.kind, "name": room.name, "topic": room.topic })
703603}
704604
705605impl ChatService {
Modified lib/service/chat/src/share/lookup.rs View lib/service/chat/src/share/lookup.rs
@@ -58,28 +58,20 @@
5858 load_room_context(service, room).await
5959}
6060
61/// 空间频道的可读性由 dir 覆盖项决定(无覆盖项 = 全部空间成员可见);
62/// DM 与迁移前没有 dir 行的历史频道回退到 `room_members`。
61/// 空间频道对空间成员开放;私密频道与 DM 需要 `room_members` 行,owner 总是可读。
6362async fn room_visible(
6463 service: &ChatService,
6564 room: &rooms::Model,
6665 space: Option<&Space>,
6766 uid: i64,
6867) -> Result<bool, ChatError> {
69 match (room.dir_id, space) {
70 (Some(dir_id), Some(space)) => Ok(space
71 .visible_dirs(uid, std::slice::from_ref(&dir_id))
72 .await?
73 .contains(&dir_id)),
74 (Some(_), None) => Ok(false),
75 (None, Some(_)) => {
76 if !room.is_private || room.owner_id == uid {
77 return Ok(true);
78 }
79 is_room_member_row(service, room.id, uid).await
80 }
81 (None, None) => is_room_member_row(service, room.id, uid).await,
68 if room.space_id.is_some() && space.is_none() {
69 return Ok(false);
70 }
71 if space.is_some() && (!room.is_private || room.owner_id == uid) {
72 return Ok(true);
8273 }
74 is_room_member_row(service, room.id, uid).await
8375}
8476
8577async fn is_room_member_row(
Modified lib/service/chat/src/share/notify.rs View lib/service/chat/src/share/notify.rs
@@ -205,19 +205,22 @@
205205 .map(|m| m.user_id)
206206 .collect();
207207 if scope.is_private {
208 let dir_id = rooms::Entity::find_by_id(room_id)
208 let mut allowed: Vec<i64> = models::room::room_members::Entity::find()
209 .filter(models::room::room_members::Column::RoomId.eq(room_id))
210 .filter(models::room::room_members::Column::LeftAt.is_null())
211 .all(service.database.read())
212 .await
213 .unwrap_or_default()
214 .into_iter()
215 .map(|row| row.user_id)
216 .collect();
217 if let Ok(Some(room)) = rooms::Entity::find_by_id(room_id)
209218 .one(service.database.read())
210219 .await
211 .ok()
212 .flatten()
213 .and_then(|room| room.dir_id);
214 match dir_id {
215 Some(dir_id) => match space.dir_visible_users(dir_id, &ids).await {
216 Ok(visible) => ids.retain(|uid| visible.contains(uid)),
217 Err(_) => ids.clear(),
218 },
219 None => ids.clear(),
220 {
221 allowed.push(room.owner_id);
220222 }
223 ids.retain(|uid| allowed.contains(uid));
221224 }
222225 ids
223226 }
Modified lib/service/chat/src/errors.rs View lib/service/chat/src/errors.rs
@@ -37,16 +37,6 @@
3737api::page_error_conversion!(ChatError, BadRequest);
3838api::access_error_conversion!(ChatError, BadRequest);
3939
40impl From<dir::Error> for ChatError {
41 fn from(error: dir::Error) -> Self {
42 match error {
43 dir::Error::InvalidInput(message) => Self::BadRequest(message),
44 dir::Error::NotFound => Self::NotFound,
45 dir::Error::Database(error) => Self::from(error),
46 }
47 }
48}
49
5040api::api_error! {
5141 log: "chat",
5242 pub enum ApiError {
Modified lib/service/chat/Cargo.toml View lib/service/chat/Cargo.toml
@@ -10,7 +10,6 @@
1010media = { workspace = true }
1111push = { workspace = true }
1212auth = { workspace = true }
13dir = { workspace = true }
1413email = { workspace = true }
1514async-trait = { workspace = true }
1615config = { workspace = true }
Modified lib/service/deletion/src/lib.rs View lib/service/deletion/src/lib.rs
@@ -2,7 +2,6 @@
22
33use auth::KenAuthError;
44use bootstrap::AppState;
5use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
65use models::repo::repos;
76use models::room::rooms;
87use models::space::{
@@ -491,7 +490,6 @@
491490
492491async fn archive_local_space(state: &AppState, space_id: Uuid) -> Result<(), DeletionError> {
493492 let db = state.database.write();
494 let now = now();
495493
496494 rooms::Entity::update_many()
497495 .col_expr(
@@ -502,64 +500,14 @@
502500 .exec(db)
503501 .await?;
504502
505 // Only the root nodes carry `space_id`; walk `parent_dir_id` to collect the
506 // whole tree, otherwise nested directories/repos survive the purge.
507 let mut dir_ids: Vec<Uuid> = dirs::Entity::find()
508 .filter(dirs::Column::SpaceId.eq(Some(space_id)))
509 .select_only()
510 .column(dirs::Column::Id)
511 .into_tuple()
512 .all(db)
503 repos::Entity::update_many()
504 .col_expr(
505 repos::Column::IsArchived,
506 sea_orm::sea_query::Expr::value(true),
507 )
508 .filter(repos::Column::SpaceId.eq(space_id))
509 .exec(db)
513510 .await?;
514 let mut seen: std::collections::HashSet<Uuid> = dir_ids.iter().copied().collect();
515 let mut frontier = dir_ids.clone();
516 while !frontier.is_empty() {
517 let children: Vec<Uuid> = dirs::Entity::find()
518 .filter(dirs::Column::ParentDirId.is_in(frontier))
519 .select_only()
520 .column(dirs::Column::Id)
521 .into_tuple()
522 .all(db)
523 .await?;
524 let mut next = Vec::new();
525 for id in children {
526 if seen.insert(id) {
527 dir_ids.push(id);
528 next.push(id);
529 }
530 }
531 frontier = next;
532 }
533 if !dir_ids.is_empty() {
534 dirs::Entity::update_many()
535 .col_expr(
536 dirs::Column::IsArchived,
537 sea_orm::sea_query::Expr::value(true),
538 )
539 .col_expr(
540 dirs::Column::ArchivedAt,
541 sea_orm::sea_query::Expr::value(Some(now)),
542 )
543 .filter(dirs::Column::Id.is_in(dir_ids.clone()))
544 .exec(db)
545 .await?;
546 repos::Entity::update_many()
547 .col_expr(
548 repos::Column::IsArchived,
549 sea_orm::sea_query::Expr::value(true),
550 )
551 .filter(repos::Column::DirId.is_in(dir_ids.clone()))
552 .exec(db)
553 .await?;
554 dir_overwrite_group::Entity::delete_many()
555 .filter(dir_overwrite_group::Column::DirId.is_in(dir_ids.clone()))
556 .exec(db)
557 .await?;
558 dir_overwrite_users::Entity::delete_many()
559 .filter(dir_overwrite_users::Column::DirId.is_in(dir_ids))
560 .exec(db)
561 .await?;
562 }
563511
564512 let group_ids: Vec<Uuid> = space_groups::Entity::find()
565513 .filter(space_groups::Column::SpaceId.eq(space_id))
Deleted lib/service/dir/src/lib.rs
@@ -1,387 +0,0 @@
1use models::dir::dir_overwrite_users;
2use models::dir::dirs::{self, DirKind};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12/// Bit used by `allow_mask` / `deny_mask` for directory visibility.
13pub const PERM_VISIBLE: i64 = 1 << 0;
14
15const MAX_NAME_LEN: usize = 255;
16
17#[derive(Debug, thiserror::Error)]
18pub enum Error {
19 #[error("{0}")]
20 InvalidInput(String),
21 #[error("dir not found")]
22 NotFound,
23 #[error(transparent)]
24 Database(#[from] sea_orm::DbErr),
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct DirDto {
29 pub id: Uuid,
30 pub space_id: Option<Uuid>,
31 pub kind: DirKind,
32 pub name: String,
33 pub parent_dir_id: Option<Uuid>,
34 pub color: Option<String>,
35 pub position: i64,
36 pub icon: Option<String>,
37 pub topic: Option<String>,
38 pub purpose: Option<String>,
39 pub is_archived: bool,
40 pub archived_at: Option<OffsetDateTime>,
41 pub created_at: OffsetDateTime,
42 pub updated_at: OffsetDateTime,
43 pub created_by_uid: i64,
44}
45
46impl From<dirs::Model> for DirDto {
47 fn from(m: dirs::Model) -> Self {
48 Self {
49 id: m.id,
50 space_id: m.space_id,
51 kind: m.kind,
52 name: m.name,
53 parent_dir_id: m.parent_dir_id,
54 color: m.color,
55 position: m.position,
56 icon: m.icon,
57 topic: m.topic,
58 purpose: m.purpose,
59 is_archived: m.is_archived,
60 archived_at: m.archived_at,
61 created_at: m.created_at,
62 updated_at: m.updated_at,
63 created_by_uid: m.created_by_uid,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
69pub struct NewDir {
70 pub space_id: Uuid,
71 pub kind: DirKind,
72 pub name: String,
73 pub parent_dir_id: Option<Uuid>,
74 pub position: Option<i64>,
75 pub topic: Option<String>,
76 pub purpose: Option<String>,
77 pub icon: Option<String>,
78 pub color: Option<String>,
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct UpdateDir {
83 pub name: Option<String>,
84 pub topic: Option<Option<String>>,
85 pub icon: Option<Option<String>>,
86 pub position: Option<i64>,
87 pub parent_dir_id: Option<Option<Uuid>>,
88}
89
90pub async fn create_folder(
91 tx: &impl ConnectionTrait,
92 actor: i64,
93 mut cmd: NewDir,
94) -> Result<DirDto, Error> {
95 cmd.kind = DirKind::Dir;
96 create(tx, actor, cmd).await
97}
98
99pub async fn create_object(
100 tx: &impl ConnectionTrait,
101 actor: i64,
102 cmd: NewDir,
103) -> Result<DirDto, Error> {
104 if cmd.kind.is_folder() {
105 return Err(Error::InvalidInput(
106 "business dirs must declare a concrete kind".to_owned(),
107 ));
108 }
109 create(tx, actor, cmd).await
110}
111
112async fn create(tx: &impl ConnectionTrait, actor: i64, cmd: NewDir) -> Result<DirDto, Error> {
113 let name = validate_name(&cmd.name)?;
114 if let Some(parent) = cmd.parent_dir_id {
115 ensure_parent(tx, cmd.space_id, parent, None).await?;
116 }
117 let position = match cmd.position {
118 Some(position) => validate_position(position)?,
119 None => next_position(tx, cmd.space_id, cmd.parent_dir_id).await?,
120 };
121 let now = OffsetDateTime::now_utc();
122 let created = dirs::ActiveModel {
123 id: Set(Uuid::now_v7()),
124 space_id: Set(Some(cmd.space_id)),
125 name: Set(name),
126 kind: Set(cmd.kind),
127 parent_dir_id: Set(cmd.parent_dir_id),
128 color: Set(cmd.color),
129 position: Set(position),
130 icon: Set(cmd.icon),
131 topic: Set(cmd.topic),
132 purpose: Set(cmd.purpose),
133 is_archived: Set(false),
134 archived_at: Set(None),
135 archived_by: Set(None),
136 created_at: Set(now),
137 updated_at: Set(now),
138 created_by_uid: Set(actor),
139 }
140 .insert(tx)
141 .await?;
142 let dto = DirDto::from(created);
143 emit(tx, events::EventKind::DirCreate, actor, &dto).await?;
144 Ok(dto)
145}
146
147pub async fn update_object(
148 tx: &impl ConnectionTrait,
149 actor: i64,
150 dir_id: Uuid,
151 cmd: UpdateDir,
152) -> Result<DirDto, Error> {
153 let existing = dirs::Entity::find_by_id(dir_id)
154 .one(tx)
155 .await?
156 .ok_or(Error::NotFound)?;
157 if existing.is_archived {
158 return Err(Error::InvalidInput("dir is archived".to_owned()));
159 }
160 let space_id = existing
161 .space_id
162 .ok_or_else(|| Error::InvalidInput("dir has no space".to_owned()))?;
163
164 let next_parent = match cmd.parent_dir_id {
165 Some(None) => None,
166 Some(Some(parent)) => {
167 ensure_parent(tx, space_id, parent, Some(dir_id)).await?;
168 Some(parent)
169 }
170 None => existing.parent_dir_id,
171 };
172 let parent_changed = next_parent != existing.parent_dir_id;
173 let position = match cmd.position {
174 Some(position) => validate_position(position)?,
175 None if parent_changed => next_position(tx, space_id, next_parent).await?,
176 None => existing.position,
177 };
178
179 let mut am: dirs::ActiveModel = existing.into();
180 if let Some(name) = cmd.name {
181 am.name = Set(validate_name(&name)?);
182 }
183 if let Some(topic) = cmd.topic {
184 am.topic = Set(normalize_optional(topic));
185 }
186 if let Some(icon) = cmd.icon {
187 am.icon = Set(normalize_optional(icon));
188 }
189 am.parent_dir_id = Set(next_parent);
190 am.position = Set(position);
191 am.updated_at = Set(OffsetDateTime::now_utc());
192 let updated = am.update(tx).await?;
193 let dto = DirDto::from(updated);
194 emit(tx, events::EventKind::DirUpdate, actor, &dto).await?;
195 Ok(dto)
196}
197
198pub async fn archive(tx: &impl ConnectionTrait, actor: i64, dir_id: Uuid) -> Result<(), Error> {
199 let existing = dirs::Entity::find_by_id(dir_id)
200 .one(tx)
201 .await?
202 .ok_or(Error::NotFound)?;
203 if existing.is_archived {
204 return Ok(());
205 }
206 let now = OffsetDateTime::now_utc();
207 let mut am: dirs::ActiveModel = existing.into();
208 am.is_archived = Set(true);
209 am.archived_at = Set(Some(now));
210 am.archived_by = Set(Some(actor));
211 am.updated_at = Set(now);
212 let updated = am.update(tx).await?;
213 let dto = DirDto::from(updated);
214 emit(tx, events::EventKind::DirArchive, actor, &dto).await?;
215 Ok(())
216}
217
218pub async fn dir(tx: &impl ConnectionTrait, id: Uuid) -> Result<DirDto, Error> {
219 dirs::Entity::find_by_id(id)
220 .one(tx)
221 .await?
222 .map(DirDto::from)
223 .ok_or(Error::NotFound)
224}
225
226fn validate_name(name: &str) -> Result<String, Error> {
227 let name = name.trim();
228 if name.is_empty() || name.chars().count() > MAX_NAME_LEN {
229 return Err(Error::InvalidInput(
230 "dir name is empty or too long".to_owned(),
231 ));
232 }
233 Ok(name.to_owned())
234}
235
236fn validate_position(position: i64) -> Result<i64, Error> {
237 if position < 0 {
238 return Err(Error::InvalidInput(
239 "position must be non-negative".to_owned(),
240 ));
241 }
242 Ok(position)
243}
244
245fn normalize_optional(value: Option<String>) -> Option<String> {
246 value
247 .map(|value| value.trim().to_owned())
248 .filter(|value| !value.is_empty())
249}
250
251async fn ensure_parent(
252 tx: &impl ConnectionTrait,
253 space_id: Uuid,
254 parent: Uuid,
255 moving: Option<Uuid>,
256) -> Result<(), Error> {
257 if moving == Some(parent) {
258 return Err(Error::InvalidInput(
259 "cannot move a dir into itself".to_owned(),
260 ));
261 }
262 let node = dirs::Entity::find_by_id(parent)
263 .filter(dirs::Column::SpaceId.eq(space_id))
264 .one(tx)
265 .await?
266 .ok_or(Error::NotFound)?;
267 if !node.kind.is_folder() || node.is_archived {
268 return Err(Error::InvalidInput(
269 "parent dir must be an active folder".to_owned(),
270 ));
271 }
272 if let Some(moving) = moving {
273 let mut cursor = node.parent_dir_id;
274 let mut guard = 0;
275 while let Some(current) = cursor {
276 if current == moving {
277 return Err(Error::InvalidInput(
278 "cannot move a dir under its own descendant".to_owned(),
279 ));
280 }
281 guard += 1;
282 if guard > 1024 {
283 break;
284 }
285 cursor = dirs::Entity::find_by_id(current)
286 .one(tx)
287 .await?
288 .and_then(|row| row.parent_dir_id);
289 }
290 }
291 Ok(())
292}
293
294async fn next_position(
295 tx: &impl ConnectionTrait,
296 space_id: Uuid,
297 parent_dir_id: Option<Uuid>,
298) -> Result<i64, Error> {
299 let last = dirs::Entity::find()
300 .filter(dirs::Column::SpaceId.eq(space_id))
301 .filter(dirs::Column::ParentDirId.eq(parent_dir_id))
302 .order_by_desc(dirs::Column::Position)
303 .lock(sea_orm::sea_query::LockType::Update)
304 .one(tx)
305 .await?;
306 Ok(last.map(|node| node.position + 1).unwrap_or(0))
307}
308
309async fn emit(
310 tx: &impl ConnectionTrait,
311 kind: events::EventKind,
312 actor: i64,
313 dir: &DirDto,
314) -> Result<(), Error> {
315 events::emit(
316 tx,
317 kind,
318 events::Labels::new()
319 .with(events::label::DIR_ID, dir.id)
320 .with(events::label::ACTOR_USER_ID, actor),
321 dir,
322 )
323 .await?;
324 Ok(())
325}
326
327pub async fn set_user_overwrite(
328 tx: &impl ConnectionTrait,
329 dir_id: Uuid,
330 user_id: i64,
331 allow_mask: i64,
332 deny_mask: i64,
333 expires_at: Option<OffsetDateTime>,
334) -> Result<(), Error> {
335 if allow_mask < 0 || deny_mask < 0 {
336 return Err(Error::InvalidInput(
337 "overwrite masks must be non-negative".to_owned(),
338 ));
339 }
340 dirs::Entity::find_by_id(dir_id)
341 .one(tx)
342 .await?
343 .ok_or(Error::NotFound)?;
344 dir_overwrite_users::Entity::insert(dir_overwrite_users::ActiveModel {
345 dir_id: Set(dir_id),
346 user_id: Set(user_id),
347 allow_mask: Set(allow_mask),
348 deny_mask: Set(deny_mask),
349 expires_at: Set(expires_at),
350 })
351 .on_conflict(
352 sea_orm::sea_query::OnConflict::columns([
353 dir_overwrite_users::Column::DirId,
354 dir_overwrite_users::Column::UserId,
355 ])
356 .update_columns([
357 dir_overwrite_users::Column::AllowMask,
358 dir_overwrite_users::Column::DenyMask,
359 dir_overwrite_users::Column::ExpiresAt,
360 ])
361 .to_owned(),
362 )
363 .exec_without_returning(tx)
364 .await?;
365 Ok(())
366}
367
368pub async fn remove_user_overwrite(
369 tx: &impl ConnectionTrait,
370 dir_id: Uuid,
371 user_id: i64,
372) -> Result<bool, Error> {
373 let result = dir_overwrite_users::Entity::delete_many()
374 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
375 .filter(dir_overwrite_users::Column::UserId.eq(user_id))
376 .exec(tx)
377 .await?;
378 Ok(result.rows_affected > 0)
379}
380
381pub async fn clear_user_overwrites(tx: &impl ConnectionTrait, dir_id: Uuid) -> Result<(), Error> {
382 dir_overwrite_users::Entity::delete_many()
383 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
384 .exec(tx)
385 .await?;
386 Ok(())
387}
Deleted lib/service/dir/Cargo.toml
@@ -1,15 +0,0 @@
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/service/git/src/service/context.rs View lib/service/git/src/service/context.rs
@@ -3,7 +3,6 @@
33
44use crate::context::facade::GitContext;
55use crate::service::GitService;
6use crate::share::access;
76use crate::share::errors::GitError;
87
98impl GitService {
@@ -22,23 +21,12 @@
2221 if repo_lower.is_empty() {
2322 return Err(GitError::NotFound);
2423 }
25 let candidates = repos::Entity::find()
24 let repo = repos::Entity::find()
2625 .filter(repos::Column::LowerName.eq(&repo_lower))
27 .all(self.db_read())
28 .await?;
29 let repo = {
30 let mut found = None;
31 for candidate in candidates {
32 if access::space_for_dir(&self.access, candidate.dir_id)
33 .await?
34 .is_some_and(|s| s.id() == space.id())
35 {
36 found = Some(candidate);
37 break;
38 }
39 }
40 found.ok_or(GitError::NotFound)?
41 };
26 .filter(repos::Column::SpaceId.eq(space.id()))
27 .one(self.db_read())
28 .await?
29 .ok_or(GitError::NotFound)?;
4230
4331 Ok(self
4432 .state
Modified lib/service/git/src/share/access.rs View lib/service/git/src/share/access.rs
@@ -1,5 +1,3 @@
1use uuid::Uuid;
2
31use crate::context::facade::GitContext;
42use crate::service::GitService;
53use crate::share::errors::GitError;
@@ -44,14 +42,5 @@
4442 service: &GitService,
4543 repo: &models::repo::repos::Model,
4644) -> Result<space::Space, GitError> {
47 space_for_dir(&service.access, repo.dir_id)
48 .await?
49 .ok_or_else(|| GitError::Internal(format!("space for repo {} not found", repo.id)))
50}
51
52pub async fn space_for_dir(
53 access: &space::Access,
54 dir_id: Uuid,
55) -> Result<Option<space::Space>, GitError> {
56 Ok(access.for_dir(dir_id).await?)
45 Ok(service.access.by_id(repo.space_id).await?)
5746}
Modified lib/service/git/src/errors.rs View lib/service/git/src/errors.rs
@@ -48,16 +48,6 @@
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
6151impl From<sea_orm::DbErr> for GitApiError {
6252 fn from(error: sea_orm::DbErr) -> Self {
6353 if is_unique_violation(&error) {
Modified lib/service/git/Cargo.toml View lib/service/git/Cargo.toml
@@ -8,7 +8,6 @@
88bootstrap = { workspace = true }
99db = { workspace = true }
1010auth = { workspace = true }
11dir = { workspace = true }
1211proto = { workspace = true }
1312tonic = { workspace = true }
1413prost-types = { workspace = true }
Modified lib/service/issues/src/services/boards.rs View lib/service/issues/src/services/boards.rs
@@ -1,10 +1,9 @@
11use std::collections::HashMap;
22
3use models::dir::dirs::{self, DirKind};
43use models::issue::{issue_item, issue_pin, issues, milestone};
54use sea_orm::{
65 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
7 QuerySelect, Set, TransactionTrait, Unchanged,
6 QuerySelect, Set, TransactionTrait,
87};
98use serde::{Deserialize, Serialize};
109use time::OffsetDateTime;
@@ -19,12 +18,13 @@
1918#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
2019pub struct BoardDto {
2120 pub id: Uuid,
22 pub dir_id: Uuid,
2321 pub name: String,
2422 /// Number prefix for the board's issues (`CAD` renders as `CAD-12`).
2523 pub prefix: Option<String>,
2624 pub icon: Option<String>,
25 pub color: Option<String>,
2726 pub description: Option<String>,
27 pub position: i64,
2828 pub open_issues_count: i64,
2929 pub closed_issues_count: i64,
3030 pub created_at: OffsetDateTime,
@@ -36,8 +36,6 @@
3636#[derive(Debug, Clone, Deserialize, ToSchema)]
3737pub struct CreateBoardCmd {
3838 pub name: String,
39 #[serde(default, alias = "dir_id")]
40 pub parent_dir_id: Option<Uuid>,
4139 #[serde(default)]
4240 pub prefix: Option<String>,
4341 #[serde(default)]
@@ -56,12 +54,15 @@
5654 pub icon: Option<Option<String>>,
5755 #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
5856 pub description: Option<Option<String>>,
57 #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
58 pub color: Option<Option<String>>,
59 #[serde(default)]
60 pub position: Option<i64>,
5961}
6062
6163#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
6264#[serde(deny_unknown_fields)]
6365pub struct ListBoardQuery {
64 pub dir_id: Option<Uuid>,
6566 pub q: Option<String>,
6667 pub limit: Option<u64>,
6768 pub offset: Option<u64>,
@@ -103,19 +104,8 @@
103104 prefix: &str,
104105 exclude_board: Option<Uuid>,
105106 ) -> Result<bool, IssueError> {
106 let dir_ids: Vec<Uuid> = dirs::Entity::find()
107 .filter(dirs::Column::SpaceId.eq(Some(space_id)))
108 .filter(dirs::Column::Kind.eq(DirKind::IssueBoard))
109 .all(self.database.read())
110 .await?
111 .into_iter()
112 .map(|d| d.id)
113 .collect();
114 if dir_ids.is_empty() {
115 return Ok(false);
116 }
117107 let mut select = issues::Entity::find()
118 .filter(issues::Column::DirId.is_in(dir_ids))
108 .filter(issues::Column::SpaceId.eq(space_id))
119109 .filter(issues::Column::Prefix.eq(prefix));
120110 if let Some(board_id) = exclude_board {
121111 select = select.filter(issues::Column::Id.ne(board_id));
@@ -130,11 +120,12 @@
130120 ) -> BoardDto {
131121 BoardDto {
132122 id: board.id,
133 dir_id: board.dir_id,
134123 name: board.name,
135124 prefix: board.prefix,
136125 icon: board.icon,
126 color: board.color,
137127 description: board.description,
128 position: board.position,
138129 open_issues_count,
139130 closed_issues_count,
140131 created_at: board.created_at,
@@ -218,48 +209,42 @@
218209 ));
219210 }
220211
221 let (_dir, board) = {
212 let position = issues::Entity::find()
213 .filter(issues::Column::SpaceId.eq(space.id()))
214 .order_by_desc(issues::Column::Position)
215 .one(self.database.read())
216 .await?
217 .map(|row| row.position + 1)
218 .unwrap_or(0);
219
220 let board = {
222221 let txn = self.database.write().begin().await?;
223222 let now = OffsetDateTime::now_utc();
224 let dir = dir::create_object(
225 &txn,
226 caller,
227 dir::NewDir {
228 space_id: space.id(),
229 kind: DirKind::IssueBoard,
230 name: name.to_string(),
231 parent_dir_id: cmd.parent_dir_id,
232 position: None,
233 topic: None,
234 purpose: cmd.description.clone(),
235 icon: cmd.icon.clone(),
236 color: None,
237 },
238 )
239 .await?;
240
241223 let board_row = issues::ActiveModel {
242224 id: Set(Uuid::now_v7()),
243 dir_id: Set(dir.id),
225 space_id: Set(space.id()),
244226 name: Set(name.to_string()),
245227 prefix: Set(prefix),
246228 icon: Set(cmd.icon),
229 color: Set(None),
247230 description: Set(cmd.description),
231 position: Set(position),
248232 created_at: Set(now),
249233 updated_at: Set(None),
250234 };
251235 let board = board_row.insert(&txn).await?;
252236 txn.commit().await?;
253 (dir, board)
237 board
254238 };
255239
256240 Ok(BoardDto {
257241 id: board.id,
258 dir_id: board.dir_id,
259242 name: board.name,
260243 prefix: board.prefix,
261244 icon: board.icon,
245 color: board.color,
262246 description: board.description,
247 position: board.position,
263248 open_issues_count: 0,
264249 closed_issues_count: 0,
265250 created_at: board.created_at,
@@ -277,42 +262,8 @@
277262
278263 let (limit, offset) = self.paginate(q.limit, q.offset)?;
279264
280 let dirs_in_space = dirs::Entity::find()
281 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
282 .filter(dirs::Column::Kind.eq(DirKind::IssueBoard))
283 .all(self.database.read())
284 .await?;
285
286 if dirs_in_space.is_empty() {
287 return Ok(BoardPage {
288 items: Vec::new(),
289 total: 0,
290 limit,
291 offset,
292 });
293 }
265 let mut select = issues::Entity::find().filter(issues::Column::SpaceId.eq(space.id()));
294266
295 let dir_ids: Vec<Uuid> = if let Some(target_dir) = q.dir_id {
296 dirs_in_space
297 .into_iter()
298 .filter(|d| d.parent_dir_id == Some(target_dir) || d.id == target_dir)
299 .map(|d| d.id)
300 .collect()
301 } else {
302 dirs_in_space.into_iter().map(|d| d.id).collect()
303 };
304
305 if dir_ids.is_empty() {
306 return Ok(BoardPage {
307 items: Vec::new(),
308 total: 0,
309 limit,
310 offset,
311 });
312 }
313
314 let mut select = issues::Entity::find().filter(issues::Column::DirId.is_in(dir_ids));
315
316267 if let Some(ref search) = q.q {
317268 let kw = search.trim();
318269 if !kw.is_empty() {
@@ -373,11 +324,6 @@
373324
374325 let now = OffsetDateTime::now_utc();
375326 let mut am: issues::ActiveModel = board.clone().into();
376 let mut dir_am = dirs::ActiveModel {
377 id: Unchanged(board.dir_id),
378 ..Default::default()
379 };
380 let mut dir_changed = false;
381327
382328 if let Some(new_name) = cmd.name {
383329 let name = new_name.trim();
@@ -387,8 +333,6 @@
387333 ));
388334 }
389335 am.name = Set(name.to_string());
390 dir_am.name = Set(name.to_string());
391 dir_changed = true;
392336 }
393337 if let Some(raw_prefix) = cmd.prefix {
394338 let prefix = match raw_prefix.as_deref() {
@@ -407,28 +351,26 @@
407351 am.prefix = Set(prefix);
408352 }
409353 if let Some(icon) = cmd.icon {
410 am.icon = Set(icon.clone());
411 dir_am.icon = Set(icon);
412 dir_changed = true;
354 am.icon = Set(icon);
413355 }
414356 if let Some(desc) = cmd.description {
415 am.description = Set(desc.clone());
416 dir_am.purpose = Set(desc);
417 dir_changed = true;
357 am.description = Set(desc);
358 }
359 if let Some(color) = cmd.color {
360 am.color = Set(color);
418361 }
362 if let Some(position) = cmd.position {
363 if position < 0 {
364 return Err(IssueError::BadRequest(
365 "position must be non-negative".to_string(),
366 ));
367 }
368 am.position = Set(position);
369 }
419370
420371 am.updated_at = Set(Some(now));
421372
422 let updated = {
423 let txn = self.database.write().begin().await?;
424 if dir_changed {
425 dir_am.updated_at = Set(now);
426 dir_am.update(&txn).await?;
427 }
428 let updated = am.update(&txn).await?;
429 txn.commit().await?;
430 updated
431 };
373 let updated = am.update(self.database.write()).await?;
432374
433375 self.build_board_dto(updated).await
434376 }
@@ -468,8 +410,6 @@
468410 .await?;
469411
470412 issues::Entity::delete_by_id(board.id).exec(txn).await?;
471
472 dirs::Entity::delete_by_id(board.dir_id).exec(txn).await?;
473413
474414 Ok(())
475415 })
Modified lib/service/issues/src/services/issues.rs View lib/service/issues/src/services/issues.rs
@@ -491,7 +491,7 @@
491491 if !cmd.label_ids.is_empty() {
492492 let count = issue_label::Entity::find()
493493 .filter(issue_label::Column::Id.is_in(cmd.label_ids.clone()))
494 .filter(issue_label::Column::DirId.eq(board.dir_id))
494 .filter(issue_label::Column::BoardId.eq(board.id))
495495 .count(self.database.read())
496496 .await?;
497497 if count != cmd.label_ids.len() as u64 {
@@ -952,7 +952,7 @@
952952 {
953953 let count = issue_label::Entity::find()
954954 .filter(issue_label::Column::Id.is_in(label_ids.clone()))
955 .filter(issue_label::Column::DirId.eq(board.dir_id))
955 .filter(issue_label::Column::BoardId.eq(board.id))
956956 .count(self.database.read())
957957 .await?;
958958 if count != label_ids.len() as u64 {
Modified lib/service/issues/src/services/labels.rs View lib/service/issues/src/services/labels.rs
@@ -1,5 +1,5 @@
11use models::issue::issue_event_line::IssueEventType;
2use models::issue::{issue_label, issue_relation_label};
2use models::issue::{issue_label, issue_relation_label, issues};
33use sea_orm::{
44 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
55 QuerySelect, Set, TransactionTrait,
@@ -19,7 +19,7 @@
1919#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
2020pub struct LabelDto {
2121 pub id: Uuid,
22 pub dir_id: Uuid,
22 pub board_id: Uuid,
2323 pub name: String,
2424 pub exclusive: bool,
2525 pub exclusive_order: i32,
@@ -34,7 +34,7 @@
3434 fn from(m: issue_label::Model) -> Self {
3535 Self {
3636 id: m.id,
37 dir_id: m.dir_id,
37 board_id: m.board_id,
3838 name: m.name,
3939 exclusive: m.exclusive,
4040 exclusive_order: m.exclusive_order,
@@ -123,7 +123,7 @@
123123 let board = lookup_board(self, &space, board_id, caller).await?;
124124 let _ = lookup_issue(self, &board, issue_id).await?;
125125 let label = issue_label::Entity::find_by_id(label_id)
126 .filter(issue_label::Column::DirId.eq(board.dir_id))
126 .filter(issue_label::Column::BoardId.eq(board.id))
127127 .one(self.database.read())
128128 .await?
129129 .ok_or(IssueError::NotFound)?;
@@ -167,7 +167,7 @@
167167 let now = OffsetDateTime::now_utc();
168168 let row = issue_label::ActiveModel {
169169 id: Set(Uuid::now_v7()),
170 dir_id: Set(board.dir_id),
170 board_id: Set(board.id),
171171 name: Set(name.to_string()),
172172 exclusive: Set(cmd.exclusive.unwrap_or(false)),
173173 exclusive_order: Set(cmd.exclusive_order.unwrap_or(0)),
@@ -194,7 +194,7 @@
194194 let (limit, offset) = self.paginate(q.limit, q.offset)?;
195195
196196 let mut select =
197 issue_label::Entity::find().filter(issue_label::Column::DirId.eq(board.dir_id));
197 issue_label::Entity::find().filter(issue_label::Column::BoardId.eq(board.id));
198198
199199 if let Some(archived) = q.is_archived {
200200 select = select.filter(issue_label::Column::IsArchived.eq(archived));
@@ -235,7 +235,7 @@
235235 let (_, board) = board_context(self, caller, space_name, board_id).await?;
236236
237237 let l = issue_label::Entity::find_by_id(label_id)
238 .filter(issue_label::Column::DirId.eq(board.dir_id))
238 .filter(issue_label::Column::BoardId.eq(board.id))
239239 .one(self.database.read())
240240 .await?
241241 .ok_or(IssueError::NotFound)?;
@@ -255,7 +255,7 @@
255255 let (_, board) = board_context(self, caller, space_name, board_id).await?;
256256
257257 let l = issue_label::Entity::find_by_id(label_id)
258 .filter(issue_label::Column::DirId.eq(board.dir_id))
258 .filter(issue_label::Column::BoardId.eq(board.id))
259259 .one(self.database.read())
260260 .await?
261261 .ok_or(IssueError::NotFound)?;
@@ -318,7 +318,7 @@
318318 let (_, board) = board_context(self, caller, space_name, board_id).await?;
319319
320320 let _ = issue_label::Entity::find_by_id(label_id)
321 .filter(issue_label::Column::DirId.eq(board.dir_id))
321 .filter(issue_label::Column::BoardId.eq(board.id))
322322 .one(self.database.read())
323323 .await?
324324 .ok_or(IssueError::NotFound)?;
@@ -474,12 +474,12 @@
474474impl IssueService {
475475 pub async fn resolve_directory_label(
476476 &self,
477 dir_id: Uuid,
477 board_id: Uuid,
478478 reference: &str,
479479 ) -> Result<LabelDto, IssueError> {
480480 let reference = reference.trim();
481481 let select = issue_label::Entity::find()
482 .filter(issue_label::Column::DirId.eq(dir_id))
482 .filter(issue_label::Column::BoardId.eq(board_id))
483483 .filter(issue_label::Column::IsArchived.eq(false));
484484 let row = if let Ok(id) = Uuid::parse_str(reference) {
485485 select.filter(issue_label::Column::Id.eq(id))
@@ -495,9 +495,58 @@
495495 })
496496 }
497497
498 pub async fn directory_labels(&self, dir_id: Uuid) -> Result<Vec<LabelDto>, IssueError> {
498 pub async fn directory_labels(&self, board_id: Uuid) -> Result<Vec<LabelDto>, IssueError> {
499499 let rows = issue_label::Entity::find()
500 .filter(issue_label::Column::DirId.eq(dir_id))
500 .filter(issue_label::Column::BoardId.eq(board_id))
501 .filter(issue_label::Column::IsArchived.eq(false))
502 .order_by_asc(issue_label::Column::Name)
503 .all(self.database.read())
504 .await?;
505 Ok(rows.into_iter().map(LabelDto::from).collect())
506 }
507
508 /// 拉取请求没有自己的标签容器,借用同一空间内所有看板的标签。
509 pub async fn resolve_space_label(
510 &self,
511 space_id: Uuid,
512 reference: &str,
513 ) -> Result<LabelDto, IssueError> {
514 let reference = reference.trim();
515 let boards: Vec<Uuid> = issues::Entity::find()
516 .filter(issues::Column::SpaceId.eq(space_id))
517 .select_only()
518 .column(issues::Column::Id)
519 .into_tuple()
520 .all(self.database.read())
521 .await?;
522 let select = issue_label::Entity::find()
523 .filter(issue_label::Column::BoardId.is_in(boards))
524 .filter(issue_label::Column::IsArchived.eq(false));
525 let row = if let Ok(id) = Uuid::parse_str(reference) {
526 select.filter(issue_label::Column::Id.eq(id))
527 } else {
528 select.filter(issue_label::Column::Name.eq(reference))
529 }
530 .one(self.database.read())
531 .await?;
532 row.map(LabelDto::from).ok_or_else(|| {
533 IssueError::BadRequest(format!("label `{reference}` not found in this space"))
534 })
535 }
536
537 pub async fn space_labels(&self, space_id: Uuid) -> Result<Vec<LabelDto>, IssueError> {
538 let boards: Vec<Uuid> = issues::Entity::find()
539 .filter(issues::Column::SpaceId.eq(space_id))
540 .select_only()
541 .column(issues::Column::Id)
542 .into_tuple()
543 .all(self.database.read())
544 .await?;
545 if boards.is_empty() {
546 return Ok(Vec::new());
547 }
548 let rows = issue_label::Entity::find()
549 .filter(issue_label::Column::BoardId.is_in(boards))
501550 .filter(issue_label::Column::IsArchived.eq(false))
502551 .order_by_asc(issue_label::Column::Name)
503552 .all(self.database.read())
Modified lib/service/issues/src/share/lookup.rs View lib/service/issues/src/share/lookup.rs
@@ -17,14 +17,14 @@
1717 service: &IssueService,
1818 space: &Space,
1919 board_id: Uuid,
20 caller: i64,
20 _caller: i64,
2121) -> Result<issues::Model, IssueError> {
2222 let board = issues::Entity::find_by_id(board_id)
2323 .one(service.database.read())
2424 .await?
2525 .ok_or(IssueError::NotFound)?;
2626
27 if !space.object_visible(caller, board.dir_id).await? {
27 if board.space_id != space.id() {
2828 return Err(IssueError::NotFound);
2929 }
3030
@@ -148,9 +148,9 @@
148148 .ok_or(IssueError::NotFound)?;
149149 let space = service
150150 .access
151 .for_dir(board.dir_id)
152 .await?
153 .ok_or(IssueError::NotFound)?;
151 .by_id(board.space_id)
152 .await
153 .map_err(|_| IssueError::NotFound)?;
154154 require_member(service, &space, caller).await?;
155155 let board = lookup_board(service, &space, board.id, caller).await?;
156156 Ok((space, board, item))
Modified lib/service/issues/src/errors.rs View lib/service/issues/src/errors.rs
@@ -30,16 +30,6 @@
3030auth::auth_error_conversion!(IssueError);
3131api::db_error_conversions!(IssueError);
3232
33impl From<dir::Error> for IssueError {
34 fn from(error: dir::Error) -> Self {
35 match error {
36 dir::Error::InvalidInput(message) => Self::BadRequest(message),
37 dir::Error::NotFound => Self::NotFound,
38 dir::Error::Database(error) => Self::from(error),
39 }
40 }
41}
42
4333api::json_payload_conversion!(IssueError);
4434api::page_error_conversion!(IssueError, BadRequest);
4535api::access_error_conversion!(IssueError, BadRequest);
Modified lib/service/issues/Cargo.toml View lib/service/issues/Cargo.toml
@@ -9,7 +9,6 @@
99db = { workspace = true }
1010push = { workspace = true }
1111storage = { workspace = true }
12dir = { workspace = true }
1312auth = { workspace = true }
1413email = { workspace = true }
1514bytes = { workspace = true }
Modified lib/service/pulls/src/handlers/labels/attach_pull_label.rs View lib/service/pulls/src/handlers/labels/attach_pull_label.rs
@@ -35,7 +35,7 @@
3535 .await?;
3636 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
3737 let labels = service
38 .attach_label(&handle.pull, handle.ctx.model.dir_id, caller, cmd)
38 .attach_label(&handle.pull, handle.ctx.model.space_id, caller, cmd)
3939 .await?;
4040 Ok(AppResponseData::ok(labels))
4141}
Modified lib/service/pulls/src/handlers/labels/detach_pull_label.rs View lib/service/pulls/src/handlers/labels/detach_pull_label.rs
@@ -42,7 +42,7 @@
4242 let labels = service
4343 .detach_label(
4444 &handle.pull,
45 handle.ctx.model.dir_id,
45 handle.ctx.model.space_id,
4646 caller,
4747 &extra.label_ref,
4848 )
Modified lib/service/pulls/src/handlers/labels/list_label_options.rs View lib/service/pulls/src/handlers/labels/list_label_options.rs
@@ -29,6 +29,6 @@
2929 path.repo_name.clone(),
3030 )
3131 .await?;
32 let labels = service.list_label_options(ctx.model.dir_id).await?;
32 let labels = service.list_label_options(ctx.model.space_id).await?;
3333 Ok(AppResponseData::ok(labels))
3434}
Modified lib/service/pulls/src/services/labels.rs View lib/service/pulls/src/services/labels.rs
@@ -21,19 +21,16 @@
2121impl PullService {
2222 async fn resolve_label(
2323 &self,
24 dir_id: Uuid,
24 space_id: Uuid,
2525 reference: &str,
2626 ) -> Result<issues::services::labels::LabelDto, PullError> {
27 Ok(self
28 .issues
29 .resolve_directory_label(dir_id, reference)
30 .await?)
27 Ok(self.issues.resolve_space_label(space_id, reference).await?)
3128 }
3229 pub async fn list_label_options(
3330 &self,
34 dir_id: Uuid,
31 space_id: Uuid,
3532 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
36 Ok(self.issues.directory_labels(dir_id).await?)
33 Ok(self.issues.space_labels(space_id).await?)
3734 }
3835 pub async fn list_labels(
3936 &self,
@@ -54,12 +51,12 @@
5451 pub async fn attach_label(
5552 &self,
5653 pull: &pull_requests::Model,
57 dir_id: Uuid,
54 space_id: Uuid,
5855 caller: i64,
5956 cmd: AttachLabelCmd,
6057 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
6158 authz::require_unlocked(pull)?;
62 let label = self.resolve_label(dir_id, &cmd.label).await?;
59 let label = self.resolve_label(space_id, &cmd.label).await?;
6360 let existing = pull_request_label::Entity::find()
6461 .filter(pull_request_label::Column::PullRequestId.eq(pull.id))
6562 .filter(pull_request_label::Column::LabelId.eq(label.id))
Deleted lib/service/space/src/handlers/create_dir_node.rs
@@ -1,34 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Json, Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::{CreateDirCmd, DirNodeDto};
7use crate::services::dto::SpacePath;
8use crate::services::service::SpaceService;
9
10#[utoipa::path(
11 post,
12 path = "/spaces/{space_name}/dir",
13 tag = "space",
14 description = "在空间目录树中创建目录节点。",
15 params(SpacePath),
16 request_body = CreateDirCmd,
17 responses(
18 (status = 200, description = "dir node created", body = AppResponseData<DirNodeDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "space or parent not found", body = AppResponseData<NoData>),
22 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
23 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
24 )
25)]
26pub async fn create_dir_node(
27 State(service): State<SpaceService>,
28 AuthedUser(uid): AuthedUser,
29 Path(path): Path<SpacePath>,
30 Json(cmd): Json<CreateDirCmd>,
31) -> Result<AppResponseData<DirNodeDto>, ApiError> {
32 let node = service.create_dir_node(uid, &path.space_name, cmd).await?;
33 Ok(AppResponseData::ok(node))
34}
Deleted lib/service/space/src/handlers/get_space_dir_tree.rs
@@ -1,39 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, Query, State};
3use api::rest::{AppResponseData, NoData};
4use serde::Deserialize;
5
6use crate::errors::ApiError;
7use crate::services::dir::DirNodeDto;
8use crate::services::dto::SpacePath;
9use crate::services::service::SpaceService;
10
11#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
12pub struct DirTreeQuery {
13 pub parent_id: Option<uuid::Uuid>,
14}
15
16#[utoipa::path(
17 get,
18 path = "/spaces/{space_name}/dir",
19 tag = "space",
20 description = "按用户与群组覆写权限返回目录树或指定层级的子树。",
21 params(SpacePath, DirTreeQuery),
22 responses(
23 (status = 200, description = "get space dir tree", body = AppResponseData<Vec<DirNodeDto>>),
24 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
25 (status = 404, description = "space not found", body = AppResponseData<NoData>),
26 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
27 )
28)]
29pub async fn get_space_dir_tree(
30 State(service): State<SpaceService>,
31 AuthedUser(uid): AuthedUser,
32 Path(path): Path<SpacePath>,
33 Query(q): Query<DirTreeQuery>,
34) -> Result<AppResponseData<Vec<DirNodeDto>>, ApiError> {
35 let tree = service
36 .get_space_dir_tree(uid, &path.space_name, q.parent_id)
37 .await?;
38 Ok(AppResponseData::ok(tree))
39}
Deleted lib/service/space/src/handlers/list_dir_group_overwrites.rs
@@ -1,31 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, GroupOverwriteDto};
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 get,
11 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups",
12 tag = "space",
13 description = "列出目录节点上的群组权限覆写。",
14 params(DirPath),
15 responses(
16 (status = 200, description = "list dir group overwrites", body = AppResponseData<Vec<GroupOverwriteDto>>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space or dir not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_dir_group_overwrites(
23 State(service): State<SpaceService>,
24 AuthedUser(uid): AuthedUser,
25 Path(path): Path<DirPath>,
26) -> Result<AppResponseData<Vec<GroupOverwriteDto>>, ApiError> {
27 let list = service
28 .list_dir_group_overwrites(uid, &path.space_name, path.dir_id)
29 .await?;
30 Ok(AppResponseData::ok(list))
31}
Deleted lib/service/space/src/handlers/list_dir_user_overwrites.rs
@@ -1,31 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, UserOverwriteDto};
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 get,
11 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users",
12 tag = "space",
13 description = "列出目录节点上的用户权限覆写。",
14 params(DirPath),
15 responses(
16 (status = 200, description = "list dir user overwrites", body = AppResponseData<Vec<UserOverwriteDto>>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space or dir not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_dir_user_overwrites(
23 State(service): State<SpaceService>,
24 AuthedUser(uid): AuthedUser,
25 Path(path): Path<DirPath>,
26) -> Result<AppResponseData<Vec<UserOverwriteDto>>, ApiError> {
27 let list = service
28 .list_dir_user_overwrites(uid, &path.space_name, path.dir_id)
29 .await?;
30 Ok(AppResponseData::ok(list))
31}
Modified lib/service/space/src/handlers/mod.rs View lib/service/space/src/handlers/mod.rs
@@ -1,5 +1,4 @@
11pub mod assign_group;
2pub mod create_dir_node;
32pub mod create_group;
43pub mod create_join_rule;
54pub mod delete_group;
@@ -7,25 +6,15 @@
76pub mod delete_space_image;
87pub mod explore_spaces;
98pub mod get_group;
10pub mod get_space_dir_tree;
119pub mod get_space_image;
1210pub mod get_space_image_file;
1311pub mod join_group_self;
1412pub mod join_requests;
1513pub mod leave_group_self;
16pub mod list_dir_group_overwrites;
17pub mod list_dir_user_overwrites;
1814pub mod list_groups;
1915pub mod list_join_rules;
20pub mod move_dir_node;
21pub mod remove_dir_group_overwrite;
22pub mod remove_dir_user_overwrite;
23pub mod repair_dir_parents;
24pub mod set_dir_group_overwrite;
25pub mod set_dir_user_overwrite;
2616pub mod space_admin;
2717pub mod unassign_group;
28pub mod update_dir_node;
2918pub mod update_group;
3019pub mod update_join_rule;
3120pub mod upload_space_image;
Deleted lib/service/space/src/handlers/move_dir_node.rs
@@ -1,34 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Json, Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirNodeDto, DirPath, MoveDirCmd};
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 patch,
11 path = "/spaces/{space_name}/dir/{dir_id}/position",
12 tag = "space",
13 description = "移动目录节点并防止父链自引用形成环。",
14 params(DirPath),
15 request_body = MoveDirCmd,
16 responses(
17 (status = 200, description = "dir node moved", body = AppResponseData<DirNodeDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "dir node not found", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn move_dir_node(
25 State(service): State<SpaceService>,
26 AuthedUser(uid): AuthedUser,
27 Path(path): Path<DirPath>,
28 Json(cmd): Json<MoveDirCmd>,
29) -> Result<AppResponseData<DirNodeDto>, ApiError> {
30 let node = service
31 .move_dir_node(uid, &path.space_name, path.dir_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(node))
34}
Deleted lib/service/space/src/handlers/remove_dir_group_overwrite.rs
@@ -1,31 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::handlers::set_dir_group_overwrite::DirGroupPath;
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 delete,
11 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
12 tag = "space",
13 description = "移除目录节点的指定群组权限覆写。",
14 params(DirGroupPath),
15 responses(
16 (status = 200, description = "remove dir group overwrite", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn remove_dir_group_overwrite(
23 State(service): State<SpaceService>,
24 AuthedUser(uid): AuthedUser,
25 Path(path): Path<DirGroupPath>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 service
28 .remove_dir_group_overwrite(uid, &path.space_name, path.dir_id, path.group_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Deleted lib/service/space/src/handlers/remove_dir_user_overwrite.rs
@@ -1,31 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::handlers::set_dir_user_overwrite::DirUserPath;
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 delete,
11 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
12 tag = "space",
13 description = "移除目录节点的指定用户权限覆写。",
14 params(DirUserPath),
15 responses(
16 (status = 200, description = "remove dir user overwrite", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn remove_dir_user_overwrite(
23 State(service): State<SpaceService>,
24 AuthedUser(uid): AuthedUser,
25 Path(path): Path<DirUserPath>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 service
28 .remove_dir_user_overwrite(uid, &path.space_name, path.dir_id, path.user_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Deleted lib/service/space/src/handlers/repair_dir_parents.rs
@@ -1,31 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::RepairDirResp;
7use crate::services::dto::SpacePath;
8use crate::services::service::SpaceService;
9
10#[utoipa::path(
11 post,
12 path = "/spaces/{space_name}/dir/repair-parents",
13 tag = "space",
14 description = "拥有者修复目录树中的悬空父引用和循环结构。",
15 params(SpacePath),
16 responses(
17 (status = 200, description = "repair dir parents", body = AppResponseData<RepairDirResp>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>),
20 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn repair_dir_parents(
25 State(service): State<SpaceService>,
26 AuthedUser(uid): AuthedUser,
27 Path(path): Path<SpacePath>,
28) -> Result<AppResponseData<RepairDirResp>, ApiError> {
29 let resp = service.repair_dir_parents(uid, &path.space_name).await?;
30 Ok(AppResponseData::ok(resp))
31}
Deleted lib/service/space/src/handlers/set_dir_group_overwrite.rs
@@ -1,44 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Json, Path, State};
3use api::rest::{AppResponseData, NoData};
4use serde::Deserialize;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::dir::{GroupOverwriteDto, SetOverwriteCmd};
10use crate::services::service::SpaceService;
11
12#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
13pub struct DirGroupPath {
14 pub space_name: String,
15 pub dir_id: Uuid,
16 pub group_id: Uuid,
17}
18
19#[utoipa::path(
20 put,
21 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
22 tag = "space",
23 description = "设置目录节点针对群组的允许与拒绝权限位。",
24 params(DirGroupPath),
25 request_body = SetOverwriteCmd,
26 responses(
27 (status = 200, description = "set dir group overwrite", body = AppResponseData<GroupOverwriteDto>),
28 (status = 400, description = "bad request", body = AppResponseData<NoData>),
29 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
30 (status = 404, description = "not found", body = AppResponseData<NoData>),
31 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
32 )
33)]
34pub async fn set_dir_group_overwrite(
35 State(service): State<SpaceService>,
36 AuthedUser(uid): AuthedUser,
37 Path(path): Path<DirGroupPath>,
38 Json(cmd): Json<SetOverwriteCmd>,
39) -> Result<AppResponseData<GroupOverwriteDto>, ApiError> {
40 let dto = service
41 .set_dir_group_overwrite(uid, &path.space_name, path.dir_id, path.group_id, cmd)
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Deleted lib/service/space/src/handlers/set_dir_user_overwrite.rs
@@ -1,44 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Json, Path, State};
3use api::rest::{AppResponseData, NoData};
4use serde::Deserialize;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::dir::{SetOverwriteCmd, UserOverwriteDto};
10use crate::services::service::SpaceService;
11
12#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
13pub struct DirUserPath {
14 pub space_name: String,
15 pub dir_id: Uuid,
16 pub user_id: i64,
17}
18
19#[utoipa::path(
20 put,
21 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
22 tag = "space",
23 description = "设置目录节点针对用户的允许与拒绝权限位。",
24 params(DirUserPath),
25 request_body = SetOverwriteCmd,
26 responses(
27 (status = 200, description = "set dir user overwrite", body = AppResponseData<UserOverwriteDto>),
28 (status = 400, description = "bad request", body = AppResponseData<NoData>),
29 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
30 (status = 404, description = "not found", body = AppResponseData<NoData>),
31 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
32 )
33)]
34pub async fn set_dir_user_overwrite(
35 State(service): State<SpaceService>,
36 AuthedUser(uid): AuthedUser,
37 Path(path): Path<DirUserPath>,
38 Json(cmd): Json<SetOverwriteCmd>,
39) -> Result<AppResponseData<UserOverwriteDto>, ApiError> {
40 let dto = service
41 .set_dir_user_overwrite(uid, &path.space_name, path.dir_id, path.user_id, cmd)
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Deleted lib/service/space/src/handlers/update_dir_node.rs
@@ -1,34 +0,0 @@
1use ::session::AuthedUser;
2use api::http::{Json, Path, State};
3use api::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirNodeDto, DirPath, UpdateDirCmd};
7use crate::services::service::SpaceService;
8
9#[utoipa::path(
10 patch,
11 path = "/spaces/{space_name}/dir/{dir_id}",
12 tag = "space",
13 description = "重命名空间目录节点。",
14 params(DirPath),
15 request_body = UpdateDirCmd,
16 responses(
17 (status = 200, description = "dir node updated", body = AppResponseData<DirNodeDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "dir node not found", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn update_dir_node(
25 State(service): State<SpaceService>,
26 AuthedUser(uid): AuthedUser,
27 Path(path): Path<DirPath>,
28 Json(cmd): Json<UpdateDirCmd>,
29) -> Result<AppResponseData<DirNodeDto>, ApiError> {
30 let node = service
31 .update_dir_node(uid, &path.space_name, path.dir_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(node))
34}
Modified lib/service/space/src/rpc/space_directory.rs View lib/service/space/src/rpc/space_directory.rs
@@ -6,7 +6,9 @@
66use proto::internal::v1::space_directory_service_server::{
77 SpaceDirectoryService as SpaceDirectoryTrait, SpaceDirectoryServiceServer,
88};
9use proto::internal::v1::{LookupSpaceRequest, LookupSpaceResponse, SpaceInfo, SpaceStatus as PbStatus};
9use proto::internal::v1::{
10 LookupSpaceRequest, LookupSpaceResponse, SpaceInfo, SpaceStatus as PbStatus,
11};
1012use sea_orm::EntityTrait;
1113use tonic::{Request, Response, Status};
1214use uuid::Uuid;
Deleted lib/service/space/src/services/dir.rs
@@ -1,815 +0,0 @@
1use std::collections::{HashMap, HashSet};
2
3use models::dir::dirs::{self, DirKind};
4use models::dir::{dir_overwrite_group, dir_overwrite_users};
5use models::space::{space_groups, space_member_groups};
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
8 TransactionTrait,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::authority;
16use crate::errors::SpaceError;
17use crate::services::dto::UserDto;
18use crate::services::service::SpaceService;
19use crate::share::authz::{
20 MANAGE_CHANNELS, MANAGE_OVERWRITES, effective_permissions, require_perm,
21};
22use crate::share::lookup;
23
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
34pub const MAX_DIR_NODE_NAME: usize = 255;
35pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
36
37#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct DirNodeDto {
39 pub id: Uuid,
40 pub name: String,
41 pub kind: DirKind,
42 pub parent_dir_id: Option<Uuid>,
43 pub color: Option<String>,
44 pub position: i64,
45 pub icon: Option<String>,
46 pub topic: Option<String>,
47 pub purpose: Option<String>,
48 pub is_archived: bool,
49 pub archived_at: Option<OffsetDateTime>,
50 pub created_at: OffsetDateTime,
51 pub updated_at: OffsetDateTime,
52 pub created_by_id: i64,
53 #[schema(no_recursion)]
54 pub children: Vec<DirNodeDto>,
55}
56
57impl From<dirs::Model> for DirNodeDto {
58 fn from(m: dirs::Model) -> Self {
59 Self {
60 id: m.id,
61 name: m.name,
62 kind: m.kind,
63 parent_dir_id: m.parent_dir_id,
64 color: m.color,
65 position: m.position,
66 icon: m.icon,
67 topic: m.topic,
68 purpose: m.purpose,
69 is_archived: m.is_archived,
70 archived_at: m.archived_at,
71 created_at: m.created_at,
72 updated_at: m.updated_at,
73 created_by_id: m.created_by_uid,
74 children: Vec::new(),
75 }
76 }
77}
78
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
101#[derive(Debug, Clone, Deserialize, ToSchema)]
102pub struct CreateDirCmd {
103 pub name: String,
104 #[serde(default)]
105 pub parent_dir_id: Option<Uuid>,
106 #[serde(default)]
107 pub position: Option<i64>,
108}
109
110#[derive(Debug, Clone, Deserialize, ToSchema)]
111pub struct UpdateDirCmd {
112 pub name: String,
113}
114
115#[derive(Debug, Clone, Deserialize, ToSchema)]
116pub struct MoveDirCmd {
117 #[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]
118 pub parent_dir_id: Option<Option<Uuid>>,
119 #[serde(default)]
120 pub position: Option<i64>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
124pub struct RepairDirResp {
125 pub scanned: i64,
126 pub fixed: i64,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
130pub struct GroupOverwriteDto {
131 pub dir_id: Uuid,
132 pub group_id: Uuid,
133 pub allow_mask: i64,
134 pub deny_mask: i64,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
138pub struct UserOverwriteDto {
139 pub dir_id: Uuid,
140 pub user_id: i64,
141 pub user: Option<UserDto>,
142 pub allow_mask: i64,
143 pub deny_mask: i64,
144}
145
146#[derive(Debug, Clone, Deserialize, ToSchema)]
147pub struct SetOverwriteCmd {
148 pub allow_mask: i64,
149 pub deny_mask: i64,
150}
151
152#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
153pub struct DirPath {
154 pub space_name: String,
155 pub dir_id: Uuid,
156}
157
158impl SpaceService {
159 async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result<dirs::Model, SpaceError> {
160 dirs::Entity::find_by_id(dir_id)
161 .filter(dirs::Column::SpaceId.eq(space_id))
162 .one(self.access.database.read())
163 .await?
164 .ok_or(SpaceError::NotFound)
165 }
166
167 #[tracing::instrument(name = "space.create_dir_node", skip_all, fields(caller = %caller, space = %space_name))]
168 pub async fn create_dir_node(
169 &self,
170 caller: i64,
171 space_name: &str,
172 cmd: CreateDirCmd,
173 ) -> Result<DirNodeDto, SpaceError> {
174 let space = lookup::lookup_space_by_name(self, space_name).await?;
175 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
176
177 let name = cmd.name.trim().to_string();
178 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
179 return Err(SpaceError::BadRequest(
180 "dir name is empty or too long".to_string(),
181 ));
182 }
183
184 let created = self
185 .access
186 .database
187 .write()
188 .transaction::<_, dir::DirDto, SpaceError>(move |tx| {
189 Box::pin(async move {
190 Ok(dir::create_folder(
191 tx,
192 caller,
193 dir::NewDir {
194 space_id: space.id,
195 kind: DirKind::Dir,
196 name,
197 parent_dir_id: cmd.parent_dir_id,
198 position: cmd.position,
199 topic: None,
200 purpose: None,
201 icon: None,
202 color: None,
203 },
204 )
205 .await?)
206 })
207 })
208 .await?;
209
210 Ok(DirNodeDto::from(created))
211 }
212
213 pub async fn get_space_dir_tree(
214 &self,
215 caller: i64,
216 space_name: &str,
217 parent_id: Option<Uuid>,
218 ) -> Result<Vec<DirNodeDto>, SpaceError> {
219 let space = lookup::lookup_space_by_name(self, space_name).await?;
220 let perms = effective_permissions(self, &space, caller).await?;
221
222 let can_manage = perms.has(MANAGE_CHANNELS);
223
224 if parent_id.is_none() {
225 let total_nodes = dirs::Entity::find()
226 .filter(dirs::Column::SpaceId.eq(space.id))
227 .count(self.access.database.read())
228 .await? as u64;
229 let max_nodes = self.policy.settings.space.max_dir_nodes;
230 if total_nodes > max_nodes {
231 return Err(SpaceError::BadRequest(format!(
232 "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; query with parent_id to load one level"
233 )));
234 }
235 }
236
237 let mut sel = dirs::Entity::find()
238 .filter(dirs::Column::SpaceId.eq(space.id))
239 .order_by_asc(dirs::Column::Position);
240 if let Some(pid) = parent_id {
241 sel = sel.filter(dirs::Column::ParentDirId.eq(Some(pid)));
242 }
243 let nodes = sel.all(self.access.database.read()).await?;
244
245 if nodes.is_empty() {
246 return Ok(Vec::new());
247 }
248
249 let node_ids: Vec<Uuid> = nodes.iter().map(|n| n.id).collect();
250
251 let group_ows = dir_overwrite_group::Entity::find()
252 .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
253 .all(self.access.database.read())
254 .await?;
255
256 let user_ows = dir_overwrite_users::Entity::find()
257 .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone()))
258 .all(self.access.database.read())
259 .await?;
260
261 let my_member_groups = space_member_groups::Entity::find()
262 .filter(space_member_groups::Column::SpaceId.eq(space.id))
263 .filter(space_member_groups::Column::UserId.eq(caller))
264 .all(self.access.database.read())
265 .await?;
266 let my_group_ids: HashSet<Uuid> =
267 my_member_groups.into_iter().map(|mg| mg.group_id).collect();
268
269 let mut group_ow_map: HashMap<Uuid, Vec<dir_overwrite_group::Model>> = HashMap::new();
270 for go in group_ows {
271 group_ow_map.entry(go.dir_id).or_default().push(go);
272 }
273
274 let now = OffsetDateTime::now_utc();
275 let mut user_ow_map: HashMap<Uuid, Vec<dir_overwrite_users::Model>> = HashMap::new();
276 for uo in user_ows {
277 if !crate::share::dir_visibility::user_overwrite_active(&uo, now) {
278 continue;
279 }
280 user_ow_map.entry(uo.dir_id).or_default().push(uo);
281 }
282
283 let mut direct_visible: HashMap<Uuid, bool> = HashMap::new();
284 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
285 let mut node_map: HashMap<Uuid, dirs::Model> = HashMap::new();
286
287 for n in &nodes {
288 parent_map.insert(n.id, n.parent_dir_id);
289 node_map.insert(n.id, n.clone());
290
291 if can_manage {
292 direct_visible.insert(n.id, true);
293 continue;
294 }
295
296 let group_ows = group_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]);
297 let user_ows = user_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]);
298 direct_visible.insert(
299 n.id,
300 crate::share::dir_visibility::node_visible(
301 caller,
302 &my_group_ids,
303 group_ows,
304 user_ows,
305 ),
306 );
307 }
308
309 fn is_truly_visible(
310 id: Uuid,
311 parent_map: &HashMap<Uuid, Option<Uuid>>,
312 direct_visible: &HashMap<Uuid, bool>,
313 memo: &mut HashMap<Uuid, bool>,
314 visiting: &mut HashSet<Uuid>,
315 ) -> bool {
316 if let Some(&vis) = memo.get(&id) {
317 return vis;
318 }
319 if visiting.contains(&id) {
320 return false;
321 }
322 if !direct_visible.get(&id).copied().unwrap_or(false) {
323 memo.insert(id, false);
324 return false;
325 }
326 visiting.insert(id);
327 let parent_opt = parent_map.get(&id).copied().flatten();
328 let parent_vis = match parent_opt {
329 Some(pid) if parent_map.contains_key(&pid) => {
330 is_truly_visible(pid, parent_map, direct_visible, memo, visiting)
331 }
332 _ => true,
333 };
334 visiting.remove(&id);
335 memo.insert(id, parent_vis);
336 parent_vis
337 }
338
339 let mut memo = HashMap::new();
340 let mut truly_visible_set = HashSet::new();
341 for &nid in &node_ids {
342 let mut visiting = HashSet::new();
343 if is_truly_visible(nid, &parent_map, &direct_visible, &mut memo, &mut visiting) {
344 truly_visible_set.insert(nid);
345 }
346 }
347
348 let mut children_map: HashMap<Option<Uuid>, Vec<DirNodeDto>> = HashMap::new();
349 for n in nodes {
350 if !truly_visible_set.contains(&n.id) {
351 continue;
352 }
353 let parent_id = match n.parent_dir_id {
354 Some(p) if truly_visible_set.contains(&p) => Some(p),
355 _ => None,
356 };
357 children_map
358 .entry(parent_id)
359 .or_default()
360 .push(DirNodeDto::from(n));
361 }
362
363 fn assemble_tree(
364 parent_id: Option<Uuid>,
365 children_map: &mut HashMap<Option<Uuid>, Vec<DirNodeDto>>,
366 ) -> Vec<DirNodeDto> {
367 let mut list = children_map.remove(&parent_id).unwrap_or_default();
368 list.sort_by(|a, b| {
369 a.position
370 .cmp(&b.position)
371 .then_with(|| a.created_at.cmp(&b.created_at))
372 });
373 for item in &mut list {
374 item.children = assemble_tree(Some(item.id), children_map);
375 }
376 list
377 }
378
379 let forest = assemble_tree(None, &mut children_map);
380 Ok(forest)
381 }
382
383 #[tracing::instrument(name = "space.update_dir_node", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id))]
384 pub async fn update_dir_node(
385 &self,
386 caller: i64,
387 space_name: &str,
388 dir_id: Uuid,
389 cmd: UpdateDirCmd,
390 ) -> Result<DirNodeDto, SpaceError> {
391 let space = lookup::lookup_space_by_name(self, space_name).await?;
392 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
393
394 let node: dirs::Model = self.dir_in_space(space.id, dir_id).await?;
395 if node.kind.is_object() {
396 return Err(SpaceError::BadRequest(
397 "object dirs are managed by their owning domain".to_string(),
398 ));
399 }
400
401 let name = cmd.name.trim();
402 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
403 return Err(SpaceError::BadRequest(
404 "dir name is empty or too long".to_string(),
405 ));
406 }
407
408 let now = OffsetDateTime::now_utc();
409 let mut am: dirs::ActiveModel = node.into();
410 am.name = Set(name.to_string());
411 am.updated_at = Set(now);
412 let updated = am.update(self.access.database.write()).await?;
413
414 Ok(DirNodeDto::from(updated))
415 }
416
417 pub async fn move_dir_node(
418 &self,
419 caller: i64,
420 space_name: &str,
421 dir_id: Uuid,
422 cmd: MoveDirCmd,
423 ) -> Result<DirNodeDto, SpaceError> {
424 let space = lookup::lookup_space_by_name(self, space_name).await?;
425 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
426
427 let node = self.dir_in_space(space.id, dir_id).await?;
428 if node.kind.is_object() {
429 return Err(SpaceError::BadRequest(
430 "object dirs are managed by their owning domain".to_string(),
431 ));
432 }
433
434 let new_parent_id = match cmd.parent_dir_id {
435 None => node.parent_dir_id,
436 Some(None) => None,
437 Some(Some(p)) => {
438 if p == dir_id {
439 return Err(SpaceError::BadRequest(
440 "cannot set parent to self".to_string(),
441 ));
442 }
443 let parent_node = self.dir_in_space(space.id, p).await?;
444 if parent_node.kind != DirKind::Dir {
445 return Err(SpaceError::BadRequest(
446 "parent node must be a dir".to_string(),
447 ));
448 }
449
450 let mut curr = parent_node.parent_dir_id;
451 while let Some(curr_p) = curr {
452 if curr_p == dir_id {
453 return Err(SpaceError::BadRequest(
454 "cannot move node into its own descendant".to_string(),
455 ));
456 }
457 let next = dirs::Entity::find_by_id(curr_p)
458 .filter(dirs::Column::SpaceId.eq(space.id))
459 .one(self.access.database.read())
460 .await?;
461 curr = next.and_then(|n| n.parent_dir_id);
462 }
463
464 Some(p)
465 }
466 };
467
468 let new_position = match cmd.position {
469 Some(pos) => {
470 if pos < 0 {
471 return Err(SpaceError::BadRequest(
472 "position must be non-negative".to_string(),
473 ));
474 }
475 pos
476 }
477 None if new_parent_id != node.parent_dir_id => {
478 let max_node = dirs::Entity::find()
479 .filter(dirs::Column::SpaceId.eq(space.id))
480 .filter(dirs::Column::ParentDirId.eq(new_parent_id))
481 .order_by_desc(dirs::Column::Position)
482 .one(self.access.database.read())
483 .await?;
484 max_node.map(|n| n.position + 1).unwrap_or(0)
485 }
486 None => node.position,
487 };
488
489 let now = OffsetDateTime::now_utc();
490 let mut am: dirs::ActiveModel = node.into();
491 am.parent_dir_id = Set(new_parent_id);
492 am.position = Set(new_position);
493 am.updated_at = Set(now);
494 let updated = am.update(self.access.database.write()).await?;
495
496 Ok(DirNodeDto::from(updated))
497 }
498
499 pub async fn repair_dir_parents(
500 &self,
501 caller: i64,
502 space_name: &str,
503 ) -> Result<RepairDirResp, SpaceError> {
504 let space = lookup::lookup_space_by_name(self, space_name).await?;
505 if space.owner_user_id != caller {
506 return Err(SpaceError::Forbidden);
507 }
508
509 let nodes = dirs::Entity::find()
510 .filter(dirs::Column::SpaceId.eq(space.id))
511 .all(self.access.database.read())
512 .await?;
513
514 let scanned = nodes.len() as i64;
515 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
516 for n in &nodes {
517 parent_map.insert(n.id, n.parent_dir_id);
518 }
519
520 let mut fixed_nodes: HashSet<Uuid> = HashSet::new();
521
522 for n in &nodes {
523 if let Some(p) = n.parent_dir_id
524 && (p == n.id || !parent_map.contains_key(&p))
525 {
526 fixed_nodes.insert(n.id);
527 parent_map.insert(n.id, None);
528 }
529 }
530
531 let mut state: HashMap<Uuid, u8> = HashMap::new();
532 let keys: Vec<Uuid> = parent_map.keys().copied().collect();
533
534 for nid in keys {
535 if state.get(&nid).copied().unwrap_or(0) != 0 {
536 continue;
537 }
538
539 let mut curr = Some(nid);
540 let mut path = Vec::new();
541
542 while let Some(c) = curr {
543 let st = state.get(&c).copied().unwrap_or(0);
544 if st == 1 {
545 fixed_nodes.insert(c);
546 parent_map.insert(c, None);
547 break;
548 } else if st == 2 {
549 break;
550 }
551
552 state.insert(c, 1);
553 path.push(c);
554 curr = parent_map.get(&c).copied().flatten();
555 }
556
557 for p in path {
558 state.insert(p, 2);
559 }
560 }
561
562 let now = OffsetDateTime::now_utc();
563 let fixed = fixed_nodes.len() as i64;
564
565 if !fixed_nodes.is_empty() {
566 self.access
567 .database
568 .write()
569 .transaction::<_, (), SpaceError>(|txn| {
570 Box::pin(async move {
571 for fid in fixed_nodes {
572 let node = dirs::Entity::find_by_id(fid).one(txn).await?;
573 if let Some(n) = node {
574 let mut am: dirs::ActiveModel = n.into();
575 am.parent_dir_id = Set(None);
576 am.updated_at = Set(now);
577 am.update(txn).await?;
578 }
579 }
580 Ok(())
581 })
582 })
583 .await?;
584 }
585
586 Ok(RepairDirResp { scanned, fixed })
587 }
588
589 pub async fn list_dir_group_overwrites(
590 &self,
591 caller: i64,
592 space_name: &str,
593 dir_id: Uuid,
594 ) -> Result<Vec<GroupOverwriteDto>, SpaceError> {
595 let space = lookup::lookup_space_by_name(self, space_name).await?;
596 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
597 self.dir_in_space(space.id, dir_id).await?;
598
599 let rows = dir_overwrite_group::Entity::find()
600 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
601 .all(self.access.database.read())
602 .await?;
603
604 Ok(rows
605 .into_iter()
606 .map(|r| GroupOverwriteDto {
607 dir_id: r.dir_id,
608 group_id: r.group_id,
609 allow_mask: r.allow_mask,
610 deny_mask: r.deny_mask,
611 })
612 .collect())
613 }
614
615 #[tracing::instrument(name = "space.set_dir_group_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, group_id = %group_id))]
616 pub async fn set_dir_group_overwrite(
617 &self,
618 caller: i64,
619 space_name: &str,
620 dir_id: Uuid,
621 group_id: Uuid,
622 cmd: SetOverwriteCmd,
623 ) -> Result<GroupOverwriteDto, SpaceError> {
624 let space = lookup::lookup_space_by_name(self, space_name).await?;
625 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
626 self.dir_in_space(space.id, dir_id).await?;
627
628 space_groups::Entity::find_by_id(group_id)
629 .filter(space_groups::Column::SpaceId.eq(space.id))
630 .one(self.access.database.read())
631 .await?
632 .ok_or(SpaceError::NotFound)?;
633
634 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
635 return Err(SpaceError::BadRequest(
636 "masks must be non-negative".to_string(),
637 ));
638 }
639
640 let existing = dir_overwrite_group::Entity::find_by_id((dir_id, group_id))
641 .one(self.access.database.read())
642 .await?;
643
644 if let Some(row) = existing {
645 let mut am: dir_overwrite_group::ActiveModel = row.into();
646 am.allow_mask = Set(cmd.allow_mask);
647 am.deny_mask = Set(cmd.deny_mask);
648 let updated = am.update(self.access.database.write()).await?;
649 Ok(GroupOverwriteDto {
650 dir_id: updated.dir_id,
651 group_id: updated.group_id,
652 allow_mask: updated.allow_mask,
653 deny_mask: updated.deny_mask,
654 })
655 } else {
656 let am = dir_overwrite_group::ActiveModel {
657 dir_id: Set(dir_id),
658 group_id: Set(group_id),
659 allow_mask: Set(cmd.allow_mask),
660 deny_mask: Set(cmd.deny_mask),
661 };
662 let inserted = am.insert(self.access.database.write()).await?;
663 Ok(GroupOverwriteDto {
664 dir_id: inserted.dir_id,
665 group_id: inserted.group_id,
666 allow_mask: inserted.allow_mask,
667 deny_mask: inserted.deny_mask,
668 })
669 }
670 }
671
672 #[tracing::instrument(name = "space.remove_dir_group_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, group_id = %group_id))]
673 pub async fn remove_dir_group_overwrite(
674 &self,
675 caller: i64,
676 space_name: &str,
677 dir_id: Uuid,
678 group_id: Uuid,
679 ) -> Result<(), SpaceError> {
680 let space = lookup::lookup_space_by_name(self, space_name).await?;
681 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
682 self.dir_in_space(space.id, dir_id).await?;
683
684 let res = dir_overwrite_group::Entity::delete_many()
685 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
686 .filter(dir_overwrite_group::Column::GroupId.eq(group_id))
687 .exec(self.access.database.write())
688 .await?;
689
690 if res.rows_affected == 0 {
691 return Err(SpaceError::NotFound);
692 }
693 Ok(())
694 }
695
696 pub async fn list_dir_user_overwrites(
697 &self,
698 caller: i64,
699 space_name: &str,
700 dir_id: Uuid,
701 ) -> Result<Vec<UserOverwriteDto>, SpaceError> {
702 let space = lookup::lookup_space_by_name(self, space_name).await?;
703 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
704 self.dir_in_space(space.id, dir_id).await?;
705
706 let rows = dir_overwrite_users::Entity::find()
707 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
708 .all(self.access.database.read())
709 .await?;
710
711 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
712 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&user_ids).await?;
713
714 Ok(rows
715 .into_iter()
716 .map(|r| {
717 let user_brief = user_map.get(&r.user_id).cloned();
718 UserOverwriteDto {
719 dir_id: r.dir_id,
720 user_id: r.user_id,
721 user: user_brief,
722 allow_mask: r.allow_mask,
723 deny_mask: r.deny_mask,
724 }
725 })
726 .collect())
727 }
728
729 #[tracing::instrument(name = "space.set_dir_user_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, user_id = %user_id))]
730 pub async fn set_dir_user_overwrite(
731 &self,
732 caller: i64,
733 space_name: &str,
734 dir_id: Uuid,
735 user_id: i64,
736 cmd: SetOverwriteCmd,
737 ) -> Result<UserOverwriteDto, SpaceError> {
738 let space = lookup::lookup_space_by_name(self, space_name).await?;
739 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
740 self.dir_in_space(space.id, dir_id).await?;
741
742 if space.owner_user_id != user_id {
743 authority::require_member(self, space.id, user_id)
744 .await
745 .map_err(|_| {
746 SpaceError::BadRequest(
747 "target user is not an active member of this space".to_string(),
748 )
749 })?;
750 }
751
752 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
753 return Err(SpaceError::BadRequest(
754 "masks must be non-negative".to_string(),
755 ));
756 }
757
758 let existing = dir_overwrite_users::Entity::find_by_id((dir_id, user_id))
759 .one(self.access.database.read())
760 .await?;
761
762 let (final_allow, final_deny) = if let Some(row) = existing {
763 let mut am: dir_overwrite_users::ActiveModel = row.into();
764 am.allow_mask = Set(cmd.allow_mask);
765 am.deny_mask = Set(cmd.deny_mask);
766 let updated = am.update(self.access.database.write()).await?;
767 (updated.allow_mask, updated.deny_mask)
768 } else {
769 let am = dir_overwrite_users::ActiveModel {
770 dir_id: Set(dir_id),
771 user_id: Set(user_id),
772 allow_mask: Set(cmd.allow_mask),
773 deny_mask: Set(cmd.deny_mask),
774 expires_at: Set(None),
775 };
776 let inserted = am.insert(self.access.database.write()).await?;
777 (inserted.allow_mask, inserted.deny_mask)
778 };
779
780 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&[user_id]).await?;
781 let user_brief = user_map.get(&user_id).cloned();
782
783 Ok(UserOverwriteDto {
784 dir_id,
785 user_id,
786 user: user_brief,
787 allow_mask: final_allow,
788 deny_mask: final_deny,
789 })
790 }
791
792 #[tracing::instrument(name = "space.remove_dir_user_overwrite", skip_all, fields(caller = %caller, space = %space_name, dir_id = %dir_id, user_id = %user_id))]
793 pub async fn remove_dir_user_overwrite(
794 &self,
795 caller: i64,
796 space_name: &str,
797 dir_id: Uuid,
798 user_id: i64,
799 ) -> Result<(), SpaceError> {
800 let space = lookup::lookup_space_by_name(self, space_name).await?;
801 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
802 self.dir_in_space(space.id, dir_id).await?;
803
804 let res = dir_overwrite_users::Entity::delete_many()
805 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
806 .filter(dir_overwrite_users::Column::UserId.eq(user_id))
807 .exec(self.access.database.write())
808 .await?;
809
810 if res.rows_affected == 0 {
811 return Err(SpaceError::NotFound);
812 }
813 Ok(())
814 }
815}
Modified lib/service/space/src/services/internal_authz.rs View lib/service/space/src/services/internal_authz.rs
@@ -10,18 +10,21 @@
1010}
1111
1212impl SpaceService {
13 async fn space_of(&self, space_id: Uuid) -> SpaceResult<Option<crate::access::Space>> {
14 match self.access.by_id(space_id).await {
15 Ok(space) => Ok(Some(space)),
16 Err(api::AccessError::NotFound) => Ok(None),
17 Err(error) => Err(access_error(error)),
18 }
19 }
20
1321 pub async fn authorize_internal_repo(&self, user_id: i64, repo_id: Uuid) -> SpaceResult<bool> {
1422 let repo = models::repo::repos::Entity::find_by_id(repo_id)
1523 .one(self.access.database.read())
1624 .await
1725 .map_err(|error| SpaceError::Internal(error.to_string()))?
1826 .ok_or(SpaceError::NotFound)?;
19 let Some(space) = self
20 .access
21 .for_dir(repo.dir_id)
22 .await
23 .map_err(access_error)?
24 else {
27 let Some(space) = self.space_of(repo.space_id).await? else {
2528 return Ok(false);
2629 };
2730 space
@@ -46,18 +49,16 @@
4649 .await
4750 .map_err(|error| SpaceError::Internal(error.to_string()))?
4851 .ok_or(SpaceError::NotFound)?;
49 let Some(space) = self
50 .access
51 .for_dir(issue.dir_id)
52 .await
53 .map_err(access_error)?
54 else {
52 let Some(space) = self.space_of(issue.space_id).await? else {
5553 return Ok(false);
5654 };
57 space
58 .object_visible(user_id, issue.dir_id)
55 require(&space, user_id, Capability::Read)
5956 .await
60 .map_err(access_error)
57 .map(|()| true)
58 .or_else(|error| match error {
59 api::AccessError::Forbidden => Ok(false),
60 other => Err(access_error(other)),
61 })
6162 }
6263
6364 pub async fn authorize_internal_pull(&self, user_id: i64, pull_id: Uuid) -> SpaceResult<bool> {
@@ -71,12 +72,7 @@
7172 .await
7273 .map_err(|error| SpaceError::Internal(error.to_string()))?
7374 .ok_or(SpaceError::NotFound)?;
74 let Some(space) = self
75 .access
76 .for_dir(repo.dir_id)
77 .await
78 .map_err(access_error)?
79 else {
75 let Some(space) = self.space_of(repo.space_id).await? else {
8076 return Ok(false);
8177 };
8278 space
Modified lib/service/space/src/services/mod.rs View lib/service/space/src/services/mod.rs
@@ -1,4 +1,3 @@
1pub mod dir;
21pub mod dto;
32pub mod error;
43pub mod groups;
Modified lib/service/space/src/share/authz.rs View lib/service/space/src/share/authz.rs
@@ -14,9 +14,8 @@
1414pub const MANAGE_INVITES: &str = "manage_invites";
1515pub const MANAGE_BANS: &str = "manage_bans";
1616pub const MANAGE_CHANNELS: &str = "manage_channels";
17pub const MANAGE_OVERWRITES: &str = "manage_overwrites";
1817
19pub const ALL_PERMISSIONS: [&str; 9] = [
18pub const ALL_PERMISSIONS: [&str; 8] = [
2019 VIEW_SPACE,
2120 READ,
2221 MESSAGE,
@@ -25,7 +24,6 @@
2524 MANAGE_INVITES,
2625 MANAGE_BANS,
2726 MANAGE_CHANNELS,
28 MANAGE_OVERWRITES,
2927];
3028
3129#[derive(Debug, Clone, Default)]
Deleted lib/service/space/src/share/dir_visibility.rs
@@ -1,128 +0,0 @@
1use std::collections::{HashMap, HashSet};
2
3use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
4use models::space::{space_member_groups, spaces};
5use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::errors::SpaceError;
10
11pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
12
13pub(crate) fn user_overwrite_active(row: &dir_overwrite_users::Model, now: OffsetDateTime) -> bool {
14 row.expires_at.is_none_or(|expires_at| expires_at > now)
15}
16
17pub(crate) fn node_visible(
18 caller: i64,
19 my_groups: &HashSet<Uuid>,
20 group_ows: &[dir_overwrite_group::Model],
21 user_ows: &[dir_overwrite_users::Model],
22) -> bool {
23 if group_ows.is_empty() && user_ows.is_empty() {
24 return true;
25 }
26 if let Some(uo) = user_ows.iter().find(|u| u.user_id == caller) {
27 if (uo.deny_mask & PERM_DIR_VISIBLE) != 0 {
28 return false;
29 }
30 if (uo.allow_mask & PERM_DIR_VISIBLE) != 0 {
31 return true;
32 }
33 }
34 let matching: Vec<&dir_overwrite_group::Model> = group_ows
35 .iter()
36 .filter(|g| my_groups.contains(&g.group_id))
37 .collect();
38 if matching
39 .iter()
40 .any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0)
41 {
42 return false;
43 }
44 matching
45 .iter()
46 .any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0)
47}
48
49pub async fn is_dir_visible(
50 db: &impl ConnectionTrait,
51 space: &spaces::Model,
52 dir_id: Uuid,
53 caller: i64,
54) -> Result<bool, SpaceError> {
55 if space.owner_user_id == caller {
56 return Ok(true);
57 }
58 let target = dirs::Entity::find_by_id(dir_id).one(db).await?;
59 let Some(target) = target else {
60 return Ok(false);
61 };
62
63 let my_groups: HashSet<Uuid> = space_member_groups::Entity::find()
64 .filter(space_member_groups::Column::SpaceId.eq(space.id))
65 .filter(space_member_groups::Column::UserId.eq(caller))
66 .all(db)
67 .await?
68 .into_iter()
69 .map(|mg| mg.group_id)
70 .collect();
71
72 let nodes = dirs::Entity::find()
73 .filter(dirs::Column::SpaceId.eq(space.id))
74 .all(db)
75 .await?;
76 let group_ows = dir_overwrite_group::Entity::find()
77 .filter(dir_overwrite_group::Column::DirId.is_in(nodes.iter().map(|n| n.id)))
78 .all(db)
79 .await?;
80 let user_ows = dir_overwrite_users::Entity::find()
81 .filter(dir_overwrite_users::Column::DirId.is_in(nodes.iter().map(|n| n.id)))
82 .all(db)
83 .await?;
84
85 let now = OffsetDateTime::now_utc();
86 let mut gmap: HashMap<Uuid, Vec<dir_overwrite_group::Model>> = HashMap::new();
87 for g in group_ows {
88 gmap.entry(g.dir_id).or_default().push(g);
89 }
90 let mut umap: HashMap<Uuid, Vec<dir_overwrite_users::Model>> = HashMap::new();
91 for u in user_ows {
92 if !user_overwrite_active(&u, now) {
93 continue;
94 }
95 umap.entry(u.dir_id).or_default().push(u);
96 }
97
98 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
99 let mut archived: HashSet<Uuid> = HashSet::new();
100 for n in &nodes {
101 parent_map.insert(n.id, n.parent_dir_id);
102 if n.is_archived {
103 archived.insert(n.id);
104 }
105 }
106
107 let direct = |id: Uuid| -> bool {
108 node_visible(
109 caller,
110 &my_groups,
111 gmap.get(&id).map(Vec::as_slice).unwrap_or(&[]),
112 umap.get(&id).map(Vec::as_slice).unwrap_or(&[]),
113 )
114 };
115
116 let mut current: Option<Uuid> = Some(target.id);
117 let mut visited = HashSet::new();
118 while let Some(id) = current {
119 if !visited.insert(id) || archived.contains(&id) {
120 return Ok(false);
121 }
122 if !direct(id) {
123 return Ok(false);
124 }
125 current = parent_map.get(&id).copied().flatten();
126 }
127 Ok(true)
128}
Modified lib/service/space/src/share/mod.rs View lib/service/space/src/share/mod.rs
@@ -1,5 +1,4 @@
11pub mod authz;
22pub mod crypto;
3pub mod dir_visibility;
43pub mod lookup;
54pub mod validate;
Modified lib/service/space/src/access.rs View lib/service/space/src/access.rs
@@ -1,10 +1,9 @@
11use cache::Cache;
22use db::DbConn;
3use models::dir::dirs;
43use models::repo::repo_collaborations::{self, CollaborationMode};
54use models::space::{SpaceStatus, space_extras, space_groups, space_members, spaces};
65use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use std::collections::{HashMap, HashSet};
6
87use time::OffsetDateTime;
98use uuid::Uuid;
109
@@ -107,41 +106,6 @@
107106 pub async fn members(&self, space: Uuid, actor: i64) -> Result<Vec<Member>, AccessError> {
108107 self.by_id(space).await?.members(actor).await
109108 }
110
111 pub async fn space_id_for_dir(&self, mut id: Uuid) -> Result<Option<Uuid>, AccessError> {
112 let mut visited = HashSet::new();
113 for _ in 0..64 {
114 if !visited.insert(id) {
115 return Err(AccessError::InvalidArgument("directory cycle".to_owned()));
116 }
117 let dir = dirs::Entity::find_by_id(id)
118 .one(self.database.read())
119 .await?
120 .ok_or(AccessError::NotFound)?;
121 if let Some(space) = dir.space_id {
122 return spaces::Entity::find_by_id(space)
123 .one(self.database.read())
124 .await?
125 .filter(|row| row.deleted_at.is_none())
126 .map(|row| Some(row.id))
127 .ok_or(AccessError::NotFound);
128 }
129 match dir.parent_dir_id {
130 Some(parent) => id = parent,
131 None => return Ok(None),
132 }
133 }
134 Err(AccessError::InvalidArgument(
135 "directory tree exceeds 64 levels".to_owned(),
136 ))
137 }
138
139 pub async fn for_dir(&self, id: Uuid) -> Result<Option<Space>, AccessError> {
140 match self.space_id_for_dir(id).await? {
141 Some(id) => self.by_id(id).await.map(Some),
142 None => Ok(None),
143 }
144 }
145109}
146110
147111impl Space {
@@ -270,225 +234,6 @@
270234 Capability::ManageInvites => allowed("manage_invites"),
271235 _ => false,
272236 })
273 }
274
275 pub async fn object_visible(&self, user: i64, dir: Uuid) -> Result<bool, AccessError> {
276 let Some(node) = dirs::Entity::find_by_id(dir)
277 .one(self.access.database.read())
278 .await?
279 else {
280 return Ok(false);
281 };
282 if !node.kind.is_object() {
283 return Ok(false);
284 }
285 self.dir_visible(user, dir).await
286 }
287
288 pub async fn dir_visible(&self, user: i64, dir: Uuid) -> Result<bool, AccessError> {
289 require(self, user, Capability::Read).await?;
290 if self
291 .access
292 .for_dir(dir)
293 .await?
294 .is_none_or(|space| space.id() != self.id())
295 {
296 return Ok(false);
297 }
298 crate::share::dir_visibility::is_dir_visible(
299 self.access.database.read(),
300 &self.record,
301 dir,
302 user,
303 )
304 .await
305 .map_err(AccessError::from)
306 }
307
308 /// 给定候选用户集合,返回其中能看到该目录的用户(含祖先链、过期覆盖项与归档节点)。
309 pub async fn dir_visible_users(
310 &self,
311 dir: Uuid,
312 users: &[i64],
313 ) -> Result<HashSet<i64>, AccessError> {
314 if users.is_empty() {
315 return Ok(HashSet::new());
316 }
317 let db = self.access.database.read();
318 let nodes = dirs::Entity::find()
319 .filter(dirs::Column::SpaceId.eq(self.id()))
320 .all(db)
321 .await?;
322 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
323 let mut archived: HashSet<Uuid> = HashSet::new();
324 for node in &nodes {
325 parent_map.insert(node.id, node.parent_dir_id);
326 if node.is_archived {
327 archived.insert(node.id);
328 }
329 }
330 if !parent_map.contains_key(&dir) {
331 return Ok(HashSet::new());
332 }
333 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
334 let member_groups = models::space::space_member_groups::Entity::find()
335 .filter(models::space::space_member_groups::Column::SpaceId.eq(self.id()))
336 .filter(models::space::space_member_groups::Column::UserId.is_in(users.to_vec()))
337 .all(db)
338 .await?;
339 let mut groups_by_user: HashMap<i64, HashSet<Uuid>> = HashMap::new();
340 for row in member_groups {
341 groups_by_user
342 .entry(row.user_id)
343 .or_default()
344 .insert(row.group_id);
345 }
346 let now = OffsetDateTime::now_utc();
347 let mut group_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_group::Model>> =
348 HashMap::new();
349 for row in models::dir::dir_overwrite_group::Entity::find()
350 .filter(models::dir::dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
351 .all(db)
352 .await?
353 {
354 group_ows.entry(row.dir_id).or_default().push(row);
355 }
356 let mut user_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_users::Model>> =
357 HashMap::new();
358 for row in models::dir::dir_overwrite_users::Entity::find()
359 .filter(models::dir::dir_overwrite_users::Column::DirId.is_in(node_ids))
360 .all(db)
361 .await?
362 {
363 if !crate::share::dir_visibility::user_overwrite_active(&row, now) {
364 continue;
365 }
366 user_ows.entry(row.dir_id).or_default().push(row);
367 }
368
369 let mut visible = HashSet::new();
370 for user in users {
371 if self.record.owner_user_id == *user {
372 visible.insert(*user);
373 continue;
374 }
375 let empty = HashSet::new();
376 let my_groups = groups_by_user.get(user).unwrap_or(&empty);
377 let mut current = Some(dir);
378 let mut seen = HashSet::new();
379 let mut allowed = true;
380 while let Some(id) = current {
381 if !seen.insert(id) || archived.contains(&id) {
382 allowed = false;
383 break;
384 }
385 let direct = crate::share::dir_visibility::node_visible(
386 *user,
387 my_groups,
388 group_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
389 user_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
390 );
391 if !direct {
392 allowed = false;
393 break;
394 }
395 current = parent_map.get(&id).copied().flatten();
396 }
397 if allowed {
398 visible.insert(*user);
399 }
400 }
401 Ok(visible)
402 }
403
404 /// 批量计算目录可见性(含祖先链、过期覆盖项与归档节点),供列表类接口一次加载。
405 pub async fn visible_dirs(
406 &self,
407 user: i64,
408 dir_ids: &[Uuid],
409 ) -> Result<HashSet<Uuid>, AccessError> {
410 if dir_ids.is_empty() {
411 return Ok(HashSet::new());
412 }
413 if self.record.owner_user_id == user {
414 return Ok(dir_ids.iter().copied().collect());
415 }
416 require(self, user, Capability::Read).await?;
417 let db = self.access.database.read();
418 let nodes = dirs::Entity::find()
419 .filter(dirs::Column::SpaceId.eq(self.id()))
420 .all(db)
421 .await?;
422 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
423 let mut archived: HashSet<Uuid> = HashSet::new();
424 for node in &nodes {
425 parent_map.insert(node.id, node.parent_dir_id);
426 if node.is_archived {
427 archived.insert(node.id);
428 }
429 }
430 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
431 let my_groups: HashSet<Uuid> = models::space::space_member_groups::Entity::find()
432 .filter(models::space::space_member_groups::Column::SpaceId.eq(self.id()))
433 .filter(models::space::space_member_groups::Column::UserId.eq(user))
434 .all(db)
435 .await?
436 .into_iter()
437 .map(|member| member.group_id)
438 .collect();
439 let now = OffsetDateTime::now_utc();
440 let mut group_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_group::Model>> =
441 HashMap::new();
442 for row in models::dir::dir_overwrite_group::Entity::find()
443 .filter(models::dir::dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
444 .all(db)
445 .await?
446 {
447 group_ows.entry(row.dir_id).or_default().push(row);
448 }
449 let mut user_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_users::Model>> =
450 HashMap::new();
451 for row in models::dir::dir_overwrite_users::Entity::find()
452 .filter(models::dir::dir_overwrite_users::Column::DirId.is_in(node_ids))
453 .all(db)
454 .await?
455 {
456 if !crate::share::dir_visibility::user_overwrite_active(&row, now) {
457 continue;
458 }
459 user_ows.entry(row.dir_id).or_default().push(row);
460 }
461
462 let mut visible = HashSet::new();
463 for dir_id in dir_ids {
464 if !parent_map.contains_key(dir_id) {
465 continue;
466 }
467 let mut current = Some(*dir_id);
468 let mut seen = HashSet::new();
469 let mut allowed = true;
470 while let Some(id) = current {
471 if !seen.insert(id) || archived.contains(&id) {
472 allowed = false;
473 break;
474 }
475 let direct = crate::share::dir_visibility::node_visible(
476 user,
477 &my_groups,
478 group_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
479 user_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
480 );
481 if !direct {
482 allowed = false;
483 break;
484 }
485 current = parent_map.get(&id).copied().flatten();
486 }
487 if allowed {
488 visible.insert(*dir_id);
489 }
490 }
491 Ok(visible)
492237 }
493238}
494239
Modified lib/service/space/src/authority.rs View lib/service/space/src/authority.rs
@@ -66,17 +66,6 @@
6666 .await?)
6767}
6868
69pub async fn require_member(
70 state: &SpaceService,
71 space_id: Uuid,
72 user_id: i64,
73) -> Result<space_members::Model, SpaceError> {
74 match member(state, space_id, user_id).await? {
75 Some(member) if member.status() == models::space::MemberStatus::Active => Ok(member),
76 _ => Err(SpaceError::Forbidden),
77 }
78}
79
8069pub async fn list_members(
8170 state: &SpaceService,
8271 space_id: Uuid,
Modified lib/service/space/src/openapi.rs View lib/service/space/src/openapi.rs
@@ -21,17 +21,6 @@
2121 crate::handlers::explore_spaces::reject_join_request,
2222 crate::handlers::join_requests::create_join_request,
2323 crate::handlers::join_requests::withdraw_join_request,
24 crate::handlers::create_dir_node::create_dir_node,
25 crate::handlers::get_space_dir_tree::get_space_dir_tree,
26 crate::handlers::repair_dir_parents::repair_dir_parents,
27 crate::handlers::update_dir_node::update_dir_node,
28 crate::handlers::move_dir_node::move_dir_node,
29 crate::handlers::list_dir_group_overwrites::list_dir_group_overwrites,
30 crate::handlers::set_dir_group_overwrite::set_dir_group_overwrite,
31 crate::handlers::remove_dir_group_overwrite::remove_dir_group_overwrite,
32 crate::handlers::list_dir_user_overwrites::list_dir_user_overwrites,
33 crate::handlers::set_dir_user_overwrite::set_dir_user_overwrite,
34 crate::handlers::remove_dir_user_overwrite::remove_dir_user_overwrite,
3524 crate::handlers::space_admin::add_member,
3625 crate::handlers::space_admin::get_member,
3726 crate::handlers::space_admin::update_member,
@@ -90,13 +79,6 @@
9079 AppResponseData<crate::services::dto::MemberDto>,
9180 AppResponseData<crate::services::images::SpaceImageDto>,
9281 AppResponseData<Option<crate::services::images::SpaceImageDto>>,
93 AppResponseData<crate::services::dir::DirNodeDto>,
94 AppResponseData<Vec<crate::services::dir::DirNodeDto>>,
95 AppResponseData<crate::services::dir::RepairDirResp>,
96 AppResponseData<crate::services::dir::GroupOverwriteDto>,
97 AppResponseData<Vec<crate::services::dir::GroupOverwriteDto>>,
98 AppResponseData<crate::services::dir::UserOverwriteDto>,
99 AppResponseData<Vec<crate::services::dir::UserOverwriteDto>>,
10082 crate::services::dto::SpaceDto,
10183 crate::services::join_requests::ExploreSpaceDto,
10284 crate::services::join_requests::ExploreViewerDto,
@@ -116,18 +98,9 @@
11698 crate::services::images::SpaceImageUploadQuery,
11799 imaging::layout::ImageVariant,
118100 crate::services::images::SpaceImageKind,
119 crate::services::dir::DirNodeDto,
120 crate::services::dir::CreateDirCmd,
121 crate::services::dir::UpdateDirCmd,
122 crate::services::dir::MoveDirCmd,
123 crate::services::dir::RepairDirResp,
124 crate::services::dir::GroupOverwriteDto,
125 crate::services::dir::UserOverwriteDto,
126 crate::services::dir::SetOverwriteCmd,
127101 models::space::AllowJoinLevel,
128102 models::space::MemberStatus,
129103 models::space::MessageNotifications,
130 models::dir::dirs::DirKind,
131104 crate::services::types::OrganizationSummary,
132105 crate::services::types::MemberSummary,
133106 crate::services::types::RoleSummary,
Modified lib/service/space/Cargo.toml View lib/service/space/Cargo.toml
@@ -8,7 +8,6 @@
88media = { workspace = true }
99push = { workspace = true }
1010auth = { workspace = true }
11dir = { workspace = true }
1211async-trait = { workspace = true }
1312base64 = { workspace = true }
1413cache = { workspace = true }
Modified lib/service/webapi/src/cleanup.rs View lib/service/webapi/src/cleanup.rs
@@ -1,25 +1,18 @@
11/**
22 * 空间「对象清理」:仅空间所有者可见的危险区。
33 *
4 * 空间对象都挂在 `dirs` 树上(目录 / 仓库 / 问题集 / 频道),频道另有
5 * `rooms` 行承载频道能力参数。这里把它们拍成一张扁平清单(频道仍按 room id
6 * 管理,其 dir 镜像跳过),并提供「删除 / 归档 / 可见性 / 清除可见性覆盖」
7 * 操作。频道的可见性以 dir 覆盖项为准,`rooms.is_private` 只是镜像。
4 * 仓库、问题看板、频道各自持有空间归属,这里把它们拍成一张扁平清单,
5 * 并提供「删除 / 归档 / 可见性」操作。对象一律按自己的主键寻址。
86 */
9use std::collections::HashMap;
10
117use ::session::AuthedUser;
128use api::http::routing::{delete, get, post};
139use api::http::{Json, Path, Router, State};
1410use api::rest::{AppResponseData, NoData};
15use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
1611use models::issue::issues as boards;
1712use models::repo::repos;
1813use models::room::rooms;
1914use sea_orm::ActiveValue::Set;
20use sea_orm::{
21 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, TransactionTrait,
22};
15use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter};
2316use serde::{Deserialize, Serialize};
2417use utoipa::{OpenApi, ToSchema};
2518use uuid::Uuid;
@@ -52,20 +45,14 @@
5245
5346#[derive(Debug, Clone, Serialize, ToSchema)]
5447pub struct CleanupObjectDto {
55 /// `dirs.id`(目录/仓库/问题集/智能体集)或 `rooms.id`(频道)。
48 /// `repos.id`、`issues.id`(看板)或 `rooms.id`。
5649 pub id: Uuid,
57 /// `dir | object | repo | board | text | voice | stage | category | news | guild_room`
50 /// `repo | board | text | voice | stage | category | news | guild_room`
5851 pub kind: String,
5952 pub name: String,
60 /// 父链拼出的可读路径,例如 `bots / 研究研究`。
61 pub path: String,
6253 /// 仅仓库与频道有可见性开关。
6354 pub is_private: Option<bool>,
6455 pub is_archived: bool,
65 /// 目录的子节点数 / 频道的子频道数。
66 pub child_count: i64,
67 /// 目录上的权限覆盖条数(group + user)。
68 pub overwrite_count: i64,
6956}
7057
7158#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -78,6 +65,19 @@
7865 pub archived: bool,
7966}
8067
68fn room_kind_label(kind: models::room::rooms::RoomKind) -> &'static str {
69 use models::room::rooms::RoomKind;
70 match kind {
71 RoomKind::Text => "text",
72 RoomKind::Voice => "voice",
73 RoomKind::Stage => "stage",
74 RoomKind::Category => "category",
75 RoomKind::News => "news",
76 RoomKind::GuildRoom => "guild_room",
77 _ => "thread",
78 }
79}
80
8181pub fn protected(
8282 database: std::sync::Arc<db::AppDatabase>,
8383 space: SpaceService,
@@ -103,10 +103,6 @@
103103 post(set_cleanup_archive),
104104 )
105105 .route(
106 "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites",
107 delete(clear_cleanup_overwrites),
108 )
109 .route(
110106 "/spaces/{space_name}/cleanup/{kind}/{object_id}",
111107 delete(delete_cleanup_object),
112108 )
@@ -129,9 +125,9 @@
129125 GitError::InvalidArgument(message) | GitError::Conflict(message) => {
130126 ApiError::bad_request(message)
131127 }
132 GitError::NotFound => ApiError::not_found("resource not found"),
133128 GitError::Unauthorized => ApiError::unauthorized(),
134129 GitError::Forbidden => ApiError::forbidden(),
130 GitError::NotFound => ApiError::not_found("resource not found"),
135131 other => ApiError::internal(other.to_string()),
136132 }
137133}
@@ -160,14 +156,6 @@
160156 ChatError::NotFound => ApiError::not_found("resource not found"),
161157 ChatError::RateLimited(message) => ApiError::bad_request(message),
162158 other => ApiError::internal(other.to_string()),
163 }
164}
165
166fn dir_internal(error: dir::Error) -> ApiError {
167 match error {
168 dir::Error::InvalidInput(message) => ApiError::bad_request(message),
169 dir::Error::NotFound => ApiError::not_found("directory not found"),
170 dir::Error::Database(error) => db_internal(error),
171159 }
172160}
173161
@@ -189,62 +177,37 @@
189177 Ok(space)
190178}
191179
192/// 目录路径:沿 `parent_dir_id` 收集名字再反转为 `a / b / c`。
193fn dir_path(node: &dirs::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> String {
194 let mut names = vec![node.name.clone()];
195 let mut cursor = node.parent_dir_id;
196 let mut guard = 0;
197 while let Some(id) = cursor {
198 let Some((name, parent)) = index.get(&id) else {
199 break;
200 };
201 names.push(name.clone());
202 cursor = *parent;
203 guard += 1;
204 if guard > 64 {
205 break;
206 }
207 }
208 names.reverse();
209 names.join(" / ")
210}
211
212fn room_path(room: &rooms::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> String {
213 let mut names = vec![room.name.clone()];
214 let mut cursor = room.parent_id;
215 let mut guard = 0;
216 while let Some(id) = cursor {
217 let Some((name, parent)) = index.get(&id) else {
218 break;
219 };
220 names.push(name.clone());
221 cursor = *parent;
222 guard += 1;
223 if guard > 64 {
224 break;
225 }
226 }
227 names.reverse();
228 names.join(" / ")
180async fn repo_in_space(
181 db: &impl sea_orm::ConnectionTrait,
182 space_id: Uuid,
183 repo_id: Uuid,
184) -> Result<repos::Model, ApiError> {
185 repos::Entity::find_by_id(repo_id)
186 .filter(repos::Column::SpaceId.eq(space_id))
187 .one(db)
188 .await
189 .map_err(db_internal)?
190 .ok_or_else(|| ApiError::not_found("repository not found"))
229191}
230192
231async fn repo_row_by_dir(
193async fn room_in_space(
232194 db: &impl sea_orm::ConnectionTrait,
233 dir_id: Uuid,
234) -> Result<repos::Model, ApiError> {
235 repos::Entity::find()
236 .filter(repos::Column::DirId.eq(dir_id))
195 space_id: Uuid,
196 room_id: Uuid,
197) -> Result<rooms::Model, ApiError> {
198 rooms::Entity::find_by_id(room_id)
199 .filter(rooms::Column::SpaceId.eq(space_id))
237200 .one(db)
238201 .await
239202 .map_err(db_internal)?
240 .ok_or_else(|| ApiError::not_found("repository not found"))
203 .ok_or_else(|| ApiError::not_found("room not found"))
241204}
242205
243206#[utoipa::path(
244207 get,
245208 path = "/spaces/{space_name}/cleanup",
246209 tag = "cleanup",
247 description = "列出空间内全部目录与对象(目录/仓库/问题集/频道),仅空间所有者可访问。",
210 description = "列出空间内全部仓库、问题看板与频道,仅空间所有者可访问。",
248211 params(("space_name" = String, Path, description = "space name")),
249212 responses(
250213 (status = 200, description = "cleanup objects", body = AppResponseData<Vec<CleanupObjectDto>>)
@@ -258,173 +221,64 @@
258221 let space = require_owner(&state, &space_name, uid).await?;
259222 let db = &state.database;
260223
261 let nodes = dirs::Entity::find()
262 .filter(dirs::Column::SpaceId.eq(space.id))
224 let repo_rows = repos::Entity::find()
225 .filter(repos::Column::SpaceId.eq(space.id))
263226 .all(db.read())
264227 .await
265228 .map_err(db_internal)?;
266 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
267
268 let (repo_rows, board_rows) = if node_ids.is_empty() {
269 (Vec::new(), Vec::new())
270 } else {
271 (
272 repos::Entity::find()
273 .filter(repos::Column::DirId.is_in(node_ids.clone()))
274 .all(db.read())
275 .await
276 .map_err(db_internal)?,
277 boards::Entity::find()
278 .filter(boards::Column::DirId.is_in(node_ids.clone()))
279 .all(db.read())
280 .await
281 .map_err(db_internal)?,
282 )
283 };
284
285 let (group_ows, user_ows) = if node_ids.is_empty() {
286 (Vec::new(), Vec::new())
287 } else {
288 (
289 dir_overwrite_group::Entity::find()
290 .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
291 .all(db.read())
292 .await
293 .map_err(db_internal)?,
294 dir_overwrite_users::Entity::find()
295 .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone()))
296 .all(db.read())
297 .await
298 .map_err(db_internal)?,
299 )
300 };
301
229 let board_rows = boards::Entity::find()
230 .filter(boards::Column::SpaceId.eq(space.id))
231 .all(db.read())
232 .await
233 .map_err(db_internal)?;
302234 let room_rows = rooms::Entity::find()
303235 .filter(rooms::Column::SpaceId.eq(space.id))
304 .filter(rooms::Column::IsActive.eq(true))
305236 .all(db.read())
306237 .await
307238 .map_err(db_internal)?;
308239
309 let repo_by_dir: HashMap<Uuid, &repos::Model> =
310 repo_rows.iter().map(|row| (row.dir_id, row)).collect();
311 let board_by_dir: HashMap<Uuid, &boards::Model> =
312 board_rows.iter().map(|row| (row.dir_id, row)).collect();
313
314 let mut overwrite_counts: HashMap<Uuid, i64> = HashMap::new();
315 for row in group_ows.iter() {
316 *overwrite_counts.entry(row.dir_id).or_default() += 1;
317 }
318 for row in user_ows.iter() {
319 *overwrite_counts.entry(row.dir_id).or_default() += 1;
320 }
321
322 let mut dir_child_counts: HashMap<Uuid, i64> = HashMap::new();
323 for node in &nodes {
324 if let Some(parent) = node.parent_dir_id {
325 *dir_child_counts.entry(parent).or_default() += 1;
326 }
240 let mut items = Vec::with_capacity(repo_rows.len() + board_rows.len() + room_rows.len());
241 for repo in repo_rows {
242 items.push(CleanupObjectDto {
243 id: repo.id,
244 kind: "repo".to_owned(),
245 name: repo.name,
246 is_private: Some(repo.is_private),
247 is_archived: repo.is_archived,
248 });
327249 }
328 let mut room_child_counts: HashMap<Uuid, i64> = HashMap::new();
329 for room in &room_rows {
330 if let Some(parent) = room.parent_id {
331 *room_child_counts.entry(parent).or_default() += 1;
332 }
333 }
334
335 let dir_index: HashMap<Uuid, (String, Option<Uuid>)> = nodes
336 .iter()
337 .map(|node| (node.id, (node.name.clone(), node.parent_dir_id)))
338 .collect();
339 let room_index: HashMap<Uuid, (String, Option<Uuid>)> = room_rows
340 .iter()
341 .map(|room| (room.id, (room.name.clone(), room.parent_id)))
342 .collect();
343
344 let mut objects = Vec::with_capacity(nodes.len() + room_rows.len());
345 for node in &nodes {
346 // Space channels own a dir row for the sidebar tree, but cleanup keeps
347 // addressing them by room id below; skip the dir mirror to avoid listing
348 // the same channel twice.
349 if matches!(
350 node.kind,
351 dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
352 ) {
353 continue;
354 }
355 let (kind, name, is_private, is_archived) = if let Some(repo) = repo_by_dir.get(&node.id) {
356 (
357 "repo",
358 repo.name.clone(),
359 Some(repo.is_private),
360 repo.is_archived,
361 )
362 } else if let Some(board) = board_by_dir.get(&node.id) {
363 ("board", board.name.clone(), None, false)
364 } else if node.kind.is_object() {
365 ("object", node.name.clone(), None, node.is_archived)
366 } else {
367 ("dir", node.name.clone(), None, node.is_archived)
368 };
369 objects.push(CleanupObjectDto {
370 id: node.id,
371 kind: kind.to_string(),
372 name,
373 path: dir_path(node, &dir_index),
374 is_private,
375 is_archived,
376 child_count: dir_child_counts.get(&node.id).copied().unwrap_or(0),
377 overwrite_count: overwrite_counts.get(&node.id).copied().unwrap_or(0),
250 for board in board_rows {
251 items.push(CleanupObjectDto {
252 id: board.id,
253 kind: "board".to_owned(),
254 name: board.name,
255 is_private: None,
256 is_archived: false,
378257 });
379258 }
380 for room in &room_rows {
381 objects.push(CleanupObjectDto {
259 for room in room_rows {
260 items.push(CleanupObjectDto {
382261 id: room.id,
383 kind: {
384 let kind = room.kind;
385 use rooms::RoomKind;
386 match kind {
387 RoomKind::GuildRoom => "guild_room",
388 RoomKind::Text => "text",
389 RoomKind::Voice => "voice",
390 RoomKind::Stage => "stage",
391 RoomKind::Category => "category",
392 RoomKind::News => "news",
393 RoomKind::Thread => "thread",
394 RoomKind::GroupDm => "group_dm",
395 RoomKind::Dm => "dm",
396 RoomKind::DirectMessage => "direct_message",
397 }
398 }
399 .to_string(),
400 name: room.name.clone(),
401 path: room_path(room, &room_index),
402 is_private: Some(match room.dir_id {
403 Some(dir_id) => overwrite_counts.get(&dir_id).copied().unwrap_or(0) > 0,
404 None => room.is_private,
405 }),
406 is_archived: false,
407 child_count: room_child_counts.get(&room.id).copied().unwrap_or(0),
408 overwrite_count: room
409 .dir_id
410 .and_then(|dir_id| overwrite_counts.get(&dir_id).copied())
411 .unwrap_or(0),
262 kind: room_kind_label(room.kind).to_owned(),
263 name: room.name,
264 is_private: Some(room.is_private),
265 is_archived: !room.is_active,
412266 });
413267 }
268 items.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.cmp(&b.name)));
414269
415 objects.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.kind.cmp(&b.kind)));
416 Ok(AppResponseData::ok(objects))
270 Ok(AppResponseData::ok(items))
417271}
418272
419273#[utoipa::path(
420274 post,
421275 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility",
422276 tag = "cleanup",
423 description = "调整对象可见性:仓库与频道支持私有/公开,其余类型不支持。",
277 description = "切换仓库或频道的可见性(私密 / 空间内公开)。",
424278 params(
425279 ("space_name" = String, Path, description = "space name"),
426280 ("kind" = String, Path, description = "object kind"),
427 ("object_id" = Uuid, Path, description = "dir id or room id")
281 ("object_id" = Uuid, Path, description = "repo id or room id")
428282 ),
429283 request_body = SetCleanupVisibilityCmd,
430284 responses((status = 200, description = "updated", body = AppResponseData<NoData>))
@@ -440,7 +294,7 @@
440294
441295 match kind.as_str() {
442296 "repo" => {
443 let repo = repo_row_by_dir(db.read(), object_id).await?;
297 let repo = repo_in_space(db.read(), space.id, object_id).await?;
444298 let update = git::service::repos::UpdateRepoCmd {
445299 is_private: Some(cmd.is_private),
446300 ..Default::default()
@@ -457,36 +311,11 @@
457311 .map_err(map_git)?;
458312 }
459313 "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
460 let room = rooms::Entity::find_by_id(object_id)
461 .filter(rooms::Column::SpaceId.eq(space.id))
462 .one(db.read())
463 .await
464 .map_err(db_internal)?
465 .ok_or_else(|| ApiError::not_found("room not found"))?;
466 let dir_id = room.dir_id;
467 let owner_id = room.owner_id;
314 let room = room_in_space(db.read(), space.id, object_id).await?;
468315 let mut am: rooms::ActiveModel = room.into();
469316 am.is_private = Set(cmd.is_private);
470317 am.updated_at = Set(time::OffsetDateTime::now_utc());
471318 am.update(db.write()).await.map_err(db_internal)?;
472 if let Some(dir_id) = dir_id {
473 if cmd.is_private {
474 dir::set_user_overwrite(
475 db.write(),
476 dir_id,
477 owner_id,
478 dir::PERM_VISIBLE,
479 0,
480 None,
481 )
482 .await
483 .map_err(dir_internal)?;
484 } else {
485 dir::clear_user_overwrites(db.write(), dir_id)
486 .await
487 .map_err(dir_internal)?;
488 }
489 }
490319 }
491320 _ => {
492321 return Err(ApiError::bad_request(
@@ -505,7 +334,7 @@
505334 params(
506335 ("space_name" = String, Path, description = "space name"),
507336 ("kind" = String, Path, description = "object kind"),
508 ("object_id" = Uuid, Path, description = "dir id")
337 ("object_id" = Uuid, Path, description = "repo id")
509338 ),
510339 request_body = SetRepoArchivedCmd,
511340 responses((status = 200, description = "updated", body = AppResponseData<NoData>))
@@ -516,11 +345,11 @@
516345 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
517346 Json(cmd): Json<SetRepoArchivedCmd>,
518347) -> Result<AppResponseData<NoData>, ApiError> {
519 require_owner(&state, &space_name, uid).await?;
348 let space = require_owner(&state, &space_name, uid).await?;
520349 if kind != "repo" {
521350 return Err(ApiError::bad_request("only repositories support archiving"));
522351 }
523 let repo = repo_row_by_dir(state.database.read(), object_id).await?;
352 let repo = repo_in_space(state.database.read(), space.id, object_id).await?;
524353 let ctx = state
525354 .git
526355 .context(space_name.clone(), repo.name.clone())
@@ -538,11 +367,11 @@
538367 delete,
539368 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}",
540369 tag = "cleanup",
541 description = "删除对象:问题集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。",
370 description = "删除对象:问题看板与频道调用各自模块的清理逻辑;仓库只能用归档。",
542371 params(
543372 ("space_name" = String, Path, description = "space name"),
544373 ("kind" = String, Path, description = "object kind"),
545 ("object_id" = Uuid, Path, description = "dir id or room id")
374 ("object_id" = Uuid, Path, description = "board id or room id")
546375 ),
547376 responses((status = 200, description = "deleted", body = AppResponseData<NoData>))
548377)]
@@ -561,8 +390,8 @@
561390 ));
562391 }
563392 "board" => {
564 let board = boards::Entity::find()
565 .filter(boards::Column::DirId.eq(object_id))
393 let board = boards::Entity::find_by_id(object_id)
394 .filter(boards::Column::SpaceId.eq(space.id))
566395 .one(db.read())
567396 .await
568397 .map_err(db_internal)?
@@ -574,88 +403,13 @@
574403 .map_err(map_issue)?;
575404 }
576405 "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
577 let room = rooms::Entity::find_by_id(object_id)
578 .filter(rooms::Column::SpaceId.eq(space.id))
579 .one(db.read())
580 .await
581 .map_err(db_internal)?
582 .ok_or_else(|| ApiError::not_found("room not found"))?;
583 let children = rooms::Entity::find()
584 .filter(rooms::Column::ParentId.eq(Some(room.id)))
585 .filter(rooms::Column::IsActive.eq(true))
586 .count(db.read())
587 .await
588 .map_err(db_internal)?;
589 if children > 0 {
590 return Err(ApiError::bad_request(format!(
591 "channel has {children} sub-channel(s); delete or move them first"
592 )));
593 }
406 let room = room_in_space(db.read(), space.id, object_id).await?;
594407 state
595408 .chat
596409 .delete_room(uid, room.id)
597410 .await
598411 .map_err(map_chat)?;
599412 }
600 "dir" | "object" => {
601 let node = dirs::Entity::find_by_id(object_id)
602 .filter(dirs::Column::SpaceId.eq(space.id))
603 .one(db.read())
604 .await
605 .map_err(db_internal)?
606 .ok_or_else(|| ApiError::not_found("directory not found"))?;
607 if matches!(
608 node.kind,
609 dirs::DirKind::ChatChannel | dirs::DirKind::VoiceChannel
610 ) {
611 return Err(ApiError::bad_request(
612 "channels are deleted from their room row instead",
613 ));
614 }
615 let has_companion = repos::Entity::find()
616 .filter(repos::Column::DirId.eq(node.id))
617 .count(db.read())
618 .await
619 .map_err(db_internal)?
620 > 0
621 || boards::Entity::find()
622 .filter(boards::Column::DirId.eq(node.id))
623 .count(db.read())
624 .await
625 .map_err(db_internal)?
626 > 0;
627 if has_companion {
628 return Err(ApiError::bad_request(
629 "this node holds an object; delete it from its own row instead",
630 ));
631 }
632 let children = dirs::Entity::find()
633 .filter(dirs::Column::ParentDirId.eq(Some(node.id)))
634 .count(db.read())
635 .await
636 .map_err(db_internal)?;
637 if children > 0 {
638 return Err(ApiError::bad_request(format!(
639 "directory has {children} child node(s); empty it first"
640 )));
641 }
642 let txn = db.write().begin().await.map_err(db_internal)?;
643 dir_overwrite_group::Entity::delete_many()
644 .filter(dir_overwrite_group::Column::DirId.eq(node.id))
645 .exec(&txn)
646 .await
647 .map_err(db_internal)?;
648 dir_overwrite_users::Entity::delete_many()
649 .filter(dir_overwrite_users::Column::DirId.eq(node.id))
650 .exec(&txn)
651 .await
652 .map_err(db_internal)?;
653 dirs::Entity::delete_by_id(node.id)
654 .exec(&txn)
655 .await
656 .map_err(db_internal)?;
657 txn.commit().await.map_err(db_internal)?;
658 }
659413 _ => {
660414 return Err(ApiError::bad_request("unsupported object kind"));
661415 }
@@ -663,51 +417,6 @@
663417 Ok(AppResponseData::empty())
664418}
665419
666#[utoipa::path(
667 delete,
668 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites",
669 tag = "cleanup",
670 description = "清除目录节点上的全部可见性覆盖(group + user),恢复继承默认可见性。",
671 params(
672 ("space_name" = String, Path, description = "space name"),
673 ("kind" = String, Path, description = "object kind"),
674 ("object_id" = Uuid, Path, description = "dir id")
675 ),
676 responses((status = 200, description = "cleared", body = AppResponseData<NoData>))
677)]
678pub async fn clear_cleanup_overwrites(
679 State(state): State<CleanupState>,
680 AuthedUser(uid): AuthedUser,
681 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
682) -> Result<AppResponseData<NoData>, ApiError> {
683 let space = require_owner(&state, &space_name, uid).await?;
684 if !matches!(kind.as_str(), "dir" | "object" | "repo" | "board") {
685 return Err(ApiError::bad_request(
686 "visibility overwrites are stored on directory nodes only",
687 ));
688 }
689 let db = &state.database;
690 dirs::Entity::find_by_id(object_id)
691 .filter(dirs::Column::SpaceId.eq(space.id))
692 .one(db.read())
693 .await
694 .map_err(db_internal)?
695 .ok_or_else(|| ApiError::not_found("directory not found"))?;
696 let txn = db.write().begin().await.map_err(db_internal)?;
697 dir_overwrite_group::Entity::delete_many()
698 .filter(dir_overwrite_group::Column::DirId.eq(object_id))
699 .exec(&txn)
700 .await
701 .map_err(db_internal)?;
702 dir_overwrite_users::Entity::delete_many()
703 .filter(dir_overwrite_users::Column::DirId.eq(object_id))
704 .exec(&txn)
705 .await
706 .map_err(db_internal)?;
707 txn.commit().await.map_err(db_internal)?;
708 Ok(AppResponseData::empty())
709}
710
711420#[derive(OpenApi)]
712421#[openapi(
713422 paths(
@@ -715,7 +424,6 @@
715424 crate::cleanup::set_cleanup_visibility,
716425 crate::cleanup::set_cleanup_archive,
717426 crate::cleanup::delete_cleanup_object,
718 crate::cleanup::clear_cleanup_overwrites,
719427 ),
720428 components(schemas(
721429 crate::cleanup::CleanupObjectDto,
Modified lib/service/webapi/Cargo.toml View lib/service/webapi/Cargo.toml
@@ -26,7 +26,6 @@
2626space = { workspace = true }
2727user = { workspace = true }
2828git = { workspace = true }
29dir = { workspace = true }
3029models = { workspace = true }
3130sea-orm = { workspace = true }
3231session = { workspace = true }
Modified lib/share/events/src/kind.rs View lib/share/events/src/kind.rs
@@ -140,9 +140,6 @@
140140 PullAutoMergeCreate: "pull.auto-merge.create" @ "pull" => "auto-merge-create";
141141 PullAutoMergeDelete: "pull.auto-merge.delete" @ "pull" => "auto-merge-delete";
142142
143 DirCreate: "dir.create" @ "dir" => "create";
144 DirUpdate: "dir.update" @ "dir" => "update";
145 DirArchive: "dir.archive" @ "dir" => "archive";
146143}
147144
148145const ORGANIZATION_MEMBER_TAGS: &[&str] = &[
@@ -229,7 +226,7 @@
229226 pub fn durable(self) -> bool {
230227 matches!(
231228 self.topic(),
232 "auth" | "org" | "chat" | "notify" | "issue" | "pull" | "dir"
229 "auth" | "org" | "chat" | "notify" | "issue" | "pull"
233230 )
234231 }
235232
@@ -293,7 +290,6 @@
293290 .get(label::USER_ID)
294291 .or_else(|| labels.get(label::REQUEST_ID)),
295292 "email" => labels.get(label::REQUEST_ID),
296 "dir" => labels.get(label::DIR_ID),
297293 _ => None,
298294 };
299295 key.filter(|key| !key.is_empty())
Modified lib/share/events/src/label.rs View lib/share/events/src/label.rs
@@ -10,7 +10,6 @@
1010pub const BOARD_ID: &str = "board_id";
1111pub const CHANNEL_ID: &str = "channel_id";
1212pub const ROOM_ID: &str = "room_id";
13pub const DIR_ID: &str = "dir_id";
1413pub const ORDERING_KEY: &str = "ordering_key";
1514
1615pub use crate::kind::EventKind;
Deleted lib/share/models/src/dir/dir_overwrite_group.rs
@@ -1,22 +0,0 @@
1use sea_orm::entity::prelude::*;
2use uuid::Uuid;
3
4#[sea_orm::model]
5#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
6#[sea_orm(table_name = "dir_overwrite_group")]
7pub struct Model {
8 #[sea_orm(primary_key, auto_increment = false)]
9 pub dir_id: Uuid,
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub group_id: Uuid,
12 #[sea_orm(default_value = 0)]
13 pub allow_mask: i64,
14 #[sea_orm(default_value = 0)]
15 pub deny_mask: i64,
16 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
17 pub dir: BelongsTo<super::dirs::Entity>,
18 #[sea_orm(belongs_to, from = "group_id", to = "id")]
19 pub group: BelongsTo<super::super::space::space_groups::Entity>,
20}
21
22impl ActiveModelBehavior for ActiveModel {}
Deleted lib/share/models/src/dir/dir_overwrite_users.rs
@@ -1,25 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "dir_overwrite_user")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub dir_id: Uuid,
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub user_id: i64,
13 #[sea_orm(default_value = 0)]
14 pub allow_mask: i64,
15 #[sea_orm(default_value = 0)]
16 pub deny_mask: i64,
17 /// 到期时间;`NULL` 表示永久。过期的覆盖项在可见性计算中被忽略。
18 pub expires_at: Option<OffsetDateTime>,
19 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
20 pub dir: BelongsTo<super::dirs::Entity>,
21 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
22 pub user: BelongsTo<super::super::users::user::Entity>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Deleted lib/share/models/src/dir/dirs.rs
@@ -1,97 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
20pub enum DirKind {
21 #[default]
22 #[sea_orm(num_value = 0)]
23 Dir,
24 #[sea_orm(num_value = 1)]
25 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 }
44}
45
46#[sea_orm::model]
47#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
48#[sea_orm(table_name = "dir")]
49pub struct Model {
50 #[sea_orm(primary_key, auto_increment = false)]
51 pub id: Uuid,
52 #[sea_orm(indexed)]
53 pub space_id: Option<Uuid>,
54 pub name: String,
55 #[sea_orm(default_value = 0)]
56 pub kind: DirKind,
57 #[sea_orm(indexed)]
58 pub parent_dir_id: Option<Uuid>,
59 pub color: Option<String>,
60 pub position: i64,
61 pub icon: Option<String>,
62 pub topic: Option<String>,
63 pub purpose: Option<String>,
64 #[sea_orm(default_value = "false")]
65 pub is_archived: bool,
66 pub archived_at: Option<OffsetDateTime>,
67 pub archived_by: Option<i64>,
68 #[sea_orm(indexed)]
69 pub created_at: OffsetDateTime,
70 pub updated_at: OffsetDateTime,
71 #[sea_orm(indexed)]
72 pub created_by_uid: i64,
73 #[sea_orm(
74 self_ref,
75 relation_enum = "Parent",
76 relation_reverse = "Children",
77 from = "parent_dir_id",
78 to = "id"
79 )]
80 pub parent: HasOne<Entity>,
81 #[sea_orm(
82 belongs_to,
83 relation_enum = "Creator",
84 from = "created_by_uid",
85 to = "uid"
86 )]
87 pub creator: BelongsTo<super::super::users::user::Entity>,
88 #[sea_orm(
89 belongs_to,
90 relation_enum = "Archiver",
91 from = "archived_by",
92 to = "uid"
93 )]
94 pub archiver: BelongsTo<Option<super::super::users::user::Entity>>,
95}
96
97impl ActiveModelBehavior for ActiveModel {}
Deleted lib/share/models/src/dir/mod.rs
@@ -1,5 +0,0 @@
1pub mod dir_overwrite_group;
2pub mod dir_overwrite_users;
3pub mod dirs;
4
5pub use dirs::*;
Modified lib/share/models/src/issue/issue_label.rs View lib/share/models/src/issue/issue_label.rs
@@ -8,7 +8,8 @@
88pub struct Model {
99 #[sea_orm(primary_key, auto_increment = false)]
1010 pub id: Uuid,
11 pub dir_id: Uuid,
11 #[sea_orm(indexed)]
12 pub board_id: Uuid,
1213 pub name: String,
1314 pub exclusive: bool,
1415 pub exclusive_order: i32,
@@ -17,8 +18,8 @@
1718 pub is_archived: bool,
1819 pub created_at: OffsetDateTime,
1920 pub updated_at: Option<OffsetDateTime>,
20 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
21 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
21 #[sea_orm(belongs_to, from = "board_id", to = "id")]
22 pub board: BelongsTo<super::issues::Entity>,
2223}
2324
2425impl ActiveModelBehavior for ActiveModel {}
Modified lib/share/models/src/issue/issues.rs View lib/share/models/src/issue/issues.rs
@@ -8,16 +8,18 @@
88pub struct Model {
99 #[sea_orm(primary_key, auto_increment = false)]
1010 pub id: Uuid,
11 pub dir_id: Uuid,
11 #[sea_orm(indexed)]
12 pub space_id: Uuid,
1213 pub name: String,
1314 /// Key prefix for issue numbers inside this board (`CAD-12`).
1415 pub prefix: Option<String>,
1516 pub icon: Option<String>,
17 pub color: Option<String>,
1618 pub description: Option<String>,
19 #[sea_orm(default_value = 0)]
20 pub position: i64,
1721 pub created_at: OffsetDateTime,
1822 pub updated_at: Option<OffsetDateTime>,
19 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
20 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
2123}
2224
2325impl ActiveModelBehavior for ActiveModel {}
Modified lib/share/models/src/migrate/mod.rs View lib/share/models/src/migrate/mod.rs
@@ -8,7 +8,7 @@
88const SCHEMA_EXTRAS: &str = include_str!("../../schema_extras.sql");
99/// Bump whenever `sync_schema` gains a Rust-side step that is not captured by
1010/// the entity registry or by `schema_pre_sync.sql` / `schema_extras.sql`.
11const SYNC_FORMAT_VERSION: u32 = 1;
11const SYNC_FORMAT_VERSION: u32 = 3;
1212pub const MIGRATION_LOCK_KEY: i64 = 5_241_915_330_551;
1313const SYNC_STATE_DDL: &str = "CREATE TABLE IF NOT EXISTS schema_sync_state (
1414 id smallint PRIMARY KEY CHECK (id = 1),
Modified lib/share/models/src/room/rooms.rs View lib/share/models/src/room/rooms.rs
@@ -49,20 +49,16 @@
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>,
5652 #[sea_orm(default_value = 0)]
5753 pub kind: RoomKind,
5854 #[sea_orm(unique_key = "space_lower_name")]
5955 pub lower_name: String,
6056 pub name: String,
6157 pub topic: Option<String>,
58 pub icon: Option<String>,
59 pub color: Option<String>,
6260 #[sea_orm(default_value = 0)]
6361 pub position: i64,
64 #[sea_orm(indexed)]
65 pub parent_id: Option<Uuid>,
6662 pub owner_id: i64,
6763 #[sea_orm(default_value = "false")]
6864 pub is_private: bool,
@@ -78,11 +74,6 @@
7874 pub updated_at: OffsetDateTime,
7975 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
8076 pub owner: BelongsTo<super::super::users::user::Entity>,
81 /// 频道父目录的镜像。权威父节点在频道的 `dir` 行(`dir_id` 的
82 /// `parent_dir_id`)上;`parent_id` 仍指向 `dir(id)` 而非本表,挂成
83 /// self_ref 会让这个外键在数据库里永远匹配不上(dir 的 id 不是 rooms 的 id)。
84 #[sea_orm(belongs_to, from = "parent_id", to = "id")]
85 pub parent: BelongsTo<Option<super::super::dir::dirs::Entity>>,
8677 #[sea_orm(has_many)]
8778 pub members: HasMany<super::room_members::Entity>,
8879}
Modified lib/share/models/src/lib.rs View lib/share/models/src/lib.rs
@@ -1,6 +1,5 @@
11pub mod chat;
22pub mod common;
3pub mod dir;
43pub mod event_outbox;
54pub mod git;
65pub mod issue;
Modified lib/share/models/schema_extras.sql View lib/share/models/schema_extras.sql
@@ -24,10 +24,6 @@
2424 ON issue_reaction (comment_id, user_id, emoji) WHERE comment_id IS NOT NULL;
2525CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_project
2626 ON issue_reaction (project_id, user_id, emoji) WHERE project_id IS NOT NULL;
27CREATE UNIQUE INDEX IF NOT EXISTS idx_branch_protection_whitelist_user
28 ON repo_branch_protection_whitelist (policy_id, kind, user_id) WHERE user_id IS NOT NULL;
29CREATE UNIQUE INDEX IF NOT EXISTS idx_branch_protection_whitelist_group
30 ON repo_branch_protection_whitelist (policy_id, kind, group_id) WHERE group_id IS NOT NULL;
3127
3228-- One pending join request per (organization, user). Existing duplicates are
3329-- collapsed to the newest row before the unique index is (re)built.
@@ -47,7 +43,6 @@
4743-- indexed/unique; lookups below span two columns).
4844-- ---------------------------------------------------------------------------
4945CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment (parent_id);
50CREATE INDEX IF NOT EXISTS idx_git_commit_status_sha ON git_commit_status (repo_id, sha);
5146
5247-- Single-column indexes for reserved project_id columns (parity with the
5348-- historical project module; columns are kept until project is restored).
@@ -103,14 +98,6 @@
10398END $$;
10499
105100DO $$ BEGIN
106 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'branch_protection_whitelist_principal_xor') THEN
107 ALTER TABLE repo_branch_protection_whitelist
108 ADD CONSTRAINT branch_protection_whitelist_principal_xor
109 CHECK ((user_id IS NOT NULL) <> (group_id IS NOT NULL));
110 END IF;
111END $$;
112
113DO $$ BEGIN
114101 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'product_review_rating_range') THEN
115102 ALTER TABLE product_review
116103 ADD CONSTRAINT product_review_rating_range
@@ -360,61 +347,13 @@
360347 DROP TABLE IF EXISTS user_emails CASCADE;
361348END $$;
362349
363-- dir.kind: legacy `Object` rows were created by the repo/issue-board domains.
364-- Split them into concrete kinds so callers read the type from the dir row.
365UPDATE dir SET kind = 3 WHERE kind = 1 AND id IN (SELECT dir_id FROM repos);
366UPDATE dir SET kind = 2 WHERE kind = 1 AND id IN (SELECT dir_id FROM issues);
367
368-- Channel dirs: space rooms created before the unified dir tree have no `dir`
369-- row. Reuse the room id as the dir id so the mapping stays deterministic and
370-- the insert can be retried safely.
371INSERT INTO dir (
372 id, space_id, name, kind, parent_dir_id, color, position, icon, topic,
373 purpose, is_archived, archived_at, archived_by, created_at, updated_at,
374 created_by_uid
375)
376SELECT
377 r.id, r.space_id, r.name,
378 CASE WHEN r.kind IN (11, 12) THEN 5 ELSE 4 END,
379 r.parent_id, NULL, r.position, NULL, r.topic,
380 NULL, NOT r.is_active, NULL, NULL, r.created_at, r.updated_at,
381 r.owner_id
382FROM rooms r
383WHERE r.space_id IS NOT NULL
384 AND r.dir_id IS NULL
385 AND NOT EXISTS (SELECT 1 FROM dir d WHERE d.id = r.id);
386
387UPDATE rooms SET dir_id = id WHERE dir_id IS NULL AND space_id IS NOT NULL;
388
389-- Private channels move their access list onto dir overwrites: any row on a
390-- dir makes the default "visible to every space member" an allow-list, so the
391-- channel members and the owner need an explicit allow row.
392INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
393SELECT r.dir_id, m.user_id, 1, 0, NULL
394FROM rooms r
395JOIN room_members m ON m.room_id = r.id
396WHERE r.dir_id IS NOT NULL AND r.is_private AND m.left_at IS NULL
397ON CONFLICT (dir_id, user_id) DO NOTHING;
398
399INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
400SELECT r.dir_id, r.owner_id, 1, 0, NULL
401FROM rooms r
402WHERE r.dir_id IS NOT NULL AND r.is_private
403ON CONFLICT (dir_id, user_id) DO NOTHING;
404
405-- Channel moderation/access moved to dir overwrites: convert legacy bans and
406-- drop the per-channel membership tables (room_members stays for DMs only).
350-- Legacy per-channel moderation tables were superseded by dir overwrites, and
351-- the dir tree removal leaves no replacement; drop whatever is left of them.
352-- room_members must survive: it is now the only thing that decides who sees a
353-- private channel.
407354DO $$
408355BEGIN
409356 IF to_regclass('room_bans') IS NOT NULL THEN
410 INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
411 SELECT r.dir_id, b.user_id, 0, 1, b.expires_at
412 FROM room_bans b
413 JOIN rooms r ON r.id = b.room_id
414 WHERE r.dir_id IS NOT NULL
415 ON CONFLICT (dir_id, user_id) DO UPDATE
416 SET deny_mask = EXCLUDED.deny_mask,
417 expires_at = EXCLUDED.expires_at;
418357 DROP TABLE room_bans;
419358 END IF;
420359 IF to_regclass('room_invites') IS NOT NULL THEN
@@ -422,5 +361,22 @@
422361 END IF;
423362END $$;
424363
425DELETE FROM room_members
426WHERE room_id IN (SELECT id FROM rooms WHERE space_id IS NOT NULL);
364-- dir tree removal (2026-11-03): the registry sync only adds, so the dir
365-- registry and every column that pointed at it have to be dropped explicitly.
366-- Objects now carry their own `space_id`, so nothing reads the tree any more.
367DO $$
368BEGIN
369 IF to_regclass('dir') IS NOT NULL THEN
370 -- issue_label's column was renamed to board_id in pre-sync, so its
371 -- foreign key still points at dir under the old constraint name.
372 ALTER TABLE IF EXISTS issue_label DROP CONSTRAINT IF EXISTS "fk-issue_label-dir_id";
373 ALTER TABLE IF EXISTS rooms DROP CONSTRAINT IF EXISTS "fk-rooms-parent_id";
374 ALTER TABLE IF EXISTS repos DROP COLUMN IF EXISTS dir_id;
375 ALTER TABLE IF EXISTS issues DROP COLUMN IF EXISTS dir_id;
376 ALTER TABLE IF EXISTS rooms DROP COLUMN IF EXISTS dir_id;
377 ALTER TABLE IF EXISTS rooms DROP COLUMN IF EXISTS parent_id;
378 DROP TABLE IF EXISTS dir_overwrite_user;
379 DROP TABLE IF EXISTS dir_overwrite_group;
380 DROP TABLE IF EXISTS dir;
381 END IF;
382END $$;
Modified lib/share/models/schema_pre_sync.sql View lib/share/models/schema_pre_sync.sql
@@ -126,30 +126,16 @@
126126 END IF;
127127END $$;
128128
129-- rooms.parent_id used to be declared as a self-reference, so sync created
130-- `fk-rooms-parent_id -> rooms(id)`. The column actually points at a directory
131-- node (dir.id) — update_room validates the target is a same-space DirKind::Dir
132-- row before writing — so every attempt to file a channel into a folder was
133-- rejected by that constraint and the feature never once succeeded. Sync only
134-- adds missing foreign keys and treats a same-named constraint as present, so
135-- the wrong one has to go before the corrected entity can be built.
136-- Guarded on confrelid so this is a no-op once the constraint is correct.
129-- rooms.parent_id pointed at a directory node and goes away with the dir tree;
130-- drop the stale foreign key so the sync can remove the column.
137131DO $$ BEGIN
138132 IF to_regclass('rooms') IS NOT NULL
139 AND to_regclass('dir') IS NOT NULL
140133 AND EXISTS (
141134 SELECT 1 FROM pg_constraint
142135 WHERE conname = 'fk-rooms-parent_id'
143136 AND conrelid = to_regclass('rooms')
144 AND confrelid <> to_regclass('dir')
145137 ) THEN
146138 ALTER TABLE rooms DROP CONSTRAINT "fk-rooms-parent_id";
147 -- Defensive: anything the old constraint let through cannot be a dir
148 -- node, so it is not a valid folder. Reset it instead of letting the
149 -- corrected foreign key fail to build.
150 UPDATE rooms SET parent_id = NULL
151 WHERE parent_id IS NOT NULL
152 AND NOT EXISTS (SELECT 1 FROM dir WHERE dir.id = rooms.parent_id);
153139 END IF;
154140END $$;
155141
@@ -236,3 +222,79 @@
236222-- must exist before SeaORM sync adds it as NOT NULL on an existing table.
237223ALTER TABLE IF EXISTS users
238224 ADD COLUMN IF NOT EXISTS local_avatar_url text NOT NULL DEFAULT '';
225
226-- 2026-11-03: the `dir` registry is gone. Repositories, issue boards and
227-- channels used to keep their display metadata on a dir row, and a repository's
228-- space membership lived there too. The registry sync only diffs structure and
229-- never moves data, so every column the object entities now own has to be
230-- created and populated here — before `dir` is dropped. Guarding the whole
231-- block on `dir` still existing keeps the file idempotent on databases that
232-- already went through it.
233DO $$
234BEGIN
235 IF to_regclass('dir') IS NULL THEN
236 RETURN;
237 END IF;
238
239 IF to_regclass('repos') IS NOT NULL THEN
240 ALTER TABLE repos ADD COLUMN IF NOT EXISTS space_id uuid;
241 ALTER TABLE repos ADD COLUMN IF NOT EXISTS icon varchar;
242 ALTER TABLE repos ADD COLUMN IF NOT EXISTS color varchar;
243 ALTER TABLE repos ADD COLUMN IF NOT EXISTS position bigint NOT NULL DEFAULT 0;
244 UPDATE repos SET
245 space_id = d.space_id,
246 icon = d.icon,
247 color = d.color,
248 position = d.position
249 FROM dir d
250 WHERE d.id = repos.dir_id;
251 -- A repository whose dir row is missing could not be reached by the
252 -- old API either (space lookup went through dir), so dropping the
253 -- residue is the only way to honour the NOT NULL column.
254 DELETE FROM repos WHERE space_id IS NULL;
255 ALTER TABLE repos ALTER COLUMN space_id SET NOT NULL;
256 END IF;
257
258 IF to_regclass('issues') IS NOT NULL THEN
259 ALTER TABLE issues ADD COLUMN IF NOT EXISTS space_id uuid;
260 ALTER TABLE issues ADD COLUMN IF NOT EXISTS color varchar;
261 ALTER TABLE issues ADD COLUMN IF NOT EXISTS position bigint NOT NULL DEFAULT 0;
262 UPDATE issues SET
263 space_id = d.space_id,
264 icon = COALESCE(issues.icon, d.icon),
265 color = d.color,
266 position = d.position
267 FROM dir d
268 WHERE d.id = issues.dir_id;
269 DELETE FROM issues WHERE space_id IS NULL;
270 ALTER TABLE issues ALTER COLUMN space_id SET NOT NULL;
271 END IF;
272
273 IF to_regclass('rooms') IS NOT NULL THEN
274 ALTER TABLE rooms ADD COLUMN IF NOT EXISTS icon varchar;
275 ALTER TABLE rooms ADD COLUMN IF NOT EXISTS color varchar;
276 UPDATE rooms SET
277 icon = d.icon,
278 color = d.color,
279 position = d.position
280 FROM dir d
281 WHERE d.id = rooms.dir_id;
282 END IF;
283
284 IF to_regclass('issue_label') IS NOT NULL
285 AND to_regclass('issues') IS NOT NULL
286 AND EXISTS (
287 SELECT 1 FROM information_schema.columns
288 WHERE table_schema = 'public'
289 AND table_name = 'issue_label'
290 AND column_name = 'dir_id'
291 ) THEN
292 -- Labels were keyed by the board's dir row; the board row itself is the
293 -- owner now, so re-point every label and rename the column in place.
294 UPDATE issue_label l SET dir_id = i.id FROM issues i WHERE i.dir_id = l.dir_id;
295 ALTER TABLE issue_label RENAME COLUMN dir_id TO board_id;
296 END IF;
297
298 -- Folders have no replacement: their children simply become top-level
299 -- entries of the flat list, keeping the position they already had.
300END $$;
Modified packages/apiclient/chat/create-room.schemas.ts View packages/apiclient/chat/create-room.schemas.ts
@@ -108,8 +108,6 @@
108108 name: string;
109109 nsfw?: boolean;
110110 /** @nullable */
111 parent_id?: string | null;
112 /** @nullable */
113111 rate_limit_per_user?: number | null;
114112 /** @nullable */
115113 topic?: string | null;
@@ -129,9 +127,11 @@
129127}
130128
131129export type EnvelopeRoomDtoData = {
130 /** @nullable */
131 color?: string | null;
132132 created_at: string;
133133 /** @nullable */
134 dir_id?: string | null;
134 icon?: string | null;
135135 id: string;
136136 is_active: boolean;
137137 is_private: boolean;
@@ -139,8 +139,6 @@
139139 name: string;
140140 nsfw: boolean;
141141 owner_id: number;
142 /** @nullable */
143 parent_id?: string | null;
144142 position: number;
145143 /** @nullable */
146144 rate_limit_per_user?: number | null;
Modified packages/apiclient/chat/get-room.schemas.ts View packages/apiclient/chat/get-room.schemas.ts
@@ -121,11 +121,13 @@
121121}
122122
123123export type EnvelopeRoomBriefDtoData = {
124 /** @nullable */
125 color?: string | null;
124126 created_at: string;
125 /** @nullable */
126 dir_id?: string | null;
127127 favorite: boolean;
128128 hidden: boolean;
129 /** @nullable */
130 icon?: string | null;
129131 id: string;
130132 is_private: boolean;
131133 kind: RoomKind;
@@ -137,8 +139,6 @@
137139 nsfw: boolean;
138140 owner?: null | UserDto;
139141 owner_id: number;
140 /** @nullable */
141 parent_id?: string | null;
142142 position: number;
143143 /** @nullable */
144144 rate_limit_per_user?: number | null;
Modified packages/apiclient/chat/list-space-rooms.schemas.ts View packages/apiclient/chat/list-space-rooms.schemas.ts
@@ -121,11 +121,13 @@
121121}
122122
123123export type EnvelopeVecRoomBriefDtoDataItem = {
124 /** @nullable */
125 color?: string | null;
124126 created_at: string;
125 /** @nullable */
126 dir_id?: string | null;
127127 favorite: boolean;
128128 hidden: boolean;
129 /** @nullable */
130 icon?: string | null;
129131 id: string;
130132 is_private: boolean;
131133 kind: RoomKind;
@@ -137,8 +139,6 @@
137139 nsfw: boolean;
138140 owner?: null | UserDto;
139141 owner_id: number;
140 /** @nullable */
141 parent_id?: string | null;
142142 position: number;
143143 /** @nullable */
144144 rate_limit_per_user?: number | null;
Modified packages/apiclient/chat/update-room.schemas.ts View packages/apiclient/chat/update-room.schemas.ts
@@ -121,11 +121,13 @@
121121}
122122
123123export type EnvelopeRoomBriefDtoData = {
124 created_at: string;
125124 /** @nullable */
126 dir_id?: string | null;
125 color?: string | null;
126 created_at: string;
127127 favorite: boolean;
128128 hidden: boolean;
129 /** @nullable */
130 icon?: string | null;
129131 id: string;
130132 is_private: boolean;
131133 kind: RoomKind;
@@ -137,8 +139,6 @@
137139 nsfw: boolean;
138140 owner?: null | UserDto;
139141 owner_id: number;
140 /** @nullable */
141 parent_id?: string | null;
142142 position: number;
143143 /** @nullable */
144144 rate_limit_per_user?: number | null;
@@ -163,17 +163,15 @@
163163
164164export interface UpdateRoomCmd {
165165 /** @nullable */
166 color?: string | null;
167 /** @nullable */
168 icon?: string | null;
169 /** @nullable */
166170 is_private?: boolean | null;
167171 /** @nullable */
168172 name?: string | null;
169173 /** @nullable */
170174 nsfw?: boolean | null;
171 /**
172 * Move the room under another directory node of the same space; `null` puts
173 * it back at the space root. Absent means "leave the parent alone".
174 * @nullable
175 */
176 parent_id?: string | null;
177175 /** @nullable */
178176 position?: number | null;
179177 /** @nullable */
Deleted packages/apiclient/cleanup/clear-cleanup-overwrites.schemas.ts
@@ -1,103 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface EnvelopeNoData {
95 code: number;
96 data?: unknown;
97 /** @nullable */
98 error?: string | null;
99 /** @nullable */
100 error_code?: string | null;
101 message: string;
102 request_id?: string;
103}
Deleted packages/apiclient/cleanup/clear-cleanup-overwrites.ts
@@ -1,143 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type { EnvelopeNoData } from "./clear-cleanup-overwrites.schemas";
95
96export type clearCleanupOverwritesResponse200 = {
97 data: EnvelopeNoData;
98 status: 200;
99};
100
101export type clearCleanupOverwritesResponseSuccess =
102 clearCleanupOverwritesResponse200 & {
103 headers: Headers;
104 };
105export type clearCleanupOverwritesResponse =
106 clearCleanupOverwritesResponseSuccess;
107
108export const getClearCleanupOverwritesUrl = (
109 spaceName: string,
110 kind: string,
111 objectId: string,
112) => {
113 return `/api/v1/spaces/${spaceName}/cleanup/${kind}/${objectId}/overwrites`;
114};
115
116/**
117 * 清除目录节点上的全部可见性覆盖(group + user),恢复继承默认可见性。
118 */
119export const clearCleanupOverwrites = async (
120 spaceName: string,
121 kind: string,
122 objectId: string,
123 options?: RequestInit,
124): Promise<clearCleanupOverwritesResponse> => {
125 const res = await fetch(
126 getClearCleanupOverwritesUrl(spaceName, kind, objectId),
127 {
128 ...options,
129 method: "DELETE",
130 },
131 );
132
133 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
134
135 const data: clearCleanupOverwritesResponse["data"] = body
136 ? JSON.parse(body)
137 : {};
138 return {
139 data,
140 status: res.status,
141 headers: res.headers,
142 } as clearCleanupOverwritesResponse;
143};
Modified packages/apiclient/cleanup/delete-cleanup-object.ts View packages/apiclient/cleanup/delete-cleanup-object.ts
@@ -113,7 +113,7 @@
113113};
114114
115115/**
116 * 删除对象:问题集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。
116 * 删除对象:问题看板与频道调用各自模块的清理逻辑;仓库只能用归档。
117117 */
118118export const deleteCleanupObject = async (
119119 spaceName: string,
Modified packages/apiclient/cleanup/index.ts View packages/apiclient/cleanup/index.ts
@@ -1,5 +1,4 @@
11export * from "./list-cleanup-objects";
22export * from "./delete-cleanup-object";
33export * from "./set-cleanup-archive";
4export * from "./clear-cleanup-overwrites";
54export * from "./set-cleanup-visibility";
Modified packages/apiclient/cleanup/list-cleanup-objects.schemas.ts View packages/apiclient/cleanup/list-cleanup-objects.schemas.ts
@@ -92,9 +92,7 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeVecCleanupObjectDtoDataItem = {
95 /** 目录的子节点数 / 频道的子频道数。 */
96 child_count: number;
97 /** `dirs.id`(目录/仓库/问题集/智能体集)或 `rooms.id`(频道)。 */
95 /** `repos.id`、`issues.id`(看板)或 `rooms.id`。 */
9896 id: string;
9997 is_archived: boolean;
10098 /**
@@ -102,13 +100,9 @@
102100 * @nullable
103101 */
104102 is_private?: boolean | null;
105 /** `dir | object | repo | board | text | voice | stage | category | news | guild_room` */
103 /** `repo | board | text | voice | stage | category | news | guild_room` */
106104 kind: string;
107105 name: string;
108 /** 目录上的权限覆盖条数(group + user)。 */
109 overwrite_count: number;
110 /** 父链拼出的可读路径,例如 `bots / 研究研究`。 */
111 path: string;
112106};
113107
114108export interface EnvelopeVecCleanupObjectDto {
Modified packages/apiclient/cleanup/list-cleanup-objects.ts View packages/apiclient/cleanup/list-cleanup-objects.ts
@@ -109,7 +109,7 @@
109109};
110110
111111/**
112 * 列出空间内全部目录与对象(目录/仓库/问题集/频道),仅空间所有者可访问。
112 * 列出空间内全部仓库、问题看板与频道,仅空间所有者可访问。
113113 */
114114export const listCleanupObjects = async (
115115 spaceName: string,
Modified packages/apiclient/cleanup/set-cleanup-visibility.ts View packages/apiclient/cleanup/set-cleanup-visibility.ts
@@ -116,7 +116,7 @@
116116};
117117
118118/**
119 * 调整对象可见性:仓库与频道支持私有/公开,其余类型不支持。
119 * 切换仓库或频道的可见性(私密 / 空间内公开)。
120120 */
121121export const setCleanupVisibility = async (
122122 spaceName: string,
Modified packages/apiclient/issues/create-board.schemas.ts View packages/apiclient/issues/create-board.schemas.ts
@@ -98,22 +98,22 @@
9898 icon?: string | null;
9999 name: string;
100100 /** @nullable */
101 parent_dir_id?: string | null;
102 /** @nullable */
103101 prefix?: string | null;
104102}
105103
106104export type EnvelopeBoardDtoData = {
107105 closed_issues_count: number;
106 /** @nullable */
107 color?: string | null;
108108 created_at: string;
109109 /** @nullable */
110110 description?: string | null;
111 dir_id: string;
112111 /** @nullable */
113112 icon?: string | null;
114113 id: string;
115114 name: string;
116115 open_issues_count: number;
116 position: number;
117117 /**
118118 * Number prefix for the board's issues (`CAD` renders as `CAD-12`).
119119 * @nullable
Modified packages/apiclient/issues/create-label.schemas.ts View packages/apiclient/issues/create-label.schemas.ts
@@ -103,11 +103,11 @@
103103}
104104
105105export type EnvelopeLabelDtoData = {
106 board_id: string;
106107 color: string;
107108 created_at: string;
108109 /** @nullable */
109110 description?: string | null;
110 dir_id: string;
111111 exclusive: boolean;
112112 exclusive_order: number;
113113 id: string;
Modified packages/apiclient/issues/get-board.schemas.ts View packages/apiclient/issues/get-board.schemas.ts
@@ -93,15 +93,17 @@
9393 */
9494export type EnvelopeBoardDtoData = {
9595 closed_issues_count: number;
96 /** @nullable */
97 color?: string | null;
9698 created_at: string;
9799 /** @nullable */
98100 description?: string | null;
99 dir_id: string;
100101 /** @nullable */
101102 icon?: string | null;
102103 id: string;
103104 name: string;
104105 open_issues_count: number;
106 position: number;
105107 /**
106108 * Number prefix for the board's issues (`CAD` renders as `CAD-12`).
107109 * @nullable
Modified packages/apiclient/issues/get-label.schemas.ts View packages/apiclient/issues/get-label.schemas.ts
@@ -92,11 +92,11 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeLabelDtoData = {
95 board_id: string;
9596 color: string;
9697 created_at: string;
9798 /** @nullable */
9899 description?: string | null;
99 dir_id: string;
100100 exclusive: boolean;
101101 exclusive_order: number;
102102 id: string;
Modified packages/apiclient/issues/list-boards.schemas.ts View packages/apiclient/issues/list-boards.schemas.ts
@@ -104,15 +104,17 @@
104104
105105export type EnvelopePageBoardDtoDataItemsItem = {
106106 closed_issues_count: number;
107 /** @nullable */
108 color?: string | null;
107109 created_at: string;
108110 /** @nullable */
109111 description?: string | null;
110 dir_id: string;
111112 /** @nullable */
112113 icon?: string | null;
113114 id: string;
114115 name: string;
115116 open_issues_count: number;
117 position: number;
116118 /**
117119 * Number prefix for the board's issues (`CAD` renders as `CAD-12`).
118120 * @nullable
@@ -144,10 +146,6 @@
144146}
145147
146148export type ListBoardsParams = {
147 /**
148 * @nullable
149 */
150 dir_id?: string | null;
151149 /**
152150 * @nullable
153151 */
Modified packages/apiclient/issues/list-issues.schemas.ts View packages/apiclient/issues/list-issues.schemas.ts
@@ -112,11 +112,11 @@
112112}
113113
114114export interface LabelDto {
115 board_id: string;
115116 color: string;
116117 created_at: string;
117118 /** @nullable */
118119 description?: string | null;
119 dir_id: string;
120120 exclusive: boolean;
121121 exclusive_order: number;
122122 id: string;
Modified packages/apiclient/issues/list-labels-for-issue.schemas.ts View packages/apiclient/issues/list-labels-for-issue.schemas.ts
@@ -103,11 +103,11 @@
103103}
104104
105105export type EnvelopeVecLabelDtoDataItem = {
106 board_id: string;
106107 color: string;
107108 created_at: string;
108109 /** @nullable */
109110 description?: string | null;
110 dir_id: string;
111111 exclusive: boolean;
112112 exclusive_order: number;
113113 id: string;
Modified packages/apiclient/issues/list-labels.schemas.ts View packages/apiclient/issues/list-labels.schemas.ts
@@ -103,11 +103,11 @@
103103}
104104
105105export type EnvelopePageLabelDtoDataItemsItem = {
106 board_id: string;
106107 color: string;
107108 created_at: string;
108109 /** @nullable */
109110 description?: string | null;
110 dir_id: string;
111111 exclusive: boolean;
112112 exclusive_order: number;
113113 id: string;
Modified packages/apiclient/issues/update-board.schemas.ts View packages/apiclient/issues/update-board.schemas.ts
@@ -93,15 +93,17 @@
9393 */
9494export type EnvelopeBoardDtoData = {
9595 closed_issues_count: number;
96 /** @nullable */
97 color?: string | null;
9698 created_at: string;
9799 /** @nullable */
98100 description?: string | null;
99 dir_id: string;
100101 /** @nullable */
101102 icon?: string | null;
102103 id: string;
103104 name: string;
104105 open_issues_count: number;
106 position: number;
105107 /**
106108 * Number prefix for the board's issues (`CAD` renders as `CAD-12`).
107109 * @nullable
@@ -135,11 +137,15 @@
135137
136138export interface UpdateBoardCmd {
137139 /** @nullable */
140 color?: string | null;
141 /** @nullable */
138142 description?: string | null;
139143 /** @nullable */
140144 icon?: string | null;
141145 /** @nullable */
142146 name?: string | null;
143147 /** @nullable */
148 position?: number | null;
149 /** @nullable */
144150 prefix?: string | null;
145151}
Modified packages/apiclient/issues/update-label.schemas.ts View packages/apiclient/issues/update-label.schemas.ts
@@ -92,11 +92,11 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeLabelDtoData = {
95 board_id: string;
9596 color: string;
9697 created_at: string;
9798 /** @nullable */
9899 description?: string | null;
99 dir_id: string;
100100 exclusive: boolean;
101101 exclusive_order: number;
102102 id: string;
Modified packages/apiclient/pulls/attach-pull-label.schemas.ts View packages/apiclient/pulls/attach-pull-label.schemas.ts
@@ -97,11 +97,11 @@
9797}
9898
9999export type EnvelopeVecLabelDtoDataItem = {
100 board_id: string;
100101 color: string;
101102 created_at: string;
102103 /** @nullable */
103104 description?: string | null;
104 dir_id: string;
105105 exclusive: boolean;
106106 exclusive_order: number;
107107 id: string;
Modified packages/apiclient/pulls/detach-pull-label.schemas.ts View packages/apiclient/pulls/detach-pull-label.schemas.ts
@@ -92,11 +92,11 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeVecLabelDtoDataItem = {
95 board_id: string;
9596 color: string;
9697 created_at: string;
9798 /** @nullable */
9899 description?: string | null;
99 dir_id: string;
100100 exclusive: boolean;
101101 exclusive_order: number;
102102 id: string;
Modified packages/apiclient/pulls/list-label-options.schemas.ts View packages/apiclient/pulls/list-label-options.schemas.ts
@@ -92,11 +92,11 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeVecLabelDtoDataItem = {
95 board_id: string;
9596 color: string;
9697 created_at: string;
9798 /** @nullable */
9899 description?: string | null;
99 dir_id: string;
100100 exclusive: boolean;
101101 exclusive_order: number;
102102 id: string;
Modified packages/apiclient/pulls/list-pull-labels.schemas.ts View packages/apiclient/pulls/list-pull-labels.schemas.ts
@@ -92,11 +92,11 @@
9292 * OpenAPI spec version: 1.0
9393 */
9494export type EnvelopeVecLabelDtoDataItem = {
95 board_id: string;
9596 color: string;
9697 created_at: string;
9798 /** @nullable */
9899 description?: string | null;
99 dir_id: string;
100100 exclusive: boolean;
101101 exclusive_order: number;
102102 id: string;
Modified packages/apiclient/repos/archive-repo.schemas.ts View packages/apiclient/repos/archive-repo.schemas.ts
@@ -105,6 +105,8 @@
105105 /** @nullable */
106106 avatar_url?: string | null;
107107 branches_count: number;
108 /** @nullable */
109 color?: string | null;
108110 commits_count: number;
109111 created_at: string;
110112 default_branch: string;
@@ -114,6 +116,8 @@
114116 git_size: number;
115117 /** @nullable */
116118 http_clone_url?: string | null;
119 /** @nullable */
120 icon?: string | null;
117121 id: string;
118122 is_archived: boolean;
119123 is_private: boolean;
@@ -125,6 +129,7 @@
125129 me?: null | RepoMeDto;
126130 name: string;
127131 object_format: string;
132 position: number;
128133 /** @nullable */
129134 primary_language?: string | null;
130135 pulls_count: number;
Modified packages/apiclient/repos/create-repo.schemas.ts View packages/apiclient/repos/create-repo.schemas.ts
@@ -103,8 +103,6 @@
103103 /** @nullable */
104104 license_name?: string | null;
105105 name: string;
106 /** @nullable */
107 parent_dir_id?: string | null;
108106}
109107
110108export type WatchMode = "unwatched" | "watching" | "ignore";
@@ -121,6 +119,8 @@
121119 /** @nullable */
122120 avatar_url?: string | null;
123121 branches_count: number;
122 /** @nullable */
123 color?: string | null;
124124 commits_count: number;
125125 created_at: string;
126126 default_branch: string;
@@ -130,6 +130,8 @@
130130 git_size: number;
131131 /** @nullable */
132132 http_clone_url?: string | null;
133 /** @nullable */
134 icon?: string | null;
133135 id: string;
134136 is_archived: boolean;
135137 is_private: boolean;
@@ -141,6 +143,7 @@
141143 me?: null | RepoMeDto;
142144 name: string;
143145 object_format: string;
146 position: number;
144147 /** @nullable */
145148 primary_language?: string | null;
146149 pulls_count: number;
Modified packages/apiclient/repos/get-repo.schemas.ts View packages/apiclient/repos/get-repo.schemas.ts
@@ -105,6 +105,8 @@
105105 /** @nullable */
106106 avatar_url?: string | null;
107107 branches_count: number;
108 /** @nullable */
109 color?: string | null;
108110 commits_count: number;
109111 created_at: string;
110112 default_branch: string;
@@ -114,6 +116,8 @@
114116 git_size: number;
115117 /** @nullable */
116118 http_clone_url?: string | null;
119 /** @nullable */
120 icon?: string | null;
117121 id: string;
118122 is_archived: boolean;
119123 is_private: boolean;
@@ -125,6 +129,7 @@
125129 me?: null | RepoMeDto;
126130 name: string;
127131 object_format: string;
132 position: number;
128133 /** @nullable */
129134 primary_language?: string | null;
130135 pulls_count: number;
Modified packages/apiclient/repos/list-repos.schemas.ts View packages/apiclient/repos/list-repos.schemas.ts
@@ -94,15 +94,20 @@
9494export type EnvelopePageRepoSummaryDtoDataItemsItem = {
9595 /** @nullable */
9696 avatar_url?: string | null;
97 /** @nullable */
98 color?: string | null;
9799 default_branch: string;
98100 /** @nullable */
99101 description?: string | null;
102 /** @nullable */
103 icon?: string | null;
100104 id: string;
101105 is_archived: boolean;
102106 is_private: boolean;
103107 /** @nullable */
104108 license_name?: string | null;
105109 name: string;
110 position: number;
106111 /** @nullable */
107112 primary_language?: string | null;
108113 size: number;
Modified packages/apiclient/repos/unarchive-repo.schemas.ts View packages/apiclient/repos/unarchive-repo.schemas.ts
@@ -105,6 +105,8 @@
105105 /** @nullable */
106106 avatar_url?: string | null;
107107 branches_count: number;
108 /** @nullable */
109 color?: string | null;
108110 commits_count: number;
109111 created_at: string;
110112 default_branch: string;
@@ -114,6 +116,8 @@
114116 git_size: number;
115117 /** @nullable */
116118 http_clone_url?: string | null;
119 /** @nullable */
120 icon?: string | null;
117121 id: string;
118122 is_archived: boolean;
119123 is_private: boolean;
@@ -125,6 +129,7 @@
125129 me?: null | RepoMeDto;
126130 name: string;
127131 object_format: string;
132 position: number;
128133 /** @nullable */
129134 primary_language?: string | null;
130135 pulls_count: number;
Modified packages/apiclient/repos/update-repo.schemas.ts View packages/apiclient/repos/update-repo.schemas.ts
@@ -105,6 +105,8 @@
105105 /** @nullable */
106106 avatar_url?: string | null;
107107 branches_count: number;
108 /** @nullable */
109 color?: string | null;
108110 commits_count: number;
109111 created_at: string;
110112 default_branch: string;
@@ -114,6 +116,8 @@
114116 git_size: number;
115117 /** @nullable */
116118 http_clone_url?: string | null;
119 /** @nullable */
120 icon?: string | null;
117121 id: string;
118122 is_archived: boolean;
119123 is_private: boolean;
@@ -125,6 +129,7 @@
125129 me?: null | RepoMeDto;
126130 name: string;
127131 object_format: string;
132 position: number;
128133 /** @nullable */
129134 primary_language?: string | null;
130135 pulls_count: number;
@@ -166,6 +171,11 @@
166171 /** @nullable */
167172 is_private?: boolean | null;
168173 /**
174 * 工作台列表中的排序位置。
175 * @nullable
176 */
177 position?: number | null;
178 /**
169179 * 中间页签顺序;传空数组恢复默认顺序。
170180 * @nullable
171181 */
Deleted packages/apiclient/space/create-dir-node.schemas.ts
@@ -1,172 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface CreateDirCmd {
95 name: string;
96 /** @nullable */
97 parent_dir_id?: string | null;
98 /** @nullable */
99 position?: number | null;
100}
101
102export type DirKind =
103 "Dir" | "Object" | "IssueBoard" | "GitRepo" | "ChatChannel" | "VoiceChannel";
104export interface DirNodeDto {
105 /** @nullable */
106 archived_at?: string | null;
107 children: DirNodeDto[];
108 /** @nullable */
109 color?: string | null;
110 created_at: string;
111 created_by_id: number;
112 /** @nullable */
113 icon?: string | null;
114 id: string;
115 is_archived: boolean;
116 kind: DirKind;
117 name: string;
118 /** @nullable */
119 parent_dir_id?: string | null;
120 position: number;
121 /** @nullable */
122 purpose?: string | null;
123 /** @nullable */
124 topic?: string | null;
125 updated_at: string;
126}
127
128export type EnvelopeDirNodeDtoData = {
129 /** @nullable */
130 archived_at?: string | null;
131 children: DirNodeDto[];
132 /** @nullable */
133 color?: string | null;
134 created_at: string;
135 created_by_id: number;
136 /** @nullable */
137 icon?: string | null;
138 id: string;
139 is_archived: boolean;
140 kind: DirKind;
141 name: string;
142 /** @nullable */
143 parent_dir_id?: string | null;
144 position: number;
145 /** @nullable */
146 purpose?: string | null;
147 /** @nullable */
148 topic?: string | null;
149 updated_at: string;
150};
151
152export interface EnvelopeDirNodeDto {
153 code: number;
154 data?: EnvelopeDirNodeDtoData;
155 /** @nullable */
156 error?: string | null;
157 /** @nullable */
158 error_code?: string | null;
159 message: string;
160 request_id?: string;
161}
162
163export interface EnvelopeNoData {
164 code: number;
165 data?: unknown;
166 /** @nullable */
167 error?: string | null;
168 /** @nullable */
169 error_code?: string | null;
170 message: string;
171 request_id?: string;
172}
Deleted packages/apiclient/space/create-dir-node.ts
@@ -1,197 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 CreateDirCmd,
96 EnvelopeDirNodeDto,
97 EnvelopeNoData,
98} from "./create-dir-node.schemas";
99
100export type createDirNodeResponse200 = {
101 data: EnvelopeDirNodeDto;
102 status: 200;
103};
104
105export type createDirNodeResponse400 = {
106 data: EnvelopeNoData;
107 status: 400;
108};
109
110export type createDirNodeResponse401 = {
111 data: EnvelopeNoData;
112 status: 401;
113};
114
115export type createDirNodeResponse403 = {
116 data: EnvelopeNoData;
117 status: 403;
118};
119
120export type createDirNodeResponse404 = {
121 data: EnvelopeNoData;
122 status: 404;
123};
124
125export type createDirNodeResponse409 = {
126 data: EnvelopeNoData;
127 status: 409;
128};
129
130export type createDirNodeResponseSuccess = createDirNodeResponse200 & {
131 headers: Headers;
132};
133export type createDirNodeResponseError = (
134 | createDirNodeResponse400
135 | createDirNodeResponse401
136 | createDirNodeResponse403
137 | createDirNodeResponse404
138 | createDirNodeResponse409
139) & {
140 headers: Headers;
141};
142
143export type createDirNodeResponse =
144 createDirNodeResponseSuccess | createDirNodeResponseError;
145
146export const getCreateDirNodeUrl = (spaceName: string) => {
147 return `/api/v1/spaces/${spaceName}/dir`;
148};
149
150/**
151 * 在空间目录树中创建目录节点。
152 */
153export const createDirNode = async (
154 spaceName: string,
155 createDirCmd: CreateDirCmd,
156 options?: RequestInit,
157): Promise<createDirNodeResponse> => {
158 const getHeaders = (
159 h?: NonNullable<RequestInit["headers"]>,
160 ): Record<string, string | readonly string[]> => {
161 if (!h) return {};
162 if (h instanceof Headers) return Object.fromEntries(h.entries());
163 if (Symbol.iterator in h) {
164 return Object.fromEntries(
165 Array.from(
166 h as Iterable<Iterable<string>>,
167 (entry) => Array.from(entry) as [string, string],
168 ),
169 );
170 }
171 const headers: Record<string, string | readonly string[]> = {};
172 for (const [name, value] of Object.entries<
173 string | readonly string[] | undefined
174 >(h)) {
175 if (value !== undefined) headers[name] = value;
176 }
177 return headers;
178 };
179 const res = await fetch(getCreateDirNodeUrl(spaceName), {
180 ...options,
181 method: "POST",
182 headers: {
183 "Content-Type": "application/json",
184 ...getHeaders(options?.headers),
185 },
186 body: JSON.stringify(createDirCmd),
187 });
188
189 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
190
191 const data: createDirNodeResponse["data"] = body ? JSON.parse(body) : {};
192 return {
193 data,
194 status: res.status,
195 headers: res.headers,
196 } as createDirNodeResponse;
197};
Deleted packages/apiclient/space/get-space-dir-tree.schemas.ts
@@ -1,171 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export type DirKind =
95 "Dir" | "Object" | "IssueBoard" | "GitRepo" | "ChatChannel" | "VoiceChannel";
96export interface DirNodeDto {
97 /** @nullable */
98 archived_at?: string | null;
99 children: DirNodeDto[];
100 /** @nullable */
101 color?: string | null;
102 created_at: string;
103 created_by_id: number;
104 /** @nullable */
105 icon?: string | null;
106 id: string;
107 is_archived: boolean;
108 kind: DirKind;
109 name: string;
110 /** @nullable */
111 parent_dir_id?: string | null;
112 position: number;
113 /** @nullable */
114 purpose?: string | null;
115 /** @nullable */
116 topic?: string | null;
117 updated_at: string;
118}
119
120export interface EnvelopeNoData {
121 code: number;
122 data?: unknown;
123 /** @nullable */
124 error?: string | null;
125 /** @nullable */
126 error_code?: string | null;
127 message: string;
128 request_id?: string;
129}
130
131export type EnvelopeVecDirNodeDtoDataItem = {
132 /** @nullable */
133 archived_at?: string | null;
134 children: DirNodeDto[];
135 /** @nullable */
136 color?: string | null;
137 created_at: string;
138 created_by_id: number;
139 /** @nullable */
140 icon?: string | null;
141 id: string;
142 is_archived: boolean;
143 kind: DirKind;
144 name: string;
145 /** @nullable */
146 parent_dir_id?: string | null;
147 position: number;
148 /** @nullable */
149 purpose?: string | null;
150 /** @nullable */
151 topic?: string | null;
152 updated_at: string;
153};
154
155export interface EnvelopeVecDirNodeDto {
156 code: number;
157 data?: EnvelopeVecDirNodeDtoDataItem[];
158 /** @nullable */
159 error?: string | null;
160 /** @nullable */
161 error_code?: string | null;
162 message: string;
163 request_id?: string;
164}
165
166export type GetSpaceDirTreeParams = {
167 /**
168 * @nullable
169 */
170 parent_id?: string | null;
171};
Deleted packages/apiclient/space/get-space-dir-tree.ts
@@ -1,174 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeNoData,
96 EnvelopeVecDirNodeDto,
97 GetSpaceDirTreeParams,
98} from "./get-space-dir-tree.schemas";
99
100export type getSpaceDirTreeResponse200 = {
101 data: EnvelopeVecDirNodeDto;
102 status: 200;
103};
104
105export type getSpaceDirTreeResponse401 = {
106 data: EnvelopeNoData;
107 status: 401;
108};
109
110export type getSpaceDirTreeResponse403 = {
111 data: EnvelopeNoData;
112 status: 403;
113};
114
115export type getSpaceDirTreeResponse404 = {
116 data: EnvelopeNoData;
117 status: 404;
118};
119
120export type getSpaceDirTreeResponseSuccess = getSpaceDirTreeResponse200 & {
121 headers: Headers;
122};
123export type getSpaceDirTreeResponseError = (
124 | getSpaceDirTreeResponse401
125 | getSpaceDirTreeResponse403
126 | getSpaceDirTreeResponse404
127) & {
128 headers: Headers;
129};
130
131export type getSpaceDirTreeResponse =
132 getSpaceDirTreeResponseSuccess | getSpaceDirTreeResponseError;
133
134export const getGetSpaceDirTreeUrl = (
135 spaceName: string,
136 params?: GetSpaceDirTreeParams,
137) => {
138 const normalizedParams = new URLSearchParams();
139
140 Object.entries(params || {}).forEach(([key, value]) => {
141 if (value !== undefined) {
142 normalizedParams.append(key, value === null ? "null" : String(value));
143 }
144 });
145
146 const stringifiedParams = normalizedParams.toString();
147
148 return stringifiedParams.length > 0
149 ? `/api/v1/spaces/${spaceName}/dir?${stringifiedParams}`
150 : `/api/v1/spaces/${spaceName}/dir`;
151};
152
153/**
154 * 按用户与群组覆写权限返回目录树或指定层级的子树。
155 */
156export const getSpaceDirTree = async (
157 spaceName: string,
158 params?: GetSpaceDirTreeParams,
159 options?: RequestInit,
160): Promise<getSpaceDirTreeResponse> => {
161 const res = await fetch(getGetSpaceDirTreeUrl(spaceName, params), {
162 ...options,
163 method: "GET",
164 });
165
166 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
167
168 const data: getSpaceDirTreeResponse["data"] = body ? JSON.parse(body) : {};
169 return {
170 data,
171 status: res.status,
172 headers: res.headers,
173 } as getSpaceDirTreeResponse;
174};
Modified packages/apiclient/space/index.ts View packages/apiclient/space/index.ts
@@ -10,17 +10,6 @@
1010export * from "./space-ban-get";
1111export * from "./space-ban-delete";
1212export * from "./space-ban-update";
13export * from "./get-space-dir-tree";
14export * from "./create-dir-node";
15export * from "./repair-dir-parents";
16export * from "./update-dir-node";
17export * from "./list-dir-group-overwrites";
18export * from "./set-dir-group-overwrite";
19export * from "./remove-dir-group-overwrite";
20export * from "./list-dir-user-overwrites";
21export * from "./set-dir-user-overwrite";
22export * from "./remove-dir-user-overwrite";
23export * from "./move-dir-node";
2413export * from "./list-groups";
2514export * from "./create-group";
2615export * from "./get-group";
Deleted packages/apiclient/space/list-dir-group-overwrites.schemas.ts
@@ -1,121 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface EnvelopeNoData {
95 code: number;
96 data?: unknown;
97 /** @nullable */
98 error?: string | null;
99 /** @nullable */
100 error_code?: string | null;
101 message: string;
102 request_id?: string;
103}
104
105export type EnvelopeVecGroupOverwriteDtoDataItem = {
106 allow_mask: number;
107 deny_mask: number;
108 dir_id: string;
109 group_id: string;
110};
111
112export interface EnvelopeVecGroupOverwriteDto {
113 code: number;
114 data?: EnvelopeVecGroupOverwriteDtoDataItem[];
115 /** @nullable */
116 error?: string | null;
117 /** @nullable */
118 error_code?: string | null;
119 message: string;
120 request_id?: string;
121}
Deleted packages/apiclient/space/list-dir-group-overwrites.ts
@@ -1,164 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeNoData,
96 EnvelopeVecGroupOverwriteDto,
97} from "./list-dir-group-overwrites.schemas";
98
99export type listDirGroupOverwritesResponse200 = {
100 data: EnvelopeVecGroupOverwriteDto;
101 status: 200;
102};
103
104export type listDirGroupOverwritesResponse401 = {
105 data: EnvelopeNoData;
106 status: 401;
107};
108
109export type listDirGroupOverwritesResponse403 = {
110 data: EnvelopeNoData;
111 status: 403;
112};
113
114export type listDirGroupOverwritesResponse404 = {
115 data: EnvelopeNoData;
116 status: 404;
117};
118
119export type listDirGroupOverwritesResponseSuccess =
120 listDirGroupOverwritesResponse200 & {
121 headers: Headers;
122 };
123export type listDirGroupOverwritesResponseError = (
124 | listDirGroupOverwritesResponse401
125 | listDirGroupOverwritesResponse403
126 | listDirGroupOverwritesResponse404
127) & {
128 headers: Headers;
129};
130
131export type listDirGroupOverwritesResponse =
132 listDirGroupOverwritesResponseSuccess | listDirGroupOverwritesResponseError;
133
134export const getListDirGroupOverwritesUrl = (
135 spaceName: string,
136 dirId: string,
137) => {
138 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/groups`;
139};
140
141/**
142 * 列出目录节点上的群组权限覆写。
143 */
144export const listDirGroupOverwrites = async (
145 spaceName: string,
146 dirId: string,
147 options?: RequestInit,
148): Promise<listDirGroupOverwritesResponse> => {
149 const res = await fetch(getListDirGroupOverwritesUrl(spaceName, dirId), {
150 ...options,
151 method: "GET",
152 });
153
154 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
155
156 const data: listDirGroupOverwritesResponse["data"] = body
157 ? JSON.parse(body)
158 : {};
159 return {
160 data,
161 status: res.status,
162 headers: res.headers,
163 } as listDirGroupOverwritesResponse;
164};
Deleted packages/apiclient/space/list-dir-user-overwrites.schemas.ts
@@ -1,129 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface UserDto {
95 avatar_url: string;
96 display_name: string;
97 user_id: number;
98 username: string;
99}
100
101export interface EnvelopeNoData {
102 code: number;
103 data?: unknown;
104 /** @nullable */
105 error?: string | null;
106 /** @nullable */
107 error_code?: string | null;
108 message: string;
109 request_id?: string;
110}
111
112export type EnvelopeVecUserOverwriteDtoDataItem = {
113 allow_mask: number;
114 deny_mask: number;
115 dir_id: string;
116 user?: null | UserDto;
117 user_id: number;
118};
119
120export interface EnvelopeVecUserOverwriteDto {
121 code: number;
122 data?: EnvelopeVecUserOverwriteDtoDataItem[];
123 /** @nullable */
124 error?: string | null;
125 /** @nullable */
126 error_code?: string | null;
127 message: string;
128 request_id?: string;
129}
Deleted packages/apiclient/space/list-dir-user-overwrites.ts
@@ -1,164 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeNoData,
96 EnvelopeVecUserOverwriteDto,
97} from "./list-dir-user-overwrites.schemas";
98
99export type listDirUserOverwritesResponse200 = {
100 data: EnvelopeVecUserOverwriteDto;
101 status: 200;
102};
103
104export type listDirUserOverwritesResponse401 = {
105 data: EnvelopeNoData;
106 status: 401;
107};
108
109export type listDirUserOverwritesResponse403 = {
110 data: EnvelopeNoData;
111 status: 403;
112};
113
114export type listDirUserOverwritesResponse404 = {
115 data: EnvelopeNoData;
116 status: 404;
117};
118
119export type listDirUserOverwritesResponseSuccess =
120 listDirUserOverwritesResponse200 & {
121 headers: Headers;
122 };
123export type listDirUserOverwritesResponseError = (
124 | listDirUserOverwritesResponse401
125 | listDirUserOverwritesResponse403
126 | listDirUserOverwritesResponse404
127) & {
128 headers: Headers;
129};
130
131export type listDirUserOverwritesResponse =
132 listDirUserOverwritesResponseSuccess | listDirUserOverwritesResponseError;
133
134export const getListDirUserOverwritesUrl = (
135 spaceName: string,
136 dirId: string,
137) => {
138 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/users`;
139};
140
141/**
142 * 列出目录节点上的用户权限覆写。
143 */
144export const listDirUserOverwrites = async (
145 spaceName: string,
146 dirId: string,
147 options?: RequestInit,
148): Promise<listDirUserOverwritesResponse> => {
149 const res = await fetch(getListDirUserOverwritesUrl(spaceName, dirId), {
150 ...options,
151 method: "GET",
152 });
153
154 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
155
156 const data: listDirUserOverwritesResponse["data"] = body
157 ? JSON.parse(body)
158 : {};
159 return {
160 data,
161 status: res.status,
162 headers: res.headers,
163 } as listDirUserOverwritesResponse;
164};
Deleted packages/apiclient/space/move-dir-node.schemas.ts
@@ -1,171 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export type DirKind =
95 "Dir" | "Object" | "IssueBoard" | "GitRepo" | "ChatChannel" | "VoiceChannel";
96export interface DirNodeDto {
97 /** @nullable */
98 archived_at?: string | null;
99 children: DirNodeDto[];
100 /** @nullable */
101 color?: string | null;
102 created_at: string;
103 created_by_id: number;
104 /** @nullable */
105 icon?: string | null;
106 id: string;
107 is_archived: boolean;
108 kind: DirKind;
109 name: string;
110 /** @nullable */
111 parent_dir_id?: string | null;
112 position: number;
113 /** @nullable */
114 purpose?: string | null;
115 /** @nullable */
116 topic?: string | null;
117 updated_at: string;
118}
119
120export type EnvelopeDirNodeDtoData = {
121 /** @nullable */
122 archived_at?: string | null;
123 children: DirNodeDto[];
124 /** @nullable */
125 color?: string | null;
126 created_at: string;
127 created_by_id: number;
128 /** @nullable */
129 icon?: string | null;
130 id: string;
131 is_archived: boolean;
132 kind: DirKind;
133 name: string;
134 /** @nullable */
135 parent_dir_id?: string | null;
136 position: number;
137 /** @nullable */
138 purpose?: string | null;
139 /** @nullable */
140 topic?: string | null;
141 updated_at: string;
142};
143
144export interface EnvelopeDirNodeDto {
145 code: number;
146 data?: EnvelopeDirNodeDtoData;
147 /** @nullable */
148 error?: string | null;
149 /** @nullable */
150 error_code?: string | null;
151 message: string;
152 request_id?: string;
153}
154
155export interface EnvelopeNoData {
156 code: number;
157 data?: unknown;
158 /** @nullable */
159 error?: string | null;
160 /** @nullable */
161 error_code?: string | null;
162 message: string;
163 request_id?: string;
164}
165
166export interface MoveDirCmd {
167 /** @nullable */
168 parent_dir_id?: string | null;
169 /** @nullable */
170 position?: number | null;
171}
Deleted packages/apiclient/space/move-dir-node.ts
@@ -1,192 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeDirNodeDto,
96 EnvelopeNoData,
97 MoveDirCmd,
98} from "./move-dir-node.schemas";
99
100export type moveDirNodeResponse200 = {
101 data: EnvelopeDirNodeDto;
102 status: 200;
103};
104
105export type moveDirNodeResponse400 = {
106 data: EnvelopeNoData;
107 status: 400;
108};
109
110export type moveDirNodeResponse401 = {
111 data: EnvelopeNoData;
112 status: 401;
113};
114
115export type moveDirNodeResponse403 = {
116 data: EnvelopeNoData;
117 status: 403;
118};
119
120export type moveDirNodeResponse404 = {
121 data: EnvelopeNoData;
122 status: 404;
123};
124
125export type moveDirNodeResponseSuccess = moveDirNodeResponse200 & {
126 headers: Headers;
127};
128export type moveDirNodeResponseError = (
129 | moveDirNodeResponse400
130 | moveDirNodeResponse401
131 | moveDirNodeResponse403
132 | moveDirNodeResponse404
133) & {
134 headers: Headers;
135};
136
137export type moveDirNodeResponse =
138 moveDirNodeResponseSuccess | moveDirNodeResponseError;
139
140export const getMoveDirNodeUrl = (spaceName: string, dirId: string) => {
141 return `/api/v1/spaces/${spaceName}/dir/${dirId}/position`;
142};
143
144/**
145 * 移动目录节点并防止父链自引用形成环。
146 */
147export const moveDirNode = async (
148 spaceName: string,
149 dirId: string,
150 moveDirCmd: MoveDirCmd,
151 options?: RequestInit,
152): Promise<moveDirNodeResponse> => {
153 const getHeaders = (
154 h?: NonNullable<RequestInit["headers"]>,
155 ): Record<string, string | readonly string[]> => {
156 if (!h) return {};
157 if (h instanceof Headers) return Object.fromEntries(h.entries());
158 if (Symbol.iterator in h) {
159 return Object.fromEntries(
160 Array.from(
161 h as Iterable<Iterable<string>>,
162 (entry) => Array.from(entry) as [string, string],
163 ),
164 );
165 }
166 const headers: Record<string, string | readonly string[]> = {};
167 for (const [name, value] of Object.entries<
168 string | readonly string[] | undefined
169 >(h)) {
170 if (value !== undefined) headers[name] = value;
171 }
172 return headers;
173 };
174 const res = await fetch(getMoveDirNodeUrl(spaceName, dirId), {
175 ...options,
176 method: "PATCH",
177 headers: {
178 "Content-Type": "application/json",
179 ...getHeaders(options?.headers),
180 },
181 body: JSON.stringify(moveDirCmd),
182 });
183
184 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
185
186 const data: moveDirNodeResponse["data"] = body ? JSON.parse(body) : {};
187 return {
188 data,
189 status: res.status,
190 headers: res.headers,
191 } as moveDirNodeResponse;
192};
Deleted packages/apiclient/space/remove-dir-group-overwrite.schemas.ts
@@ -1,103 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface EnvelopeNoData {
95 code: number;
96 data?: unknown;
97 /** @nullable */
98 error?: string | null;
99 /** @nullable */
100 error_code?: string | null;
101 message: string;
102 request_id?: string;
103}
Deleted packages/apiclient/space/remove-dir-group-overwrite.ts
@@ -1,166 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type { EnvelopeNoData } from "./remove-dir-group-overwrite.schemas";
95
96export type removeDirGroupOverwriteResponse200 = {
97 data: EnvelopeNoData;
98 status: 200;
99};
100
101export type removeDirGroupOverwriteResponse401 = {
102 data: EnvelopeNoData;
103 status: 401;
104};
105
106export type removeDirGroupOverwriteResponse403 = {
107 data: EnvelopeNoData;
108 status: 403;
109};
110
111export type removeDirGroupOverwriteResponse404 = {
112 data: EnvelopeNoData;
113 status: 404;
114};
115
116export type removeDirGroupOverwriteResponseSuccess =
117 removeDirGroupOverwriteResponse200 & {
118 headers: Headers;
119 };
120export type removeDirGroupOverwriteResponseError = (
121 | removeDirGroupOverwriteResponse401
122 | removeDirGroupOverwriteResponse403
123 | removeDirGroupOverwriteResponse404
124) & {
125 headers: Headers;
126};
127
128export type removeDirGroupOverwriteResponse =
129 removeDirGroupOverwriteResponseSuccess | removeDirGroupOverwriteResponseError;
130
131export const getRemoveDirGroupOverwriteUrl = (
132 spaceName: string,
133 dirId: string,
134 groupId: string,
135) => {
136 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/groups/${groupId}`;
137};
138
139/**
140 * 移除目录节点的指定群组权限覆写。
141 */
142export const removeDirGroupOverwrite = async (
143 spaceName: string,
144 dirId: string,
145 groupId: string,
146 options?: RequestInit,
147): Promise<removeDirGroupOverwriteResponse> => {
148 const res = await fetch(
149 getRemoveDirGroupOverwriteUrl(spaceName, dirId, groupId),
150 {
151 ...options,
152 method: "DELETE",
153 },
154 );
155
156 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
157
158 const data: removeDirGroupOverwriteResponse["data"] = body
159 ? JSON.parse(body)
160 : {};
161 return {
162 data,
163 status: res.status,
164 headers: res.headers,
165 } as removeDirGroupOverwriteResponse;
166};
Deleted packages/apiclient/space/remove-dir-user-overwrite.schemas.ts
@@ -1,103 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface EnvelopeNoData {
95 code: number;
96 data?: unknown;
97 /** @nullable */
98 error?: string | null;
99 /** @nullable */
100 error_code?: string | null;
101 message: string;
102 request_id?: string;
103}
Deleted packages/apiclient/space/remove-dir-user-overwrite.ts
@@ -1,166 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type { EnvelopeNoData } from "./remove-dir-user-overwrite.schemas";
95
96export type removeDirUserOverwriteResponse200 = {
97 data: EnvelopeNoData;
98 status: 200;
99};
100
101export type removeDirUserOverwriteResponse401 = {
102 data: EnvelopeNoData;
103 status: 401;
104};
105
106export type removeDirUserOverwriteResponse403 = {
107 data: EnvelopeNoData;
108 status: 403;
109};
110
111export type removeDirUserOverwriteResponse404 = {
112 data: EnvelopeNoData;
113 status: 404;
114};
115
116export type removeDirUserOverwriteResponseSuccess =
117 removeDirUserOverwriteResponse200 & {
118 headers: Headers;
119 };
120export type removeDirUserOverwriteResponseError = (
121 | removeDirUserOverwriteResponse401
122 | removeDirUserOverwriteResponse403
123 | removeDirUserOverwriteResponse404
124) & {
125 headers: Headers;
126};
127
128export type removeDirUserOverwriteResponse =
129 removeDirUserOverwriteResponseSuccess | removeDirUserOverwriteResponseError;
130
131export const getRemoveDirUserOverwriteUrl = (
132 spaceName: string,
133 dirId: string,
134 userId: number,
135) => {
136 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/users/${userId}`;
137};
138
139/**
140 * 移除目录节点的指定用户权限覆写。
141 */
142export const removeDirUserOverwrite = async (
143 spaceName: string,
144 dirId: string,
145 userId: number,
146 options?: RequestInit,
147): Promise<removeDirUserOverwriteResponse> => {
148 const res = await fetch(
149 getRemoveDirUserOverwriteUrl(spaceName, dirId, userId),
150 {
151 ...options,
152 method: "DELETE",
153 },
154 );
155
156 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
157
158 const data: removeDirUserOverwriteResponse["data"] = body
159 ? JSON.parse(body)
160 : {};
161 return {
162 data,
163 status: res.status,
164 headers: res.headers,
165 } as removeDirUserOverwriteResponse;
166};
Deleted packages/apiclient/space/repair-dir-parents.schemas.ts
@@ -1,119 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface EnvelopeNoData {
95 code: number;
96 data?: unknown;
97 /** @nullable */
98 error?: string | null;
99 /** @nullable */
100 error_code?: string | null;
101 message: string;
102 request_id?: string;
103}
104
105export type EnvelopeRepairDirRespData = {
106 fixed: number;
107 scanned: number;
108};
109
110export interface EnvelopeRepairDirResp {
111 code: number;
112 data?: EnvelopeRepairDirRespData;
113 /** @nullable */
114 error?: string | null;
115 /** @nullable */
116 error_code?: string | null;
117 message: string;
118 request_id?: string;
119}
Deleted packages/apiclient/space/repair-dir-parents.ts
@@ -1,163 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeNoData,
96 EnvelopeRepairDirResp,
97} from "./repair-dir-parents.schemas";
98
99export type repairDirParentsResponse200 = {
100 data: EnvelopeRepairDirResp;
101 status: 200;
102};
103
104export type repairDirParentsResponse401 = {
105 data: EnvelopeNoData;
106 status: 401;
107};
108
109export type repairDirParentsResponse403 = {
110 data: EnvelopeNoData;
111 status: 403;
112};
113
114export type repairDirParentsResponse404 = {
115 data: EnvelopeNoData;
116 status: 404;
117};
118
119export type repairDirParentsResponse409 = {
120 data: EnvelopeNoData;
121 status: 409;
122};
123
124export type repairDirParentsResponseSuccess = repairDirParentsResponse200 & {
125 headers: Headers;
126};
127export type repairDirParentsResponseError = (
128 | repairDirParentsResponse401
129 | repairDirParentsResponse403
130 | repairDirParentsResponse404
131 | repairDirParentsResponse409
132) & {
133 headers: Headers;
134};
135
136export type repairDirParentsResponse =
137 repairDirParentsResponseSuccess | repairDirParentsResponseError;
138
139export const getRepairDirParentsUrl = (spaceName: string) => {
140 return `/api/v1/spaces/${spaceName}/dir/repair-parents`;
141};
142
143/**
144 * 拥有者修复目录树中的悬空父引用和循环结构。
145 */
146export const repairDirParents = async (
147 spaceName: string,
148 options?: RequestInit,
149): Promise<repairDirParentsResponse> => {
150 const res = await fetch(getRepairDirParentsUrl(spaceName), {
151 ...options,
152 method: "POST",
153 });
154
155 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
156
157 const data: repairDirParentsResponse["data"] = body ? JSON.parse(body) : {};
158 return {
159 data,
160 status: res.status,
161 headers: res.headers,
162 } as repairDirParentsResponse;
163};
Deleted packages/apiclient/space/set-dir-group-overwrite.schemas.ts
@@ -1,126 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export type EnvelopeGroupOverwriteDtoData = {
95 allow_mask: number;
96 deny_mask: number;
97 dir_id: string;
98 group_id: string;
99};
100
101export interface EnvelopeGroupOverwriteDto {
102 code: number;
103 data?: EnvelopeGroupOverwriteDtoData;
104 /** @nullable */
105 error?: string | null;
106 /** @nullable */
107 error_code?: string | null;
108 message: string;
109 request_id?: string;
110}
111
112export interface EnvelopeNoData {
113 code: number;
114 data?: unknown;
115 /** @nullable */
116 error?: string | null;
117 /** @nullable */
118 error_code?: string | null;
119 message: string;
120 request_id?: string;
121}
122
123export interface SetOverwriteCmd {
124 allow_mask: number;
125 deny_mask: number;
126}
Deleted packages/apiclient/space/set-dir-group-overwrite.ts
@@ -1,203 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeGroupOverwriteDto,
96 EnvelopeNoData,
97 SetOverwriteCmd,
98} from "./set-dir-group-overwrite.schemas";
99
100export type setDirGroupOverwriteResponse200 = {
101 data: EnvelopeGroupOverwriteDto;
102 status: 200;
103};
104
105export type setDirGroupOverwriteResponse400 = {
106 data: EnvelopeNoData;
107 status: 400;
108};
109
110export type setDirGroupOverwriteResponse401 = {
111 data: EnvelopeNoData;
112 status: 401;
113};
114
115export type setDirGroupOverwriteResponse403 = {
116 data: EnvelopeNoData;
117 status: 403;
118};
119
120export type setDirGroupOverwriteResponse404 = {
121 data: EnvelopeNoData;
122 status: 404;
123};
124
125export type setDirGroupOverwriteResponseSuccess =
126 setDirGroupOverwriteResponse200 & {
127 headers: Headers;
128 };
129export type setDirGroupOverwriteResponseError = (
130 | setDirGroupOverwriteResponse400
131 | setDirGroupOverwriteResponse401
132 | setDirGroupOverwriteResponse403
133 | setDirGroupOverwriteResponse404
134) & {
135 headers: Headers;
136};
137
138export type setDirGroupOverwriteResponse =
139 setDirGroupOverwriteResponseSuccess | setDirGroupOverwriteResponseError;
140
141export const getSetDirGroupOverwriteUrl = (
142 spaceName: string,
143 dirId: string,
144 groupId: string,
145) => {
146 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/groups/${groupId}`;
147};
148
149/**
150 * 设置目录节点针对群组的允许与拒绝权限位。
151 */
152export const setDirGroupOverwrite = async (
153 spaceName: string,
154 dirId: string,
155 groupId: string,
156 setOverwriteCmd: SetOverwriteCmd,
157 options?: RequestInit,
158): Promise<setDirGroupOverwriteResponse> => {
159 const getHeaders = (
160 h?: NonNullable<RequestInit["headers"]>,
161 ): Record<string, string | readonly string[]> => {
162 if (!h) return {};
163 if (h instanceof Headers) return Object.fromEntries(h.entries());
164 if (Symbol.iterator in h) {
165 return Object.fromEntries(
166 Array.from(
167 h as Iterable<Iterable<string>>,
168 (entry) => Array.from(entry) as [string, string],
169 ),
170 );
171 }
172 const headers: Record<string, string | readonly string[]> = {};
173 for (const [name, value] of Object.entries<
174 string | readonly string[] | undefined
175 >(h)) {
176 if (value !== undefined) headers[name] = value;
177 }
178 return headers;
179 };
180 const res = await fetch(
181 getSetDirGroupOverwriteUrl(spaceName, dirId, groupId),
182 {
183 ...options,
184 method: "PUT",
185 headers: {
186 "Content-Type": "application/json",
187 ...getHeaders(options?.headers),
188 },
189 body: JSON.stringify(setOverwriteCmd),
190 },
191 );
192
193 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
194
195 const data: setDirGroupOverwriteResponse["data"] = body
196 ? JSON.parse(body)
197 : {};
198 return {
199 data,
200 status: res.status,
201 headers: res.headers,
202 } as setDirGroupOverwriteResponse;
203};
Deleted packages/apiclient/space/set-dir-user-overwrite.schemas.ts
@@ -1,134 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export interface UserDto {
95 avatar_url: string;
96 display_name: string;
97 user_id: number;
98 username: string;
99}
100
101export interface EnvelopeNoData {
102 code: number;
103 data?: unknown;
104 /** @nullable */
105 error?: string | null;
106 /** @nullable */
107 error_code?: string | null;
108 message: string;
109 request_id?: string;
110}
111
112export type EnvelopeUserOverwriteDtoData = {
113 allow_mask: number;
114 deny_mask: number;
115 dir_id: string;
116 user?: null | UserDto;
117 user_id: number;
118};
119
120export interface EnvelopeUserOverwriteDto {
121 code: number;
122 data?: EnvelopeUserOverwriteDtoData;
123 /** @nullable */
124 error?: string | null;
125 /** @nullable */
126 error_code?: string | null;
127 message: string;
128 request_id?: string;
129}
130
131export interface SetOverwriteCmd {
132 allow_mask: number;
133 deny_mask: number;
134}
Deleted packages/apiclient/space/set-dir-user-overwrite.ts
@@ -1,200 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeNoData,
96 EnvelopeUserOverwriteDto,
97 SetOverwriteCmd,
98} from "./set-dir-user-overwrite.schemas";
99
100export type setDirUserOverwriteResponse200 = {
101 data: EnvelopeUserOverwriteDto;
102 status: 200;
103};
104
105export type setDirUserOverwriteResponse400 = {
106 data: EnvelopeNoData;
107 status: 400;
108};
109
110export type setDirUserOverwriteResponse401 = {
111 data: EnvelopeNoData;
112 status: 401;
113};
114
115export type setDirUserOverwriteResponse403 = {
116 data: EnvelopeNoData;
117 status: 403;
118};
119
120export type setDirUserOverwriteResponse404 = {
121 data: EnvelopeNoData;
122 status: 404;
123};
124
125export type setDirUserOverwriteResponseSuccess =
126 setDirUserOverwriteResponse200 & {
127 headers: Headers;
128 };
129export type setDirUserOverwriteResponseError = (
130 | setDirUserOverwriteResponse400
131 | setDirUserOverwriteResponse401
132 | setDirUserOverwriteResponse403
133 | setDirUserOverwriteResponse404
134) & {
135 headers: Headers;
136};
137
138export type setDirUserOverwriteResponse =
139 setDirUserOverwriteResponseSuccess | setDirUserOverwriteResponseError;
140
141export const getSetDirUserOverwriteUrl = (
142 spaceName: string,
143 dirId: string,
144 userId: number,
145) => {
146 return `/api/v1/spaces/${spaceName}/dir/${dirId}/overwrites/users/${userId}`;
147};
148
149/**
150 * 设置目录节点针对用户的允许与拒绝权限位。
151 */
152export const setDirUserOverwrite = async (
153 spaceName: string,
154 dirId: string,
155 userId: number,
156 setOverwriteCmd: SetOverwriteCmd,
157 options?: RequestInit,
158): Promise<setDirUserOverwriteResponse> => {
159 const getHeaders = (
160 h?: NonNullable<RequestInit["headers"]>,
161 ): Record<string, string | readonly string[]> => {
162 if (!h) return {};
163 if (h instanceof Headers) return Object.fromEntries(h.entries());
164 if (Symbol.iterator in h) {
165 return Object.fromEntries(
166 Array.from(
167 h as Iterable<Iterable<string>>,
168 (entry) => Array.from(entry) as [string, string],
169 ),
170 );
171 }
172 const headers: Record<string, string | readonly string[]> = {};
173 for (const [name, value] of Object.entries<
174 string | readonly string[] | undefined
175 >(h)) {
176 if (value !== undefined) headers[name] = value;
177 }
178 return headers;
179 };
180 const res = await fetch(getSetDirUserOverwriteUrl(spaceName, dirId, userId), {
181 ...options,
182 method: "PUT",
183 headers: {
184 "Content-Type": "application/json",
185 ...getHeaders(options?.headers),
186 },
187 body: JSON.stringify(setOverwriteCmd),
188 });
189
190 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
191
192 const data: setDirUserOverwriteResponse["data"] = body
193 ? JSON.parse(body)
194 : {};
195 return {
196 data,
197 status: res.status,
198 headers: res.headers,
199 } as setDirUserOverwriteResponse;
200};
Deleted packages/apiclient/space/update-dir-node.schemas.ts
@@ -1,168 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94export type DirKind =
95 "Dir" | "Object" | "IssueBoard" | "GitRepo" | "ChatChannel" | "VoiceChannel";
96export interface DirNodeDto {
97 /** @nullable */
98 archived_at?: string | null;
99 children: DirNodeDto[];
100 /** @nullable */
101 color?: string | null;
102 created_at: string;
103 created_by_id: number;
104 /** @nullable */
105 icon?: string | null;
106 id: string;
107 is_archived: boolean;
108 kind: DirKind;
109 name: string;
110 /** @nullable */
111 parent_dir_id?: string | null;
112 position: number;
113 /** @nullable */
114 purpose?: string | null;
115 /** @nullable */
116 topic?: string | null;
117 updated_at: string;
118}
119
120export type EnvelopeDirNodeDtoData = {
121 /** @nullable */
122 archived_at?: string | null;
123 children: DirNodeDto[];
124 /** @nullable */
125 color?: string | null;
126 created_at: string;
127 created_by_id: number;
128 /** @nullable */
129 icon?: string | null;
130 id: string;
131 is_archived: boolean;
132 kind: DirKind;
133 name: string;
134 /** @nullable */
135 parent_dir_id?: string | null;
136 position: number;
137 /** @nullable */
138 purpose?: string | null;
139 /** @nullable */
140 topic?: string | null;
141 updated_at: string;
142};
143
144export interface EnvelopeDirNodeDto {
145 code: number;
146 data?: EnvelopeDirNodeDtoData;
147 /** @nullable */
148 error?: string | null;
149 /** @nullable */
150 error_code?: string | null;
151 message: string;
152 request_id?: string;
153}
154
155export interface EnvelopeNoData {
156 code: number;
157 data?: unknown;
158 /** @nullable */
159 error?: string | null;
160 /** @nullable */
161 error_code?: string | null;
162 message: string;
163 request_id?: string;
164}
165
166export interface UpdateDirCmd {
167 name: string;
168}
Deleted packages/apiclient/space/update-dir-node.ts
@@ -1,192 +0,0 @@
1/**
2 * Generated by orval 🍺
3 * Do not edit manually.
4 * gitdata api
5 *
6 * ## Conventions
7 *
8 * ### Response envelope
9 * Every JSON response uses the same envelope:
10 *
11 * | field | meaning |
12 * |---|---|
13 * | `code` | always equal to the HTTP status code |
14 * | `message` | human-readable result / error summary |
15 * | `data` | payload, `null` for errors and empty results |
16 * | `error` | field-level / technical detail (e.g. validation errors), may be `null` |
17 * | `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
18 * | `request_id` | correlation id, also returned as the `X-Request-Id` header |
19 *
20 * Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
21 * - `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
22 *
23 * ### Authentication
24 * - `session_cookie`: opaque session carried in the `session` cookie and stored in
25 * Redis. `GET /auth/login` redirects to the same-origin login page;
26 * `POST /auth/logout` revokes the session and clears the cookie.
27 *
28 * ### URL & parameter style
29 * - Paths are `kebab-case`, plural nouns, nesting expresses ownership.
30 * - Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
31 * - All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` is guarded
32 * by the `x-gitdata-service-key` header and is NOT documented here.
33 * - State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
34 * idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
35 * partial updates use `PATCH`.
36 *
37 * ### Pagination
38 * - 唯一 offset 风格:`?limit=&offset=`(`limit` 默认 50;`offset` 上限 5000),
39 * 分页响应的 `data` 恒为 `{ items, total, limit, offset }`,字段均为无符号整数。
40 * - `limit` 为 0、超过端点上限或 `offset` 超过 5000 时返回 400
41 * (`error_code: bad_request`),不会静默截断。
42 * - 排序参数为 `?sort=<field>&order=asc|desc`;`sort` 的取值按端点枚举校验,
43 * 未知值返回 400,未知参数同样返回 400。
44 * - chat 消息额外接受 `before_seq` / `after_seq` 作为筛选条件,响应仍是同一 `Page` 形状。
45 * - Git LFS 锁定列表的 `next_cursor` 是 LFS 协议字段,不适用本约定。
46 *
47 * ### PATCH semantics (three-state fields)
48 * Update commands use `Option<Option<T>>` for clearable fields:
49 * - field **absent** → unchanged
50 * - field `null` → cleared
51 * - field value → set
52 *
53 * Example: `{"milestone_id": null}` unassigns the milestone; omitting
54 * `milestone_id` leaves it untouched.
55 *
56 * ### 2FA
57 * TOTP enrollment, recovery codes and sensitive-operation verification are
58 * handled by identity; GitData only proxies the account-management endpoints.
59 *
60 * ### Operations
61 * - `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
62 * - `GET /readyz` — readiness probe: database (primary) + redis live checks,
63 * rocketmq + s3 sampled every 10s, `503` when any is down.
64 * - Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 * there is no scrape endpoint.
66 * - Cross-origin browser access is disabled by default; the API expects a
67 * same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 * other origins.
69 *
70 * ### Abuse controls
71 * - Creation endpoints are subject to per-user rolling 24h quotas
72 * (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 * with `error_code: "rate_limited"`.
74 *
75 * ### Idempotency
76 * - `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 * retrying with the same nonce returns the original message instead of a duplicate.
78 *
79 * ### Resource references
80 * - User references in responses embed a `user` object (`UserDto`) wherever a
81 * single person is shown; raw uuid fields are named `*_id` consistently.
82 * - Viewer-specific state is embedded, not fetched separately: `RepoDto.me`
83 * carries `starred`/`watch_mode`, `IssueDto.viewer_watch` carries the caller
84 * watch mode. `PUT /repos/{space}/{repo}/star|unstar` style reads do not exist.
85 *
86 * ### Identifiers
87 * - Issues are addressable by UUID **or** board-scoped number
88 * (e.g. `/issues/{space}/boards/{board}/issues/123`).
89 * - Binary uploads use raw request bodies with a content-type header
90 * (e.g. `PUT /me/avatar` accepts `image/*` bytes, not base64 JSON).
91 *
92 * OpenAPI spec version: 1.0
93 */
94import type {
95 EnvelopeDirNodeDto,
96 EnvelopeNoData,
97 UpdateDirCmd,
98} from "./update-dir-node.schemas";
99
100export type updateDirNodeResponse200 = {
101 data: EnvelopeDirNodeDto;
102 status: 200;
103};
104
105export type updateDirNodeResponse400 = {
106 data: EnvelopeNoData;
107 status: 400;
108};
109
110export type updateDirNodeResponse401 = {
111 data: EnvelopeNoData;
112 status: 401;
113};
114
115export type updateDirNodeResponse403 = {
116 data: EnvelopeNoData;
117 status: 403;
118};
119
120export type updateDirNodeResponse404 = {
121 data: EnvelopeNoData;
122 status: 404;
123};
124
125export type updateDirNodeResponseSuccess = updateDirNodeResponse200 & {
126 headers: Headers;
127};
128export type updateDirNodeResponseError = (
129 | updateDirNodeResponse400
130 | updateDirNodeResponse401
131 | updateDirNodeResponse403
132 | updateDirNodeResponse404
133) & {
134 headers: Headers;
135};
136
137export type updateDirNodeResponse =
138 updateDirNodeResponseSuccess | updateDirNodeResponseError;
139
140export const getUpdateDirNodeUrl = (spaceName: string, dirId: string) => {
141 return `/api/v1/spaces/${spaceName}/dir/${dirId}`;
142};
143
144/**
145 * 重命名空间目录节点。
146 */
147export const updateDirNode = async (
148 spaceName: string,
149 dirId: string,
150 updateDirCmd: UpdateDirCmd,
151 options?: RequestInit,
152): Promise<updateDirNodeResponse> => {
153 const getHeaders = (
154 h?: NonNullable<RequestInit["headers"]>,
155 ): Record<string, string | readonly string[]> => {
156 if (!h) return {};
157 if (h instanceof Headers) return Object.fromEntries(h.entries());
158 if (Symbol.iterator in h) {
159 return Object.fromEntries(
160 Array.from(
161 h as Iterable<Iterable<string>>,
162 (entry) => Array.from(entry) as [string, string],
163 ),
164 );
165 }
166 const headers: Record<string, string | readonly string[]> = {};
167 for (const [name, value] of Object.entries<
168 string | readonly string[] | undefined
169 >(h)) {
170 if (value !== undefined) headers[name] = value;
171 }
172 return headers;
173 };
174 const res = await fetch(getUpdateDirNodeUrl(spaceName, dirId), {
175 ...options,
176 method: "PATCH",
177 headers: {
178 "Content-Type": "application/json",
179 ...getHeaders(options?.headers),
180 },
181 body: JSON.stringify(updateDirCmd),
182 });
183
184 const body = [204, 205, 304].includes(res.status) ? null : await res.text();
185
186 const data: updateDirNodeResponse["data"] = body ? JSON.parse(body) : {};
187 return {
188 data,
189 status: res.status,
190 headers: res.headers,
191 } as updateDirNodeResponse;
192};
Modified view/app/src/components/modal.tsx View view/app/src/components/modal.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { X } from 'lucide-react'
2import { IconX as X } from '@tabler/icons-react'
33import { type ReactNode, useEffect, useId, useRef } from 'react'
44
55/**
Modified view/app/src/components/panels.tsx View view/app/src/components/panels.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { Check, Copy } from 'lucide-react'
2import { IconCheck as Check, IconCopy as Copy } from '@tabler/icons-react'
33import { useState } from 'react'
44
55/** Shared empty / error / copy affordances for the repo views. */
Modified view/app/src/components/text-field.tsx View view/app/src/components/text-field.tsx
@@ -1,9 +1,9 @@
1import type { LucideIcon } from 'lucide-react'
1import type { Icon } from '@tabler/icons-react'
22import { forwardRef, type InputHTMLAttributes, type ReactNode, useId } from 'react'
33
44export interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
55 label: string
6 icon?: LucideIcon
6 icon?: Icon
77 /** Right-side element in the label row (link, badge, timer, ...). */
88 aside?: ReactNode
99 /** Trailing interactive element inside the input box (reveal toggle, ...). */
Modified view/app/src/domains/chat/ui/voice/voice-avatar-tile.tsx View view/app/src/domains/chat/ui/voice/voice-avatar-tile.tsx
@@ -18,8 +18,8 @@
1818 useIsSpeaking,
1919 useTrackVolume,
2020} from '@app/vendor/livekit'
21import { IconMicrophoneOff as MicOff } from '@tabler/icons-react'
2122import { Track } from 'livekit-client'
22import { MicOff } from 'lucide-react'
2323import { type CSSProperties, useMemo } from 'react'
2424import { UserAvatar } from '../parts'
2525import { initials } from './voice-format'
Modified view/app/src/domains/chat/ui/voice/voice-call-bar.tsx View view/app/src/domains/chat/ui/voice/voice-call-bar.tsx
@@ -11,17 +11,17 @@
1111import { useVoiceSession, voiceSession } from '@app/services/voice'
1212import { ConnectionQualityIndicator } from '@app/vendor/livekit'
1313import {
14 AlertCircle,
15 GripVertical,
16 Loader2,
17 Mic,
18 MicOff,
19 MonitorUp,
20 PhoneOff,
21 Video,
22 VideoOff,
23 Volume2,
24} from 'lucide-react'
14 IconAlertCircle as AlertCircle,
15 IconGripVertical as GripVertical,
16 IconLoader2 as Loader2,
17 IconMicrophone as Mic,
18 IconMicrophoneOff as MicOff,
19 IconScreenShare as MonitorUp,
20 IconPhoneOff as PhoneOff,
21 IconVideo as Video,
22 IconVideoOff as VideoOff,
23 IconVolume2 as Volume2,
24} from '@tabler/icons-react'
2525import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'
2626import { Link, useLocation } from 'react-router-dom'
2727
Modified view/app/src/domains/chat/ui/voice/voice-danmaku-history.tsx View view/app/src/domains/chat/ui/voice/voice-danmaku-history.tsx
@@ -7,7 +7,7 @@
77import type { ChatMessage } from '@app/domains/chat/model'
88import { useT } from '@app/i18n'
99import { fmtRelative } from '@app/lib/format'
10import { X } from 'lucide-react'
10import { IconX as X } from '@tabler/icons-react'
1111import { useEffect, useRef } from 'react'
1212
1313export interface VoiceDanmakuHistoryProps {
Modified view/app/src/domains/chat/ui/voice/voice-danmaku-input.tsx View view/app/src/domains/chat/ui/voice/voice-danmaku-input.tsx
@@ -7,7 +7,7 @@
77
88import { MAX_MESSAGE_LEN } from '@app/domains/chat/model'
99import { useT } from '@app/i18n'
10import { MessageSquare, Send } from 'lucide-react'
10import { IconMessage as MessageSquare, IconSend as Send } from '@tabler/icons-react'
1111import { type KeyboardEvent as ReactKeyboardEvent, useState } from 'react'
1212
1313export interface VoiceDanmakuInputProps {
Modified view/app/src/domains/chat/ui/voice/voice-device-sheet.tsx View view/app/src/domains/chat/ui/voice/voice-device-sheet.tsx
@@ -14,8 +14,13 @@
1414
1515import { useT } from '@app/i18n'
1616import { useMediaDevices, usePersistentUserChoices, usePreviewTracks, useTrackVolume } from '@app/vendor/livekit'
17import {
18 IconLoader2 as Loader2,
19 IconMicrophone as Mic,
20 IconVideo as Video,
21 IconVolume2 as Volume2,
22} from '@tabler/icons-react'
1723import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
18import { Loader2, Mic, Video, Volume2 } from 'lucide-react'
1924import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
2025import { readOutputDevice, rememberOutputDevice } from './voice-devices'
2126
Modified view/app/src/domains/chat/ui/voice/voice-room-panel.tsx View view/app/src/domains/chat/ui/voice/voice-room-panel.tsx
@@ -27,7 +27,16 @@
2727import { useWsFrames } from '@app/services/realtime'
2828import { useVoiceSession, VoiceProvider, voiceSession } from '@app/services/voice'
2929import { ControlBar, MediaDeviceSelect, usePersistentUserChoices } from '@app/vendor/livekit'
30import { AlertCircle, Hand, Loader2, MessageSquare, PhoneCall, Settings2, ShieldCheck, Volume2 } from 'lucide-react'
30import {
31 IconAlertCircle as AlertCircle,
32 IconHandStop as Hand,
33 IconLoader2 as Loader2,
34 IconMessage as MessageSquare,
35 IconPhoneCall as PhoneCall,
36 IconSettings2 as Settings2,
37 IconShieldCheck as ShieldCheck,
38 IconVolume2 as Volume2,
39} from '@tabler/icons-react'
3140import { useCallback, useEffect, useMemo, useState } from 'react'
3241import { joinVoiceRoom } from '../../model/voice-connection'
3342import { VoiceDanmaku } from './voice-danmaku'
Modified view/app/src/domains/chat/ui/voice/voice-tiles.tsx View view/app/src/domains/chat/ui/voice/voice-tiles.tsx
@@ -36,8 +36,15 @@
3636 useRoomContext,
3737 useTracks,
3838} from '@app/vendor/livekit'
39import {
40 IconLogout as LogOut,
41 IconMaximize as Maximize2,
42 IconMicrophone as Mic,
43 IconMicrophoneOff as MicOff,
44 IconMinimize as Minimize2,
45 IconVolume2 as Volume2,
46} from '@tabler/icons-react'
3947import { type RemoteParticipant, RoomEvent, Track } from 'livekit-client'
40import { LogOut, Maximize2, Mic, MicOff, Minimize2, Volume2 } from 'lucide-react'
4148import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
4249import { VoiceAvatarTile } from './voice-avatar-tile'
4350import { initials } from './voice-format'
Modified view/app/src/domains/chat/ui/chat-composer.tsx View view/app/src/domains/chat/ui/chat-composer.tsx
@@ -24,7 +24,17 @@
2424 textLength,
2525} from '@app/domains/chat/model'
2626import { useT } from '@app/i18n'
27import { AtSign, FileText, Loader2, Paperclip, RefreshCw, Send, Smile, TriangleAlert, X } from 'lucide-react'
27import {
28 IconAt as AtSign,
29 IconFileText as FileText,
30 IconLoader2 as Loader2,
31 IconPaperclip as Paperclip,
32 IconRefresh as RefreshCw,
33 IconSend as Send,
34 IconMoodSmile as Smile,
35 IconAlertTriangle as TriangleAlert,
36 IconX as X,
37} from '@tabler/icons-react'
2838import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2939import type { ComposerState } from '../model/use-composer'
3040import { EmojiPicker } from './emoji-picker'
Modified view/app/src/domains/chat/ui/parts.tsx View view/app/src/domains/chat/ui/parts.tsx
@@ -18,8 +18,13 @@
1818} from '@app/domains/chat/model'
1919import { useT } from '@app/i18n'
2020import { useSpaLinkClick } from '@app/lib'
21import {
22 IconDownload as Download,
23 IconFileText as FileText,
24 IconMaximize as Maximize2,
25 IconX as X,
26} from '@tabler/icons-react'
2127import DOMPurify from 'dompurify'
22import { Download, FileText, Maximize2, X } from 'lucide-react'
2328import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2429
2530const avatarPalette = ['#b77945', '#448675', '#b6638d', '#6871c9', '#b96369', '#4f8a90', '#9272bb']
Modified view/app/src/domains/chat/ui/side-panel.tsx View view/app/src/domains/chat/ui/side-panel.tsx
@@ -26,7 +26,18 @@
2626import { useT } from '@app/i18n'
2727import { useAsync } from '@app/lib'
2828import { sendTypingSignal } from '@app/services/realtime'
29import { Bell, BellOff, Clock, Crosshair, Loader2, Lock, MessageSquare, Pin, Users, X } from 'lucide-react'
29import {
30 IconBell as Bell,
31 IconBellOff as BellOff,
32 IconClock as Clock,
33 IconCrosshair as Crosshair,
34 IconLoader2 as Loader2,
35 IconLock as Lock,
36 IconMessage as MessageSquare,
37 IconPin as Pin,
38 IconUsers as Users,
39 IconX as X,
40} from '@tabler/icons-react'
3041import { useEffect, useMemo, useRef, useState } from 'react'
3142import { useComposer } from '../model/use-composer'
3243import { useRoomStream } from '../model/use-room-stream'
Modified view/app/src/domains/chat/ui/transcript.tsx View view/app/src/domains/chat/ui/transcript.tsx
@@ -28,21 +28,21 @@
2828} from '@app/domains/chat/model'
2929import { useT } from '@app/i18n'
3030import {
31 ArrowDown,
32 Check,
33 Copy,
34 CornerUpLeft,
35 Loader2,
36 MessageSquare,
37 MoreHorizontal,
38 Pencil,
39 Pin,
40 RefreshCw,
41 SmilePlus,
42 Trash2,
43 TriangleAlert,
44 X,
45} from 'lucide-react'
31 IconArrowDown as ArrowDown,
32 IconCheck as Check,
33 IconCopy as Copy,
34 IconCornerUpLeft as CornerUpLeft,
35 IconLoader2 as Loader2,
36 IconMessage as MessageSquare,
37 IconDots as MoreHorizontal,
38 IconPencil as Pencil,
39 IconPin as Pin,
40 IconRefresh as RefreshCw,
41 IconMoodPlus as SmilePlus,
42 IconTrash as Trash2,
43 IconAlertTriangle as TriangleAlert,
44 IconX as X,
45} from '@tabler/icons-react'
4646import { type ReactNode, type UIEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
4747import type { TranscriptState } from '../model/transcript'
4848import { EmojiPicker } from './emoji-picker'
Modified view/app/src/domains/issues/ui/board-filters.tsx View view/app/src/domains/issues/ui/board-filters.tsx
@@ -27,17 +27,17 @@
2727} from '@app/domains/issues/model'
2828import { useT } from '@app/i18n'
2929import {
30 Check,
31 ChevronDown,
32 LayoutGrid,
33 List,
34 MoreHorizontal,
35 Plus,
36 Search,
37 Settings2,
38 SlidersHorizontal,
39 X,
40} from 'lucide-react'
30 IconCheck as Check,
31 IconChevronDown as ChevronDown,
32 IconLayoutGrid as LayoutGrid,
33 IconList as List,
34 IconDots as MoreHorizontal,
35 IconPlus as Plus,
36 IconSearch as Search,
37 IconSettings2 as Settings2,
38 IconAdjustmentsHorizontal as SlidersHorizontal,
39 IconX as X,
40} from '@tabler/icons-react'
4141import { useEffect, useRef, useState } from 'react'
4242import { FilterMenu } from './parts'
4343
Modified view/app/src/domains/issues/ui/board-manage-modal.tsx View view/app/src/domains/issues/ui/board-manage-modal.tsx
@@ -33,7 +33,7 @@
3333import { useT } from '@app/i18n'
3434import { ApiFailure } from '@app/lib'
3535import { Button } from '@app/ui'
36import { ChevronRight, Loader2 } from 'lucide-react'
36import { IconChevronRight as ChevronRight, IconLoader2 as Loader2 } from '@tabler/icons-react'
3737import { useEffect, useState } from 'react'
3838import type { BoardData } from '../model/use-board-data'
3939import { Dialog, smallInputClass } from './dialog'
Modified view/app/src/domains/issues/ui/dialog.tsx View view/app/src/domains/issues/ui/dialog.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { X } from 'lucide-react'
2import { IconX as X } from '@tabler/icons-react'
33import { type ReactNode, useEffect, useId, useRef } from 'react'
44
55/**
Modified view/app/src/domains/issues/ui/issue-board.tsx View view/app/src/domains/issues/ui/issue-board.tsx
@@ -8,7 +8,15 @@
88 * the request state, never from placeholder data.
99 */
1010
11import { ArrowRight, Circle, CircleCheck, Inbox, Loader2, Milestone as MilestoneIcon, Plus } from 'lucide-react'
11import {
12 IconArrowRight as ArrowRight,
13 IconCircle as Circle,
14 IconCircleCheck as CircleCheck,
15 IconInbox as Inbox,
16 IconLoader2 as Loader2,
17 IconFlag as MilestoneIcon,
18 IconPlus as Plus,
19} from '@tabler/icons-react'
1220import { useState } from 'react'
1321import './board-polish.css'
1422import { ErrorPanel, Markdown } from '@app/components'
Modified view/app/src/domains/issues/ui/issue-detail.tsx View view/app/src/domains/issues/ui/issue-detail.tsx
@@ -5,7 +5,6 @@
55 * behind it keeps its filters.
66 */
77
8import { useResourceEvents } from '@app/services/realtime/topic'
98import { ErrorPanel, Markdown } from '@app/components'
109import {
1110 type BoardRef,
@@ -40,25 +39,26 @@
4039import { listSpaceRepos } from '@app/domains/repo/model'
4140import { parseDateValue, t as tr, useT } from '@app/i18n'
4241import { fmtTime, formatBytes, issueKey, spaceReposPickKey, useAsync } from '@app/lib'
42import { useResourceEvents } from '@app/services/realtime/topic'
4343import {
44 ArrowLeft,
45 ArrowUp,
46 Bell,
47 Calendar,
48 Check,
49 ChevronDown,
50 FileText,
51 Flag,
52 FolderGit2,
53 GitPullRequest,
54 Loader2,
55 Milestone as MilestoneIcon,
56 MoreHorizontal,
57 Pencil,
58 Plus,
59 Users,
60 X,
61} from 'lucide-react'
44 IconArrowLeft as ArrowLeft,
45 IconArrowUp as ArrowUp,
46 IconBell as Bell,
47 IconCalendar as Calendar,
48 IconCheck as Check,
49 IconChevronDown as ChevronDown,
50 IconFileText as FileText,
51 IconFlag as Flag,
52 IconFolderCode as FolderGit2,
53 IconGitPullRequest as GitPullRequest,
54 IconLoader2 as Loader2,
55 IconFlag as MilestoneIcon,
56 IconDots as MoreHorizontal,
57 IconPencil as Pencil,
58 IconPlus as Plus,
59 IconUsers as Users,
60 IconX as X,
61} from '@tabler/icons-react'
6262import { type ReactNode, useEffect, useRef, useState } from 'react'
6363import type { BoardData } from '../model/use-board-data'
6464import { AvatarStack, DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
Modified view/app/src/domains/issues/ui/parts.tsx View view/app/src/domains/issues/ui/parts.tsx
@@ -8,7 +8,7 @@
88import type { IssueLabel, IssuePriority, IssueState, UserDto } from '@app/domains/issues/model'
99import { describeDue, normalizeColor, priorityOf, readableText, STATES } from '@app/domains/issues/model'
1010import { useT } from '@app/i18n'
11import { Check, ChevronDown } from 'lucide-react'
11import { IconCheck as Check, IconChevronDown as ChevronDown } from '@tabler/icons-react'
1212import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
1313
1414export function PriorityBadge({ priority, compact = false }: { priority: IssuePriority; compact?: boolean }) {
Modified view/app/src/domains/notifications/ui/meta.ts View view/app/src/domains/notifications/ui/meta.ts
@@ -1,6 +1,13 @@
11import { t } from '@app/i18n'
22import type { NotificationType } from '@app/services/api'
3import { AtSign, Bell, Flag, MessageSquare, UserPlus, Users } from 'lucide-react'
3import {
4 IconAt as AtSign,
5 IconBell as Bell,
6 IconFlag as Flag,
7 IconMessage as MessageSquare,
8 IconUserPlus as UserPlus,
9 IconUsers as Users,
10} from '@tabler/icons-react'
411
512export const NOTIFY_META: Record<NotificationType, { label: string; icon: typeof AtSign }> = {
613 mention: {
Modified view/app/src/domains/notifications/ui/notify-nav-item.tsx View view/app/src/domains/notifications/ui/notify-nav-item.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { Bell } from 'lucide-react'
2import { IconBell as Bell } from '@tabler/icons-react'
33import { Link, useLocation } from 'react-router-dom'
44import { useUnreadNotificationCount } from '../model/use-unread-count'
55
Modified view/app/src/domains/notifications/ui/notify-toaster.tsx View view/app/src/domains/notifications/ui/notify-toaster.tsx
@@ -1,6 +1,6 @@
11import { useT } from '@app/i18n'
22import { fmtRelative } from '@app/lib'
3import { X } from 'lucide-react'
3import { IconX as X } from '@tabler/icons-react'
44import { useEffect, useSyncExternalStore } from 'react'
55import { useNavigate } from 'react-router-dom'
66import { dismissToast, getToasts, type NotifyToast, subscribeToasts } from '../model/live'
Modified view/app/src/domains/notifications/ui/notify-view.tsx View view/app/src/domains/notifications/ui/notify-view.tsx
@@ -1,9 +1,15 @@
11import { Alert } from '@app/components'
22import { EmptyNote, errText, SectionTitle, Busy as Spinner } from '@app/domains/shared'
3import { useResourceEvents } from '@app/services/realtime/topic'
43import { useT } from '@app/i18n'
54import { ApiFailure, fmtRelative, notifyKey, useAsync } from '@app/lib'
6import { ArrowUpRight, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react'
5import { useResourceEvents } from '@app/services/realtime/topic'
6import {
7 IconArrowUpRight as ArrowUpRight,
8 IconCheck as Check,
9 IconChecks as CheckCheck,
10 IconLoader2 as Loader2,
11 IconTrash as Trash2,
12} from '@tabler/icons-react'
713import { useCallback, useEffect, useState } from 'react'
814import { useNavigate } from 'react-router-dom'
915import {
Modified view/app/src/domains/pulls/ui/conversation.tsx View view/app/src/domains/pulls/ui/conversation.tsx
@@ -25,7 +25,17 @@
2525import { type MessageKey, useT } from '@app/i18n'
2626import { fmtRelative, fmtTime, useAsync } from '@app/lib'
2727import { Button } from '@app/ui'
28import { Check, CheckCheck, Lock, MessageSquare, Pencil, Reply, RotateCcw, Send, Trash2 } from 'lucide-react'
28import {
29 IconCheck as Check,
30 IconChecks as CheckCheck,
31 IconLock as Lock,
32 IconMessage as MessageSquare,
33 IconPencil as Pencil,
34 IconArrowBackUp as Reply,
35 IconRotate as RotateCcw,
36 IconSend as Send,
37 IconTrash as Trash2,
38} from '@tabler/icons-react'
2939import { type ReactNode, useMemo, useState } from 'react'
3040import { eventLabel, REVIEW_STATE_LABEL, REVIEW_STATE_TONE, TONE, userName } from '../model/meta'
3141import { Avatar, Copyable, SideSection } from './parts'
Modified view/app/src/domains/pulls/ui/parts.tsx View view/app/src/domains/pulls/ui/parts.tsx
@@ -5,7 +5,13 @@
55import type { MessageKey } from '@app/i18n'
66import { t as tr, useT } from '@app/i18n'
77import { shortSha } from '@app/lib'
8import { ChevronDown, GitMerge, MessageSquareText, Search, Tag as TagIcon } from 'lucide-react'
8import {
9 IconChevronDown as ChevronDown,
10 IconGitMerge as GitMerge,
11 IconMessage as MessageSquareText,
12 IconSearch as Search,
13 IconTag as TagIcon,
14} from '@tabler/icons-react'
915import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
1016import { type AnchoredComment, anchorKey, type InlineAnchor, PULL_STATE_LABEL, TONE, userName } from '../model/meta'
1117
Modified view/app/src/domains/pulls/ui/pull-detail.tsx View view/app/src/domains/pulls/ui/pull-detail.tsx
@@ -39,18 +39,18 @@
3939import { fmtRelative, fmtTime, repoKey, useAsync } from '@app/lib'
4040import { Button } from '@app/ui'
4141import {
42 Download,
43 FileCode2,
44 GitMerge,
45 ListChecks,
46 Loader2,
47 MessagesSquare,
48 Pencil,
49 RotateCcw,
50 ShieldCheck,
51 Unlock,
52 X,
53} from 'lucide-react'
42 IconDownload as Download,
43 IconFileCode2 as FileCode2,
44 IconGitMerge as GitMerge,
45 IconListCheck as ListChecks,
46 IconLoader2 as Loader2,
47 IconMessages as MessagesSquare,
48 IconPencil as Pencil,
49 IconRotate as RotateCcw,
50 IconShieldCheck as ShieldCheck,
51 IconLockOpen as Unlock,
52 IconX as X,
53} from '@tabler/icons-react'
5454import { useState } from 'react'
5555import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
5656import { ChecksTab } from './checks-tab'
Modified view/app/src/domains/pulls/ui/pull-form-modal.tsx View view/app/src/domains/pulls/ui/pull-form-modal.tsx
@@ -4,7 +4,7 @@
44import { listBranches } from '@app/domains/repo/model'
55import { useT } from '@app/i18n'
66import { Button } from '@app/ui'
7import { AlertCircle, CheckCircle2 } from 'lucide-react'
7import { IconAlertCircle as AlertCircle, IconCircleCheck as CheckCircle2 } from '@tabler/icons-react'
88import { useEffect, useState } from 'react'
99
1010export interface PullFormModalProps {
Modified view/app/src/domains/pulls/ui/pulls-tab.tsx View view/app/src/domains/pulls/ui/pulls-tab.tsx
@@ -4,7 +4,7 @@
44import { useT } from '@app/i18n'
55import { fmtRelative, repoKey, useAsync } from '@app/lib'
66import { Button } from '@app/ui'
7import { Inbox, Plus, Search } from 'lucide-react'
7import { IconInbox as Inbox, IconPlus as Plus, IconSearch as Search } from '@tabler/icons-react'
88import { useMemo, useState } from 'react'
99import { PullFilter, type PullFilterValue } from '../model/filters'
1010import { userName } from '../model/meta'
Modified view/app/src/domains/repo/branches/ui/submodules-section.tsx View view/app/src/domains/repo/branches/ui/submodules-section.tsx
@@ -3,7 +3,7 @@
33import { useT } from '@app/i18n'
44import { shortSha, useAsync } from '@app/lib'
55import { Button } from '@app/ui'
6import { Boxes } from 'lucide-react'
6import { IconBoxMultiple as Boxes } from '@tabler/icons-react'
77import { useState } from 'react'
88
99/** Read-only `.gitmodules` listing at the current revision. */
Modified view/app/src/domains/repo/browse/ui/code-tab.tsx View view/app/src/domains/repo/browse/ui/code-tab.tsx
@@ -14,7 +14,16 @@
1414import { InitialAvatar } from '@app/domains/shared'
1515import { useT } from '@app/i18n'
1616import { fmtRelative, fmtTime, formatBytes, repoKey, shortSha, useAsync } from '@app/lib'
17import { ArrowLeft, ArrowUp, ChevronRight, Eye, FileCode, FolderGit2, History, Users } from 'lucide-react'
17import {
18 IconArrowLeft as ArrowLeft,
19 IconArrowUp as ArrowUp,
20 IconChevronRight as ChevronRight,
21 IconEye as Eye,
22 IconFileCode as FileCode,
23 IconFolderCode as FolderGit2,
24 IconHistory as History,
25 IconUsers as Users,
26} from '@tabler/icons-react'
1827import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
1928import { isMarkdownPath, isPdfPath } from '../model/blob-kind'
2029import { prepareMarkdownHtml, prepareReadmeHtml, resolveRepoPath, statRepoPath } from '../model/readme'
Modified view/app/src/domains/repo/browse/ui/pdf-viewer.tsx View view/app/src/domains/repo/browse/ui/pdf-viewer.tsx
@@ -1,6 +1,11 @@
11import { htmlFragmentUrl } from '@app/domains/repo/model'
22import { useT } from '@app/i18n'
3import { ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
3import {
4 IconChevronLeft as ChevronLeft,
5 IconChevronRight as ChevronRight,
6 IconMinus as Minus,
7 IconPlus as Plus,
8} from '@tabler/icons-react'
49import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
510import { fitScale, type PdfPage, readPdfStream } from '../model/pdf-stream'
611
Modified view/app/src/domains/repo/history/ui/commit-insights.tsx View view/app/src/domains/repo/history/ui/commit-insights.tsx
@@ -8,7 +8,7 @@
88} from '@app/domains/repo/model'
99import { useT } from '@app/i18n'
1010import { Button } from '@app/ui'
11import { Trash2 } from 'lucide-react'
11import { IconTrash as Trash2 } from '@tabler/icons-react'
1212import { useState } from 'react'
1313
1414/** 行内评论卡片:钉在差异行下方,带作者、过期标记与解决/删除操作。 */
Modified view/app/src/domains/repo/releases/ui/releases-tab.tsx View view/app/src/domains/repo/releases/ui/releases-tab.tsx
@@ -20,7 +20,13 @@
2020import { useT } from '@app/i18n'
2121import { fmtRelative, useAsync } from '@app/lib'
2222import { Button } from '@app/ui'
23import { Loader2, Pencil, Plus, Tag, Trash2 } from 'lucide-react'
23import {
24 IconLoader2 as Loader2,
25 IconPencil as Pencil,
26 IconPlus as Plus,
27 IconTag as Tag,
28 IconTrash as Trash2,
29} from '@tabler/icons-react'
2430import { useMemo, useState } from 'react'
2531
2632export interface ReleasesTabProps {
Modified view/app/src/domains/repo/settings/ui/sections/features-settings.tsx View view/app/src/domains/repo/settings/ui/sections/features-settings.tsx
@@ -1,7 +1,7 @@
11import { type RepoDetail, updateRepo } from '@app/domains/repo/model'
22import { type MessageKey, useT } from '@app/i18n'
33import { Button } from '@app/ui'
4import { ArrowDown, ArrowUp } from 'lucide-react'
4import { IconArrowDown as ArrowDown, IconArrowUp as ArrowUp } from '@tabler/icons-react'
55import { useState } from 'react'
66import { Notice, SectionHead } from '../parts'
77
Modified view/app/src/domains/repo/settings/ui/sections/topics-settings.tsx View view/app/src/domains/repo/settings/ui/sections/topics-settings.tsx
@@ -2,7 +2,7 @@
22import { useT } from '@app/i18n'
33import { repoKey, useAsync } from '@app/lib'
44import { Button } from '@app/ui'
5import { X } from 'lucide-react'
5import { IconX as X } from '@tabler/icons-react'
66import { useState } from 'react'
77import { inputCls, ListRow, Notice, SectionHead } from '../parts'
88import { useAction } from '../use-action'
Modified view/app/src/domains/session/ui/auth-shell.tsx View view/app/src/domains/session/ui/auth-shell.tsx
@@ -1,7 +1,7 @@
11import { KernelMark } from '@app/components/brand'
22import { useT } from '@app/i18n'
33import { getRegion, toggleTheme, useTheme } from '@app/lib'
4import { Moon, Sun } from 'lucide-react'
4import { IconMoon as Moon, IconSun as Sun } from '@tabler/icons-react'
55import { type ReactNode, useEffect } from 'react'
66import { Link } from 'react-router-dom'
77
Modified view/app/src/domains/settings/ui/user-settings-modal.tsx View view/app/src/domains/settings/ui/user-settings-modal.tsx
@@ -1,6 +1,16 @@
11import { SettingsModalShell, type SettingsSection } from '@app/domains/shared'
22import { useT } from '@app/i18n'
3import { Activity, Bell, Eye, IdCard, MonitorSmartphone, Palette, ShieldCheck, Terminal, UserRound } from 'lucide-react'
3import {
4 IconActivity as Activity,
5 IconBell as Bell,
6 IconEye as Eye,
7 IconId as IdCard,
8 IconDevices as MonitorSmartphone,
9 IconPalette as Palette,
10 IconShieldCheck as ShieldCheck,
11 IconTerminal2 as Terminal,
12 IconUser as UserRound,
13} from '@tabler/icons-react'
414import { useState } from 'react'
515import type { Me } from '../model/user-settings'
616import { AccountSection } from './sections/account-section'
Modified view/app/src/domains/shared/form-modal.tsx View view/app/src/domains/shared/form-modal.tsx
@@ -2,7 +2,7 @@
22import { useT } from '@app/i18n'
33import { ApiFailure } from '@app/lib'
44import { Button, Checkbox } from '@app/ui'
5import { Loader2, X } from 'lucide-react'
5import { IconLoader2 as Loader2, IconX as X } from '@tabler/icons-react'
66import { type FormEvent, type ReactNode, useEffect, useState } from 'react'
77import { parseList } from './form-values'
88
Modified view/app/src/domains/shared/settings-modal.tsx View view/app/src/domains/shared/settings-modal.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { type LucideIcon, SquareArrowOutUpRight, X } from 'lucide-react'
2import { type Icon, IconExternalLink as SquareArrowOutUpRight, IconX as X } from '@tabler/icons-react'
33import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
44import { Link } from 'react-router-dom'
55
@@ -7,7 +7,7 @@
77 id: string
88 label: string
99 /** Row glyph, mirroring the reference settings dialog. */
10 icon?: LucideIcon
10 icon?: Icon
1111 /** Rows sharing a group sit under one small caption. */
1212 group?: string
1313 /** Rows that leave the dialog for a full page instead of switching panes. */
Modified view/app/src/domains/shared/settings-primitives.tsx View view/app/src/domains/shared/settings-primitives.tsx
@@ -7,7 +7,7 @@
77import { Alert } from '@app/components'
88import { useT } from '@app/i18n'
99import { Button, Checkbox } from '@app/ui'
10import { Copy, Loader2 } from 'lucide-react'
10import { IconCopy as Copy, IconLoader2 as Loader2 } from '@tabler/icons-react'
1111import { type ReactNode, useState } from 'react'
1212
1313export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
Modified view/app/src/domains/space/management/model/api.ts View view/app/src/domains/space/management/model/api.ts
@@ -1,8 +1,12 @@
11/**
22 * Space-management API: space mutations behind the header menu (create
3 * invite / repo / board / room / dir, update + leave) plus the personal
4 * privacy dialog's preference endpoints.
3 * invite / repo / board / room, update + leave), the workbench's flat asset
4 * list and the personal privacy dialog's preference endpoints.
55 *
6 * The asset list is assembled from the three owning domains — git repos,
7 * issue boards and chat channels each return their own rows — so this module
8 * is the only place that knows how to merge them.
9 *
610 * `fetchPreferences` / `updatePreferences` intentionally duplicate the
711 * wrappers in features/user-settings: features may not import each other,
812 * and the privacy dialog needs the same two endpoints. Keep both copies
@@ -12,73 +16,82 @@
1216import { settingsKey, unwrapApi } from '@app/lib'
1317import type {
1418 CreateBoardCmd,
15 CreateDirCmd,
1619 CreateInviteCmd,
1720 CreateRepoCmd,
1821 CreateRoomCmd,
1922 CreateSpaceCmd as CreateSpaceCmdSchema,
2023 EnvelopeInviteSummary,
24 EnvelopePageBoardDto,
2125 EnvelopePageMySpaceDto,
26 EnvelopePageRepoSummaryDto,
2227 EnvelopeSpaceDto,
2328 EnvelopeUserPreferencesDto,
24 EnvelopeVecDirNodeDto,
29 EnvelopeVecRoomBriefDto,
2530 UpdatePreferencesCmd,
26 UpdateRoomCmd,
2731 UpdateSpaceCmd,
2832} from '@app/services/api'
2933import Apis from '@app/services/api'
3034import { invalidateCache, writeCache } from '@app/services/storage'
3135
3236export type SpaceDto = NonNullable<EnvelopeSpaceDto['data']>
33export type DirNode = NonNullable<EnvelopeVecDirNodeDto['data']>[number]
3437export type MySpace = NonNullable<EnvelopePageMySpaceDto['data']>['items'][number]
3538export type UserPreferences = NonNullable<EnvelopeUserPreferencesDto['data']>
3639export type CreateSpaceCmd = CreateSpaceCmdSchema
3740
41export type SpaceRepo = NonNullable<EnvelopePageRepoSummaryDto['data']>['items'][number]
42export type SpaceBoard = NonNullable<EnvelopePageBoardDto['data']>['items'][number]
43export type SpaceChannel = NonNullable<EnvelopeVecRoomBriefDto['data']>[number]
44
45/** Flat workbench assets: no folders, no nesting — three lists side by side. */
46export interface SpaceAssets {
47 repos: SpaceRepo[]
48 boards: SpaceBoard[]
49 channels: SpaceChannel[]
50}
51
3852export const createSpace = (cmd: CreateSpaceCmd) => unwrapApi<SpaceDto>(Apis.space.create_space({ data: cmd }))
3953
4054/** Authoritative full SpaceDto — the /me/spaces payload is a slim subset. */
4155export const fetchSpace = (spaceName: string) =>
4256 unwrapApi<SpaceDto>(Apis.space.get_space({ pathParams: { space_name: spaceName } }))
4357
44export const fetchSpaceDir = (spaceName: string) =>
45 unwrapApi<DirNode[]>(Apis.space.get_space_dir_tree({ pathParams: { space_name: spaceName }, params: {} }))
46
47export const createSpaceDir = (spaceName: string, cmd: CreateDirCmd) =>
48 unwrapApi<DirNode>(Apis.space.create_dir_node({ pathParams: { space_name: spaceName }, data: cmd }))
49
5058/**
51 * Move a directory node — folders and the object nodes that stand for a repo or
52 * a board — to another parent (`null` = space root) and position. This is the
53 * only ordering write the tree has, and the sidebar drag-and-drop is the only
54 * caller: `PATCH /spaces/{space}/dir/{dir_id}/position`.
59 * Every object the space owns, straight from its own domain. Repositories and
60 * boards come back paginated (server cap is 100), channels as a plain array.
5561 */
56export const moveSpaceDir = (spaceName: string, dirId: string, cmd: { parentDirId: string | null; position: number }) =>
57 unwrapApi<DirNode>(
58 Apis.space.move_dir_node({
59 pathParams: { space_name: spaceName, dir_id: dirId },
60 data: { parent_dir_id: cmd.parentDirId, position: cmd.position },
62export const fetchSpaceAssets = async (spaceName: string): Promise<SpaceAssets> => {
63 const [repos, boards, channels] = await Promise.all([
64 unwrapApi<NonNullable<EnvelopePageRepoSummaryDto['data']>>(
65 Apis.repos.list_repos({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
66 ),
67 unwrapApi<NonNullable<EnvelopePageBoardDto['data']>>(
68 Apis.issues.list_boards({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
69 ),
70 unwrapApi<SpaceChannel[]>(Apis.chat.list_space_rooms({ pathParams: { space_name: spaceName } })),
71 ])
72 return { repos: repos.items, boards: boards.items, channels }
73}
74
75/** Drag-and-drop ordering: each object writes its own `position`. */
76export const reorderRepo = (spaceName: string, repoName: string, position: number) =>
77 unwrapApi<unknown>(
78 Apis.repos.update_repo({
79 pathParams: { space_name: spaceName, repo_name: repoName },
80 data: { position },
6181 }),
6282 )
6383
64/**
65 * Reorder a channel and/or file it into a folder. `parent_id` is a three-state
66 * field on the backend (`null` = move to the space root); it is added through an
67 * intersection until `bun run gen:api` sees it in a refreshed schema. The
68 * response is the room's own row, so the caller can verify the server actually
69 * applied the parent — an apihost built without it silently ignores the key.
70 */
71export const moveRoom = (roomId: string, cmd: { position: number; parentId: string | null }) =>
72 unwrapApi<{ parent_id?: string | null }>(
73 Apis.chat.update_room({
74 pathParams: { room_id: roomId },
75 data: {
76 position: cmd.position,
77 parent_id: cmd.parentId,
78 } as UpdateRoomCmd & { parent_id?: string | null },
84export const reorderBoard = (spaceName: string, boardId: string, position: number) =>
85 unwrapApi<unknown>(
86 Apis.issues.update_board({
87 pathParams: { space_name: spaceName, board_id: boardId },
88 data: { position },
7989 }),
8090 )
8191
92export const reorderChannel = (roomId: string, position: number) =>
93 unwrapApi<unknown>(Apis.chat.update_room({ pathParams: { room_id: roomId }, data: { position } }))
94
8295export const createInvite = (spaceName: string, cmd: CreateInviteCmd) =>
8396 unwrapApi<NonNullable<EnvelopeInviteSummary['data']>>(
8497 Apis.space.create_invite({ pathParams: { space_name: spaceName }, data: cmd }),
Deleted view/app/src/domains/space/management/ui/space-settings/channel-permissions-section.tsx
@@ -1,394 +0,0 @@
1import { Alert } from '@app/components'
2import { Chip, EmptyNote, Field, fmtDate, inputCls, SectionTitle, SettingRow, useSave } from '@app/domains/shared'
3import { t as tr, useT } from '@app/i18n'
4import { spaceAdminKey, spaceAdminMembersKey, spaceDirKey, useAsync } from '@app/lib'
5import { Button } from '@app/ui'
6import { useState } from 'react'
7import { fetchSpaceDir } from '../../model/api'
8import {
9 findUserByUsername,
10 listDirGroupOverwrites,
11 listDirUserOverwrites,
12 listSpaceGroups,
13 listSpaceMembers,
14 removeDirGroupOverwrite,
15 removeDirUserOverwrite,
16 renameSpaceDir,
17 repairSpaceDirs,
18 setDirGroupOverwrite,
19 setDirUserOverwrite,
20} from '../../model/space-admin'
21import { allDirNodes, PERM, PERM_DIR_VISIBLE, userLabel } from './meta'
22import type { SpaceSectionProps } from './props'
23
24/** Tri-state of one subject on one node: hidden, inherited, or forced visible. */
25type Stance = 'deny' | 'inherit' | 'allow'
26
27const stanceOf = (allow: number, deny: number): Stance =>
28 deny & PERM_DIR_VISIBLE ? 'deny' : allow & PERM_DIR_VISIBLE ? 'allow' : 'inherit'
29
30const STANCE_KEYS = {
31 deny: 'space.channelPermissions.stance.deny',
32 inherit: 'space.channelPermissions.stance.inherit',
33 allow: 'space.channelPermissions.stance.allow',
34} as const
35
36const STANCES: { value: Stance; label: string }[] = (Object.keys(STANCE_KEYS) as Stance[]).map((value) => ({
37 value,
38 get label() {
39 return tr(STANCE_KEYS[value])
40 },
41}))
42
43/**
44 * Per-node visibility overrides. The server stores a single mask bit today
45 * (`PERM_DIR_VISIBLE`), so the editor is a hide / inherit / show switch.
46 */
47export function ChannelPermissionsSection({ spaceName, perms, isOwner }: SpaceSectionProps) {
48 const { t, tc } = useT()
49 const [tick, setTick] = useState(0)
50 const [dirId, setDirId] = useState('')
51 const [revision, setRevision] = useState(0)
52 const { busy, notice, error, run } = useSave()
53 const canRename = perms.has(PERM.channels)
54 const canOverwrite = perms.has(PERM.overwrites)
55 const tree = useAsync(() => fetchSpaceDir(spaceName), [spaceName, tick], {
56 // Same key as the shell's sidebar tree: one payload, two consumers.
57 cacheKey: spaceDirKey(spaceName),
58 })
59 const nodes = allDirNodes(tree.data ?? [])
60 const active = dirId || nodes[0]?.node.id || ''
61 const activeNode = nodes.find((entry) => entry.node.id === active)?.node
62 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName], {
63 cacheKey: spaceAdminKey(spaceName, 'groups'),
64 })
65 // The member list needs manage_members; without it the editor falls back to
66 // naming a user, which `findUserByUsername` resolves on demand. The empty
67 // fallback must not be stored under the shared admin key.
68 const members = useAsync(
69 () =>
70 perms.has(PERM.members)
71 ? listSpaceMembers(spaceName)
72 : Promise.resolve([] as Awaited<ReturnType<typeof listSpaceMembers>>),
73 [spaceName, perms.has(PERM.members)],
74 { cacheKey: perms.has(PERM.members) ? spaceAdminMembersKey(spaceName) : null },
75 )
76 const overwrite = useAsync(
77 async () => {
78 if (!active) return { groups: [], users: [] }
79 const [g, u] = await Promise.all([
80 listDirGroupOverwrites(spaceName, active).catch(() => []),
81 listDirUserOverwrites(spaceName, active).catch(() => []),
82 ])
83 return { groups: g, users: u }
84 },
85 [spaceName, active, revision],
86 { cacheKey: active ? spaceAdminKey(spaceName, `overwrites:${active}`) : null },
87 )
88 const refresh = () => setTick((t) => t + 1)
89
90 const setStance = (kind: 'group' | 'user', id: string | number, stance: Stance) =>
91 void run(async () => {
92 if (!active) return
93 if (stance === 'inherit') {
94 if (kind === 'group') await removeDirGroupOverwrite(spaceName, active, String(id))
95 else await removeDirUserOverwrite(spaceName, active, Number(id))
96 } else {
97 const mask = {
98 allow_mask: stance === 'allow' ? PERM_DIR_VISIBLE : 0,
99 deny_mask: stance === 'deny' ? PERM_DIR_VISIBLE : 0,
100 }
101 if (kind === 'group') await setDirGroupOverwrite(spaceName, active, String(id), mask)
102 else await setDirUserOverwrite(spaceName, active, Number(id), mask)
103 }
104 setRevision((r) => r + 1)
105 }, t('space.channelPermissions.saved'))
106
107 return (
108 <section>
109 <SectionTitle title={t('space.channelPermissions.title')} sub={t('space.channelPermissions.sub2')} />
110 <div className="mb-5 flex flex-wrap items-center gap-2">
111 <select
112 className={`${inputCls} h-9 w-auto max-w-[340px]`}
113 value={active}
114 onChange={(e) => setDirId(e.target.value)}
115 aria-label={t('space.channelPermissions.selectNode')}
116 >
117 {nodes.length === 0 ? <option value="">{t('space.channelPermissions.emptyTree')}</option> : null}
118 {nodes.map(({ node, depth }) => (
119 <option key={node.id} value={node.id}>
120 {`${' '.repeat(depth)}${node.kind === 'Dir' ? '▸ ' : '· '}${node.name}`}
121 </option>
122 ))}
123 </select>
124 {isOwner ? (
125 <Button
126 variant="secondary"
127 className="h-9 text-[12.5px]"
128 disabled={busy}
129 onClick={() =>
130 void run(
131 async () => {
132 const result = await repairSpaceDirs(spaceName)
133 refresh()
134 return result
135 },
136 (result) => t('space.channelPermissions.repaired', result),
137 )
138 }
139 >
140 {t('space.channelPermissions.repair')}
141 </Button>
142 ) : null}
143 </div>
144 {error ? (
145 <div className="mb-3">
146 <Alert tone="error">{error}</Alert>
147 </div>
148 ) : null}
149 {!activeNode ? (
150 <EmptyNote>{t('space.channelPermissions.emptyNote')}</EmptyNote>
151 ) : (
152 <>
153 <div className="settings-card mb-5">
154 <SettingRow
155 title={activeNode.name}
156 meta={
157 <>
158 {activeNode.kind === 'Dir'
159 ? t('space.channelPermissions.folder')
160 : t('space.channelPermissions.entry')}{' '}
161 · {t('space.channelPermissions.position', { position: activeNode.position })} ·{' '}
162 {t('space.roles.createdAt', { date: fmtDate(activeNode.created_at) })}
163 {activeNode.is_archived ? ` · ${t('space.channelPermissions.archived')}` : ''}
164 </>
165 }
166 actions={<Chip>{activeNode.topic ? activeNode.topic : t('space.channelPermissions.noTopic')}</Chip>}
167 />
168 {canRename ? (
169 <RenameField
170 key={activeNode.id}
171 spaceName={spaceName}
172 nodeId={activeNode.id}
173 current={activeNode.name}
174 busy={busy}
175 run={run}
176 onRenamed={refresh}
177 />
178 ) : null}
179 </div>
180
181 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byRole')}</h3>
182 {(groups.data ?? []).length === 0 ? (
183 <EmptyNote>{t('space.roles.emptyShort')}</EmptyNote>
184 ) : (
185 <div className="mb-8 space-y-2">
186 {(groups.data ?? []).map((group) => {
187 const row = overwrite.data?.groups.find((entry) => entry.group_id === group.id)
188 return (
189 <StanceRow
190 key={group.id}
191 title={group.unicode_emoji ? `${group.unicode_emoji} ${group.name}` : group.name}
192 meta={tc('space.roles.permissionCount', group.permissions.length)}
193 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
194 busy={busy || overwrite.loading || !canOverwrite}
195 onChange={(value) => setStance('group', group.id, value)}
196 />
197 )
198 })}
199 </div>
200 )}
201
202 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byMember')}</h3>
203 <div className="space-y-2">
204 {(members.data ?? []).map((member) => {
205 const userId = member.user?.user_id
206 if (!userId) return null
207 const row = overwrite.data?.users.find((entry) => entry.user_id === userId)
208 return (
209 <StanceRow
210 key={userId}
211 title={userLabel(member.user)}
212 meta={member.alias_name ?? t('space.channelPermissions.defaultAlias')}
213 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
214 busy={busy || overwrite.loading || !canOverwrite}
215 onChange={(value) => setStance('user', userId, value)}
216 />
217 )
218 })}
219 </div>
220 {!perms.has(PERM.members) ? (
221 <AddUserOverride
222 spaceName={spaceName}
223 dirId={active}
224 busy={busy}
225 disabled={!canOverwrite || overwrite.loading}
226 current={overwrite.data?.users ?? []}
227 onApplied={() => setRevision((r) => r + 1)}
228 />
229 ) : null}
230 </>
231 )}
232 {notice ? (
233 <div className="mt-5">
234 <Alert tone="success">{notice}</Alert>
235 </div>
236 ) : null}
237 </section>
238 )
239}
240
241/** Keyed by node so switching nodes cannot carry the previous name over. */
242function RenameField({
243 spaceName,
244 nodeId,
245 current,
246 busy,
247 run,
248 onRenamed,
249}: {
250 spaceName: string
251 nodeId: string
252 current: string
253 busy: boolean
254 run: ReturnType<typeof useSave>['run']
255 onRenamed: () => void
256}) {
257 const { t } = useT()
258 const [name, setName] = useState(current)
259 return (
260 <div className="settings-subpanel">
261 <Field label={t('space.channelPermissions.rename')} hint={t('space.channelPermissions.renameHint')}>
262 <div className="flex flex-wrap items-center gap-2">
263 <input className={inputCls} maxLength={64} value={name} onChange={(e) => setName(e.target.value)} />
264 <Button
265 variant="secondary"
266 className="h-9 text-[12.5px]"
267 disabled={busy || !name.trim() || name.trim() === current}
268 onClick={() =>
269 void run(async () => {
270 await renameSpaceDir(spaceName, nodeId, name.trim())
271 onRenamed()
272 }, t('space.channelPermissions.renamed'))
273 }
274 >
275 {t('space.channelPermissions.saveName')}
276 </Button>
277 </div>
278 </Field>
279 </div>
280 )
281}
282
283function StanceRow({
284 title,
285 meta,
286 stance,
287 busy,
288 onChange,
289}: {
290 title: string
291 meta: string
292 stance: Stance
293 busy: boolean
294 onChange: (value: Stance) => void
295}) {
296 const { t } = useT()
297 return (
298 <div className="settings-card">
299 <SettingRow
300 title={title}
301 meta={meta}
302 actions={
303 <span
304 className="settings-segment-group"
305 role="group"
306 aria-label={t('space.channelPermissions.visibility', { title })}
307 >
308 {STANCES.map((option) => (
309 <button
310 key={option.value}
311 type="button"
312 disabled={busy}
313 aria-pressed={stance === option.value}
314 className="settings-segment"
315 onClick={() => option.value !== stance && onChange(option.value)}
316 >
317 {option.label}
318 </button>
319 ))}
320 </span>
321 }
322 />
323 </div>
324 )
325}
326
327/**
328 * Fallback for holders of `manage_overwrites` without `manage_members`: they may
329 * name a user, and the row is written once the username resolves.
330 */
331function AddUserOverride({
332 spaceName,
333 dirId,
334 busy,
335 disabled,
336 current,
337 onApplied,
338}: {
339 spaceName: string
340 dirId: string
341 busy: boolean
342 disabled: boolean
343 current: { user_id: number }[]
344 onApplied: () => void
345}) {
346 const { t } = useT()
347 const [handle, setHandle] = useState('')
348 const { run, busy: rowBusy, notice, error } = useSave()
349 return (
350 <div className="settings-card mt-3">
351 <div className="settings-subpanel is-flat">
352 <Field
353 label={t('space.channelPermissions.addByUsername')}
354 hint={t('space.channelPermissions.addByUsernameHint')}
355 >
356 <div className="flex flex-wrap items-center gap-2">
357 <input className={inputCls} placeholder="ada" value={handle} onChange={(e) => setHandle(e.target.value)} />
358 <Button
359 variant="secondary"
360 className="h-9 text-[12.5px]"
361 disabled={busy || rowBusy || disabled || !handle.trim()}
362 onClick={() =>
363 void run(async () => {
364 const found = await findUserByUsername(handle.trim())
365 if (current.some((row) => row.user_id === found.user_id)) {
366 throw new Error(t('space.channelPermissions.alreadyExceptional'))
367 }
368 await setDirUserOverwrite(spaceName, dirId, found.user_id, {
369 allow_mask: PERM_DIR_VISIBLE,
370 deny_mask: 0,
371 })
372 setHandle('')
373 onApplied()
374 }, t('space.channelPermissions.exceptionAdded'))
375 }
376 >
377 {t('space.channelPermissions.addVisible')}
378 </Button>
379 </div>
380 </Field>
381 {notice ? (
382 <div className="mt-3">
383 <Alert tone="success">{notice}</Alert>
384 </div>
385 ) : null}
386 {error ? (
387 <div className="mt-3">
388 <Alert tone="error">{error}</Alert>
389 </div>
390 ) : null}
391 </div>
392 </div>
393 )
394}
Modified view/app/src/domains/space/management/ui/space-settings/cleanup-section.tsx View view/app/src/domains/space/management/ui/space-settings/cleanup-section.tsx
@@ -1,12 +1,10 @@
11/**
22 * 对象清理(危险区,仅空间所有者)。
33 *
4 * 一张扁平清单覆盖空间里的两棵树:目录节点(目录 / 仓库 / 问题集 / 智能体集)
5 * 与频道(文字 / 语音 / 舞台 …)。每行给出与类型匹配的危险操作:
6 * 仓库可切换公开/私有并归档,频道可切换公开/私有或删除,问题集/智能体集可删除,
7 * 空目录可删除,带可见性覆盖的节点可一键恢复默认。删除一律走各模块自己的清理
8 * 逻辑(见 webapi::cleanup),所以这里只负责把危险讲清楚:每个破坏性动作都要
9 * 在确认框里报出对象路径。
4 * 一张扁平清单覆盖空间里的三类对象:仓库、问题看板与频道。每行给出与类型
5 * 匹配的危险操作:仓库可切换公开/私有并归档,频道可切换公开/私有或删除,
6 * 问题看板可删除。删除一律走各模块自己的清理逻辑(见 webapi::cleanup),
7 * 所以这里只负责把危险讲清楚:每个破坏性动作都要在确认框里报出对象名。
108 */
119
1210import { Alert } from '@app/components'
@@ -17,7 +15,6 @@
1715import { useMemo, useState } from 'react'
1816import {
1917 type CleanupObject,
20 clearCleanupOverwrites,
2118 deleteCleanupObject,
2219 listCleanupObjects,
2320 setCleanupArchived,
@@ -26,8 +23,6 @@
2623import type { SpaceSectionProps } from './props'
2724
2825const KIND_KEYS: Record<string, MessageKey> = {
29 dir: 'space.cleanup.kind.dir',
30 object: 'space.cleanup.kind.object',
3126 repo: 'space.cleanup.kind.repo',
3227 board: 'space.cleanup.kind.board',
3328 text: 'space.cleanup.kind.text',
@@ -42,20 +37,7 @@
4237/** 支持公开/私有的类型:仓库与频道。 */
4338const VISIBILITY_KINDS = new Set(['repo', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread'])
4439/** 可以删除的类型(仓库除外,它只能归档)。 */
45const DELETE_KINDS = new Set([
46 'board',
47 'text',
48 'voice',
49 'stage',
50 'category',
51 'news',
52 'guild_room',
53 'thread',
54 'dir',
55 'object',
56])
57/** 可见性覆盖存放在目录节点上。 */
58const OVERWRITE_KINDS = new Set(['dir', 'object', 'repo', 'board'])
40const DELETE_KINDS = new Set(['board', 'text', 'voice', 'stage', 'category', 'news', 'guild_room', 'thread'])
5941
6042export function CleanupSection({ spaceName, isOwner }: SpaceSectionProps) {
6143 const { t } = useT()
@@ -71,7 +53,7 @@
7153 const list = state.data ?? []
7254 const needle = query.trim().toLowerCase()
7355 if (!needle) return list
74 return list.filter((item) => `${item.name} ${item.path} ${item.kind}`.toLowerCase().includes(needle))
56 return list.filter((item) => `${item.name} ${item.kind}`.toLowerCase().includes(needle))
7557 }, [state.data, query])
7658
7759 if (!isOwner) {
@@ -142,7 +124,6 @@
142124}) {
143125 const { t } = useT()
144126 const rowKey = `${item.kind}:${item.id}`
145 const hasChildren = item.child_count > 0
146127
147128 const toggleVisibility = () =>
148129 onAct(
@@ -165,7 +146,7 @@
165146 )
166147
167148 const remove = () => {
168 if (!window.confirm(t('space.cleanup.confirmDelete', { name: item.path || item.name }))) return
149 if (!window.confirm(t('space.cleanup.confirmDelete', { name: item.name }))) return
169150 onAct(
170151 rowKey,
171152 async () => {
@@ -176,25 +157,7 @@
176157 )
177158 }
178159
179 const clearOverwrites = () => {
180 if (!window.confirm(t('space.cleanup.confirmClearOverwrites', { name: item.path || item.name }))) return
181 onAct(
182 rowKey,
183 async () => {
184 await clearCleanupOverwrites(spaceName, item.kind, item.id)
185 onChanged()
186 },
187 t('space.cleanup.overwritesCleared'),
188 )
189 }
190
191 const meta: string[] = [item.path]
192 if (item.kind === 'dir' || item.kind === 'object') {
193 meta.push(t('space.cleanup.children', { count: item.child_count }))
194 }
195 if (item.overwrite_count > 0) {
196 meta.push(t('space.cleanup.overwrites', { count: item.overwrite_count }))
197 }
160 const meta: string[] = []
198161 if (item.is_archived) meta.push(t('space.cleanup.archived'))
199162
200163 return (
@@ -219,17 +182,11 @@
219182 {item.is_archived ? t('space.cleanup.unarchive') : t('space.cleanup.archive')}
220183 </Button>
221184 ) : null}
222 {OVERWRITE_KINDS.has(item.kind) && item.overwrite_count > 0 ? (
223 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={clearOverwrites}>
224 {t('space.cleanup.clearOverwrites')}
225 </Button>
226 ) : null}
227185 {DELETE_KINDS.has(item.kind) ? (
228186 <Button
229187 variant="ghost"
230188 className="settings-danger-btn h-8 text-[12.5px]"
231 disabled={busy || hasChildren}
232 title={hasChildren ? t('space.cleanup.deleteBlocked') : undefined}
189 disabled={busy}
233190 onClick={remove}
234191 >
235192 {t('common.action.delete')}
Modified view/app/src/domains/space/management/ui/space-settings/join-requests-section.tsx View view/app/src/domains/space/management/ui/space-settings/join-requests-section.tsx
@@ -3,7 +3,7 @@
33import { useT } from '@app/i18n'
44import { spaceAdminKey, useAsync } from '@app/lib'
55import { Button } from '@app/ui'
6import { Loader2 } from 'lucide-react'
6import { IconLoader2 as Loader2 } from '@tabler/icons-react'
77import { useState } from 'react'
88import {
99 approveSpaceJoinRequest,
Modified view/app/src/domains/space/management/ui/space-settings/meta.ts View view/app/src/domains/space/management/ui/space-settings/meta.ts
@@ -4,7 +4,6 @@
44 * helpers: react-refresh wants component-only modules next to JSX.
55 */
66import { type MessageKey, t } from '@app/i18n'
7import type { DirNode } from '../../model/api'
87import type { PublicUser } from '../../model/space-admin'
98
109/** Server-side gating per endpoint group (see `lib/space/src/share/authz.rs`). */
@@ -14,7 +13,6 @@
1413 invites: 'manage_invites',
1514 bans: 'manage_bans',
1615 channels: 'manage_channels',
17 overwrites: 'manage_overwrites',
1816} as const
1917
2018export const MANAGE_PERMS = Object.values(PERM) as string[]
@@ -29,7 +27,6 @@
2927 | 'space.permission.manageInvites'
3028 | 'space.permission.manageBans'
3129 | 'space.permission.manageChannels'
32 | 'space.permission.manageOverwrites'
3330> = {
3431 view_space: 'space.permission.viewSpace',
3532 read: 'space.permission.read',
@@ -39,7 +36,6 @@
3936 manage_invites: 'space.permission.manageInvites',
4037 manage_bans: 'space.permission.manageBans',
4138 manage_channels: 'space.permission.manageChannels',
42 manage_overwrites: 'space.permission.manageOverwrites',
4339}
4440
4541/** Resolved lazily so permission chips follow the active locale. */
@@ -57,7 +53,6 @@
5753 'manage_invites',
5854 'manage_bans',
5955 'manage_channels',
60 'manage_overwrites',
6156] as const
6257
6358export const permissionLabel = (key: string) => (PERMISSION_KEYS[key] ? t(PERMISSION_KEYS[key]) : key)
@@ -180,24 +175,6 @@
180175 },
181176 },
182177] as const
183
184/** Bit 0 of a directory overwrite: the node is visible to that subject. */
185export const PERM_DIR_VISIBLE = 1
186
187/** Indented options for a parent picker: only real folders can hold things. */
188export function parentDirOptions(nodes: DirNode[], prefix = ''): { value: string; label: string }[] {
189 const out: { value: string; label: string }[] = []
190 for (const node of nodes) {
191 if (node.kind === 'Dir') out.push({ value: node.id, label: `${prefix}${node.name}` })
192 out.push(...parentDirOptions(node.children ?? [], `${prefix}${node.name} / `))
193 }
194 return out
195}
196
197/** Every node in the tree, folders and object nodes alike: overwrites attach to both. */
198export function allDirNodes(nodes: DirNode[], depth = 0): { node: DirNode; depth: number }[] {
199 return nodes.flatMap((node) => [{ node, depth }, ...allDirNodes(node.children ?? [], depth + 1)])
200}
201178
202179/** Repo/board/channel slugs follow the space-name grammar. */
203180export const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '')
Modified view/app/src/domains/space/management/ui/space-settings/roles-section.tsx View view/app/src/domains/space/management/ui/space-settings/roles-section.tsx
@@ -3,7 +3,7 @@
33import { useT } from '@app/i18n'
44import { spaceAdminMembersKey, useAsync } from '@app/lib'
55import { Button, Checkbox } from '@app/ui'
6import { ArrowLeft, Plus, Search } from 'lucide-react'
6import { IconArrowLeft as ArrowLeft, IconPlus as Plus, IconSearch as Search } from '@tabler/icons-react'
77import { useState } from 'react'
88import {
99 assignSpaceGroup,
@@ -27,6 +27,8 @@
2727 loading,
2828 selectedId,
2929 creating,
30 showBack = true,
31 showTitle = true,
3032 onSelect,
3133 onCreate,
3234 onBack,
@@ -35,6 +37,8 @@
3537 loading: boolean
3638 selectedId: string | null
3739 creating: boolean
40 showBack?: boolean
41 showTitle?: boolean
3842 onSelect: (id: string) => void
3943 onCreate: () => void
4044 onBack: () => void
@@ -42,11 +46,13 @@
4246 const { t } = useT()
4347 return (
4448 <div className="flex min-h-0 flex-1 flex-col">
45 <p className="settings-nav-title">{t('space.roles.title')}</p>
46 <button type="button" onClick={onBack} className="settings-nav-item">
47 <ArrowLeft className="h-4 w-4 shrink-0" aria-hidden />
48 <span className="truncate">{t('space.roles.back')}</span>
49 </button>
49 {showTitle ? <p className="settings-nav-title">{t('space.roles.title')}</p> : null}
50 {showBack ? (
51 <button type="button" onClick={onBack} className="settings-nav-item">
52 <ArrowLeft className="h-4 w-4 shrink-0" aria-hidden />
53 <span className="truncate">{t('space.roles.back')}</span>
54 </button>
55 ) : null}
5056 <button
5157 type="button"
5258 onClick={onCreate}
Added view/app/src/domains/space/management/ui/space-settings/sections.ts View view/app/src/domains/space/management/ui/space-settings/sections.ts
@@ -0,0 +1,74 @@
1import type { SettingsSection } from '@app/domains/shared'
2import { useT } from '@app/i18n'
3import {
4 IconBan as Ban,
5 IconClipboardList as ClipboardList,
6 IconEraser as Eraser,
7 IconPhoto as Image,
8 IconSettings as Settings,
9 IconShield as Shield,
10 IconTrash as Trash2,
11 IconUserPlus as UserPlus,
12 IconUser as UserRound,
13 IconUsers as Users,
14} from '@tabler/icons-react'
15import type { MySpace } from '../../model/api'
16import { PERM } from './meta'
17
18export function spaceOwnerId(mySpace: MySpace): string | number | undefined {
19 const { space } = mySpace
20 return space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
21}
22
23export function isSpaceOwner(mySpace: MySpace, viewerId?: number): boolean {
24 const owner = spaceOwnerId(mySpace)
25 return Boolean(owner && viewerId && owner === viewerId)
26}
27
28/** Sections shown in the settings page's sidebar, mirroring the server's perms. */
29export function useSpaceSettingsSections(mySpace: MySpace, viewerId?: number): SettingsSection[] {
30 const { t } = useT()
31 const { space, permissions } = mySpace
32 const isOwner = isSpaceOwner(mySpace, viewerId)
33 const perms = new Set(permissions)
34 const communityGroup = t('space.settings.group.community')
35 const membersGroup = t('space.settings.group.members')
36
37 return [
38 { id: 'mine', label: t('space.settings.sections.mine'), icon: UserRound, group: communityGroup },
39 { id: 'general', label: t('space.settings.sections.general'), icon: Settings, group: communityGroup },
40 ...(perms.has(PERM.channels)
41 ? [{ id: 'images', label: t('space.settings.sections.images'), icon: Image, group: communityGroup }]
42 : []),
43 ...(perms.has(PERM.members)
44 ? [
45 {
46 id: 'members',
47 label: t('space.members.title'),
48 icon: Users,
49 group: membersGroup,
50 href: `/app/${encodeURIComponent(space.name)}/members`,
51 },
52 {
53 id: 'join-requests',
54 label: t('space.settings.sections.joinRequests'),
55 icon: UserPlus,
56 group: membersGroup,
57 },
58 ]
59 : []),
60 ...(perms.has(PERM.roles)
61 ? [{ id: 'roles', label: t('space.roles.title'), icon: Shield, group: membersGroup }]
62 : []),
63 ...(perms.has(PERM.roles)
64 ? [{ id: 'join', label: t('space.settings.sections.join'), icon: ClipboardList, group: membersGroup }]
65 : []),
66 ...(perms.has(PERM.bans)
67 ? [{ id: 'bans', label: t('space.settings.sections.bans'), icon: Ban, group: membersGroup }]
68 : []),
69 ...(isOwner
70 ? [{ id: 'cleanup', label: t('space.settings.sections.cleanup'), icon: Eraser, group: membersGroup }]
71 : []),
72 { id: 'danger', label: t('space.settings.danger'), icon: Trash2 },
73 ]
74}
Modified view/app/src/domains/space/management/ui/create-space-modal.tsx View view/app/src/domains/space/management/ui/create-space-modal.tsx
@@ -2,7 +2,7 @@
22import { useT } from '@app/i18n'
33import { ApiFailure } from '@app/lib'
44import { Button, Checkbox } from '@app/ui'
5import { X } from 'lucide-react'
5import { IconX as X } from '@tabler/icons-react'
66import { type FormEvent, useEffect, useState } from 'react'
77import { createSpace } from '../model/api'
88
Modified view/app/src/domains/space/management/ui/privacy-dialog.tsx View view/app/src/domains/space/management/ui/privacy-dialog.tsx
@@ -1,6 +1,6 @@
11import { errText } from '@app/domains/shared'
22import { useT } from '@app/i18n'
3import { Check, X } from 'lucide-react'
3import { IconCheck as Check, IconX as X } from '@tabler/icons-react'
44import { useEffect, useId, useRef, useState } from 'react'
55import { fetchPreferences, type UserPreferences, updatePreferences } from '../model/api'
66
Deleted view/app/src/domains/space/management/ui/space-settings-modal.tsx
@@ -1,220 +0,0 @@
1import { Alert } from '@app/components'
2import { Busy, SettingsModalShell, type SettingsSection } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminKey, useAsync } from '@app/lib'
5import {
6 Ban,
7 ClipboardList,
8 Eraser,
9 Image,
10 KeyRound,
11 Settings,
12 Shield,
13 Trash2,
14 UserPlus,
15 UserRound,
16 Users,
17} from 'lucide-react'
18import { useEffect, useState } from 'react'
19import type { MySpace } from '../model/api'
20import { fetchSpace } from '../model/api'
21import { listSpaceGroups } from '../model/space-admin'
22import { BansSection } from './space-settings/bans-section'
23import { ChannelPermissionsSection } from './space-settings/channel-permissions-section'
24import { CleanupSection } from './space-settings/cleanup-section'
25import { DangerSection } from './space-settings/danger-section'
26import { GeneralSection } from './space-settings/general-section'
27import { ImagesSection } from './space-settings/images-section'
28import { JoinRequestsSection } from './space-settings/join-requests-section'
29import { JoinRulesSection } from './space-settings/join-rules-section'
30import { PERM } from './space-settings/meta'
31import { MyMembershipSection } from './space-settings/my-membership-section'
32import type { SpaceSectionProps } from './space-settings/props'
33import { RolesNav, RolesSection } from './space-settings/roles-section'
34
35export interface SpaceSettingsModalProps {
36 open: boolean
37 onClose: () => void
38 mySpace: MySpace
39 viewerId?: number
40 /** Deep-linked section to select on open (e.g. from a join-request notice). */
41 initialSection?: string
42 /** The space row changed — the rail tile and header need the fresh name/icon. */
43 onSpaceUpdated: () => void
44 /** The space was deleted — the shell reloads the list and leaves the space. */
45 onDeleted: () => void
46}
47
48/**
49 * Community settings, in the same large overlay as the user's own settings.
50 * `GET /spaces/{name}` is authoritative for the editable fields, and every
51 * section is gated by the permission its endpoints actually check on the
52 * server — `manage_roles` alone used to open a form that could only 403.
53 */
54export function SpaceSettingsModal({
55 open,
56 onClose,
57 mySpace,
58 viewerId,
59 initialSection,
60 onSpaceUpdated,
61 onDeleted,
62}: SpaceSettingsModalProps) {
63 const { t } = useT()
64 const [section, setSection] = useState('mine')
65 const [roleId, setRoleId] = useState<string | null>(null)
66 const [roleCreating, setRoleCreating] = useState(false)
67 const [rolesTick, setRolesTick] = useState(0)
68 const { permissions } = mySpace
69 const rolesOpen = section === 'roles'
70 const roleGroups = useAsync(
71 () => (open && rolesOpen ? listSpaceGroups(mySpace.space.name) : Promise.resolve([])),
72 [open, rolesOpen, mySpace.space.name, rolesTick],
73 { cacheKey: open && rolesOpen ? spaceAdminKey(mySpace.space.name, 'groups') : null },
74 )
75 const loaded = useAsync(
76 () => (open ? fetchSpace(mySpace.space.name) : Promise.resolve(null)),
77 [open, mySpace.space.name, mySpace.space.updated_at],
78 { cacheKey: open ? spaceAdminKey(mySpace.space.name, 'settings') : null },
79 )
80 // A cached full row from the previously opened space must not be mistaken
81 // for this one: only a payload naming this space counts as authoritative.
82 const full = loaded.data?.name === mySpace.space.name ? loaded.data : null
83 const space = full ?? mySpace.space
84
85 const ownerUid = space.owner?.user_id ?? (space as unknown as { owner_uid?: string }).owner_uid
86 const isOwner = Boolean(ownerUid && viewerId && ownerUid === viewerId)
87 const perms = new Set(permissions)
88
89 const communityGroup = t('space.settings.group.community')
90 const membersGroup = t('space.settings.group.members')
91 const visible: SettingsSection[] = [
92 { id: 'mine', label: t('space.settings.sections.mine'), icon: UserRound, group: communityGroup },
93 { id: 'general', label: t('space.settings.sections.general'), icon: Settings, group: communityGroup },
94 ...(perms.has(PERM.channels)
95 ? [{ id: 'images', label: t('space.settings.sections.images'), icon: Image, group: communityGroup }]
96 : []),
97 ...(perms.has(PERM.members)
98 ? [
99 {
100 id: 'members',
101 label: t('space.members.title'),
102 icon: Users,
103 group: membersGroup,
104 href: `/app/${encodeURIComponent(mySpace.space.name)}/members`,
105 },
106 {
107 id: 'join-requests',
108 label: t('space.settings.sections.joinRequests'),
109 icon: UserPlus,
110 group: membersGroup,
111 },
112 ]
113 : []),
114 ...(perms.has(PERM.roles)
115 ? [{ id: 'roles', label: t('space.roles.title'), icon: Shield, group: membersGroup }]
116 : []),
117 ...(perms.has(PERM.roles)
118 ? [{ id: 'join', label: t('space.settings.sections.join'), icon: ClipboardList, group: membersGroup }]
119 : []),
120 ...(perms.has(PERM.bans)
121 ? [{ id: 'bans', label: t('space.settings.sections.bans'), icon: Ban, group: membersGroup }]
122 : []),
123 ...(perms.has(PERM.channels) || perms.has(PERM.overwrites)
124 ? [{ id: 'channels', label: t('space.channelPermissions.title'), icon: KeyRound, group: membersGroup }]
125 : []),
126 // 对象清理是危险区里的一员,但比删空间频繁得多:单列一项,且仅所有者可见。
127 ...(isOwner
128 ? [{ id: 'cleanup', label: t('space.settings.sections.cleanup'), icon: Eraser, group: membersGroup }]
129 : []),
130 { id: 'danger', label: t('space.settings.danger'), icon: Trash2 },
131 ]
132
133 // A deep-linked section applies on each open, as long as this space's
134 // permissions actually expose it.
135 const paneSections = visible.filter((item) => !item.href)
136 useEffect(() => {
137 if (!open || !initialSection) return
138 if (paneSections.some((item) => item.id === initialSection)) setSection(initialSection)
139 // eslint-disable-next-line react-hooks/exhaustive-deps
140 }, [open, initialSection, mySpace.space.name])
141
142 // Losing the permission that owned the current section (or opening a
143 // different space) must not leave the content column blank.
144 useEffect(() => {
145 if (!paneSections.some((item) => item.id === section)) setSection(paneSections[0].id)
146 // eslint-disable-next-line react-hooks/exhaustive-deps
147 }, [section, permissions.join(','), mySpace.space.name])
148
149 if (!open) return null
150
151 const props: SpaceSectionProps = {
152 spaceName: mySpace.space.name,
153 space,
154 perms,
155 viewerId,
156 isOwner,
157 onSpaceUpdated,
158 }
159
160 return (
161 <SettingsModalShell
162 key={mySpace.space.name}
163 title={t('space.settings.title')}
164 sections={visible}
165 active={section}
166 contentToken={section === 'roles' ? `roles:${roleId ?? roleGroups.data?.[0]?.id ?? ''}` : section}
167 onSelect={setSection}
168 onClose={onClose}
169 navOverride={
170 rolesOpen ? (
171 <RolesNav
172 groups={roleGroups.data ?? []}
173 loading={roleGroups.loading}
174 selectedId={roleId ?? roleGroups.data?.[0]?.id ?? null}
175 creating={roleCreating}
176 onSelect={(id) => {
177 setRoleCreating(false)
178 setRoleId(id)
179 }}
180 onCreate={() => setRoleCreating(true)}
181 onBack={() => {
182 setRoleCreating(false)
183 setSection(paneSections[0]?.id ?? 'mine')
184 }}
185 />
186 ) : undefined
187 }
188 >
189 {loaded.error && !loaded.data ? (
190 <div className="mb-5">
191 <Alert tone="info">{t('space.settings.fullLoadFailed')}</Alert>
192 </div>
193 ) : null}
194 {section === 'mine' ? <MyMembershipSection {...props} /> : null}
195 {section === 'general' ? full || !loaded.loading ? <GeneralSection {...props} /> : <Busy /> : null}
196 {section === 'images' ? <ImagesSection {...props} /> : null}
197 {section === 'join-requests' ? <JoinRequestsSection {...props} /> : null}
198 {section === 'roles' ? (
199 <RolesSection
200 spaceName={mySpace.space.name}
201 groups={roleGroups.data ?? []}
202 loading={roleGroups.loading}
203 roleId={roleId ?? roleGroups.data?.[0]?.id ?? null}
204 creating={roleCreating}
205 onRefresh={() => setRolesTick((value) => value + 1)}
206 onCreated={(id) => {
207 setRoleCreating(false)
208 setRoleId(id ?? null)
209 setRolesTick((value) => value + 1)
210 }}
211 />
212 ) : null}
213 {section === 'join' ? <JoinRulesSection {...props} /> : null}
214 {section === 'bans' ? <BansSection {...props} /> : null}
215 {section === 'channels' ? <ChannelPermissionsSection {...props} /> : null}
216 {section === 'cleanup' ? <CleanupSection {...props} /> : null}
217 {section === 'danger' ? <DangerSection {...props} onDeleted={onDeleted} /> : null}
218 </SettingsModalShell>
219 )
220}
Added view/app/src/domains/space/management/ui/space-settings-nav.tsx View view/app/src/domains/space/management/ui/space-settings-nav.tsx
@@ -0,0 +1,43 @@
1import { IconExternalLink as SquareArrowOutUpRight } from '@tabler/icons-react'
2import { Fragment } from 'react'
3import { Link } from 'react-router-dom'
4import type { MySpace } from '../model/api'
5import { useSpaceSettingsSections } from './space-settings/sections'
6
7export interface SpaceSettingsNavProps {
8 mySpace: MySpace
9 viewerId?: number
10 /** Active settings section id, read from the settings route. */
11 active?: string
12}
13
14/** Sidebar list for `/app/{space}/settings`: every section the viewer may open. */
15export function SpaceSettingsNav({ mySpace, viewerId, active }: SpaceSettingsNavProps) {
16 const sections = useSpaceSettingsSections(mySpace, viewerId)
17 const base = `/app/${encodeURIComponent(mySpace.space.name)}/settings`
18
19 return (
20 <>
21 {sections.map((item, index) => {
22 const Icon = item.icon
23 const opensGroup = Boolean(item.group) && item.group !== sections[index - 1]?.group
24 return (
25 <Fragment key={item.id}>
26 {opensGroup ? <p className="settings-nav-caption">{item.group}</p> : null}
27 <Link
28 to={item.href ?? `${base}/${item.id}`}
29 aria-current={!item.href && item.id === active ? 'page' : undefined}
30 className={`settings-nav-item ${item.id === 'danger' ? 'settings-nav-danger' : ''}`}
31 >
32 {Icon ? <Icon className="h-4 w-4 shrink-0" aria-hidden /> : null}
33 <span className="truncate">{item.label}</span>
34 {item.href ? (
35 <SquareArrowOutUpRight className="ml-auto h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
36 ) : null}
37 </Link>
38 </Fragment>
39 )
40 })}
41 </>
42 )
43}
Added view/app/src/domains/space/management/ui/space-settings-page.tsx View view/app/src/domains/space/management/ui/space-settings-page.tsx
@@ -0,0 +1,116 @@
1import { Alert } from '@app/components'
2import { Busy } from '@app/domains/shared'
3import { useT } from '@app/i18n'
4import { spaceAdminKey, useAsync } from '@app/lib'
5import { useState } from 'react'
6import { fetchSpace, type MySpace } from '../model/api'
7import { listSpaceGroups } from '../model/space-admin'
8import { BansSection } from './space-settings/bans-section'
9import { CleanupSection } from './space-settings/cleanup-section'
10import { DangerSection } from './space-settings/danger-section'
11import { GeneralSection } from './space-settings/general-section'
12import { ImagesSection } from './space-settings/images-section'
13import { JoinRequestsSection } from './space-settings/join-requests-section'
14import { JoinRulesSection } from './space-settings/join-rules-section'
15import { MyMembershipSection } from './space-settings/my-membership-section'
16import type { SpaceSectionProps } from './space-settings/props'
17import { RolesNav, RolesSection } from './space-settings/roles-section'
18import { isSpaceOwner, useSpaceSettingsSections } from './space-settings/sections'
19
20export interface SpaceSettingsPageProps {
21 mySpace: MySpace
22 viewerId?: number
23 /** Section id from `/app/{space}/settings/{section}`. */
24 section?: string
25 onSpaceUpdated: () => void
26 onDeleted: () => void
27}
28
29/** Page body for the settings route; the section list lives in the sidebar. */
30export function SpaceSettingsPage({ mySpace, viewerId, section, onSpaceUpdated, onDeleted }: SpaceSettingsPageProps) {
31 const { t } = useT()
32 const sections = useSpaceSettingsSections(mySpace, viewerId)
33 const panes = sections.filter((item) => !item.href)
34 const active = panes.find((item) => item.id === section) ?? panes[0]
35 const loaded = useAsync(() => fetchSpace(mySpace.space.name), [mySpace.space.name, mySpace.space.updated_at], {
36 cacheKey: spaceAdminKey(mySpace.space.name, 'settings'),
37 })
38 const full = loaded.data?.name === mySpace.space.name ? loaded.data : null
39 const space = full ?? mySpace.space
40 const props: SpaceSectionProps = {
41 spaceName: mySpace.space.name,
42 space,
43 perms: new Set(mySpace.permissions),
44 viewerId,
45 isOwner: isSpaceOwner(mySpace, viewerId),
46 onSpaceUpdated,
47 }
48
49 return (
50 <div className="settings-pane">
51 <div className="settings-content">
52 {loaded.error && !loaded.data ? (
53 <div className="mb-5">
54 <Alert tone="info">{t('space.settings.fullLoadFailed')}</Alert>
55 </div>
56 ) : null}
57 {active?.id === 'mine' ? <MyMembershipSection {...props} /> : null}
58 {active?.id === 'general' ? full || !loaded.loading ? <GeneralSection {...props} /> : <Busy /> : null}
59 {active?.id === 'images' ? <ImagesSection {...props} /> : null}
60 {active?.id === 'join-requests' ? <JoinRequestsSection {...props} /> : null}
61 {active?.id === 'roles' ? <RolesPane spaceName={mySpace.space.name} /> : null}
62 {active?.id === 'join' ? <JoinRulesSection {...props} /> : null}
63 {active?.id === 'bans' ? <BansSection {...props} /> : null}
64 {active?.id === 'cleanup' ? <CleanupSection {...props} /> : null}
65 {active?.id === 'danger' ? <DangerSection {...props} onDeleted={onDeleted} /> : null}
66 </div>
67 </div>
68 )
69}
70
71function RolesPane({ spaceName }: { spaceName: string }) {
72 const { t } = useT()
73 const [roleId, setRoleId] = useState<string | null>(null)
74 const [creating, setCreating] = useState(false)
75 const [tick, setTick] = useState(0)
76 const groups = useAsync(() => listSpaceGroups(spaceName), [spaceName, tick], {
77 cacheKey: spaceAdminKey(spaceName, 'groups'),
78 })
79 const selectedId = roleId ?? groups.data?.[0]?.id ?? null
80
81 return (
82 <div className="space-settings-roles">
83 <nav className="space-settings-subnav" aria-label={t('space.roles.title')}>
84 <RolesNav
85 groups={groups.data ?? []}
86 loading={groups.loading}
87 selectedId={selectedId}
88 creating={creating}
89 showBack={false}
90 showTitle={false}
91 onSelect={(id) => {
92 setCreating(false)
93 setRoleId(id)
94 }}
95 onCreate={() => setCreating(true)}
96 onBack={() => setCreating(false)}
97 />
98 </nav>
99 <div className="min-w-0 flex-1">
100 <RolesSection
101 spaceName={spaceName}
102 groups={groups.data ?? []}
103 loading={groups.loading}
104 roleId={selectedId}
105 creating={creating}
106 onRefresh={() => setTick((value) => value + 1)}
107 onCreated={(id) => {
108 setCreating(false)
109 setRoleId(id ?? null)
110 setTick((value) => value + 1)
111 }}
112 />
113 </div>
114 </div>
115 )
116}
Modified view/app/src/i18n/messages/en/nav.ts View view/app/src/i18n/messages/en/nav.ts
@@ -2,11 +2,10 @@
22export const nav = {
33 'nav.empty': 'Nothing here yet. Create a channel from the space menu to start collaborating.',
44 'nav.emptyDms': 'No direct messages yet. Start one from a member profile in a space.',
5 'nav.dropHere': 'Drop here: move to root',
65 'nav.savingPosition': 'Saving position…',
76 'nav.moveFailed': 'Move failed. Please try again.',
8 'nav.moveIntoSelf': 'A folder cannot be moved into itself or one of its subfolders.',
9 'nav.unsupportedFolderMove': 'The backend rejected folder placement for channels (update and restart apihost).',
7 'nav.sameKindOnly':
8 'Rows can only be reordered within their own kind (repos, boards and channels sort independently).',
109 'nav.favorite': 'Favorited',
1110 'nav.inCall': '{count} in call',
1211 'nav.unreadCount': '{count} unread',
Modified view/app/src/i18n/messages/en/ui.ts View view/app/src/i18n/messages/en/ui.ts
@@ -5,6 +5,7 @@
55 'ui.closeNavigation': 'Close workspace navigation',
66 'ui.collapseNavigation': 'Collapse navigation',
77 'ui.navigation': 'Workspace navigation',
8 'ui.rail': 'Quick navigation',
89 'ui.spaceList': 'Spaces',
910 'ui.currentSpace': 'Current space',
1011 'ui.personalSpace': 'Personal space',
Modified view/app/src/i18n/messages/zh-CN/nav.ts View view/app/src/i18n/messages/zh-CN/nav.ts
@@ -2,11 +2,9 @@
22export const nav = {
33 'nav.empty': '这里还没有内容。使用空间菜单创建频道,开始一起协作。',
44 'nav.emptyDms': '还没有私聊。从空间的成员资料发起一个私聊吧。',
5 'nav.dropHere': '放到这里:移到根目录',
65 'nav.savingPosition': '正在保存位置…',
76 'nav.moveFailed': '移动失败,请稍后重试。',
8 'nav.moveIntoSelf': '不能把文件夹移动到它自己或它的子文件夹里。',
9 'nav.unsupportedFolderMove': '后端未接受频道的文件夹归属(需更新并重启 apihost)。',
7 'nav.sameKindOnly': '只能在同一类资产内排序(仓库、看板、频道各自独立)。',
108 'nav.favorite': '已收藏',
119 'nav.inCall': '{count} 人在通话',
1210 'nav.unreadCount': '未读数 {count}',
Modified view/app/src/i18n/messages/zh-CN/ui.ts View view/app/src/i18n/messages/zh-CN/ui.ts
@@ -5,6 +5,7 @@
55 'ui.closeNavigation': '收起工作区导航',
66 'ui.collapseNavigation': '收起导航',
77 'ui.navigation': '工作区导航',
8 'ui.rail': '快捷导航',
89 'ui.spaceList': '空间列表',
910 'ui.currentSpace': '当前空间',
1011 'ui.personalSpace': '个人空间',
Modified view/app/src/lib/cache-keys.ts View view/app/src/lib/cache-keys.ts
@@ -24,7 +24,6 @@
2424/* ------------------------------------------------------- space-scoped lists */
2525
2626export const spaceRoomsKey = (space: string) => `space:${space}:rooms`
27export const spaceDirKey = (space: string) => `space:${space}:dir`
2827export const spaceReposKey = (space: string) => `space:${space}:repos`
2928/**
3029 * The full name-sorted repo list behind the repo pickers (link a PR, …).
Modified view/app/src/pages/invite/ui/space-invite-page.tsx View view/app/src/pages/invite/ui/space-invite-page.tsx
@@ -4,7 +4,12 @@
44import { useT } from '@app/i18n'
55import { ApiFailure, meKey, useAsync } from '@app/lib'
66import { Button } from '@app/ui'
7import { ArrowRight, Loader2, MailQuestionMark, TriangleAlert } from 'lucide-react'
7import {
8 IconArrowRight as ArrowRight,
9 IconLoader2 as Loader2,
10 IconMailQuestion as MailQuestionMark,
11 IconAlertTriangle as TriangleAlert,
12} from '@tabler/icons-react'
813import { useEffect, useRef, useState } from 'react'
914import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
1015
Modified view/app/src/pages/workspace/model/route.ts View view/app/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'settings' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId = 'code' | 'commits' | 'pulls' | 'pull' | 'branches' | 'releases' | 'settings'
@@ -11,4 +11,5 @@
1111 roomId?: string
1212 boardId?: string
1313 repoName?: string
14 settingsSection?: string
1415}
Modified view/app/src/pages/workspace/model/workspace-data.ts View view/app/src/pages/workspace/model/workspace-data.ts
@@ -1,16 +1,5 @@
1import { fetchSpaceDir } from '@app/domains/space/management'
21import { applyAccountLocale } from '@app/i18n'
3import {
4 dmsKey,
5 meKey,
6 repoKey,
7 spaceBoardsKey,
8 spaceDirKey,
9 spaceReposKey,
10 spaceRoomsKey,
11 spacesKey,
12 useAsync,
13} from '@app/lib'
2import { dmsKey, meKey, repoKey, spaceBoardsKey, spaceReposKey, spaceRoomsKey, spacesKey, useAsync } from '@app/lib'
143import { setCacheOwner } from '@app/services/storage'
154import { useEffect } from 'react'
165import type { ShellRoute } from './route'
@@ -49,9 +38,6 @@
4938 [spaceName, listsReloadKey],
5039 { cacheKey: scoped ? spaceRoomsKey(spaceName) : null },
5140 )
52 const dirs = useAsync(() => (scoped ? fetchSpaceDir(spaceName) : Promise.resolve([])), [spaceName, listsReloadKey], {
53 cacheKey: scoped ? spaceDirKey(spaceName) : null,
54 })
5541 const dms = useAsync(fetchDms, [], { cacheKey: dmsKey() })
5642 const repos = useAsync(
5743 () => (scoped ? fetchSpaceRepos(spaceName) : Promise.resolve([])),
@@ -90,7 +76,6 @@
9076 me,
9177 spaces,
9278 rooms,
93 dirs,
9479 dms,
9580 repos,
9681 boards,
Modified view/app/src/pages/workspace/model/workspace-route.ts View view/app/src/pages/workspace/model/workspace-route.ts
@@ -30,9 +30,11 @@
3030 ? 'board'
3131 : section === 'members'
3232 ? 'members'
33 : section === 'repo'
34 ? 'repo'
35 : 'chat'
33 : section === 'settings'
34 ? 'settings'
35 : section === 'repo'
36 ? 'repo'
37 : 'chat'
3638 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
3739 // data loading and the rail can treat it like any other scope.
3840 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
@@ -43,5 +45,6 @@
4345 roomId: section === 'chat' ? decode(rawId) : undefined,
4446 boardId: section === 'board' ? decode(rawId) : undefined,
4547 repoName: section === 'repo' ? decode(rawId) : undefined,
48 settingsSection: section === 'settings' ? decode(rawId) : undefined,
4649 }
4750}
Modified view/app/src/pages/workspace/model/workspace.ts View view/app/src/pages/workspace/model/workspace.ts
@@ -11,7 +11,6 @@
1111 EnvelopePageRepoSummaryDto,
1212 EnvelopeSpaceDto,
1313 EnvelopeUserPreferencesDto,
14 EnvelopeVecDirNodeDto,
1514} from '@app/services/api'
1615
1716export type MySpace = NonNullable<EnvelopePageMySpaceDto['data']>['items'][number]
@@ -26,9 +25,6 @@
2625export type Invite = NonNullable<EnvelopeInviteSummary['data']>
2726/** The signed-in user's preference row — the same payload the settings modal edits. */
2827export type UserPreferences = NonNullable<EnvelopeUserPreferencesDto['data']>
29
30/** Directory node of the sidebar asset tree (rooms/repos/boards nested under dirs). */
31export type DirNode = NonNullable<EnvelopeVecDirNodeDto['data']>[number]
3228
3329/** Repo tag, as shown by the repo toolbar's ref chip. */
3430export type TagItem = { name: string }
Modified view/app/src/pages/workspace/ui/repo/clone-menu.tsx View view/app/src/pages/workspace/ui/repo/clone-menu.tsx
@@ -1,6 +1,12 @@
11import { type ArchiveFormat, type CloneTarget, createArchive } from '@app/domains/repo/model'
22import { useT } from '@app/i18n'
3import { Check, ChevronDown, Code, Copy, Loader2 } from 'lucide-react'
3import {
4 IconCheck as Check,
5 IconChevronDown as ChevronDown,
6 IconCode as Code,
7 IconCopy as Copy,
8 IconLoader2 as Loader2,
9} from '@tabler/icons-react'
410import { useEffect, useRef, useState } from 'react'
511
612const ARCHIVE_FORMATS: Array<{ value: ArchiveFormat; label: string }> = [
Modified view/app/src/pages/workspace/ui/repo/ref-picker.tsx View view/app/src/pages/workspace/ui/repo/ref-picker.tsx
@@ -1,7 +1,14 @@
11import { listBranches, listTags } from '@app/domains/repo/model'
22import { useT } from '@app/i18n'
33import { repoKey, shortSha, useAsync } from '@app/lib'
4import { Check, ChevronDown, GitBranch, GitCommitHorizontal, Search, Tag } from 'lucide-react'
4import {
5 IconCheck as Check,
6 IconChevronDown as ChevronDown,
7 IconGitBranch as GitBranch,
8 IconGitCommit as GitCommitHorizontal,
9 IconSearch as Search,
10 IconTag as Tag,
11} from '@tabler/icons-react'
512import { useEffect, useId, useRef, useState } from 'react'
613
714export interface RefPickerProps {
Modified view/app/src/pages/workspace/ui/repo/repo-aside.tsx View view/app/src/pages/workspace/ui/repo/repo-aside.tsx
@@ -12,7 +12,15 @@
1212import { InitialAvatar } from '@app/domains/shared'
1313import { useT } from '@app/i18n'
1414import { fmtRelative, repoKey, shortSha, useAsync } from '@app/lib'
15import { ExternalLink, Eye, GitCommitHorizontal, Scale, Star, Tag, Users } from 'lucide-react'
15import {
16 IconExternalLink as ExternalLink,
17 IconEye as Eye,
18 IconGitCommit as GitCommitHorizontal,
19 IconScale as Scale,
20 IconStar as Star,
21 IconTag as Tag,
22 IconUsers as Users,
23} from '@tabler/icons-react'
1624import { useState } from 'react'
1725
1826export interface RepoAsideProps {
Modified view/app/src/pages/workspace/ui/repo/repo-view.tsx View view/app/src/pages/workspace/ui/repo/repo-view.tsx
@@ -6,10 +6,10 @@
66import { ReleasesTab } from '@app/domains/repo/releases'
77import { SettingsTab } from '@app/domains/repo/settings'
88import type { MySpace } from '@app/domains/space/management'
9import { useResourceEvents } from '@app/services/realtime/topic'
109import { useT } from '@app/i18n'
1110import { repoKey, useAsync } from '@app/lib'
12import { ArchiveRestore } from 'lucide-react'
11import { useResourceEvents } from '@app/services/realtime/topic'
12import { IconArchiveRestore as ArchiveRestore } from '@tabler/icons-react'
1313import { useEffect, useMemo, useRef, useState } from 'react'
1414import { useRepoViewLocation } from '../../model/repo-location'
1515import { RepoLayout } from '../layout/repo-layout'
Modified view/app/src/pages/workspace/ui/asset-menu.tsx View view/app/src/pages/workspace/ui/asset-menu.tsx
@@ -1,6 +1,12 @@
11import { type MessageKey, useT } from '@app/i18n'
22import { spaceBoardsKey, spaceReposKey, spaceRoomsKey, useAsync } from '@app/lib'
3import { ChevronDown, FolderGit2, Hash, Search, SquareKanban } from 'lucide-react'
3import {
4 IconChevronDown as ChevronDown,
5 IconFolderCode as FolderGit2,
6 IconHash as Hash,
7 IconSearch as Search,
8 IconLayoutKanban as SquareKanban,
9} from '@tabler/icons-react'
410import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
511import { useNavigate } from 'react-router-dom'
612import { fetchSpaceBoards, fetchSpaceRepos, fetchSpaceRooms } from '../model/shell-api'
Modified view/app/src/pages/workspace/ui/pending-panel.tsx View view/app/src/pages/workspace/ui/pending-panel.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { ArrowLeft, MessagesSquare } from 'lucide-react'
2import { IconArrowLeft as ArrowLeft, IconMessages as MessagesSquare } from '@tabler/icons-react'
33
44/** Placeholder column shown when no channel / board / repo is selected. */
55export function PendingPanel({ label }: { label: string }) {
Added view/app/src/pages/workspace/ui/settings-view.tsx View view/app/src/pages/workspace/ui/settings-view.tsx
@@ -0,0 +1,48 @@
1import { ShellTopBar } from '@app/blocks'
2import { SpaceSettingsPage, useSpaceSettingsSections } from '@app/domains/space/management'
3import { useT } from '@app/i18n'
4import { IconArrowLeft as ArrowLeft, IconSettings2 as Settings2 } from '@tabler/icons-react'
5import { Link } from 'react-router-dom'
6import type { MySpace } from '../model/workspace'
7
8export interface SettingsViewProps {
9 mySpace: MySpace
10 viewerId?: number
11 section?: string
12 onSpaceUpdated: () => void
13 onDeleted: () => void
14}
15
16/** Route-level settings page: top bar + section content, with the list in the sidebar. */
17export function SettingsView({ mySpace, viewerId, section, onSpaceUpdated, onDeleted }: SettingsViewProps) {
18 const { t } = useT()
19 const sections = useSpaceSettingsSections(mySpace, viewerId)
20 const active = sections.find((item) => !item.href && item.id === section) ?? sections.find((item) => !item.href)
21 const spacePath = `/app/${encodeURIComponent(mySpace.space.name)}`
22
23 return (
24 <>
25 <ShellTopBar
26 icon={<Settings2 className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
27 title={t('space.settings.title')}
28 topic={active?.label}
29 actions={
30 <Link
31 to={spacePath}
32 className="inline-flex items-center gap-1.5 text-sm text-shell-ink-muted transition-colors hover:text-shell-ink"
33 >
34 <ArrowLeft size={15} aria-hidden="true" />
35 {t('space.settings.back')}
36 </Link>
37 }
38 />
39 <SpaceSettingsPage
40 mySpace={mySpace}
41 viewerId={viewerId}
42 section={active?.id}
43 onSpaceUpdated={onSpaceUpdated}
44 onDeleted={onDeleted}
45 />
46 </>
47 )
48}
Modified view/app/src/pages/workspace/ui/user-panel.tsx View view/app/src/pages/workspace/ui/user-panel.tsx
@@ -2,7 +2,16 @@
22import { useT } from '@app/i18n'
33import { useWsUserEvents } from '@app/services/realtime'
44import { useVoiceSession, voiceSession } from '@app/services/voice'
5import { Check, Copy, HeadphoneOff, Headphones, Mic, MicOff, Pencil, Settings } from 'lucide-react'
5import {
6 IconCheck as Check,
7 IconCopy as Copy,
8 IconHeadphonesOff as HeadphoneOff,
9 IconHeadphones as Headphones,
10 IconMicrophone as Mic,
11 IconMicrophoneOff as MicOff,
12 IconPencil as Pencil,
13 IconSettings as Settings,
14} from '@tabler/icons-react'
615import { useEffect, useRef, useState } from 'react'
716import { initial } from '../model/initial'
817import type { Me } from '../model/workspace'
Added view/app/src/pages/workspace/ui/workspace-rail.tsx View view/app/src/pages/workspace/ui/workspace-rail.tsx
@@ -0,0 +1,59 @@
1import { useT } from '@app/i18n'
2import {
3 IconFolderCode as FolderGit2,
4 IconHash as Hash,
5 type Icon,
6 IconSettings2 as Settings2,
7 IconLayoutKanban as SquareKanban,
8 IconUsers as Users,
9} from '@tabler/icons-react'
10import { Link } from 'react-router-dom'
11import { useShellRoute } from '../model/workspace-route'
12
13export interface WorkspaceRailProps {
14 /** Owner/admin only: the space settings page is otherwise unreachable. */
15 canManageSpace: boolean
16}
17
18/**
19 * Space section rail. Each entry switches the sidebar list rather than the
20 * shell itself: repo/chat/issues filter the asset rows, members opens the
21 * member table, and settings opens the space settings page.
22 */
23export function WorkspaceRail({ canManageSpace }: WorkspaceRailProps) {
24 const { t } = useT()
25 const { mode, spaceName } = useShellRoute()
26 const spacePath = `/app/${encodeURIComponent(spaceName)}`
27
28 return (
29 <>
30 <span className="workspace-rail-item is-empty" title={t('shell.rail.start')} aria-hidden="true" />
31 <RailLink to={`${spacePath}/repo`} label={t('shell.nav.repo')} active={mode === 'repo'} icon={FolderGit2} />
32 <RailLink to={spacePath} label={t('shell.nav.chat')} active={mode === 'chat'} icon={Hash} />
33 <RailLink to={`${spacePath}/board`} label={t('shell.nav.board')} active={mode === 'board'} icon={SquareKanban} />
34 <RailLink to={`${spacePath}/members`} label={t('space.members.title')} active={mode === 'members'} icon={Users} />
35 {canManageSpace ? (
36 <RailLink
37 to={`${spacePath}/settings`}
38 label={t('shell.nav.settings')}
39 active={mode === 'settings'}
40 icon={Settings2}
41 />
42 ) : null}
43 </>
44 )
45}
46
47function RailLink({ to, label, active, icon: Icon }: { to: string; label: string; active: boolean; icon: Icon }) {
48 return (
49 <Link
50 to={to}
51 title={label}
52 aria-label={label}
53 aria-current={active ? 'page' : undefined}
54 className="workspace-rail-item"
55 >
56 <Icon size={20} strokeWidth={1.75} aria-hidden="true" />
57 </Link>
58 )
59}
Modified view/app/src/providers/toast-provider.tsx View view/app/src/providers/toast-provider.tsx
@@ -1,4 +1,4 @@
1import { X } from 'lucide-react'
1import { IconX as X } from '@tabler/icons-react'
22import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'
33
44export interface ToastAction {
Modified view/app/src/routing/deferred-routes.tsx View view/app/src/routing/deferred-routes.tsx
@@ -1,5 +1,5 @@
11import { useT } from '@app/i18n'
2import { Loader2 } from 'lucide-react'
2import { IconLoader2 as Loader2 } from '@tabler/icons-react'
33import { lazy, type ReactNode, Suspense } from 'react'
44
55const WorkspaceRouteContent = lazy(() =>
Modified view/app/src/ui/checkbox.tsx View view/app/src/ui/checkbox.tsx
@@ -1,4 +1,4 @@
1import { Check } from 'lucide-react'
1import { IconCheck as Check } from '@tabler/icons-react'
22
33export interface CheckboxProps {
44 checked: boolean
Modified view/app/src/ui/reveal-toggle.tsx View view/app/src/ui/reveal-toggle.tsx
@@ -1,4 +1,4 @@
1import { Eye, EyeOff } from 'lucide-react'
1import { IconEye as Eye, IconEyeOff as EyeOff } from '@tabler/icons-react'
22
33export interface RevealToggleProps {
44 revealed: boolean
Modified view/auth/src/features/theme-toggle/ui/theme-switch.tsx View view/auth/src/features/theme-toggle/ui/theme-switch.tsx
@@ -1,4 +1,4 @@
1import { Moon, Sun } from 'lucide-react'
1import { IconMoon as Moon, IconSun as Sun } from '@tabler/icons-react'
22import { t } from '@/shared/i18n'
33import type { ThemeModel } from '../model/use-theme'
44
Modified view/auth/src/pages/account/ui/account-page.tsx View view/auth/src/pages/account/ui/account-page.tsx
@@ -1,4 +1,4 @@
1import { CircleCheck } from 'lucide-react'
1import { IconCircleCheck as CircleCheck } from '@tabler/icons-react'
22import { useEffect } from 'react'
33import { useNavigate } from 'react-router-dom'
44import { callbackUrl, clearCallbackUrl } from '@/entities/auth-callback'
Modified view/auth/src/pages/forgot-password/ui/forgot-password-page.tsx View view/auth/src/pages/forgot-password/ui/forgot-password-page.tsx
@@ -1,4 +1,4 @@
1import { KeyRound } from 'lucide-react'
1import { IconKey as KeyRound } from '@tabler/icons-react'
22import { ForgotPasswordForm, useRequestPasswordReset } from '@/features/request-password-reset'
33import { t } from '@/shared/i18n'
44import { BackLink, Heading } from '@/shared/ui'
Modified view/auth/src/pages/login-verify/ui/login-verify-page.tsx View view/auth/src/pages/login-verify/ui/login-verify-page.tsx
@@ -1,4 +1,4 @@
1import { ShieldCheck } from 'lucide-react'
1import { IconShieldCheck as ShieldCheck } from '@tabler/icons-react'
22import type { LoginComplete } from '@/entities/session'
33import { useMfaChallenge } from '@/entities/session'
44import { useVerifyMfa, VerifyMfaForm } from '@/features/verify-mfa'
Modified view/auth/src/pages/register-verify/ui/register-verify-page.tsx View view/auth/src/pages/register-verify/ui/register-verify-page.tsx
@@ -1,4 +1,4 @@
1import { ShieldCheck } from 'lucide-react'
1import { IconShieldCheck as ShieldCheck } from '@tabler/icons-react'
22import { RegisterVerifyForm, useRegisterVerify } from '@/features/register-account'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
Modified view/auth/src/shared/ui/atoms/button.tsx View view/auth/src/shared/ui/atoms/button.tsx
@@ -1,4 +1,4 @@
1import { LoaderCircle } from 'lucide-react'
1import { IconLoader2 as LoaderCircle } from '@tabler/icons-react'
22import type { ReactNode } from 'react'
33
44export function Button({
Modified view/auth/src/shared/ui/molecules/alert.tsx View view/auth/src/shared/ui/molecules/alert.tsx
@@ -1,4 +1,4 @@
1import { AlertCircle, Check, Info } from 'lucide-react'
1import { IconAlertCircle as AlertCircle, IconCheck as Check, IconInfoCircle as Info } from '@tabler/icons-react'
22import type { Notice } from '@/shared/lib'
33
44export function Alert({ notice }: { notice?: Notice }) {
Modified view/auth/src/shared/ui/molecules/back-link.tsx View view/auth/src/shared/ui/molecules/back-link.tsx
@@ -1,4 +1,4 @@
1import { ArrowLeft } from 'lucide-react'
1import { IconArrowLeft as ArrowLeft } from '@tabler/icons-react'
22import type { ReactNode } from 'react'
33import { routes } from '@/shared/config'
44import { t } from '@/shared/i18n'
Modified view/auth/src/shared/ui/molecules/birth-date.tsx View view/auth/src/shared/ui/molecules/birth-date.tsx
@@ -1,4 +1,4 @@
1import { ChevronDown } from 'lucide-react'
1import { IconChevronDown as ChevronDown } from '@tabler/icons-react'
22import { t } from '@/shared/i18n'
33
44export interface BirthValue {
Modified view/auth/src/shared/ui/molecules/field.tsx View view/auth/src/shared/ui/molecules/field.tsx
@@ -1,4 +1,12 @@
1import { AlertCircle, Eye, EyeOff, KeyRound, LockKeyhole, Mail, UserRound } from 'lucide-react'
1import {
2 IconAlertCircle as AlertCircle,
3 IconEye as Eye,
4 IconEyeOff as EyeOff,
5 IconKey as KeyRound,
6 IconLock as LockKeyhole,
7 IconMail as Mail,
8 IconUser as UserRound,
9} from '@tabler/icons-react'
210import { useState } from 'react'
311import { t } from '@/shared/i18n'
412
Modified view/auth/src/shared/ui/organisms/agreement.tsx View view/auth/src/shared/ui/organisms/agreement.tsx
@@ -1,4 +1,4 @@
1import { Check } from 'lucide-react'
1import { IconCheck as Check } from '@tabler/icons-react'
22import { t } from '@/shared/i18n'
33import { LegalLink } from './legal-link'
44
Modified view/auth/src/shared/ui/organisms/heading.tsx View view/auth/src/shared/ui/organisms/heading.tsx
@@ -1,4 +1,4 @@
1import type { LucideIcon } from 'lucide-react'
1import type { Icon } from '@tabler/icons-react'
22import type { ReactNode } from 'react'
33
44export function Heading({
@@ -11,7 +11,7 @@
1111 title: string
1212 description?: ReactNode
1313 step?: string
14 icon?: LucideIcon
14 icon?: Icon
1515 success?: boolean
1616}) {
1717 return (
Modified view/public/sw.js View view/public/sw.js
@@ -10,11 +10,11 @@
1010 self.skipWaiting()
1111})
1212
13self.addEventListener('activate', event => {
13self.addEventListener('activate', (event) => {
1414 event.waitUntil(self.clients.claim())
1515})
1616
17self.addEventListener('push', event => {
17self.addEventListener('push', (event) => {
1818 let payload = {}
1919 try {
2020 payload = event.data ? event.data.json() : {}
@@ -38,7 +38,7 @@
3838 )
3939})
4040
41self.addEventListener('notificationclick', event => {
41self.addEventListener('notificationclick', (event) => {
4242 event.notification.close()
4343 const target = new URL(event.notification.data?.url || '/app/@me/notify', self.location.origin)
4444
@@ -63,7 +63,7 @@
6363 )
6464})
6565
66self.addEventListener('pushsubscriptionchange', event => {
66self.addEventListener('pushsubscriptionchange', (event) => {
6767 // 浏览器会不定期轮换订阅(尤其是长期未使用的设备)。不重新注册的话,服务端
6868 // 存着的 endpoint 会失效,推送从此静默丢失。
6969 event.waitUntil(
Modified Cargo.lock View Cargo.lock
@@ -1356,7 +1356,6 @@
13561356 "cache",
13571357 "config 1.0.0",
13581358 "db",
1359 "dir",
13601359 "email",
13611360 "encoding_rs",
13621361 "events",
@@ -2113,21 +2112,6 @@
21132112]
21142113
21152114[[package]]
2116name = "dir"
2117version = "1.0.0"
2118dependencies = [
2119 "events",
2120 "models",
2121 "sea-orm",
2122 "serde",
2123 "serde_json",
2124 "thiserror 2.0.21",
2125 "time 0.3.55",
2126 "utoipa 5.5.0",
2127 "uuid",
2128]
2129
2130[[package]]
21312115name = "dirs"
21322116version = "5.0.1"
21332117source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2782,7 +2766,6 @@
27822766 "cache",
27832767 "config 1.0.0",
27842768 "db",
2785 "dir",
27862769 "flate2",
27872770 "futures",
27882771 "globset",
@@ -3662,7 +3645,6 @@
36623645 "cache",
36633646 "config 1.0.0",
36643647 "db",
3665 "dir",
36663648 "email",
36673649 "events",
36683650 "futures",
@@ -7300,7 +7282,6 @@
73007282 "config 1.0.0",
73017283 "db",
73027284 "deletion",
7303 "dir",
73047285 "envelope",
73057286 "events",
73067287 "i18n",
@@ -8865,7 +8846,6 @@
88658846 "config 1.0.0",
88668847 "db",
88678848 "deletion",
8868 "dir",
88698849 "git",
88708850 "i18n",
88718851 "issues",
Modified Cargo.toml View Cargo.toml
@@ -23,7 +23,6 @@
2323chat = { path = "lib/service/chat" }
2424config = { path = "lib/infra/config" }
2525deletion = { path = "lib/service/deletion" }
26dir = { path = "lib/service/dir" }
2726models = { path = "lib/share/models" }
2827push = { path = "lib/service/push" }
2928storage = { path = "lib/infra/storage" }
Modified package.json View package.json
@@ -20,13 +20,13 @@
2020 "@fontsource-variable/noto-sans-sc": "^5.3.1",
2121 "@livekit/protocol": "1.50.4",
2222 "@noble/hashes": "^2.4.0",
23 "@tabler/icons-react": "^3.50.0",
2324 "clsx": "2.1.1",
2425 "dompurify": "^3.4.17",
2526 "emoji-picker-react": "^4.22.3",
2627 "events": "^3.3.0",
2728 "livekit-client": "2.22.3",
2829 "loglevel": "^1.9.2",
29 "lucide-react": "^1.66.0",
3030 "marked": "^18.1.2",
3131 "react": "^19.3.1",
3232 "react-dom": "^19.3.1",
Modified pnpm-lock.yaml View pnpm-lock.yaml
@@ -26,6 +26,9 @@
2626 '@noble/hashes':
2727 specifier: ^2.4.0
2828 version: 2.4.0
29 '@tabler/icons-react':
30 specifier: ^3.50.0
31 version: 3.50.0(react@19.3.1)
2932 clsx:
3033 specifier: 2.1.1
3134 version: 2.1.1
@@ -44,9 +47,6 @@
4447 loglevel:
4548 specifier: ^1.9.2
4649 version: 1.9.2
47 lucide-react:
48 specifier: ^1.66.0
49 version: 1.66.0(@types/react@19.3.0)(react@19.3.1)
5050 marked:
5151 specifier: ^18.1.2
5252 version: 18.1.2
@@ -628,6 +628,14 @@
628628 '@socket.io/component-emitter@3.1.2':
629629 resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
630630
631 '@tabler/icons-react@3.50.0':
632 resolution: {integrity: sha512-X9XhFbxAmSNimQ71VAd7opfd4z84x1pLrEbBT33VCIEsFs8zWVqdhh5r/Jyi/3iJjQ861kE5Wm9ixiJfS2nwBA==}
633 peerDependencies:
634 react: '>= 16'
635
636 '@tabler/icons@3.50.0':
637 resolution: {integrity: sha512-HflLxyrH1cqC7s7jzQGSjpsTAVIoaf7+/yHjbdM59rJQBMjliibp+LYhDPFxOMcwa1M8DNpOk6ENzQiWn9a1Dg==}
638
631639 '@tailwindcss/node@4.3.3':
632640 resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
633641
@@ -1089,15 +1097,6 @@
10891097 loglevel@1.9.2:
10901098 resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
10911099 engines: {node: '>= 0.6.0'}
1092
1093 lucide-react@1.66.0:
1094 resolution: {integrity: sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw==}
1095 peerDependencies:
1096 '@types/react': '*'
1097 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
1098 peerDependenciesMeta:
1099 '@types/react':
1100 optional: true
11011100
11021101 machina@7.0.1:
11031102 resolution: {integrity: sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==}
@@ -1793,6 +1792,13 @@
17931792
17941793 '@socket.io/component-emitter@3.1.2': {}
17951794
1795 '@tabler/icons-react@3.50.0(react@19.3.1)':
1796 dependencies:
1797 '@tabler/icons': 3.50.0
1798 react: 19.3.1
1799
1800 '@tabler/icons@3.50.0': {}
1801
17961802 '@tailwindcss/node@4.3.3':
17971803 dependencies:
17981804 '@jridgewell/remapping': 2.3.5
@@ -2169,12 +2175,6 @@
21692175 lodash.debounce@4.0.8: {}
21702176
21712177 loglevel@1.9.2: {}
2172
2173 lucide-react@1.66.0(@types/react@19.3.0)(react@19.3.1):
2174 dependencies:
2175 react: 19.3.1
2176 optionalDependencies:
2177 '@types/react': 19.3.0
21782178
21792179 machina@7.0.1: {}
21802180
Modified pnpm-workspace.yaml View pnpm-workspace.yaml
@@ -2,5 +2,3 @@
22# esbuild 的 postinstall 只负责把平台二进制落到 node_modules,是 vite 构建的前置。
33allowBuilds:
44 esbuild: true
5minimumReleaseAgeExclude:
6 - lucide-react@1.66.0