1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18use backbone_auth::middleware::AuthContext;
20#[cfg(feature = "auth")]
21use backbone_auth::AuthMiddleware;
22
23use crate::domain::entity::*;
25use crate::application::service::{FileCommentService, ServiceError};
26
27use crate::presentation::dto::{CreateFileCommentDto, UpdateFileCommentDto, PatchFileCommentDto, FileCommentResponseDto};
29
30use crate::domain::state_machine::{FileCommentState, FileCommentStateMachine, FileCommentTransition};
31
32#[derive(Debug, thiserror::Error)]
34pub enum FileCommentError {
35 #[error("Not found: {0}")]
36 NotFound(String),
37 #[error("Validation error: {0}")]
38 Validation(String),
39 #[error("Database error: {0}")]
40 Database(String),
41 #[error("Internal error: {0}")]
42 Internal(String),
43 #[error("File must exist: {0}")]
45 FileNotFound(String),
46 #[error("File must be accessible: {0}")]
47 FileNotAccessible(String),
48 #[error("Comment content is required: {0}")]
49 EmptyComment(String),
50 #[error("Comment content max 10000 characters: {0}")]
51 CommentTooLong(String),
52 #[error("Parent comment must exist if provided: {0}")]
53 InvalidParentComment(String),
54 #[error("All mentioned users must exist: {0}")]
55 InvalidMention(String),
56 #[error("Annotation region must be valid JSON: {0}")]
57 InvalidAnnotation(String),
58 #[error("Only comment owner can edit: {0}")]
59 NotCommentOwner(String),
60 #[error("Cannot edit deleted comment: {0}")]
61 CommentDeleted(String),
62 #[error("Cannot edit resolved comment after 5 minutes: {0}")]
63 EditTimeout(String),
64 #[error("Maximum thread depth exceeded: {0}")]
65 MaxThreadDepthExceeded(String),
66}
67
68impl From<ServiceError> for FileCommentError {
69 fn from(err: ServiceError) -> Self {
70 match err {
71 ServiceError::NotFound => Self::NotFound(err.to_string()),
72 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
73 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
74 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
75 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
76 ServiceError::Violations(_) => Self::Validation(err.to_string()),
77 }
78 }
79}
80
81impl axum::response::IntoResponse for FileCommentError {
82 fn into_response(self) -> axum::response::Response {
83 use axum::http::StatusCode;
84 use axum::Json;
85
86 let (status, code) = match &self {
87 Self::NotFound(_) => (StatusCode::NOT_FOUND, "FILECOMMENT_NOT_FOUND"),
88 Self::Validation(_) => (StatusCode::BAD_REQUEST, "FILECOMMENT_VALIDATION_ERROR"),
89 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILECOMMENT_DATABASE_ERROR"),
90 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILECOMMENT_INTERNAL_ERROR"),
91 Self::FileNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_FILE_NOT_FOUND"),
92 Self::FileNotAccessible(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_FILE_NOT_ACCESSIBLE"),
93 Self::EmptyComment(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_EMPTY_COMMENT"),
94 Self::CommentTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_COMMENT_TOO_LONG"),
95 Self::InvalidParentComment(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_INVALID_PARENT_COMMENT"),
96 Self::InvalidMention(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_INVALID_MENTION"),
97 Self::InvalidAnnotation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_INVALID_ANNOTATION"),
98 Self::NotCommentOwner(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_NOT_COMMENT_OWNER"),
99 Self::CommentDeleted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_COMMENT_DELETED"),
100 Self::EditTimeout(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_EDIT_TIMEOUT"),
101 Self::MaxThreadDepthExceeded(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILECOMMENT_MAX_THREAD_DEPTH_EXCEEDED"),
102 };
103
104 let body = serde_json::json!({
105 "success": false,
106 "error": code,
107 "message": self.to_string(),
108 });
109
110 (status, Json(body)).into_response()
111 }
112}
113
114pub mod file_comment_errors {
116 pub const FILE_NOT_FOUND: &str = "FILECOMMENT_FILE_NOT_FOUND";
117 pub const FILE_NOT_ACCESSIBLE: &str = "FILECOMMENT_FILE_NOT_ACCESSIBLE";
118 pub const EMPTY_COMMENT: &str = "FILECOMMENT_EMPTY_COMMENT";
119 pub const COMMENT_TOO_LONG: &str = "FILECOMMENT_COMMENT_TOO_LONG";
120 pub const INVALID_PARENT_COMMENT: &str = "FILECOMMENT_INVALID_PARENT_COMMENT";
121 pub const INVALID_MENTION: &str = "FILECOMMENT_INVALID_MENTION";
122 pub const INVALID_ANNOTATION: &str = "FILECOMMENT_INVALID_ANNOTATION";
123 pub const NOT_COMMENT_OWNER: &str = "FILECOMMENT_NOT_COMMENT_OWNER";
124 pub const COMMENT_DELETED: &str = "FILECOMMENT_COMMENT_DELETED";
125 pub const EDIT_TIMEOUT: &str = "FILECOMMENT_EDIT_TIMEOUT";
126 pub const MAX_THREAD_DEPTH_EXCEEDED: &str = "FILECOMMENT_MAX_THREAD_DEPTH_EXCEEDED";
127}
128
129pub fn create_file_comment_routes(service: Arc<FileCommentService>) -> Router {
162 BackboneCrudHandler::<FileCommentService, FileComment, CreateFileCommentDto, UpdateFileCommentDto, FileCommentResponseDto>::routes(
163 service,
164 "/file_comments",
165 )
166}
167
168pub fn create_file_comment_read_routes(service: Arc<FileCommentService>) -> Router {
174 BackboneCrudHandler::<FileCommentService, FileComment, CreateFileCommentDto, UpdateFileCommentDto, FileCommentResponseDto>::read_routes(
175 service,
176 "/file_comments",
177 )
178}
179
180pub fn create_file_comment_write_routes(service: Arc<FileCommentService>) -> Router {
192 BackboneCrudHandler::<FileCommentService, FileComment, CreateFileCommentDto, UpdateFileCommentDto, FileCommentResponseDto>::write_routes(
193 service,
194 "/file_comments",
195 )
196}
197
198#[cfg(feature = "auth")]
204pub fn create_protected_file_comment_routes<A: AuthMiddleware + Send + Sync + 'static>(
205 service: Arc<FileCommentService>,
206 auth: Arc<A>,
207) -> Router {
208 use axum::middleware;
209 use axum::response::IntoResponse;
210
211 let auth_layer = auth.clone();
212 create_file_comment_routes(service)
213 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
214 let auth = auth_layer.clone();
215 async move {
216 let token = req.headers()
217 .get(axum::http::header::AUTHORIZATION)
218 .and_then(|h| h.to_str().ok())
219 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
220 .unwrap_or("");
221 match auth.authenticate(token).await {
222 Ok(ctx) => {
223 req.extensions_mut().insert(ctx);
224 next.run(req).await
225 }
226 Err(_) => {
227 (axum::http::StatusCode::UNAUTHORIZED,
228 axum::Json(serde_json::json!({
229 "success": false,
230 "error": "unauthorized",
231 "message": "Authentication required"
232 }))
233 ).into_response()
234 }
235 }
236 }
237 }))
238}
239
240pub async fn resolve_transition(
248 axum::extract::State(service): axum::extract::State<Arc<FileCommentService>>,
249 axum::extract::Path(id): axum::extract::Path<String>,
250 auth: Option<axum::Extension<AuthContext>>,
251) -> impl axum::response::IntoResponse {
252 use axum::{http::StatusCode, Json};
253
254 let entity = match service.get_by_id(&id).await {
256 Ok(Some(e)) => e,
257 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
258 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
259 };
260
261 let Some(axum::Extension(auth)) = auth else {
263 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileCommentResponseDto>::error("A transition needs an authenticated caller")));
264 };
265 {
266 let allowed_roles = FileCommentTransition::Resolve.allowed_roles();
267 let granted = |required: &str| auth.permissions.iter().any(|p| {
268 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
269 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
270 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
271 });
272 let has_specific_perm = granted("file_comment:transition:resolve");
273 let has_update_perm = granted("file_comment:update");
274 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
275 if !has_specific_perm && !has_role {
276 if !has_update_perm {
277 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileCommentResponseDto>::error("Insufficient permissions for resolve transition")));
278 }
279 tracing::warn!(target: "backbone::write_guard", entity = "FileComment", verb = "resolve", code = "transition_by_update_permission", "a transition granted only by file_comment:update, running in shadow; it needs file_comment:transition:resolve or one of the roles owner, file_owner");
280 }
281 }
282
283 let current_state: FileCommentState = match entity.status.to_string().parse() {
285 Ok(s) => s,
286 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileCommentResponseDto>::error("The stored state is not a state of this machine"))),
287 };
288 let sm = FileCommentStateMachine::from_state(current_state);
289 if !sm.can_transition(FileCommentTransition::Resolve) {
290 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileCommentResponseDto>::error("Transition not allowed from current state")));
291 }
292
293 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
296 fields.insert("status".to_string(), serde_json::Value::String("resolved".to_string()));
297
298 match backbone_core::write_guard::as_transition("resolve", service.partial_update(&id, fields)).await {
299 Ok(Some(updated)) => {
300 let response: FileCommentResponseDto = updated.into();
301 (StatusCode::OK, Json(ApiResponse::ok(response)))
302 }
303 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
304 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
305 }
306}
307
308pub async fn edit_transition(
312 axum::extract::State(service): axum::extract::State<Arc<FileCommentService>>,
313 axum::extract::Path(id): axum::extract::Path<String>,
314 auth: Option<axum::Extension<AuthContext>>,
315) -> impl axum::response::IntoResponse {
316 use axum::{http::StatusCode, Json};
317
318 let entity = match service.get_by_id(&id).await {
320 Ok(Some(e)) => e,
321 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
322 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
323 };
324
325 let Some(axum::Extension(auth)) = auth else {
327 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileCommentResponseDto>::error("A transition needs an authenticated caller")));
328 };
329 {
330 let allowed_roles = FileCommentTransition::Edit.allowed_roles();
331 let granted = |required: &str| auth.permissions.iter().any(|p| {
332 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
333 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
334 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
335 });
336 let has_specific_perm = granted("file_comment:transition:edit");
337 let has_update_perm = granted("file_comment:update");
338 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
339 if !has_specific_perm && !has_role {
340 if !has_update_perm {
341 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileCommentResponseDto>::error("Insufficient permissions for edit transition")));
342 }
343 tracing::warn!(target: "backbone::write_guard", entity = "FileComment", verb = "edit", code = "transition_by_update_permission", "a transition granted only by file_comment:update, running in shadow; it needs file_comment:transition:edit or one of the roles owner");
344 }
345 }
346
347 let current_state: FileCommentState = match entity.status.to_string().parse() {
349 Ok(s) => s,
350 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileCommentResponseDto>::error("The stored state is not a state of this machine"))),
351 };
352 let sm = FileCommentStateMachine::from_state(current_state);
353 if !sm.can_transition(FileCommentTransition::Edit) {
354 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileCommentResponseDto>::error("Transition not allowed from current state")));
355 }
356
357 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
360 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
361
362 match backbone_core::write_guard::as_transition("edit", service.partial_update(&id, fields)).await {
363 Ok(Some(updated)) => {
364 let response: FileCommentResponseDto = updated.into();
365 (StatusCode::OK, Json(ApiResponse::ok(response)))
366 }
367 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
368 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
369 }
370}
371
372pub async fn delete_active_transition(
376 axum::extract::State(service): axum::extract::State<Arc<FileCommentService>>,
377 axum::extract::Path(id): axum::extract::Path<String>,
378 auth: Option<axum::Extension<AuthContext>>,
379) -> impl axum::response::IntoResponse {
380 use axum::{http::StatusCode, Json};
381
382 let entity = match service.get_by_id(&id).await {
384 Ok(Some(e)) => e,
385 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
386 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
387 };
388
389 let Some(axum::Extension(auth)) = auth else {
391 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileCommentResponseDto>::error("A transition needs an authenticated caller")));
392 };
393 {
394 let allowed_roles = FileCommentTransition::DeleteActive.allowed_roles();
395 let granted = |required: &str| auth.permissions.iter().any(|p| {
396 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
397 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
398 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
399 });
400 let has_specific_perm = granted("file_comment:transition:delete_active");
401 let has_update_perm = granted("file_comment:update");
402 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
403 if !has_specific_perm && !has_role {
404 if !has_update_perm {
405 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileCommentResponseDto>::error("Insufficient permissions for delete_active transition")));
406 }
407 tracing::warn!(target: "backbone::write_guard", entity = "FileComment", verb = "delete_active", code = "transition_by_update_permission", "a transition granted only by file_comment:update, running in shadow; it needs file_comment:transition:delete_active or one of the roles owner, file_owner");
408 }
409 }
410
411 let current_state: FileCommentState = match entity.status.to_string().parse() {
413 Ok(s) => s,
414 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileCommentResponseDto>::error("The stored state is not a state of this machine"))),
415 };
416 let sm = FileCommentStateMachine::from_state(current_state);
417 if !sm.can_transition(FileCommentTransition::DeleteActive) {
418 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileCommentResponseDto>::error("Transition not allowed from current state")));
419 }
420
421 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
424 fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
425
426 match backbone_core::write_guard::as_transition("delete_active", service.partial_update(&id, fields)).await {
427 Ok(Some(updated)) => {
428 let response: FileCommentResponseDto = updated.into();
429 (StatusCode::OK, Json(ApiResponse::ok(response)))
430 }
431 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
432 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
433 }
434}
435
436pub async fn reopen_transition(
440 axum::extract::State(service): axum::extract::State<Arc<FileCommentService>>,
441 axum::extract::Path(id): axum::extract::Path<String>,
442 auth: Option<axum::Extension<AuthContext>>,
443) -> impl axum::response::IntoResponse {
444 use axum::{http::StatusCode, Json};
445
446 let entity = match service.get_by_id(&id).await {
448 Ok(Some(e)) => e,
449 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
450 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
451 };
452
453 let Some(axum::Extension(auth)) = auth else {
455 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileCommentResponseDto>::error("A transition needs an authenticated caller")));
456 };
457 {
458 let allowed_roles = FileCommentTransition::Reopen.allowed_roles();
459 let granted = |required: &str| auth.permissions.iter().any(|p| {
460 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
461 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
462 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
463 });
464 let has_specific_perm = granted("file_comment:transition:reopen");
465 let has_update_perm = granted("file_comment:update");
466 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
467 if !has_specific_perm && !has_role {
468 if !has_update_perm {
469 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileCommentResponseDto>::error("Insufficient permissions for reopen transition")));
470 }
471 tracing::warn!(target: "backbone::write_guard", entity = "FileComment", verb = "reopen", code = "transition_by_update_permission", "a transition granted only by file_comment:update, running in shadow; it needs file_comment:transition:reopen or one of the roles user");
472 }
473 }
474
475 let current_state: FileCommentState = match entity.status.to_string().parse() {
477 Ok(s) => s,
478 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileCommentResponseDto>::error("The stored state is not a state of this machine"))),
479 };
480 let sm = FileCommentStateMachine::from_state(current_state);
481 if !sm.can_transition(FileCommentTransition::Reopen) {
482 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileCommentResponseDto>::error("Transition not allowed from current state")));
483 }
484
485 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
488 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
489
490 match backbone_core::write_guard::as_transition("reopen", service.partial_update(&id, fields)).await {
491 Ok(Some(updated)) => {
492 let response: FileCommentResponseDto = updated.into();
493 (StatusCode::OK, Json(ApiResponse::ok(response)))
494 }
495 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
496 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
497 }
498}
499
500pub async fn delete_resolved_transition(
504 axum::extract::State(service): axum::extract::State<Arc<FileCommentService>>,
505 axum::extract::Path(id): axum::extract::Path<String>,
506 auth: Option<axum::Extension<AuthContext>>,
507) -> impl axum::response::IntoResponse {
508 use axum::{http::StatusCode, Json};
509
510 let entity = match service.get_by_id(&id).await {
512 Ok(Some(e)) => e,
513 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
514 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
515 };
516
517 let Some(axum::Extension(auth)) = auth else {
519 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileCommentResponseDto>::error("A transition needs an authenticated caller")));
520 };
521 {
522 let allowed_roles = FileCommentTransition::DeleteResolved.allowed_roles();
523 let granted = |required: &str| auth.permissions.iter().any(|p| {
524 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
525 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
526 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
527 });
528 let has_specific_perm = granted("file_comment:transition:delete_resolved");
529 let has_update_perm = granted("file_comment:update");
530 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
531 if !has_specific_perm && !has_role {
532 if !has_update_perm {
533 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileCommentResponseDto>::error("Insufficient permissions for delete_resolved transition")));
534 }
535 tracing::warn!(target: "backbone::write_guard", entity = "FileComment", verb = "delete_resolved", code = "transition_by_update_permission", "a transition granted only by file_comment:update, running in shadow; it needs file_comment:transition:delete_resolved or one of the roles owner, file_owner");
536 }
537 }
538
539 let current_state: FileCommentState = match entity.status.to_string().parse() {
541 Ok(s) => s,
542 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileCommentResponseDto>::error("The stored state is not a state of this machine"))),
543 };
544 let sm = FileCommentStateMachine::from_state(current_state);
545 if !sm.can_transition(FileCommentTransition::DeleteResolved) {
546 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileCommentResponseDto>::error("Transition not allowed from current state")));
547 }
548
549 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
552 fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
553
554 match backbone_core::write_guard::as_transition("delete_resolved", service.partial_update(&id, fields)).await {
555 Ok(Some(updated)) => {
556 let response: FileCommentResponseDto = updated.into();
557 (StatusCode::OK, Json(ApiResponse::ok(response)))
558 }
559 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileCommentResponseDto>::not_found("FileComment", &id))),
560 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileCommentResponseDto>::error(e.to_string()))),
561 }
562}
563
564pub fn create_file_comment_transition_routes(service: Arc<FileCommentService>) -> Router {
566 use axum::routing::post;
567
568 Router::new()
569 .route("/file_comments/:id/transitions/resolve", post(resolve_transition))
570 .route("/file_comments/:id/transitions/edit", post(edit_transition))
571 .route("/file_comments/:id/transitions/delete_active", post(delete_active_transition))
572 .route("/file_comments/:id/transitions/reopen", post(reopen_transition))
573 .route("/file_comments/:id/transitions/delete_resolved", post(delete_resolved_transition))
574 .with_state(service)
575}
576