style: apply cargo fmt to entire codebase
Standardize code formatting across all 173 Rust source files using rustfmt. No functional changes - purely cosmetic. This establishes a consistent code style baseline for the project going forward.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,286 +1,339 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
use std::collections::HashMap;
|
||||
use chrono::{Utc, TimeZone};
|
||||
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType, QualifiedName};
|
||||
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
|
||||
|
||||
fn sample_calendar() -> CalendarDto {
|
||||
CalendarDto {
|
||||
id: "cal-001".to_string(),
|
||||
name: "Personal".to_string(),
|
||||
owner_id: "user-001".to_string(),
|
||||
description: Some("My personal calendar".to_string()),
|
||||
color: Some("#FF0000".to_string()),
|
||||
is_public: false,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
|
||||
custom_properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_event() -> CalendarEventDto {
|
||||
CalendarEventDto {
|
||||
id: "evt-001".to_string(),
|
||||
calendar_id: "cal-001".to_string(),
|
||||
summary: "Team Meeting".to_string(),
|
||||
description: Some("Weekly team sync".to_string()),
|
||||
location: Some("Conference Room A".to_string()),
|
||||
start_time: Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(),
|
||||
end_time: Utc.with_ymd_and_hms(2025, 6, 15, 11, 0, 0).unwrap(),
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: "uid-evt-001@oxicloud".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MKCALENDAR parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkcalendar_full() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Work Calendar</D:displayname>
|
||||
<C:calendar-description>Work related events</C:calendar-description>
|
||||
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">#0000FF</A:calendar-color>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse MKCALENDAR: {:?}", result.err());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Work Calendar");
|
||||
assert_eq!(desc, Some("Work related events".to_string()));
|
||||
assert_eq!(color, Some("#0000FF".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkcalendar_name_only() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Minimal Calendar</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml));
|
||||
assert!(result.is_ok());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Minimal Calendar");
|
||||
assert!(desc.is_none());
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
// ========================
|
||||
// REPORT parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_calendar_query_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="2025-06-01T00:00:00Z" end="2025-07-01T00:00:00Z"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse report: {:?}", result.err());
|
||||
|
||||
match result.unwrap() {
|
||||
CalDavReportType::CalendarQuery { time_range, props } => {
|
||||
assert!(time_range.is_some(), "Time range should be parsed");
|
||||
let (start, end) = time_range.unwrap();
|
||||
assert_eq!(start, Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap());
|
||||
assert_eq!(end, Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap());
|
||||
assert!(!props.is_empty(), "Props should not be empty");
|
||||
}
|
||||
other => panic!("Expected CalendarQuery, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_calendar_multiget_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<D:href>/caldav/cal-001/evt-001.ics</D:href>
|
||||
<D:href>/caldav/cal-001/evt-002.ics</D:href>
|
||||
</C:calendar-multiget>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse multiget: {:?}", result.err());
|
||||
|
||||
match result.unwrap() {
|
||||
CalDavReportType::CalendarMultiget { hrefs, props } => {
|
||||
assert_eq!(hrefs.len(), 2);
|
||||
assert_eq!(hrefs[0], "/caldav/cal-001/evt-001.ics");
|
||||
assert_eq!(hrefs[1], "/caldav/cal-001/evt-002.ics");
|
||||
assert!(!props.is_empty());
|
||||
}
|
||||
other => panic!("Expected CalendarMultiget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// PROPFIND response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendars_propfind_response() {
|
||||
let calendars = vec![sample_calendar()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendars_propfind_response(
|
||||
&mut output,
|
||||
&calendars,
|
||||
&request,
|
||||
"/caldav/",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate propfind response: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8 in response");
|
||||
assert!(xml_str.contains("multistatus"), "Response should contain multistatus element");
|
||||
assert!(xml_str.contains("Personal"), "Response should contain calendar name");
|
||||
assert!(xml_str.contains("cal-001"), "Response should contain calendar ID in href");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_collection_propfind_depth_0() {
|
||||
let calendar = sample_calendar();
|
||||
let events = vec![sample_event()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_collection_propfind(
|
||||
&mut output,
|
||||
&calendar,
|
||||
&events,
|
||||
&request,
|
||||
"/caldav/cal-001",
|
||||
"0",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate collection propfind: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("Personal"), "Should have calendar name");
|
||||
// Depth 0 should NOT include individual event resources
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_collection_propfind_depth_1() {
|
||||
let calendar = sample_calendar();
|
||||
let events = vec![sample_event()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_collection_propfind(
|
||||
&mut output,
|
||||
&calendar,
|
||||
&events,
|
||||
&request,
|
||||
"/caldav/cal-001",
|
||||
"1",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate depth-1 propfind: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("Personal"), "Should have calendar name");
|
||||
// Depth 1 should include event resources
|
||||
assert!(xml_str.contains("evt-001"), "Depth 1 should include event resources");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Calendar events response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_events_response() {
|
||||
let events = vec![sample_event()];
|
||||
let report = CalDavReportType::CalendarQuery {
|
||||
time_range: None,
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
namespace: "DAV:".to_string(),
|
||||
name: "getetag".to_string(),
|
||||
},
|
||||
QualifiedName {
|
||||
namespace: "urn:ietf:params:xml:ns:caldav".to_string(),
|
||||
name: "calendar-data".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_events_response(
|
||||
&mut output,
|
||||
&events,
|
||||
&report,
|
||||
"/caldav/cal-001",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate events response: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("evt-001"), "Should reference event ID");
|
||||
assert!(xml_str.contains("BEGIN:VCALENDAR"), "Should contain iCal data");
|
||||
assert!(xml_str.contains("VEVENT"), "Should contain VEVENT component");
|
||||
assert!(xml_str.contains("Team Meeting"), "Should contain event summary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_empty_events_response() {
|
||||
let events: Vec<CalendarEventDto> = vec![];
|
||||
let report = CalDavReportType::CalendarQuery {
|
||||
time_range: None,
|
||||
props: vec![],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_events_response(
|
||||
&mut output,
|
||||
&events,
|
||||
&report,
|
||||
"/caldav/cal-001",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Empty events should still produce valid response");
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus even for empty");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropFindType, QualifiedName,
|
||||
};
|
||||
use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn sample_calendar() -> CalendarDto {
|
||||
CalendarDto {
|
||||
id: "cal-001".to_string(),
|
||||
name: "Personal".to_string(),
|
||||
owner_id: "user-001".to_string(),
|
||||
description: Some("My personal calendar".to_string()),
|
||||
color: Some("#FF0000".to_string()),
|
||||
is_public: false,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
|
||||
custom_properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_event() -> CalendarEventDto {
|
||||
CalendarEventDto {
|
||||
id: "evt-001".to_string(),
|
||||
calendar_id: "cal-001".to_string(),
|
||||
summary: "Team Meeting".to_string(),
|
||||
description: Some("Weekly team sync".to_string()),
|
||||
location: Some("Conference Room A".to_string()),
|
||||
start_time: Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(),
|
||||
end_time: Utc.with_ymd_and_hms(2025, 6, 15, 11, 0, 0).unwrap(),
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: "uid-evt-001@oxicloud".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MKCALENDAR parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkcalendar_full() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Work Calendar</D:displayname>
|
||||
<C:calendar-description>Work related events</C:calendar-description>
|
||||
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">#0000FF</A:calendar-color>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse MKCALENDAR: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Work Calendar");
|
||||
assert_eq!(desc, Some("Work related events".to_string()));
|
||||
assert_eq!(color, Some("#0000FF".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkcalendar_name_only() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Minimal Calendar</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</C:mkcalendar>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_mkcalendar(Cursor::new(xml));
|
||||
assert!(result.is_ok());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Minimal Calendar");
|
||||
assert!(desc.is_none());
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
// ========================
|
||||
// REPORT parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_calendar_query_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="2025-06-01T00:00:00Z" end="2025-07-01T00:00:00Z"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse report: {:?}", result.err());
|
||||
|
||||
match result.unwrap() {
|
||||
CalDavReportType::CalendarQuery { time_range, props } => {
|
||||
assert!(time_range.is_some(), "Time range should be parsed");
|
||||
let (start, end) = time_range.unwrap();
|
||||
assert_eq!(start, Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap());
|
||||
assert_eq!(end, Utc.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap());
|
||||
assert!(!props.is_empty(), "Props should not be empty");
|
||||
}
|
||||
other => panic!("Expected CalendarQuery, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_calendar_multiget_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<D:href>/caldav/cal-001/evt-001.ics</D:href>
|
||||
<D:href>/caldav/cal-001/evt-002.ics</D:href>
|
||||
</C:calendar-multiget>"#;
|
||||
|
||||
let result = CalDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse multiget: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
match result.unwrap() {
|
||||
CalDavReportType::CalendarMultiget { hrefs, props } => {
|
||||
assert_eq!(hrefs.len(), 2);
|
||||
assert_eq!(hrefs[0], "/caldav/cal-001/evt-001.ics");
|
||||
assert_eq!(hrefs[1], "/caldav/cal-001/evt-002.ics");
|
||||
assert!(!props.is_empty());
|
||||
}
|
||||
other => panic!("Expected CalendarMultiget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// PROPFIND response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendars_propfind_response() {
|
||||
let calendars = vec![sample_calendar()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendars_propfind_response(
|
||||
&mut output,
|
||||
&calendars,
|
||||
&request,
|
||||
"/caldav/",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate propfind response: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8 in response");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Response should contain multistatus element"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("Personal"),
|
||||
"Response should contain calendar name"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("cal-001"),
|
||||
"Response should contain calendar ID in href"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_collection_propfind_depth_0() {
|
||||
let calendar = sample_calendar();
|
||||
let events = vec![sample_event()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_collection_propfind(
|
||||
&mut output,
|
||||
&calendar,
|
||||
&events,
|
||||
&request,
|
||||
"/caldav/cal-001",
|
||||
"0",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate collection propfind: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("Personal"), "Should have calendar name");
|
||||
// Depth 0 should NOT include individual event resources
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_collection_propfind_depth_1() {
|
||||
let calendar = sample_calendar();
|
||||
let events = vec![sample_event()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_collection_propfind(
|
||||
&mut output,
|
||||
&calendar,
|
||||
&events,
|
||||
&request,
|
||||
"/caldav/cal-001",
|
||||
"1",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate depth-1 propfind: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("Personal"), "Should have calendar name");
|
||||
// Depth 1 should include event resources
|
||||
assert!(
|
||||
xml_str.contains("evt-001"),
|
||||
"Depth 1 should include event resources"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Calendar events response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_calendar_events_response() {
|
||||
let events = vec![sample_event()];
|
||||
let report = CalDavReportType::CalendarQuery {
|
||||
time_range: None,
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
namespace: "DAV:".to_string(),
|
||||
name: "getetag".to_string(),
|
||||
},
|
||||
QualifiedName {
|
||||
namespace: "urn:ietf:params:xml:ns:caldav".to_string(),
|
||||
name: "calendar-data".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_events_response(
|
||||
&mut output,
|
||||
&events,
|
||||
&report,
|
||||
"/caldav/cal-001",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate events response: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
assert!(xml_str.contains("evt-001"), "Should reference event ID");
|
||||
assert!(
|
||||
xml_str.contains("BEGIN:VCALENDAR"),
|
||||
"Should contain iCal data"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("VEVENT"),
|
||||
"Should contain VEVENT component"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("Team Meeting"),
|
||||
"Should contain event summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_empty_events_response() {
|
||||
let events: Vec<CalendarEventDto> = vec![];
|
||||
let report = CalDavReportType::CalendarQuery {
|
||||
time_range: None,
|
||||
props: vec![],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_calendar_events_response(
|
||||
&mut output,
|
||||
&events,
|
||||
&report,
|
||||
"/caldav/cal-001",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Empty events should still produce valid response"
|
||||
);
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Should have multistatus even for empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,432 +1,524 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
use chrono::{Utc, TimeZone, NaiveDate};
|
||||
use crate::application::adapters::carddav_adapter::{CardDavAdapter, CardDavReportType, contact_to_vcard};
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType, QualifiedName};
|
||||
use crate::application::dtos::address_book_dto::AddressBookDto;
|
||||
use crate::application::dtos::contact_dto::{ContactDto, EmailDto, PhoneDto, AddressDto};
|
||||
|
||||
fn sample_address_book() -> AddressBookDto {
|
||||
AddressBookDto {
|
||||
id: "ab-001".to_string(),
|
||||
name: "My Contacts".to_string(),
|
||||
owner_id: "user-001".to_string(),
|
||||
description: Some("Personal address book".to_string()),
|
||||
color: Some("#00FF00".to_string()),
|
||||
is_public: false,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_contact() -> ContactDto {
|
||||
ContactDto {
|
||||
id: "contact-001".to_string(),
|
||||
address_book_id: "ab-001".to_string(),
|
||||
uid: "uid-contact-001@oxicloud".to_string(),
|
||||
full_name: Some("John Doe".to_string()),
|
||||
first_name: Some("John".to_string()),
|
||||
last_name: Some("Doe".to_string()),
|
||||
nickname: Some("Johnny".to_string()),
|
||||
email: vec![
|
||||
EmailDto {
|
||||
email: "john@example.com".to_string(),
|
||||
r#type: "work".to_string(),
|
||||
is_primary: true,
|
||||
},
|
||||
EmailDto {
|
||||
email: "john.doe@personal.com".to_string(),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: false,
|
||||
},
|
||||
],
|
||||
phone: vec![
|
||||
PhoneDto {
|
||||
number: "+1-555-0100".to_string(),
|
||||
r#type: "cell".to_string(),
|
||||
is_primary: true,
|
||||
},
|
||||
],
|
||||
address: vec![
|
||||
AddressDto {
|
||||
street: Some("123 Main St".to_string()),
|
||||
city: Some("Springfield".to_string()),
|
||||
state: Some("IL".to_string()),
|
||||
postal_code: Some("62701".to_string()),
|
||||
country: Some("US".to_string()),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: true,
|
||||
},
|
||||
],
|
||||
organization: Some("Acme Corp".to_string()),
|
||||
title: Some("Software Engineer".to_string()),
|
||||
notes: Some("Met at conference".to_string()),
|
||||
photo_url: None,
|
||||
birthday: Some(NaiveDate::from_ymd_opt(1990, 5, 15).unwrap()),
|
||||
anniversary: None,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 3, 10, 8, 30, 0).unwrap(),
|
||||
etag: "etag-abc123".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_contact_minimal() -> ContactDto {
|
||||
ContactDto {
|
||||
id: "contact-002".to_string(),
|
||||
address_book_id: "ab-001".to_string(),
|
||||
uid: "uid-contact-002@oxicloud".to_string(),
|
||||
full_name: Some("Jane Smith".to_string()),
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
nickname: None,
|
||||
email: vec![],
|
||||
phone: vec![],
|
||||
address: vec![],
|
||||
organization: None,
|
||||
title: None,
|
||||
notes: None,
|
||||
photo_url: None,
|
||||
birthday: None,
|
||||
anniversary: None,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(),
|
||||
etag: "etag-def456".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// vCard generation tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_full() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
assert!(vcard.starts_with("BEGIN:VCARD"), "vCard should start with BEGIN:VCARD");
|
||||
assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0");
|
||||
assert!(vcard.contains("FN:John Doe"), "Should contain full name");
|
||||
assert!(vcard.contains("N:Doe;John"), "Should contain structured name");
|
||||
assert!(vcard.contains("NICKNAME:Johnny"), "Should contain nickname");
|
||||
assert!(vcard.contains("john@example.com"), "Should contain email");
|
||||
assert!(vcard.contains("+1-555-0100"), "Should contain phone number");
|
||||
assert!(vcard.contains("ORG:Acme Corp"), "Should contain organization");
|
||||
assert!(vcard.contains("TITLE:Software Engineer"), "Should contain title");
|
||||
assert!(vcard.contains("NOTE:Met at conference"), "Should contain notes");
|
||||
assert!(vcard.contains("BDAY:1990-05-15"), "Should contain birthday");
|
||||
assert!(vcard.contains("UID:uid-contact-001@oxicloud"), "Should contain UID");
|
||||
assert!(vcard.ends_with("END:VCARD\r\n") || vcard.trim_end().ends_with("END:VCARD"),
|
||||
"vCard should end with END:VCARD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_minimal() {
|
||||
let contact = sample_contact_minimal();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
assert!(vcard.contains("BEGIN:VCARD"), "Should start correctly");
|
||||
assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0");
|
||||
assert!(vcard.contains("FN:Jane Smith"), "Should have full name");
|
||||
assert!(vcard.contains("UID:uid-contact-002@oxicloud"), "Should have UID");
|
||||
assert!(vcard.contains("END:VCARD"), "Should end correctly");
|
||||
// Should NOT contain optional fields
|
||||
assert!(!vcard.contains("NICKNAME:"), "Should not have nickname");
|
||||
assert!(!vcard.contains("ORG:"), "Should not have org");
|
||||
assert!(!vcard.contains("TITLE:"), "Should not have title");
|
||||
assert!(!vcard.contains("BDAY:"), "Should not have birthday");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MKADDRESSBOOK parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkaddressbook_full() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:mkcol xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Work Contacts</D:displayname>
|
||||
<CR:addressbook-description>Colleagues and clients</CR:addressbook-description>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:mkcol>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse mkaddressbook: {:?}", result.err());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Work Contacts");
|
||||
assert_eq!(desc, Some("Colleagues and clients".to_string()));
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkaddressbook_name_only() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:mkcol xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Simple Book</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:mkcol>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml));
|
||||
assert!(result.is_ok());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Simple Book");
|
||||
assert!(desc.is_none());
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
// ========================
|
||||
// REPORT parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_addressbook_query_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CR:addressbook-query xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CR:address-data/>
|
||||
</D:prop>
|
||||
</CR:addressbook-query>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse addressbook-query: {:?}", result.err());
|
||||
|
||||
match result.unwrap() {
|
||||
CardDavReportType::AddressbookQuery { props } => {
|
||||
assert!(!props.is_empty(), "Props should not be empty");
|
||||
}
|
||||
other => panic!("Expected AddressbookQuery, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_addressbook_multiget_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CR:addressbook-multiget xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CR:address-data/>
|
||||
</D:prop>
|
||||
<D:href>/carddav/ab-001/contact-001.vcf</D:href>
|
||||
<D:href>/carddav/ab-001/contact-002.vcf</D:href>
|
||||
<D:href>/carddav/ab-001/contact-003.vcf</D:href>
|
||||
</CR:addressbook-multiget>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse multiget: {:?}", result.err());
|
||||
|
||||
match result.unwrap() {
|
||||
CardDavReportType::AddressbookMultiget { hrefs, props } => {
|
||||
assert_eq!(hrefs.len(), 3, "Should have 3 hrefs");
|
||||
assert_eq!(hrefs[0], "/carddav/ab-001/contact-001.vcf");
|
||||
assert_eq!(hrefs[2], "/carddav/ab-001/contact-003.vcf");
|
||||
assert!(!props.is_empty());
|
||||
}
|
||||
other => panic!("Expected AddressbookMultiget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// PROPFIND response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbooks_propfind_response() {
|
||||
let addressbooks = vec![sample_address_book()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbooks_propfind_response(
|
||||
&mut output,
|
||||
&addressbooks,
|
||||
&request,
|
||||
"/carddav",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate propfind response: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should contain multistatus");
|
||||
assert!(xml_str.contains("My Contacts"), "Should contain address book name");
|
||||
assert!(xml_str.contains("ab-001"), "Should contain address book ID in href");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbook_collection_propfind_depth_0() {
|
||||
let addressbook = sample_address_book();
|
||||
let contacts = vec![sample_contact()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut output,
|
||||
&addressbook,
|
||||
&contacts,
|
||||
&request,
|
||||
"/carddav/ab-001",
|
||||
"0",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate depth-0 propfind: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should contain multistatus");
|
||||
assert!(xml_str.contains("My Contacts"), "Should contain address book name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbook_collection_propfind_depth_1() {
|
||||
let addressbook = sample_address_book();
|
||||
let contacts = vec![sample_contact(), sample_contact_minimal()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut output,
|
||||
&addressbook,
|
||||
&contacts,
|
||||
&request,
|
||||
"/carddav/ab-001",
|
||||
"1",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate depth-1 propfind: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should contain multistatus");
|
||||
assert!(xml_str.contains("My Contacts"), "Should contain address book name");
|
||||
// Depth 1 should include contact resources
|
||||
assert!(xml_str.contains("contact-001"), "Should include contact-001");
|
||||
assert!(xml_str.contains("contact-002"), "Should include contact-002");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Contacts response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_contacts_response() {
|
||||
let contacts = vec![sample_contact()];
|
||||
let vcards = vec![
|
||||
("contact-001".to_string(), contact_to_vcard(&sample_contact())),
|
||||
];
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
namespace: "DAV:".to_string(),
|
||||
name: "getetag".to_string(),
|
||||
},
|
||||
QualifiedName {
|
||||
namespace: "urn:ietf:params:xml:ns:carddav".to_string(),
|
||||
name: "address-data".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Failed to generate contacts response: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should contain multistatus");
|
||||
assert!(xml_str.contains("contact-001"), "Should reference contact");
|
||||
assert!(xml_str.contains("etag-abc123"), "Should contain etag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_empty_contacts_response() {
|
||||
let contacts: Vec<ContactDto> = vec![];
|
||||
let vcards: Vec<(String, String)> = vec![];
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "Empty contacts should produce valid response");
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Multiple address books test
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_multiple_addressbooks() {
|
||||
let mut ab2 = sample_address_book();
|
||||
ab2.id = "ab-002".to_string();
|
||||
ab2.name = "Work Contacts".to_string();
|
||||
|
||||
let addressbooks = vec![sample_address_book(), ab2];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbooks_propfind_response(
|
||||
&mut output,
|
||||
&addressbooks,
|
||||
&request,
|
||||
"/carddav/",
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("My Contacts"), "Should contain first address book");
|
||||
assert!(xml_str.contains("Work Contacts"), "Should contain second address book");
|
||||
assert!(xml_str.contains("ab-001"), "Should have first ID");
|
||||
assert!(xml_str.contains("ab-002"), "Should have second ID");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// vCard edge cases
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_with_multiple_emails() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
// Should contain both emails
|
||||
assert!(vcard.contains("john@example.com"), "Should have work email");
|
||||
assert!(vcard.contains("john.doe@personal.com"), "Should have personal email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_address_formatting() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
// vCard ADR format: ;;street;city;state;postal;country
|
||||
assert!(vcard.contains("123 Main St"), "Should have street");
|
||||
assert!(vcard.contains("Springfield"), "Should have city");
|
||||
assert!(vcard.contains("62701"), "Should have postal code");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::application::adapters::carddav_adapter::{
|
||||
CardDavAdapter, CardDavReportType, contact_to_vcard,
|
||||
};
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropFindType, QualifiedName,
|
||||
};
|
||||
use crate::application::dtos::address_book_dto::AddressBookDto;
|
||||
use crate::application::dtos::contact_dto::{AddressDto, ContactDto, EmailDto, PhoneDto};
|
||||
use chrono::{NaiveDate, TimeZone, Utc};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn sample_address_book() -> AddressBookDto {
|
||||
AddressBookDto {
|
||||
id: "ab-001".to_string(),
|
||||
name: "My Contacts".to_string(),
|
||||
owner_id: "user-001".to_string(),
|
||||
description: Some("Personal address book".to_string()),
|
||||
color: Some("#00FF00".to_string()),
|
||||
is_public: false,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_contact() -> ContactDto {
|
||||
ContactDto {
|
||||
id: "contact-001".to_string(),
|
||||
address_book_id: "ab-001".to_string(),
|
||||
uid: "uid-contact-001@oxicloud".to_string(),
|
||||
full_name: Some("John Doe".to_string()),
|
||||
first_name: Some("John".to_string()),
|
||||
last_name: Some("Doe".to_string()),
|
||||
nickname: Some("Johnny".to_string()),
|
||||
email: vec![
|
||||
EmailDto {
|
||||
email: "john@example.com".to_string(),
|
||||
r#type: "work".to_string(),
|
||||
is_primary: true,
|
||||
},
|
||||
EmailDto {
|
||||
email: "john.doe@personal.com".to_string(),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: false,
|
||||
},
|
||||
],
|
||||
phone: vec![PhoneDto {
|
||||
number: "+1-555-0100".to_string(),
|
||||
r#type: "cell".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
address: vec![AddressDto {
|
||||
street: Some("123 Main St".to_string()),
|
||||
city: Some("Springfield".to_string()),
|
||||
state: Some("IL".to_string()),
|
||||
postal_code: Some("62701".to_string()),
|
||||
country: Some("US".to_string()),
|
||||
r#type: "home".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
organization: Some("Acme Corp".to_string()),
|
||||
title: Some("Software Engineer".to_string()),
|
||||
notes: Some("Met at conference".to_string()),
|
||||
photo_url: None,
|
||||
birthday: Some(NaiveDate::from_ymd_opt(1990, 5, 15).unwrap()),
|
||||
anniversary: None,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 3, 10, 8, 30, 0).unwrap(),
|
||||
etag: "etag-abc123".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_contact_minimal() -> ContactDto {
|
||||
ContactDto {
|
||||
id: "contact-002".to_string(),
|
||||
address_book_id: "ab-001".to_string(),
|
||||
uid: "uid-contact-002@oxicloud".to_string(),
|
||||
full_name: Some("Jane Smith".to_string()),
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
nickname: None,
|
||||
email: vec![],
|
||||
phone: vec![],
|
||||
address: vec![],
|
||||
organization: None,
|
||||
title: None,
|
||||
notes: None,
|
||||
photo_url: None,
|
||||
birthday: None,
|
||||
anniversary: None,
|
||||
created_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap(),
|
||||
etag: "etag-def456".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// vCard generation tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_full() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
assert!(
|
||||
vcard.starts_with("BEGIN:VCARD"),
|
||||
"vCard should start with BEGIN:VCARD"
|
||||
);
|
||||
assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0");
|
||||
assert!(vcard.contains("FN:John Doe"), "Should contain full name");
|
||||
assert!(
|
||||
vcard.contains("N:Doe;John"),
|
||||
"Should contain structured name"
|
||||
);
|
||||
assert!(vcard.contains("NICKNAME:Johnny"), "Should contain nickname");
|
||||
assert!(vcard.contains("john@example.com"), "Should contain email");
|
||||
assert!(vcard.contains("+1-555-0100"), "Should contain phone number");
|
||||
assert!(
|
||||
vcard.contains("ORG:Acme Corp"),
|
||||
"Should contain organization"
|
||||
);
|
||||
assert!(
|
||||
vcard.contains("TITLE:Software Engineer"),
|
||||
"Should contain title"
|
||||
);
|
||||
assert!(
|
||||
vcard.contains("NOTE:Met at conference"),
|
||||
"Should contain notes"
|
||||
);
|
||||
assert!(vcard.contains("BDAY:1990-05-15"), "Should contain birthday");
|
||||
assert!(
|
||||
vcard.contains("UID:uid-contact-001@oxicloud"),
|
||||
"Should contain UID"
|
||||
);
|
||||
assert!(
|
||||
vcard.ends_with("END:VCARD\r\n") || vcard.trim_end().ends_with("END:VCARD"),
|
||||
"vCard should end with END:VCARD"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_minimal() {
|
||||
let contact = sample_contact_minimal();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
assert!(vcard.contains("BEGIN:VCARD"), "Should start correctly");
|
||||
assert!(vcard.contains("VERSION:3.0"), "Should be vCard 3.0");
|
||||
assert!(vcard.contains("FN:Jane Smith"), "Should have full name");
|
||||
assert!(
|
||||
vcard.contains("UID:uid-contact-002@oxicloud"),
|
||||
"Should have UID"
|
||||
);
|
||||
assert!(vcard.contains("END:VCARD"), "Should end correctly");
|
||||
// Should NOT contain optional fields
|
||||
assert!(!vcard.contains("NICKNAME:"), "Should not have nickname");
|
||||
assert!(!vcard.contains("ORG:"), "Should not have org");
|
||||
assert!(!vcard.contains("TITLE:"), "Should not have title");
|
||||
assert!(!vcard.contains("BDAY:"), "Should not have birthday");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// MKADDRESSBOOK parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkaddressbook_full() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:mkcol xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Work Contacts</D:displayname>
|
||||
<CR:addressbook-description>Colleagues and clients</CR:addressbook-description>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:mkcol>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse mkaddressbook: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Work Contacts");
|
||||
assert_eq!(desc, Some("Colleagues and clients".to_string()));
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mkaddressbook_name_only() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:mkcol xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>Simple Book</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:mkcol>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_mkaddressbook(Cursor::new(xml));
|
||||
assert!(result.is_ok());
|
||||
let (name, desc, color) = result.unwrap();
|
||||
assert_eq!(name, "Simple Book");
|
||||
assert!(desc.is_none());
|
||||
assert!(color.is_none());
|
||||
}
|
||||
|
||||
// ========================
|
||||
// REPORT parsing tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_parse_addressbook_query_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CR:addressbook-query xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CR:address-data/>
|
||||
</D:prop>
|
||||
</CR:addressbook-query>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse addressbook-query: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
match result.unwrap() {
|
||||
CardDavReportType::AddressbookQuery { props } => {
|
||||
assert!(!props.is_empty(), "Props should not be empty");
|
||||
}
|
||||
other => panic!("Expected AddressbookQuery, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_addressbook_multiget_report() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CR:addressbook-multiget xmlns:D="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<CR:address-data/>
|
||||
</D:prop>
|
||||
<D:href>/carddav/ab-001/contact-001.vcf</D:href>
|
||||
<D:href>/carddav/ab-001/contact-002.vcf</D:href>
|
||||
<D:href>/carddav/ab-001/contact-003.vcf</D:href>
|
||||
</CR:addressbook-multiget>"#;
|
||||
|
||||
let result = CardDavAdapter::parse_report(Cursor::new(xml));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse multiget: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
match result.unwrap() {
|
||||
CardDavReportType::AddressbookMultiget { hrefs, props } => {
|
||||
assert_eq!(hrefs.len(), 3, "Should have 3 hrefs");
|
||||
assert_eq!(hrefs[0], "/carddav/ab-001/contact-001.vcf");
|
||||
assert_eq!(hrefs[2], "/carddav/ab-001/contact-003.vcf");
|
||||
assert!(!props.is_empty());
|
||||
}
|
||||
other => panic!("Expected AddressbookMultiget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// PROPFIND response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbooks_propfind_response() {
|
||||
let addressbooks = vec![sample_address_book()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbooks_propfind_response(
|
||||
&mut output,
|
||||
&addressbooks,
|
||||
&request,
|
||||
"/carddav",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate propfind response: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Should contain multistatus"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("My Contacts"),
|
||||
"Should contain address book name"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("ab-001"),
|
||||
"Should contain address book ID in href"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbook_collection_propfind_depth_0() {
|
||||
let addressbook = sample_address_book();
|
||||
let contacts = vec![sample_contact()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut output,
|
||||
&addressbook,
|
||||
&contacts,
|
||||
&request,
|
||||
"/carddav/ab-001",
|
||||
"0",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate depth-0 propfind: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Should contain multistatus"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("My Contacts"),
|
||||
"Should contain address book name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_addressbook_collection_propfind_depth_1() {
|
||||
let addressbook = sample_address_book();
|
||||
let contacts = vec![sample_contact(), sample_contact_minimal()];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbook_collection_propfind(
|
||||
&mut output,
|
||||
&addressbook,
|
||||
&contacts,
|
||||
&request,
|
||||
"/carddav/ab-001",
|
||||
"1",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate depth-1 propfind: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Should contain multistatus"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("My Contacts"),
|
||||
"Should contain address book name"
|
||||
);
|
||||
// Depth 1 should include contact resources
|
||||
assert!(
|
||||
xml_str.contains("contact-001"),
|
||||
"Should include contact-001"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("contact-002"),
|
||||
"Should include contact-002"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Contacts response tests
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_contacts_response() {
|
||||
let contacts = vec![sample_contact()];
|
||||
let vcards = vec![(
|
||||
"contact-001".to_string(),
|
||||
contact_to_vcard(&sample_contact()),
|
||||
)];
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
namespace: "DAV:".to_string(),
|
||||
name: "getetag".to_string(),
|
||||
},
|
||||
QualifiedName {
|
||||
namespace: "urn:ietf:params:xml:ns:carddav".to_string(),
|
||||
name: "address-data".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate contacts response: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("multistatus"),
|
||||
"Should contain multistatus"
|
||||
);
|
||||
assert!(xml_str.contains("contact-001"), "Should reference contact");
|
||||
assert!(xml_str.contains("etag-abc123"), "Should contain etag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_empty_contacts_response() {
|
||||
let contacts: Vec<ContactDto> = vec![];
|
||||
let vcards: Vec<(String, String)> = vec![];
|
||||
let report = CardDavReportType::AddressbookQuery { props: vec![] };
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Empty contacts should produce valid response"
|
||||
);
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(xml_str.contains("multistatus"), "Should have multistatus");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Multiple address books test
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_generate_multiple_addressbooks() {
|
||||
let mut ab2 = sample_address_book();
|
||||
ab2.id = "ab-002".to_string();
|
||||
ab2.name = "Work Contacts".to_string();
|
||||
|
||||
let addressbooks = vec![sample_address_book(), ab2];
|
||||
let request = PropFindRequest {
|
||||
prop_find_type: PropFindType::AllProp,
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_addressbooks_propfind_response(
|
||||
&mut output,
|
||||
&addressbooks,
|
||||
&request,
|
||||
"/carddav/",
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
assert!(
|
||||
xml_str.contains("My Contacts"),
|
||||
"Should contain first address book"
|
||||
);
|
||||
assert!(
|
||||
xml_str.contains("Work Contacts"),
|
||||
"Should contain second address book"
|
||||
);
|
||||
assert!(xml_str.contains("ab-001"), "Should have first ID");
|
||||
assert!(xml_str.contains("ab-002"), "Should have second ID");
|
||||
}
|
||||
|
||||
// ========================
|
||||
// vCard edge cases
|
||||
// ========================
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_with_multiple_emails() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
// Should contain both emails
|
||||
assert!(vcard.contains("john@example.com"), "Should have work email");
|
||||
assert!(
|
||||
vcard.contains("john.doe@personal.com"),
|
||||
"Should have personal email"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_to_vcard_address_formatting() {
|
||||
let contact = sample_contact();
|
||||
let vcard = contact_to_vcard(&contact);
|
||||
|
||||
// vCard ADR format: ;;street;city;state;postal;country
|
||||
assert!(vcard.contains("123 Main St"), "Should have street");
|
||||
assert!(vcard.contains("Springfield"), "Should have city");
|
||||
assert!(vcard.contains("62701"), "Should have postal code");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Adapters module for translating between external protocols and internal models
|
||||
|
||||
pub mod webdav_adapter;
|
||||
pub mod caldav_adapter;
|
||||
pub mod carddav_adapter;
|
||||
pub mod webdav_adapter;
|
||||
|
||||
#[cfg(test)]
|
||||
mod caldav_adapter_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use crate::domain::entities::contact::AddressBook;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::entities::contact::AddressBook;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddressBookDto {
|
||||
@@ -73,4 +73,4 @@ pub struct ShareAddressBookDto {
|
||||
pub struct UnshareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use crate::domain::entities::calendar::Calendar;
|
||||
use crate::domain::entities::calendar_event::CalendarEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// DTO for calendar data transfer
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -178,4 +178,4 @@ pub struct EventQueryDto {
|
||||
pub struct PaginationDto {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::domain::entities::contact::{Address, Contact, ContactGroup, Email, Phone};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::entities::contact::{Contact, Email, Phone, Address, ContactGroup};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmailDto {
|
||||
@@ -221,4 +221,4 @@ pub struct UpdateContactGroupDto {
|
||||
pub struct GroupMembershipDto {
|
||||
pub group_id: String,
|
||||
pub contact_id: String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// DTO for favorites item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FavoriteItemDto {
|
||||
/// Unique identifier for the favorite entry
|
||||
pub id: String,
|
||||
|
||||
|
||||
/// User ID who owns this favorite
|
||||
pub user_id: String,
|
||||
|
||||
|
||||
/// ID of the favorited item (file or folder)
|
||||
pub item_id: String,
|
||||
|
||||
|
||||
/// Type of the item ('file' or 'folder')
|
||||
pub item_type: String,
|
||||
|
||||
|
||||
/// When the item was added to favorites
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::domain::entities::file::File;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// DTO for file responses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileDto {
|
||||
/// File ID
|
||||
pub id: String,
|
||||
|
||||
|
||||
/// File name
|
||||
pub name: String,
|
||||
|
||||
|
||||
/// Path to the file (relative)
|
||||
pub path: String,
|
||||
|
||||
|
||||
/// Size in bytes
|
||||
pub size: u64,
|
||||
|
||||
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
|
||||
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
|
||||
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
}
|
||||
@@ -51,14 +51,14 @@ impl From<FileDto> for File {
|
||||
// Note: this should be simplified if File has a proper constructor
|
||||
// If not, make the conversion as best as possible
|
||||
File::from_dto(
|
||||
dto.id,
|
||||
dto.name,
|
||||
dto.id,
|
||||
dto.name,
|
||||
dto.path,
|
||||
dto.size,
|
||||
dto.mime_type,
|
||||
dto.folder_id,
|
||||
dto.created_at,
|
||||
dto.modified_at
|
||||
dto.modified_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,4 +83,4 @@ impl Default for FileDto {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// DTO for folder creation requests
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateFolderDto {
|
||||
/// Name of the folder to create
|
||||
pub name: String,
|
||||
|
||||
|
||||
/// Parent folder ID (None for root level)
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
@@ -30,22 +30,22 @@ pub struct MoveFolderDto {
|
||||
pub struct FolderDto {
|
||||
/// Folder ID
|
||||
pub id: String,
|
||||
|
||||
|
||||
/// Folder name
|
||||
pub name: String,
|
||||
|
||||
|
||||
/// Path to the folder (relative)
|
||||
pub path: String,
|
||||
|
||||
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
|
||||
|
||||
/// Last modification timestamp
|
||||
pub modified_at: u64,
|
||||
|
||||
|
||||
/// Whether this is a root folder
|
||||
pub is_root: bool,
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub struct FolderDto {
|
||||
impl From<Folder> for FolderDto {
|
||||
fn from(folder: Folder) -> Self {
|
||||
let is_root = folder.parent_id().is_none();
|
||||
|
||||
|
||||
Self {
|
||||
id: folder.id().to_string(),
|
||||
name: folder.name().to_string(),
|
||||
@@ -77,7 +77,7 @@ impl From<FolderDto> for Folder {
|
||||
dto.path,
|
||||
dto.parent_id,
|
||||
dto.created_at,
|
||||
dto.modified_at
|
||||
dto.modified_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,4 +101,4 @@ impl Default for FolderDto {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::domain::services::i18n_service::Locale;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// DTO for locale information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LocaleDto {
|
||||
/// Locale code (e.g., "en", "es")
|
||||
pub code: String,
|
||||
|
||||
|
||||
/// Locale name in its own language (e.g., "English", "Español")
|
||||
pub name: String,
|
||||
}
|
||||
@@ -20,7 +20,7 @@ impl From<Locale> for LocaleDto {
|
||||
Locale::German => ("de", "Deutsch"),
|
||||
Locale::Portuguese => ("pt", "Português"),
|
||||
};
|
||||
|
||||
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
@@ -33,7 +33,7 @@ impl From<Locale> for LocaleDto {
|
||||
pub struct TranslationRequestDto {
|
||||
/// The translation key
|
||||
pub key: String,
|
||||
|
||||
|
||||
/// The locale code (optional, defaults to "en")
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
@@ -43,10 +43,10 @@ pub struct TranslationRequestDto {
|
||||
pub struct TranslationResponseDto {
|
||||
/// The translation key
|
||||
pub key: String,
|
||||
|
||||
|
||||
/// The locale code used for translation
|
||||
pub locale: String,
|
||||
|
||||
|
||||
/// The translated text
|
||||
pub text: String,
|
||||
}
|
||||
@@ -56,10 +56,10 @@ pub struct TranslationResponseDto {
|
||||
pub struct TranslationErrorDto {
|
||||
/// The translation key that was not found
|
||||
pub key: String,
|
||||
|
||||
|
||||
/// The locale code used for translation
|
||||
pub locale: String,
|
||||
|
||||
|
||||
/// The error message
|
||||
pub error: String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,3 @@ pub mod settings_dto;
|
||||
pub mod share_dto;
|
||||
pub mod trash_dto;
|
||||
pub mod user_dto;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A DTO to represent pagination information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -56,50 +56,42 @@ impl PaginationRequestDto {
|
||||
pub fn offset(&self) -> usize {
|
||||
self.page * self.page_size
|
||||
}
|
||||
|
||||
|
||||
/// Calculates the limit for paginated queries
|
||||
pub fn limit(&self) -> usize {
|
||||
self.page_size
|
||||
}
|
||||
|
||||
|
||||
/// Validates and adjusts the pagination parameters
|
||||
pub fn validate_and_adjust(&self) -> Self {
|
||||
let mut page = self.page;
|
||||
let mut page_size = self.page_size;
|
||||
|
||||
|
||||
// Ensure the page is at least 0
|
||||
if page < 1 {
|
||||
page = 0;
|
||||
}
|
||||
|
||||
|
||||
// Ensure the page size is between 10 and 500
|
||||
if page_size < 10 {
|
||||
page_size = 10;
|
||||
} else if page_size > 500 {
|
||||
page_size = 500;
|
||||
}
|
||||
|
||||
Self {
|
||||
page,
|
||||
page_size,
|
||||
}
|
||||
|
||||
Self { page, page_size }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PaginatedResponseDto<T> {
|
||||
/// Creates a new paginated response from the data and pagination information
|
||||
pub fn new(
|
||||
items: Vec<T>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
total_items: usize,
|
||||
) -> Self {
|
||||
pub fn new(items: Vec<T>, page: usize, page_size: usize, total_items: usize) -> Self {
|
||||
let total_pages = if total_items == 0 {
|
||||
0
|
||||
} else {
|
||||
total_items.div_ceil(page_size)
|
||||
};
|
||||
|
||||
|
||||
let pagination = PaginationDto {
|
||||
page,
|
||||
page_size,
|
||||
@@ -108,10 +100,7 @@ impl<T> PaginatedResponseDto<T> {
|
||||
has_next: page < total_pages - 1,
|
||||
has_prev: page > 0,
|
||||
};
|
||||
|
||||
Self {
|
||||
items,
|
||||
pagination,
|
||||
}
|
||||
|
||||
Self { items, pagination }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// DTO for recent items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecentItemDto {
|
||||
/// Unique identifier for the recent item
|
||||
pub id: String,
|
||||
|
||||
|
||||
/// Owner user ID
|
||||
pub user_id: String,
|
||||
|
||||
|
||||
/// Item ID (file or folder)
|
||||
pub item_id: String,
|
||||
|
||||
|
||||
/// Item type ('file' or 'folder')
|
||||
pub item_type: String,
|
||||
|
||||
|
||||
/// When the item was accessed
|
||||
pub accessed_at: DateTime<Utc>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/**
|
||||
* Data Transfer Object for file search criteria.
|
||||
*
|
||||
* This structure represents all possible search parameters that can be used
|
||||
*
|
||||
* This structure represents all possible search parameters that can be used
|
||||
* to filter files and folders in the system. It supports various filter types
|
||||
* including name matching, file types, date ranges, and size constraints.
|
||||
*/
|
||||
@@ -12,47 +12,47 @@ pub struct SearchCriteriaDto {
|
||||
/// Optional text to search in file/folder names
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name_contains: Option<String>,
|
||||
|
||||
|
||||
/// Optional list of file extensions to include (e.g., "pdf", "jpg")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_types: Option<Vec<String>>,
|
||||
|
||||
|
||||
/// Optional minimum creation date (seconds since epoch)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_after: Option<u64>,
|
||||
|
||||
|
||||
/// Optional maximum creation date (seconds since epoch)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_before: Option<u64>,
|
||||
|
||||
|
||||
/// Optional minimum modification date (seconds since epoch)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modified_after: Option<u64>,
|
||||
|
||||
|
||||
/// Optional maximum modification date (seconds since epoch)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modified_before: Option<u64>,
|
||||
|
||||
|
||||
/// Optional minimum file size in bytes
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_size: Option<u64>,
|
||||
|
||||
|
||||
/// Optional maximum file size in bytes
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_size: Option<u64>,
|
||||
|
||||
|
||||
/// Optional folder ID to limit search scope
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub folder_id: Option<String>,
|
||||
|
||||
|
||||
/// Whether to search recursively within subfolders (default: true)
|
||||
#[serde(default = "default_recursive")]
|
||||
pub recursive: bool,
|
||||
|
||||
|
||||
/// Maximum number of results to return
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
|
||||
|
||||
/// Offset for pagination
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
@@ -89,7 +89,7 @@ impl Default for SearchCriteriaDto {
|
||||
|
||||
/**
|
||||
* Data Transfer Object for search results.
|
||||
*
|
||||
*
|
||||
* This structure encapsulates the results of a search operation, including
|
||||
* both files and folders that match the search criteria, along with pagination information.
|
||||
*/
|
||||
@@ -97,19 +97,19 @@ impl Default for SearchCriteriaDto {
|
||||
pub struct SearchResultsDto {
|
||||
/// Files matching the search criteria
|
||||
pub files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
|
||||
|
||||
/// Folders matching the search criteria
|
||||
pub folders: Vec<crate::application::dtos::folder_dto::FolderDto>,
|
||||
|
||||
|
||||
/// Total count of matching items (for pagination)
|
||||
pub total_count: Option<usize>,
|
||||
|
||||
|
||||
/// Limit used in the search
|
||||
pub limit: usize,
|
||||
|
||||
|
||||
/// Offset used in the search
|
||||
pub offset: usize,
|
||||
|
||||
|
||||
/// Whether there are more results available
|
||||
pub has_more: bool,
|
||||
}
|
||||
@@ -126,7 +126,7 @@ impl SearchResultsDto {
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Creates a new search results object from files and folders
|
||||
pub fn new(
|
||||
files: Vec<crate::application::dtos::file_dto::FileDto>,
|
||||
@@ -139,7 +139,7 @@ impl SearchResultsDto {
|
||||
Some(total) => (offset + files.len() + folders.len()) < total,
|
||||
None => false,
|
||||
};
|
||||
|
||||
|
||||
Self {
|
||||
files,
|
||||
folders,
|
||||
@@ -149,4 +149,4 @@ impl SearchResultsDto {
|
||||
has_more,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Current OIDC settings returned to admin UI (secrets masked)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// True if a client secret is configured (never reveals the actual value)
|
||||
pub client_secret_set: bool,
|
||||
pub scopes: String,
|
||||
pub auto_provision: bool,
|
||||
pub admin_groups: String,
|
||||
pub disable_password_login: bool,
|
||||
pub provider_name: String,
|
||||
/// Auto-generated callback URL the admin must register in their IdP
|
||||
pub callback_url: String,
|
||||
/// Field names overridden by environment variables (read-only in UI)
|
||||
pub env_overrides: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request body for saving OIDC settings from the admin panel
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SaveOidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// Only update if provided and non-empty (None = keep existing)
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Option<String>,
|
||||
pub auto_provision: Option<bool>,
|
||||
pub admin_groups: Option<String>,
|
||||
pub disable_password_login: Option<bool>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for testing OIDC discovery
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TestOidcConnectionDto {
|
||||
pub issuer_url: String,
|
||||
}
|
||||
|
||||
/// Result of OIDC connection test
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcTestResultDto {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub issuer: Option<String>,
|
||||
pub authorization_endpoint: Option<String>,
|
||||
pub token_endpoint: Option<String>,
|
||||
pub userinfo_endpoint: Option<String>,
|
||||
/// Suggested provider name (derived from issuer hostname)
|
||||
pub provider_name_suggestion: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin User Management DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for updating a user's role
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserRoleDto {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Request body for updating a user's active status
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserActiveDto {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Request body for updating a user's storage quota
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserQuotaDto {
|
||||
/// Quota in bytes. Use 0 for unlimited.
|
||||
pub quota_bytes: i64,
|
||||
}
|
||||
|
||||
/// Request body for admin-created users
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AdminCreateUserDto {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Optional — if omitted, a placeholder email is generated
|
||||
pub email: Option<String>,
|
||||
/// "admin" or "user"; defaults to "user"
|
||||
pub role: Option<String>,
|
||||
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
|
||||
pub quota_bytes: Option<i64>,
|
||||
/// Whether the account is active; defaults to true
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request body for admin password reset
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminResetPasswordDto {
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Query parameters for listing users
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListUsersQueryDto {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// Dashboard statistics
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DashboardStatsDto {
|
||||
// System info
|
||||
pub server_version: String,
|
||||
pub auth_enabled: bool,
|
||||
pub oidc_configured: bool,
|
||||
pub quotas_enabled: bool,
|
||||
// User stats
|
||||
pub total_users: i64,
|
||||
pub active_users: i64,
|
||||
pub admin_users: i64,
|
||||
// Storage stats
|
||||
pub total_quota_bytes: i64,
|
||||
pub total_used_bytes: i64,
|
||||
pub storage_usage_percent: f64,
|
||||
pub users_over_80_percent: i64,
|
||||
pub users_over_quota: i64,
|
||||
pub registration_enabled: bool,
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Current OIDC settings returned to admin UI (secrets masked)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// True if a client secret is configured (never reveals the actual value)
|
||||
pub client_secret_set: bool,
|
||||
pub scopes: String,
|
||||
pub auto_provision: bool,
|
||||
pub admin_groups: String,
|
||||
pub disable_password_login: bool,
|
||||
pub provider_name: String,
|
||||
/// Auto-generated callback URL the admin must register in their IdP
|
||||
pub callback_url: String,
|
||||
/// Field names overridden by environment variables (read-only in UI)
|
||||
pub env_overrides: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request body for saving OIDC settings from the admin panel
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SaveOidcSettingsDto {
|
||||
pub enabled: bool,
|
||||
pub issuer_url: String,
|
||||
pub client_id: String,
|
||||
/// Only update if provided and non-empty (None = keep existing)
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Option<String>,
|
||||
pub auto_provision: Option<bool>,
|
||||
pub admin_groups: Option<String>,
|
||||
pub disable_password_login: Option<bool>,
|
||||
pub provider_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for testing OIDC discovery
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TestOidcConnectionDto {
|
||||
pub issuer_url: String,
|
||||
}
|
||||
|
||||
/// Result of OIDC connection test
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcTestResultDto {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub issuer: Option<String>,
|
||||
pub authorization_endpoint: Option<String>,
|
||||
pub token_endpoint: Option<String>,
|
||||
pub userinfo_endpoint: Option<String>,
|
||||
/// Suggested provider name (derived from issuer hostname)
|
||||
pub provider_name_suggestion: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin User Management DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for updating a user's role
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserRoleDto {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Request body for updating a user's active status
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserActiveDto {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Request body for updating a user's storage quota
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserQuotaDto {
|
||||
/// Quota in bytes. Use 0 for unlimited.
|
||||
pub quota_bytes: i64,
|
||||
}
|
||||
|
||||
/// Request body for admin-created users
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AdminCreateUserDto {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Optional — if omitted, a placeholder email is generated
|
||||
pub email: Option<String>,
|
||||
/// "admin" or "user"; defaults to "user"
|
||||
pub role: Option<String>,
|
||||
/// Storage quota in bytes; 0 = unlimited. If omitted, uses role default.
|
||||
pub quota_bytes: Option<i64>,
|
||||
/// Whether the account is active; defaults to true
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request body for admin password reset
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminResetPasswordDto {
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Query parameters for listing users
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListUsersQueryDto {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// Dashboard statistics
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DashboardStatsDto {
|
||||
// System info
|
||||
pub server_version: String,
|
||||
pub auth_enabled: bool,
|
||||
pub oidc_configured: bool,
|
||||
pub quotas_enabled: bool,
|
||||
// User stats
|
||||
pub total_users: i64,
|
||||
pub active_users: i64,
|
||||
pub admin_users: i64,
|
||||
// Storage stats
|
||||
pub total_quota_bytes: i64,
|
||||
pub total_used_bytes: i64,
|
||||
pub storage_usage_percent: f64,
|
||||
pub users_over_80_percent: i64,
|
||||
pub users_over_quota: i64,
|
||||
pub registration_enabled: bool,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub struct UpdateShareDto {
|
||||
impl ShareDto {
|
||||
pub fn from_entity(share: &Share, base_url: &str) -> Self {
|
||||
let url = format!("{}/s/{}", base_url, share.token());
|
||||
|
||||
|
||||
Self {
|
||||
id: share.id().to_string(),
|
||||
item_id: share.item_id().to_string(),
|
||||
@@ -69,7 +69,7 @@ impl SharePermissionsDto {
|
||||
reshare: permissions.reshare(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn to_entity(&self) -> SharePermissions {
|
||||
SharePermissions::new(self.read, self.write, self.reshare)
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ pub struct RestoreFromTrashRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeletePermanentlyRequest {
|
||||
pub trash_id: String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::domain::entities::user::User;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserDto {
|
||||
@@ -117,4 +117,4 @@ pub struct OidcUserInfoDto {
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod adapters;
|
||||
pub mod dtos;
|
||||
pub mod ports;
|
||||
pub mod services;
|
||||
pub mod transactions;
|
||||
pub mod adapters;
|
||||
|
||||
// Re-exportaciones para facilitar el acceso a los principales puertos
|
||||
// Re-exportaciones para facilitar el acceso a los principales puertos
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::domain::entities::user::User;
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ============================================================================
|
||||
// Cryptography Ports - Extracted from Domain to maintain Clean Architecture
|
||||
// ============================================================================
|
||||
|
||||
/// Port for password hashing operations.
|
||||
///
|
||||
///
|
||||
/// This trait abstracts cryptographic password operations, allowing the domain
|
||||
/// layer to remain independent of specific hashing implementations (argon2, bcrypt, etc.)
|
||||
pub trait PasswordHasherPort: Send + Sync + 'static {
|
||||
/// Hash a plain text password
|
||||
fn hash_password(&self, password: &str) -> Result<String, DomainError>;
|
||||
|
||||
|
||||
/// Verify a plain text password against a hash
|
||||
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, DomainError>;
|
||||
}
|
||||
@@ -39,22 +39,22 @@ pub struct TokenClaims {
|
||||
}
|
||||
|
||||
/// Port for JWT token operations.
|
||||
///
|
||||
///
|
||||
/// This trait abstracts token generation and validation, allowing the domain
|
||||
/// layer to remain independent of specific JWT implementations.
|
||||
pub trait TokenServicePort: Send + Sync + 'static {
|
||||
/// Generate an access token for a user
|
||||
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
|
||||
|
||||
|
||||
/// Validate a token and extract its claims
|
||||
fn validate_token(&self, token: &str) -> Result<TokenClaims, DomainError>;
|
||||
|
||||
|
||||
/// Generate a refresh token
|
||||
fn generate_refresh_token(&self) -> String;
|
||||
|
||||
|
||||
/// Get refresh token expiry in seconds
|
||||
fn refresh_token_expiry_secs(&self) -> i64;
|
||||
|
||||
|
||||
/// Get refresh token expiry in days
|
||||
fn refresh_token_expiry_days(&self) -> i64;
|
||||
}
|
||||
@@ -65,38 +65,46 @@ pub trait TokenServicePort: Send + Sync + 'static {
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserStoragePort: Send + Sync + 'static {
|
||||
/// Creates a new user
|
||||
/// Creates a new user
|
||||
async fn create_user(&self, user: User) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
/// Gets a user by ID
|
||||
async fn get_user_by_id(&self, id: &str) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
/// Gets a user by username
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
/// Gets a user by email
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
/// Updates an existing user
|
||||
async fn update_user(&self, user: User) -> Result<User, DomainError>;
|
||||
|
||||
|
||||
/// Updates only the storage usage of a user
|
||||
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> Result<(), DomainError>;
|
||||
|
||||
async fn update_storage_usage(
|
||||
&self,
|
||||
user_id: &str,
|
||||
usage_bytes: i64,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
|
||||
/// Lists users by role (e.g., "admin" or "user")
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
|
||||
/// Deletes a user by their ID
|
||||
async fn delete_user(&self, user_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Changes a user's password
|
||||
async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Finds a user by OIDC provider + subject pair
|
||||
async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result<User, DomainError>;
|
||||
async fn get_user_by_oidc_subject(
|
||||
&self,
|
||||
provider: &str,
|
||||
subject: &str,
|
||||
) -> Result<User, DomainError>;
|
||||
|
||||
/// Activates or deactivates a user
|
||||
async fn set_user_active_status(&self, user_id: &str, active: bool) -> Result<(), DomainError>;
|
||||
@@ -105,7 +113,11 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
async fn change_role(&self, user_id: &str, role: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Updates a user's storage quota
|
||||
async fn update_storage_quota(&self, user_id: &str, quota_bytes: i64) -> Result<(), DomainError>;
|
||||
async fn update_storage_quota(
|
||||
&self,
|
||||
user_id: &str,
|
||||
quota_bytes: i64,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Counts the total number of users
|
||||
async fn count_users(&self) -> Result<i64, DomainError>;
|
||||
@@ -139,14 +151,27 @@ pub trait OidcServicePort: Send + Sync + 'static {
|
||||
/// Get the authorization URL for redirecting the user to the IdP.
|
||||
/// Includes PKCE code_challenge (S256) and nonce for ID token binding.
|
||||
/// This is async because it may need to fetch the OIDC discovery document.
|
||||
async fn get_authorize_url(&self, state: &str, nonce: &str, pkce_challenge: &str) -> Result<String, DomainError>;
|
||||
async fn get_authorize_url(
|
||||
&self,
|
||||
state: &str,
|
||||
nonce: &str,
|
||||
pkce_challenge: &str,
|
||||
) -> Result<String, DomainError>;
|
||||
|
||||
/// Exchange an authorization code for tokens, providing PKCE code_verifier.
|
||||
async fn exchange_code(&self, code: &str, pkce_verifier: &str) -> Result<OidcTokenSet, DomainError>;
|
||||
async fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
) -> Result<OidcTokenSet, DomainError>;
|
||||
|
||||
/// Validate an ID token and extract claims.
|
||||
/// If `expected_nonce` is provided, verifies the `nonce` claim matches.
|
||||
async fn validate_id_token(&self, id_token: &str, expected_nonce: Option<&str>) -> Result<OidcIdClaims, DomainError>;
|
||||
async fn validate_id_token(
|
||||
&self,
|
||||
id_token: &str,
|
||||
expected_nonce: Option<&str>,
|
||||
) -> Result<OidcIdClaims, DomainError>;
|
||||
|
||||
/// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims)
|
||||
async fn fetch_user_info(&self, access_token: &str) -> Result<OidcIdClaims, DomainError>;
|
||||
@@ -159,13 +184,16 @@ pub trait OidcServicePort: Send + Sync + 'static {
|
||||
pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// Creates a new session
|
||||
async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
|
||||
|
||||
|
||||
/// Gets a session by refresh token
|
||||
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> Result<Session, DomainError>;
|
||||
|
||||
async fn get_session_by_refresh_token(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<Session, DomainError>;
|
||||
|
||||
/// Revokes a specific session
|
||||
async fn revoke_session(&self, session_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Revokes all sessions of a user
|
||||
async fn revoke_all_user_sessions(&self, user_id: &str) -> Result<u64, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
//! The application and interface layers remain independent of the caching
|
||||
//! implementation details.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use crate::common::errors::DomainError;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Statistics for monitoring write-behind cache status.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
||||
@@ -1,47 +1,108 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, UpdateCalendarDto,
|
||||
CreateEventDto, UpdateEventDto, CreateEventICalDto
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Port for external calendar storage mechanisms
|
||||
#[async_trait]
|
||||
pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
// Calendar operations
|
||||
async fn create_calendar(&self, calendar: CreateCalendarDto, owner_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result<CalendarDto, DomainError>;
|
||||
async fn create_calendar(
|
||||
&self,
|
||||
calendar: CreateCalendarDto,
|
||||
owner_id: &str,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
update: UpdateCalendarDto,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_calendars_by_owner(&self, owner_id: &str) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_shared_with_user(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(&self, limit: i64, offset: i64) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn check_calendar_access(&self, calendar_id: &str, user_id: &str) -> Result<bool, DomainError>;
|
||||
|
||||
async fn list_calendars_by_owner(
|
||||
&self,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn check_calendar_access(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(&self, calendar_id: &str) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Calendar properties
|
||||
async fn set_calendar_property(&self, calendar_id: &str, property_name: &str, property_value: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar_property(&self, calendar_id: &str, property_name: &str) -> Result<Option<String>, DomainError>;
|
||||
async fn get_calendar_properties(&self, calendar_id: &str) -> Result<std::collections::HashMap<String, String>, DomainError>;
|
||||
|
||||
async fn set_calendar_property(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
property_name: &str,
|
||||
property_value: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_property(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
property_name: &str,
|
||||
) -> Result<Option<String>, DomainError>;
|
||||
async fn get_calendar_properties(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<std::collections::HashMap<String, String>, DomainError>;
|
||||
|
||||
// Event operations
|
||||
async fn create_event(&self, event: CreateEventDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn create_event_from_ical(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
update: UpdateEventDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn list_events_by_calendar(&self, calendar_id: &str) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn list_events_by_calendar_paginated(&self, calendar_id: &str, limit: i64, offset: i64) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn list_events_by_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn list_events_by_calendar_paginated(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn get_events_in_time_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: &DateTime<Utc>,
|
||||
end: &DateTime<Utc>
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: &DateTime<Utc>,
|
||||
end: &DateTime<Utc>,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -49,41 +110,113 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
#[async_trait]
|
||||
pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
// Calendar operations
|
||||
async fn create_calendar(&self, calendar: CreateCalendarDto) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result<CalendarDto, DomainError>;
|
||||
async fn create_calendar(
|
||||
&self,
|
||||
calendar: CreateCalendarDto,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
update: UpdateCalendarDto,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_my_calendars(&self) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_shared_calendars(&self) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(&self, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(&self, calendar_id: &str) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Event operations
|
||||
async fn create_event(&self, event: CreateEventDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn create_event_from_ical(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
update: UpdateEventDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn list_events(&self, calendar_id: &str, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>
|
||||
async fn list_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
|
||||
// ─── User-contextualized variants (for CalDAV protocol handler) ──
|
||||
async fn create_calendar_for_user(&self, calendar: CreateCalendarDto, user_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar_for_user(&self, calendar_id: &str, update: UpdateCalendarDto, user_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_my_calendars_for_user(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_events_for_user(&self, calendar_id: &str, limit: Option<i64>, offset: Option<i64>, user_id: &str) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn get_events_in_range_for_user(&self, calendar_id: &str, start: DateTime<Utc>, end: DateTime<Utc>, user_id: &str) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn create_event_from_ical_for_user(&self, event: CreateEventICalDto, user_id: &str) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event_for_user(&self, event_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
async fn create_calendar_for_user(
|
||||
&self,
|
||||
calendar: CreateCalendarDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn update_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
update: UpdateCalendarDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_my_calendars_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_events_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn get_events_in_range_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
async fn create_event_from_ical_for_user(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event_for_user(&self, event_id: &str, user_id: &str)
|
||||
-> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,143 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::application::dtos::address_book_dto::{
|
||||
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
||||
ShareAddressBookDto, UnshareAddressBookDto
|
||||
AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto,
|
||||
UpdateAddressBookDto,
|
||||
};
|
||||
use crate::application::dtos::contact_dto::{
|
||||
ContactDto, CreateContactDto, UpdateContactDto, CreateContactVCardDto,
|
||||
ContactGroupDto, CreateContactGroupDto, UpdateContactGroupDto, GroupMembershipDto
|
||||
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub type CardDavRepositoryError = DomainError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||
// Address Book operations
|
||||
async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result<AddressBookDto, DomainError>;
|
||||
async fn update_address_book(&self, address_book_id: &str, update: UpdateAddressBookDto) -> Result<AddressBookDto, DomainError>;
|
||||
async fn delete_address_book(&self, address_book_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_address_book(&self, address_book_id: &str, user_id: &str) -> Result<AddressBookDto, DomainError>;
|
||||
async fn list_user_address_books(&self, user_id: &str) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
async fn create_address_book(
|
||||
&self,
|
||||
dto: CreateAddressBookDto,
|
||||
) -> Result<AddressBookDto, DomainError>;
|
||||
async fn update_address_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
update: UpdateAddressBookDto,
|
||||
) -> Result<AddressBookDto, DomainError>;
|
||||
async fn delete_address_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_address_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<AddressBookDto, DomainError>;
|
||||
async fn list_user_address_books(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
async fn list_public_address_books(&self) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
|
||||
|
||||
// Address Book sharing
|
||||
async fn share_address_book(&self, dto: ShareAddressBookDto, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn unshare_address_book(&self, dto: UnshareAddressBookDto, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_address_book_shares(&self, address_book_id: &str, user_id: &str) -> Result<Vec<(String, bool)>, DomainError>;
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
dto: ShareAddressBookDto,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
dto: UnshareAddressBookDto,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<(String, bool)>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContactUseCase: Send + Sync + 'static {
|
||||
// Contact operations
|
||||
async fn create_contact(&self, dto: CreateContactDto) -> Result<ContactDto, DomainError>;
|
||||
async fn create_contact_from_vcard(&self, dto: CreateContactVCardDto) -> Result<ContactDto, DomainError>;
|
||||
async fn update_contact(&self, contact_id: &str, update: UpdateContactDto) -> Result<ContactDto, DomainError>;
|
||||
async fn create_contact_from_vcard(
|
||||
&self,
|
||||
dto: CreateContactVCardDto,
|
||||
) -> Result<ContactDto, DomainError>;
|
||||
async fn update_contact(
|
||||
&self,
|
||||
contact_id: &str,
|
||||
update: UpdateContactDto,
|
||||
) -> Result<ContactDto, DomainError>;
|
||||
async fn delete_contact(&self, contact_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_contact(&self, contact_id: &str, user_id: &str) -> Result<ContactDto, DomainError>;
|
||||
async fn list_contacts(&self, address_book_id: &str, user_id: &str) -> Result<Vec<ContactDto>, DomainError>;
|
||||
async fn search_contacts(&self, address_book_id: &str, query: &str, user_id: &str) -> Result<Vec<ContactDto>, DomainError>;
|
||||
|
||||
async fn get_contact(&self, contact_id: &str, user_id: &str)
|
||||
-> Result<ContactDto, DomainError>;
|
||||
async fn list_contacts(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ContactDto>, DomainError>;
|
||||
async fn search_contacts(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
query: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ContactDto>, DomainError>;
|
||||
|
||||
// Contact Group operations
|
||||
async fn create_group(&self, dto: CreateContactGroupDto) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn update_group(&self, group_id: &str, update: UpdateContactGroupDto) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn create_group(
|
||||
&self,
|
||||
dto: CreateContactGroupDto,
|
||||
) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn update_group(
|
||||
&self,
|
||||
group_id: &str,
|
||||
update: UpdateContactGroupDto,
|
||||
) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn delete_group(&self, group_id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_group(&self, group_id: &str, user_id: &str) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn list_groups(&self, address_book_id: &str, user_id: &str) -> Result<Vec<ContactGroupDto>, DomainError>;
|
||||
|
||||
async fn get_group(
|
||||
&self,
|
||||
group_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<ContactGroupDto, DomainError>;
|
||||
async fn list_groups(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ContactGroupDto>, DomainError>;
|
||||
|
||||
// Group membership
|
||||
async fn add_contact_to_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn remove_contact_from_group(&self, dto: GroupMembershipDto, user_id: &str) -> Result<(), DomainError>;
|
||||
async fn list_contacts_in_group(&self, group_id: &str, user_id: &str) -> Result<Vec<ContactDto>, DomainError>;
|
||||
async fn list_groups_for_contact(&self, contact_id: &str, user_id: &str) -> Result<Vec<ContactGroupDto>, DomainError>;
|
||||
|
||||
async fn add_contact_to_group(
|
||||
&self,
|
||||
dto: GroupMembershipDto,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_contact_from_group(
|
||||
&self,
|
||||
dto: GroupMembershipDto,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn list_contacts_in_group(
|
||||
&self,
|
||||
group_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ContactDto>, DomainError>;
|
||||
async fn list_groups_for_contact(
|
||||
&self,
|
||||
contact_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ContactGroupDto>, DomainError>;
|
||||
|
||||
// vCard operations
|
||||
async fn get_contact_vcard(&self, contact_id: &str, user_id: &str) -> Result<String, DomainError>;
|
||||
async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result<Vec<(String, String)>, DomainError>;
|
||||
}
|
||||
async fn get_contact_vcard(
|
||||
&self,
|
||||
contact_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<String, DomainError>;
|
||||
async fn get_contacts_as_vcards(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! operations, keeping the application and interface layers independent of
|
||||
//! the specific upload implementation (TUS-like protocol, S3 multipart, etc.).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use serde::Serialize;
|
||||
use crate::common::errors::DomainError;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Default chunk size (5 MB) — optimised for parallel transfers.
|
||||
pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024;
|
||||
@@ -80,10 +80,7 @@ pub trait ChunkedUploadPort: Send + Sync + 'static {
|
||||
) -> Result<ChunkUploadResponseDto, DomainError>;
|
||||
|
||||
/// Get the current status of an upload session.
|
||||
async fn get_status(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<UploadStatusResponseDto, DomainError>;
|
||||
async fn get_status(&self, upload_id: &str) -> Result<UploadStatusResponseDto, DomainError>;
|
||||
|
||||
/// Assemble all chunks into the final file.
|
||||
///
|
||||
@@ -94,16 +91,10 @@ pub trait ChunkedUploadPort: Send + Sync + 'static {
|
||||
) -> Result<(PathBuf, String, Option<String>, String, u64), DomainError>;
|
||||
|
||||
/// Finalize upload: clean up the session and temporary files.
|
||||
async fn finalize_upload(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn finalize_upload(&self, upload_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Cancel an upload and clean up all temporary data.
|
||||
async fn cancel_upload(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn cancel_upload(&self, upload_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Check if a file size qualifies for chunked upload.
|
||||
fn should_use_chunked(&self, size: u64) -> bool;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! keeping the application and interface layers independent of specific
|
||||
//! compression implementations (gzip, zstd, etc.).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Compression level settings for file compression operations.
|
||||
///
|
||||
@@ -30,7 +30,11 @@ pub enum CompressionLevel {
|
||||
#[async_trait]
|
||||
pub trait CompressionPort: Send + Sync + 'static {
|
||||
/// Compress data in memory.
|
||||
async fn compress_data(&self, data: &[u8], level: CompressionLevel) -> Result<Vec<u8>, DomainError>;
|
||||
async fn compress_data(
|
||||
&self,
|
||||
data: &[u8],
|
||||
level: CompressionLevel,
|
||||
) -> Result<Vec<u8>, DomainError>;
|
||||
|
||||
/// Decompress data in memory.
|
||||
async fn decompress_data(&self, compressed_data: &[u8]) -> Result<Vec<u8>, DomainError>;
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! keeping the application and interface layers independent of the specific
|
||||
//! content-addressable storage implementation.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use serde::Serialize;
|
||||
use crate::common::errors::DomainError;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata of a stored blob in the dedup system.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::Result;
|
||||
use crate::application::dtos::favorites_dto::FavoriteItemDto;
|
||||
use crate::common::errors::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Defines operations for managing user favorites
|
||||
#[async_trait]
|
||||
pub trait FavoritesUseCase: Send + Sync {
|
||||
/// Get all favorites for a user
|
||||
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>>;
|
||||
|
||||
|
||||
/// Add an item to user's favorites
|
||||
async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>;
|
||||
|
||||
|
||||
/// Remove an item from user's favorites
|
||||
async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
async fn remove_from_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<bool>;
|
||||
|
||||
/// Check if an item is in user's favorites
|
||||
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
}
|
||||
@@ -40,4 +45,4 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
||||
|
||||
/// Checks if an item is in favorites.
|
||||
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::pin::Pin;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -48,7 +48,13 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
) -> Result<(FileDto, UploadStrategy), DomainError>;
|
||||
|
||||
/// Creates a new file at the specified path (for WebDAV)
|
||||
async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result<FileDto, DomainError>;
|
||||
async fn create_file(
|
||||
&self,
|
||||
parent_path: &str,
|
||||
filename: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Updates the content of an existing file (for WebDAV)
|
||||
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>;
|
||||
@@ -81,18 +87,21 @@ pub enum OptimizedFileContent {
|
||||
pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
/// Gets a file by its ID
|
||||
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError>;
|
||||
|
||||
|
||||
/// Gets a file by its path (for WebDAV)
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
|
||||
|
||||
|
||||
/// Lists files in a folder
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
|
||||
|
||||
|
||||
/// Gets file content as bytes (for small files)
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
|
||||
|
||||
|
||||
/// Gets file content as a stream (for large files)
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Optimized multi-tier download.
|
||||
///
|
||||
@@ -123,11 +132,15 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
#[async_trait]
|
||||
pub trait FileManagementUseCase: Send + Sync + 'static {
|
||||
/// Moves a file to another folder
|
||||
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError>;
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Renames a file
|
||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError>;
|
||||
|
||||
|
||||
/// Deletes a file
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
@@ -138,11 +151,7 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
|
||||
/// 3. Decrements the dedup reference count for the content hash.
|
||||
///
|
||||
/// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted.
|
||||
async fn delete_with_cleanup(
|
||||
&self,
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
/// Factory for creating file use case implementations
|
||||
@@ -150,4 +159,4 @@ pub trait FileUseCaseFactory: Send + Sync + 'static {
|
||||
fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase>;
|
||||
fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase>;
|
||||
fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto};
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
@@ -9,36 +11,37 @@ use crate::common::errors::DomainError;
|
||||
pub trait FolderUseCase: Send + Sync + 'static {
|
||||
/// Creates a new folder
|
||||
async fn create_folder(&self, dto: CreateFolderDto) -> Result<FolderDto, DomainError>;
|
||||
|
||||
|
||||
/// Gets a folder by its ID
|
||||
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError>;
|
||||
|
||||
|
||||
/// Gets a folder by its path
|
||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
|
||||
|
||||
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
|
||||
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
|
||||
/// Renames a folder
|
||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto) -> Result<FolderDto, DomainError>;
|
||||
|
||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto)
|
||||
-> Result<FolderDto, DomainError>;
|
||||
|
||||
/// Moves a folder to another parent
|
||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError>;
|
||||
|
||||
|
||||
/// Deletes a folder
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary port for file and folder search
|
||||
*
|
||||
*
|
||||
* Defines the operations related to advanced search of
|
||||
* files and folders based on various criteria.
|
||||
*/
|
||||
@@ -46,16 +49,16 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
pub trait SearchUseCase: Send + Sync + 'static {
|
||||
/**
|
||||
* Performs a search based on the specified criteria
|
||||
*
|
||||
*
|
||||
* @param criteria Search criteria including text, dates, sizes, etc.
|
||||
* @return Search results containing matching files and folders
|
||||
*/
|
||||
async fn search(&self, criteria: SearchCriteriaDto) -> Result<SearchResultsDto, DomainError>;
|
||||
|
||||
|
||||
/**
|
||||
* Clears the search results cache
|
||||
*
|
||||
*
|
||||
* @return Result indicating success or error
|
||||
*/
|
||||
async fn clear_search_cache(&self) -> Result<(), DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@ pub mod storage_ports;
|
||||
pub mod thumbnail_ports;
|
||||
pub mod transcode_ports;
|
||||
pub mod trash_ports;
|
||||
pub mod zip_ports;
|
||||
pub mod zip_ports;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
// Re-export domain repository traits for backward compatibility
|
||||
pub use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
@@ -14,13 +14,13 @@ use super::storage_ports::{FileReadPort, FileWritePort};
|
||||
pub trait StoragePort: Send + Sync + 'static {
|
||||
/// Resolves a domain path to a physical path
|
||||
fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf;
|
||||
|
||||
|
||||
/// Creates directories if they don't exist
|
||||
async fn ensure_directory(&self, storage_path: &StoragePath) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Checks if a file exists at the given path
|
||||
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
|
||||
|
||||
/// Checks if a directory exists at the given path
|
||||
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
|
||||
}
|
||||
@@ -54,28 +54,28 @@ impl<T: FolderRepository> FolderStoragePort for T {}
|
||||
pub trait IdMappingPort: Send + Sync + 'static {
|
||||
/// Gets or creates an ID for a path
|
||||
async fn get_or_create_id(&self, path: &StoragePath) -> Result<String, DomainError>;
|
||||
|
||||
|
||||
/// Gets a path by its ID
|
||||
async fn get_path_by_id(&self, id: &str) -> Result<StoragePath, DomainError>;
|
||||
|
||||
|
||||
/// Updates the path for an existing ID
|
||||
async fn update_path(&self, id: &str, new_path: &StoragePath) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Removes an ID from the mapping
|
||||
async fn remove_id(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Saves pending changes
|
||||
async fn save_changes(&self) -> Result<(), DomainError>;
|
||||
|
||||
|
||||
/// Gets the file path as a PathBuf
|
||||
async fn get_file_path(&self, file_id: &str) -> Result<PathBuf, DomainError> {
|
||||
let storage_path = self.get_path_by_id(file_id).await?;
|
||||
Ok(PathBuf::from(storage_path.to_string()))
|
||||
}
|
||||
|
||||
|
||||
/// Updates a file's path
|
||||
async fn update_file_path(&self, file_id: &str, new_path: &PathBuf) -> Result<(), DomainError> {
|
||||
let storage_path = StoragePath::from_string(new_path.to_string_lossy().as_ref());
|
||||
self.update_path(file_id, &storage_path).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::Result;
|
||||
use crate::application::dtos::recent_dto::RecentItemDto;
|
||||
use crate::common::errors::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Defines operations for managing user recent items
|
||||
#[async_trait]
|
||||
pub trait RecentItemsUseCase: Send + Sync {
|
||||
/// Get all recent items for a user
|
||||
async fn get_recent_items(&self, user_id: &str, limit: Option<i32>) -> Result<Vec<RecentItemDto>>;
|
||||
|
||||
async fn get_recent_items(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: Option<i32>,
|
||||
) -> Result<Vec<RecentItemDto>>;
|
||||
|
||||
/// Record access to an item
|
||||
async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>;
|
||||
|
||||
async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str)
|
||||
-> Result<()>;
|
||||
|
||||
/// Remove an item from recents
|
||||
async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
async fn remove_from_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<bool>;
|
||||
|
||||
/// Clear the entire recent items list
|
||||
async fn clear_recent_items(&self, user_id: &str) -> Result<()>;
|
||||
}
|
||||
@@ -42,4 +52,4 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
|
||||
|
||||
/// Removes items exceeding `max_items` (the oldest ones).
|
||||
async fn prune(&self, user_id: &str, max_items: i32) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@ use async_trait::async_trait;
|
||||
use crate::{
|
||||
application::dtos::{
|
||||
pagination::PaginatedResponseDto,
|
||||
share_dto::{CreateShareDto, ShareDto, UpdateShareDto}
|
||||
share_dto::{CreateShareDto, ShareDto, UpdateShareDto},
|
||||
},
|
||||
common::errors::DomainError,
|
||||
domain::entities::share::ShareItemType,
|
||||
};
|
||||
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareUseCase: Send + Sync + 'static {
|
||||
/// Create a new shared link for a file or folder
|
||||
@@ -56,30 +55,45 @@ pub trait ShareUseCase: Send + Sync + 'static {
|
||||
token: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
|
||||
/// Register an access to a shared link
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShareStoragePort: Send + Sync + 'static {
|
||||
async fn save_share(&self, share: &crate::domain::entities::share::Share)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_id(&self, id: &str)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_token(&self, token: &str)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType)
|
||||
-> Result<Vec<crate::domain::entities::share::Share>, DomainError>;
|
||||
|
||||
async fn update_share(&self, share: &crate::domain::entities::share::Share)
|
||||
-> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn save_share(
|
||||
&self,
|
||||
share: &crate::domain::entities::share::Share,
|
||||
) -> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_share_by_token(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn find_shares_by_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<Vec<crate::domain::entities::share::Share>, DomainError>;
|
||||
|
||||
async fn update_share(
|
||||
&self,
|
||||
share: &crate::domain::entities::share::Share,
|
||||
) -> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
async fn delete_share(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize)
|
||||
-> Result<(Vec<crate::domain::entities::share::Share>, usize), DomainError>;
|
||||
|
||||
async fn find_shares_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<crate::domain::entities::share::Share>, usize), DomainError>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::path::PathBuf;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
// Re-export domain repository traits for backward compatibility.
|
||||
// The canonical definitions now live in domain/repositories/.
|
||||
pub use crate::domain::repositories::file_repository::{FileReadRepository, FileWriteRepository, FileRepository};
|
||||
pub use crate::domain::repositories::file_repository::{
|
||||
FileReadRepository, FileRepository, FileWriteRepository,
|
||||
};
|
||||
pub use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
@@ -92,17 +94,14 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
) -> Result<File, DomainError>;
|
||||
|
||||
/// Renames a file (same folder, different name).
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<File, DomainError>;
|
||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError>;
|
||||
|
||||
/// Deletes a file.
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Updates the content of an existing file.
|
||||
async fn update_file_content(&self, file_id: &str, content: Vec<u8>) -> Result<(), DomainError>;
|
||||
async fn update_file_content(&self, file_id: &str, content: Vec<u8>)
|
||||
-> Result<(), DomainError>;
|
||||
|
||||
/// Registers file metadata WITHOUT writing content to disk (write-behind).
|
||||
///
|
||||
@@ -122,7 +121,11 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Restores a file from the trash to its original location
|
||||
async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>;
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
file_id: &str,
|
||||
original_path: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Permanently deletes a file (used by the trash)
|
||||
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>;
|
||||
@@ -174,4 +177,4 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
pub trait StorageUseCase: Send + Sync + 'static {
|
||||
/// Handle a request with the specified action and parameters
|
||||
async fn handle_request(&self, action: &str, params: Value) -> Result<Value, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! keeping the application and interface layers independent of specific
|
||||
//! image processing implementations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use crate::common::errors::DomainError;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Thumbnail sizes supported by the system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -42,7 +42,11 @@ impl ThumbnailSize {
|
||||
|
||||
/// Get all thumbnail sizes.
|
||||
pub fn all() -> &'static [ThumbnailSize] {
|
||||
&[ThumbnailSize::Icon, ThumbnailSize::Preview, ThumbnailSize::Large]
|
||||
&[
|
||||
ThumbnailSize::Icon,
|
||||
ThumbnailSize::Preview,
|
||||
ThumbnailSize::Large,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +81,7 @@ pub trait ThumbnailPort: Send + Sync + 'static {
|
||||
/// Generate all thumbnail sizes for a file in the background.
|
||||
///
|
||||
/// Called after file upload to pre-generate thumbnails.
|
||||
fn generate_all_sizes_background(
|
||||
self: Arc<Self>,
|
||||
file_id: String,
|
||||
original_path: PathBuf,
|
||||
);
|
||||
fn generate_all_sizes_background(self: Arc<Self>, file_id: String, original_path: PathBuf);
|
||||
|
||||
/// Delete all thumbnails for a file.
|
||||
async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! (e.g., JPEG/PNG → WebP), keeping the application and interface layers
|
||||
//! independent of specific image processing implementations.
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Supported output formats for image transcoding.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -8,16 +8,16 @@ use crate::common::errors::Result;
|
||||
pub trait TrashUseCase: Send + Sync {
|
||||
/// List items in the user's trash
|
||||
async fn get_trash_items(&self, user_id: &str) -> Result<Vec<TrashedItemDto>>;
|
||||
|
||||
|
||||
/// Move a file or folder to trash
|
||||
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()>;
|
||||
|
||||
|
||||
/// Restore an item from trash to its original location
|
||||
async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()>;
|
||||
|
||||
|
||||
/// Permanently delete an item from trash
|
||||
async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()>;
|
||||
|
||||
|
||||
/// Empty the trash for a specific user
|
||||
async fn empty_trash(&self, user_id: &str) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! keeping the interface layer independent of specific ZIP
|
||||
//! implementation details.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Port for ZIP archive operations.
|
||||
///
|
||||
|
||||
@@ -1,302 +1,398 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::dtos::settings_dto::{
|
||||
OidcSettingsDto, SaveOidcSettingsDto, OidcTestResultDto, TestOidcConnectionDto,
|
||||
};
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Admin settings service — manages platform configuration in the database.
|
||||
///
|
||||
/// Configuration priority: **env vars > DB settings > defaults**.
|
||||
/// Supports hot-reloading OIDC configuration without server restart.
|
||||
pub struct AdminSettingsService {
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
}
|
||||
|
||||
impl AdminSettingsService {
|
||||
pub fn new(
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
env_oidc_config,
|
||||
auth_app_service,
|
||||
server_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-generated OIDC callback URL
|
||||
fn callback_url(&self) -> String {
|
||||
let base = self.server_base_url.trim_end_matches('/');
|
||||
format!("{}/api/auth/oidc/callback", base)
|
||||
}
|
||||
|
||||
/// Detect which OIDC fields are overridden by environment variables
|
||||
fn get_env_overrides(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let vars = [
|
||||
("OXICLOUD_OIDC_ENABLED", "enabled"),
|
||||
("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"),
|
||||
("OXICLOUD_OIDC_CLIENT_ID", "client_id"),
|
||||
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
|
||||
("OXICLOUD_OIDC_SCOPES", "scopes"),
|
||||
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
|
||||
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
|
||||
("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", "disable_password_login"),
|
||||
("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"),
|
||||
];
|
||||
for (env_key, field_name) in &vars {
|
||||
if std::env::var(env_key).is_ok() {
|
||||
out.push(field_name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides on top of a config
|
||||
fn apply_env_overrides(&self, config: &mut OidcConfig) {
|
||||
let e = &self.env_oidc_config;
|
||||
if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { config.enabled = e.enabled; }
|
||||
if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { config.issuer_url = e.issuer_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { config.client_id = e.client_id.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { config.client_secret = e.client_secret.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { config.scopes = e.scopes.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { config.redirect_uri = e.redirect_uri.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { config.frontend_url = e.frontend_url.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { config.auto_provision = e.auto_provision; }
|
||||
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { config.admin_groups = e.admin_groups.clone(); }
|
||||
if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { config.disable_password_login = e.disable_password_login; }
|
||||
if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { config.provider_name = e.provider_name.clone(); }
|
||||
}
|
||||
|
||||
/// Load the effective OIDC config: DB settings + env var overrides + defaults.
|
||||
pub async fn load_effective_oidc_config(&self) -> Result<OidcConfig, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let mut config = OidcConfig {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id),
|
||||
client_secret: db.get("oidc.client_secret").cloned().unwrap_or(d.client_secret),
|
||||
redirect_uri: self.callback_url(),
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
frontend_url: self.server_base_url.clone(),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or(d.admin_groups),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
};
|
||||
|
||||
// Env vars override DB
|
||||
self.apply_env_overrides(&mut config);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get OIDC settings for display in admin UI (secrets masked).
|
||||
pub async fn get_oidc_settings(&self) -> Result<OidcSettingsDto, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let has_secret = db.get("oidc.client_secret").map(|s| !s.is_empty()).unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
Ok(OidcSettingsDto {
|
||||
enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or_default(),
|
||||
client_secret_set: has_secret,
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(),
|
||||
disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login),
|
||||
provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name),
|
||||
callback_url: self.callback_url(),
|
||||
env_overrides: self.get_env_overrides(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save OIDC settings to DB and hot-reload the OIDC service.
|
||||
pub async fn save_oidc_settings(
|
||||
&self,
|
||||
dto: SaveOidcSettingsDto,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let cat = "oidc";
|
||||
let by = Some(updated_by);
|
||||
|
||||
self.settings_repo.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.issuer_url", &dto.issuer_url, cat, false, by).await?;
|
||||
self.settings_repo.set("oidc.client_id", &dto.client_id, cat, false, by).await?;
|
||||
|
||||
if let Some(ref secret) = dto.client_secret
|
||||
&& !secret.is_empty() {
|
||||
self.settings_repo.set("oidc.client_secret", secret, cat, true, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.scopes {
|
||||
self.settings_repo.set("oidc.scopes", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.auto_provision {
|
||||
self.settings_repo.set("oidc.auto_provision", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.admin_groups {
|
||||
self.settings_repo.set("oidc.admin_groups", v, cat, false, by).await?;
|
||||
}
|
||||
if let Some(v) = dto.disable_password_login {
|
||||
self.settings_repo.set("oidc.disable_password_login", &v.to_string(), cat, false, by).await?;
|
||||
}
|
||||
if let Some(ref v) = dto.provider_name {
|
||||
self.settings_repo.set("oidc.provider_name", v, cat, false, by).await?;
|
||||
}
|
||||
|
||||
// Hot-reload OIDC service
|
||||
let eff = self.load_effective_oidc_config().await?;
|
||||
if eff.enabled && !eff.issuer_url.is_empty()
|
||||
&& !eff.client_id.is_empty() && !eff.client_secret.is_empty()
|
||||
{
|
||||
let svc = Arc::new(OidcService::new(eff.clone()));
|
||||
self.auth_app_service.reload_oidc(svc, eff);
|
||||
tracing::info!("OIDC service hot-reloaded with new configuration");
|
||||
} else if !eff.enabled {
|
||||
self.auth_app_service.disable_oidc();
|
||||
tracing::info!("OIDC service disabled via admin panel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test OIDC connection by fetching the discovery document.
|
||||
pub async fn test_oidc_connection(
|
||||
&self,
|
||||
dto: TestOidcConnectionDto,
|
||||
) -> Result<OidcTestResultDto, DomainError> {
|
||||
let issuer = dto.issuer_url.trim_end_matches('/');
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError, "OIDC", format!("HTTP client error: {}", e),
|
||||
))?;
|
||||
|
||||
let resp = match client.get(&discovery_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Cannot reach the OIDC provider: {}. Check your Issuer URL.", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!(
|
||||
"OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.",
|
||||
resp.status()
|
||||
),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Discovery {
|
||||
issuer: Option<String>,
|
||||
authorization_endpoint: Option<String>,
|
||||
token_endpoint: Option<String>,
|
||||
userinfo_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
let disc: Discovery = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Invalid discovery document: {}", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suggest provider name from hostname
|
||||
let suggestion = issuer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|host| {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
let name = if parts.len() >= 2 { parts[0] } else { host };
|
||||
let mut c = name.chars();
|
||||
c.next().map(|f| f.to_uppercase().to_string() + c.as_str())
|
||||
});
|
||||
|
||||
Ok(OidcTestResultDto {
|
||||
success: true,
|
||||
message: "OIDC provider is reachable and returned a valid discovery document.".into(),
|
||||
issuer: disc.issuer,
|
||||
authorization_endpoint: disc.authorization_endpoint,
|
||||
token_endpoint: disc.token_endpoint,
|
||||
userinfo_endpoint: disc.userinfo_endpoint,
|
||||
provider_name_suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Registration Control
|
||||
// ========================================================================
|
||||
|
||||
/// Check if public self-registration is enabled.
|
||||
/// Priority: env var `OXICLOUD_DISABLE_REGISTRATION` > DB setting > default (true).
|
||||
pub async fn get_registration_enabled(&self) -> bool {
|
||||
// Env var override takes priority
|
||||
if let Ok(val) = std::env::var("OXICLOUD_DISABLE_REGISTRATION") {
|
||||
return !matches!(val.to_lowercase().as_str(), "true" | "1" | "yes");
|
||||
}
|
||||
// Check DB setting
|
||||
match self.settings_repo.get("registration_enabled").await {
|
||||
Ok(Some(val)) => val == "true",
|
||||
_ => true, // default: enabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable public self-registration.
|
||||
pub async fn set_registration_enabled(
|
||||
&self,
|
||||
enabled: bool,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.settings_repo.set(
|
||||
"registration_enabled",
|
||||
if enabled { "true" } else { "false" },
|
||||
"general",
|
||||
false,
|
||||
Some(updated_by),
|
||||
).await
|
||||
}
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::settings_dto::{
|
||||
OidcSettingsDto, OidcTestResultDto, SaveOidcSettingsDto, TestOidcConnectionDto,
|
||||
};
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
|
||||
/// Admin settings service — manages platform configuration in the database.
|
||||
///
|
||||
/// Configuration priority: **env vars > DB settings > defaults**.
|
||||
/// Supports hot-reloading OIDC configuration without server restart.
|
||||
pub struct AdminSettingsService {
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
}
|
||||
|
||||
impl AdminSettingsService {
|
||||
pub fn new(
|
||||
settings_repo: Arc<dyn SettingsRepository>,
|
||||
env_oidc_config: OidcConfig,
|
||||
auth_app_service: Arc<AuthApplicationService>,
|
||||
server_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
env_oidc_config,
|
||||
auth_app_service,
|
||||
server_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-generated OIDC callback URL
|
||||
fn callback_url(&self) -> String {
|
||||
let base = self.server_base_url.trim_end_matches('/');
|
||||
format!("{}/api/auth/oidc/callback", base)
|
||||
}
|
||||
|
||||
/// Detect which OIDC fields are overridden by environment variables
|
||||
fn get_env_overrides(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let vars = [
|
||||
("OXICLOUD_OIDC_ENABLED", "enabled"),
|
||||
("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"),
|
||||
("OXICLOUD_OIDC_CLIENT_ID", "client_id"),
|
||||
("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"),
|
||||
("OXICLOUD_OIDC_SCOPES", "scopes"),
|
||||
("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"),
|
||||
("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"),
|
||||
(
|
||||
"OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN",
|
||||
"disable_password_login",
|
||||
),
|
||||
("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"),
|
||||
];
|
||||
for (env_key, field_name) in &vars {
|
||||
if std::env::var(env_key).is_ok() {
|
||||
out.push(field_name.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides on top of a config
|
||||
fn apply_env_overrides(&self, config: &mut OidcConfig) {
|
||||
let e = &self.env_oidc_config;
|
||||
if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() {
|
||||
config.enabled = e.enabled;
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() {
|
||||
config.issuer_url = e.issuer_url.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() {
|
||||
config.client_id = e.client_id.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() {
|
||||
config.client_secret = e.client_secret.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() {
|
||||
config.scopes = e.scopes.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() {
|
||||
config.redirect_uri = e.redirect_uri.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() {
|
||||
config.frontend_url = e.frontend_url.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() {
|
||||
config.auto_provision = e.auto_provision;
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() {
|
||||
config.admin_groups = e.admin_groups.clone();
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() {
|
||||
config.disable_password_login = e.disable_password_login;
|
||||
}
|
||||
if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() {
|
||||
config.provider_name = e.provider_name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the effective OIDC config: DB settings + env var overrides + defaults.
|
||||
pub async fn load_effective_oidc_config(&self) -> Result<OidcConfig, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let mut config = OidcConfig {
|
||||
enabled: db
|
||||
.get("oidc.enabled")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id),
|
||||
client_secret: db
|
||||
.get("oidc.client_secret")
|
||||
.cloned()
|
||||
.unwrap_or(d.client_secret),
|
||||
redirect_uri: self.callback_url(),
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
frontend_url: self.server_base_url.clone(),
|
||||
auto_provision: db
|
||||
.get("oidc.auto_provision")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.auto_provision),
|
||||
admin_groups: db
|
||||
.get("oidc.admin_groups")
|
||||
.cloned()
|
||||
.unwrap_or(d.admin_groups),
|
||||
disable_password_login: db
|
||||
.get("oidc.disable_password_login")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.disable_password_login),
|
||||
provider_name: db
|
||||
.get("oidc.provider_name")
|
||||
.cloned()
|
||||
.unwrap_or(d.provider_name),
|
||||
};
|
||||
|
||||
// Env vars override DB
|
||||
self.apply_env_overrides(&mut config);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get OIDC settings for display in admin UI (secrets masked).
|
||||
pub async fn get_oidc_settings(&self) -> Result<OidcSettingsDto, DomainError> {
|
||||
let db = self.settings_repo.get_by_category("oidc").await?;
|
||||
let d = OidcConfig::default();
|
||||
|
||||
let has_secret = db
|
||||
.get("oidc.client_secret")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("OXICLOUD_OIDC_CLIENT_SECRET")
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(OidcSettingsDto {
|
||||
enabled: db
|
||||
.get("oidc.enabled")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.enabled),
|
||||
issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(),
|
||||
client_id: db.get("oidc.client_id").cloned().unwrap_or_default(),
|
||||
client_secret_set: has_secret,
|
||||
scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes),
|
||||
auto_provision: db
|
||||
.get("oidc.auto_provision")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.auto_provision),
|
||||
admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(),
|
||||
disable_password_login: db
|
||||
.get("oidc.disable_password_login")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.disable_password_login),
|
||||
provider_name: db
|
||||
.get("oidc.provider_name")
|
||||
.cloned()
|
||||
.unwrap_or(d.provider_name),
|
||||
callback_url: self.callback_url(),
|
||||
env_overrides: self.get_env_overrides(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save OIDC settings to DB and hot-reload the OIDC service.
|
||||
pub async fn save_oidc_settings(
|
||||
&self,
|
||||
dto: SaveOidcSettingsDto,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let cat = "oidc";
|
||||
let by = Some(updated_by);
|
||||
|
||||
self.settings_repo
|
||||
.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by)
|
||||
.await?;
|
||||
self.settings_repo
|
||||
.set("oidc.issuer_url", &dto.issuer_url, cat, false, by)
|
||||
.await?;
|
||||
self.settings_repo
|
||||
.set("oidc.client_id", &dto.client_id, cat, false, by)
|
||||
.await?;
|
||||
|
||||
if let Some(ref secret) = dto.client_secret
|
||||
&& !secret.is_empty()
|
||||
{
|
||||
self.settings_repo
|
||||
.set("oidc.client_secret", secret, cat, true, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.scopes {
|
||||
self.settings_repo
|
||||
.set("oidc.scopes", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(v) = dto.auto_provision {
|
||||
self.settings_repo
|
||||
.set("oidc.auto_provision", &v.to_string(), cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.admin_groups {
|
||||
self.settings_repo
|
||||
.set("oidc.admin_groups", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
if let Some(v) = dto.disable_password_login {
|
||||
self.settings_repo
|
||||
.set(
|
||||
"oidc.disable_password_login",
|
||||
&v.to_string(),
|
||||
cat,
|
||||
false,
|
||||
by,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if let Some(ref v) = dto.provider_name {
|
||||
self.settings_repo
|
||||
.set("oidc.provider_name", v, cat, false, by)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Hot-reload OIDC service
|
||||
let eff = self.load_effective_oidc_config().await?;
|
||||
if eff.enabled
|
||||
&& !eff.issuer_url.is_empty()
|
||||
&& !eff.client_id.is_empty()
|
||||
&& !eff.client_secret.is_empty()
|
||||
{
|
||||
let svc = Arc::new(OidcService::new(eff.clone()));
|
||||
self.auth_app_service.reload_oidc(svc, eff);
|
||||
tracing::info!("OIDC service hot-reloaded with new configuration");
|
||||
} else if !eff.enabled {
|
||||
self.auth_app_service.disable_oidc();
|
||||
tracing::info!("OIDC service disabled via admin panel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test OIDC connection by fetching the discovery document.
|
||||
pub async fn test_oidc_connection(
|
||||
&self,
|
||||
dto: TestOidcConnectionDto,
|
||||
) -> Result<OidcTestResultDto, DomainError> {
|
||||
let issuer = dto.issuer_url.trim_end_matches('/');
|
||||
let discovery_url = format!("{}/.well-known/openid-configuration", issuer);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"OIDC",
|
||||
format!("HTTP client error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let resp = match client.get(&discovery_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!(
|
||||
"Cannot reach the OIDC provider: {}. Check your Issuer URL.",
|
||||
e
|
||||
),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!(
|
||||
"OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.",
|
||||
resp.status()
|
||||
),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Discovery {
|
||||
issuer: Option<String>,
|
||||
authorization_endpoint: Option<String>,
|
||||
token_endpoint: Option<String>,
|
||||
userinfo_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
let disc: Discovery = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return Ok(OidcTestResultDto {
|
||||
success: false,
|
||||
message: format!("Invalid discovery document: {}", e),
|
||||
issuer: None,
|
||||
authorization_endpoint: None,
|
||||
token_endpoint: None,
|
||||
userinfo_endpoint: None,
|
||||
provider_name_suggestion: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suggest provider name from hostname
|
||||
let suggestion = issuer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|host| {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
let name = if parts.len() >= 2 { parts[0] } else { host };
|
||||
let mut c = name.chars();
|
||||
c.next().map(|f| f.to_uppercase().to_string() + c.as_str())
|
||||
});
|
||||
|
||||
Ok(OidcTestResultDto {
|
||||
success: true,
|
||||
message: "OIDC provider is reachable and returned a valid discovery document.".into(),
|
||||
issuer: disc.issuer,
|
||||
authorization_endpoint: disc.authorization_endpoint,
|
||||
token_endpoint: disc.token_endpoint,
|
||||
userinfo_endpoint: disc.userinfo_endpoint,
|
||||
provider_name_suggestion: suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Registration Control
|
||||
// ========================================================================
|
||||
|
||||
/// Check if public self-registration is enabled.
|
||||
/// Priority: env var `OXICLOUD_DISABLE_REGISTRATION` > DB setting > default (true).
|
||||
pub async fn get_registration_enabled(&self) -> bool {
|
||||
// Env var override takes priority
|
||||
if let Ok(val) = std::env::var("OXICLOUD_DISABLE_REGISTRATION") {
|
||||
return !matches!(val.to_lowercase().as_str(), "true" | "1" | "yes");
|
||||
}
|
||||
// Check DB setting
|
||||
match self.settings_repo.get("registration_enabled").await {
|
||||
Ok(Some(val)) => val == "true",
|
||||
_ => true, // default: enabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable public self-registration.
|
||||
pub async fn set_registration_enabled(
|
||||
&self,
|
||||
enabled: bool,
|
||||
updated_by: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.settings_repo
|
||||
.set(
|
||||
"registration_enabled",
|
||||
if enabled { "true" } else { "false" },
|
||||
"general",
|
||||
false,
|
||||
Some(updated_by),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,32 @@
|
||||
use futures::{Future, future::join_all};
|
||||
use std::sync::Arc;
|
||||
use futures::{future::join_all, Future};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileManagementUseCase};
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Specific errors for batch operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BatchOperationError {
|
||||
#[error("Domain error: {0}")]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
|
||||
#[error("Operation cancelled: {0}")]
|
||||
Cancelled(String),
|
||||
|
||||
|
||||
#[error("Concurrency limit exceeded: {0}")]
|
||||
ConcurrencyLimit(String),
|
||||
|
||||
|
||||
#[error("Batch operation error: {0} ({1} of {2} completed)")]
|
||||
PartialFailure(String, usize, usize),
|
||||
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
@@ -72,11 +72,11 @@ impl BatchOperationService {
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
config: AppConfig
|
||||
config: AppConfig,
|
||||
) -> Self {
|
||||
// Limit concurrency based on configuration
|
||||
let max_concurrency = config.concurrency.max_concurrent_files;
|
||||
|
||||
|
||||
Self {
|
||||
file_retrieval,
|
||||
file_management,
|
||||
@@ -85,16 +85,21 @@ impl BatchOperationService {
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrency)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Creates a new instance with default configuration
|
||||
pub fn default(
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>
|
||||
folder_service: Arc<FolderService>,
|
||||
) -> Self {
|
||||
Self::new(file_retrieval, file_management, folder_service, AppConfig::default())
|
||||
Self::new(
|
||||
file_retrieval,
|
||||
file_management,
|
||||
folder_service,
|
||||
AppConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Copies multiple files in parallel
|
||||
pub async fn copy_files(
|
||||
&self,
|
||||
@@ -103,7 +108,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||
info!("Starting batch copy of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -113,30 +118,30 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
|
||||
// Release the permit explicitly (also released on drop)
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result along with the ID to identify successes/failures
|
||||
(file_id, copy_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -150,22 +155,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch copy completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Moves multiple files in parallel
|
||||
pub async fn move_files(
|
||||
&self,
|
||||
@@ -174,7 +180,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||
info!("Starting batch move of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -184,30 +190,30 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let move_result = mgmt.move_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result along with the ID to identify successes/failures
|
||||
(file_id, move_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -221,22 +227,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch move completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Deletes multiple files in parallel
|
||||
pub async fn delete_files(
|
||||
&self,
|
||||
@@ -244,7 +251,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
info!("Starting batch deletion of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -254,30 +261,30 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let mgmt = self.file_management.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let id_clone = file_id.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let delete_result = mgmt.delete_file(&file_id).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result along with the ID
|
||||
(id_clone.clone(), delete_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -291,22 +298,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch deletion completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Loads multiple files in parallel (data in memory)
|
||||
pub async fn get_multiple_files(
|
||||
&self,
|
||||
@@ -314,7 +322,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FileDto>, BatchOperationError> {
|
||||
info!("Starting batch load of {} files", file_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -324,29 +332,29 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation to perform for each file
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let retrieval = self.file_retrieval.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let get_result = retrieval.get_file(&file_id).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result along with the ID
|
||||
(file_id, get_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (file_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -360,22 +368,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch load completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Deletes multiple folders in parallel
|
||||
pub async fn delete_folders(
|
||||
&self,
|
||||
@@ -384,7 +393,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<String>, BatchOperationError> {
|
||||
info!("Starting batch deletion of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -394,32 +403,32 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation to perform for each folder
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let id_clone = folder_id.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
// For both recursive and non-recursive, use the standard delete_folder method
|
||||
// since FolderUseCase only has a single delete_folder method
|
||||
let delete_result = folder_service.delete_folder(&folder_id).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result along with the ID
|
||||
(id_clone.clone(), delete_result.map(|_| id_clone))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel with concurrency control
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -433,22 +442,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch folder deletion completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Generic batch operation for any type of async function
|
||||
pub async fn generic_batch_operation<T, F, Fut>(
|
||||
&self,
|
||||
@@ -460,9 +470,12 @@ impl BatchOperationService {
|
||||
F: Fn(T, Arc<Semaphore>) -> Fut + Clone + Send + Sync + 'static,
|
||||
Fut: Future<Output = Result<T, DomainError>> + Send + 'static,
|
||||
{
|
||||
info!("Starting generic batch operation with {} items", items.len());
|
||||
info!(
|
||||
"Starting generic batch operation with {} items",
|
||||
items.len()
|
||||
);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -472,25 +485,25 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Convert each item to a task
|
||||
let tasks = items.iter().map(|item| {
|
||||
let item_clone = item.clone();
|
||||
let op = operation.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// The provided function must handle semaphore acquisition
|
||||
let op_result = op(item_clone.clone(), semaphore).await;
|
||||
|
||||
|
||||
// Return the result along with the original item for identification
|
||||
(item_clone, op_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all tasks in parallel
|
||||
let operation_results = join_all(tasks).await;
|
||||
|
||||
|
||||
// Process results
|
||||
for (item, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -505,22 +518,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Generic batch operation completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Create multiple folders in parallel
|
||||
pub async fn create_folders(
|
||||
&self,
|
||||
@@ -528,7 +542,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||
info!("Starting batch creation of {} folders", folders.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -538,34 +552,34 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation for each folder
|
||||
let operations = folders.into_iter().map(|(name, parent_id)| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: name.clone(),
|
||||
parent_id: parent_id.clone()
|
||||
parent_id: parent_id.clone(),
|
||||
};
|
||||
let create_result = folder_service.create_folder(dto).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result with an identifier for errors
|
||||
let id = format!("{}:{}", name, parent_id.unwrap_or_default());
|
||||
(id, create_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -579,22 +593,23 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch folder creation completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
/// Get metadata of multiple folders in parallel
|
||||
pub async fn get_multiple_folders(
|
||||
&self,
|
||||
@@ -602,7 +617,7 @@ impl BatchOperationService {
|
||||
) -> Result<BatchResult<FolderDto>, BatchOperationError> {
|
||||
info!("Starting batch load of {} folders", folder_ids.len());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
|
||||
// Create result structure
|
||||
let mut result = BatchResult {
|
||||
successful: Vec::new(),
|
||||
@@ -612,29 +627,29 @@ impl BatchOperationService {
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Define the operation for each folder
|
||||
let operations = folder_ids.into_iter().map(|folder_id| {
|
||||
let folder_service = self.folder_service.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
|
||||
async move {
|
||||
// Acquire semaphore permit
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
let get_result = folder_service.get_folder(&folder_id).await;
|
||||
|
||||
|
||||
// Release the permit explicitly
|
||||
drop(permit);
|
||||
|
||||
|
||||
// Return the result with its ID
|
||||
(folder_id, get_result)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute all operations in parallel
|
||||
let operation_results = join_all(operations).await;
|
||||
|
||||
|
||||
// Process the results
|
||||
for (folder_id, operation_result) in operation_results {
|
||||
match operation_result {
|
||||
@@ -648,19 +663,20 @@ impl BatchOperationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete statistics
|
||||
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||
result.stats.max_concurrency = self.config.concurrency.max_concurrent_files
|
||||
result.stats.max_concurrency = self
|
||||
.config
|
||||
.concurrency
|
||||
.max_concurrent_files
|
||||
.min(result.stats.total);
|
||||
|
||||
|
||||
info!(
|
||||
"Batch folder load completed: {}/{} successful in {}ms",
|
||||
result.stats.successful,
|
||||
result.stats.total,
|
||||
result.stats.execution_time_ms
|
||||
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||
);
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -668,26 +684,26 @@ impl BatchOperationService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase};
|
||||
use std::sync::Arc;
|
||||
use crate::common::stubs::{StubFileRetrievalUseCase, StubFileManagementUseCase};
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generic_batch_operation() {
|
||||
// Create the batch service with stubs
|
||||
let batch_service = BatchOperationService::new(
|
||||
Arc::new(StubFileRetrievalUseCase),
|
||||
Arc::new(StubFileManagementUseCase),
|
||||
Arc::new(FolderService::new(
|
||||
Arc::new(crate::common::stubs::StubFolderStoragePort)
|
||||
)),
|
||||
AppConfig::default()
|
||||
Arc::new(FolderService::new(Arc::new(
|
||||
crate::common::stubs::StubFolderStoragePort,
|
||||
))),
|
||||
AppConfig::default(),
|
||||
);
|
||||
|
||||
|
||||
// Define a generic test operation
|
||||
let operation = |item: i32, semaphore: Arc<Semaphore>| async move {
|
||||
// Acquire and release the semaphore
|
||||
let _permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
|
||||
if item % 2 == 0 {
|
||||
// Simulate success for even numbers
|
||||
Ok(item * 2)
|
||||
@@ -696,22 +712,25 @@ mod tests {
|
||||
Err(DomainError::validation_error("Odd number not allowed"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Execute the batch operation
|
||||
let items = vec![1, 2, 3, 4, 5];
|
||||
|
||||
let result = batch_service.generic_batch_operation(items, operation).await.unwrap();
|
||||
|
||||
|
||||
let result = batch_service
|
||||
.generic_batch_operation(items, operation)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the results
|
||||
assert_eq!(result.stats.total, 5);
|
||||
assert_eq!(result.stats.successful, 2);
|
||||
assert_eq!(result.stats.failed, 3);
|
||||
|
||||
|
||||
// Even numbers should be in successes, doubled
|
||||
assert!(result.successful.contains(&4)); // 2*2
|
||||
assert!(result.successful.contains(&8)); // 4*2
|
||||
|
||||
|
||||
// Odd numbers should be in failures
|
||||
assert_eq!(result.failed.len(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, UpdateCalendarDto,
|
||||
CreateEventDto, UpdateEventDto, CreateEventICalDto
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
@@ -15,389 +15,577 @@ pub struct CalendarService {
|
||||
|
||||
impl CalendarService {
|
||||
pub fn new(calendar_storage: Arc<dyn CalendarStoragePort>) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
}
|
||||
Self { calendar_storage }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CalendarUseCase for CalendarService {
|
||||
async fn create_calendar(&self, calendar: CreateCalendarDto) -> Result<CalendarDto, DomainError> {
|
||||
async fn create_calendar(
|
||||
&self,
|
||||
calendar: CreateCalendarDto,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
// This function requires the current user context which will come from middleware
|
||||
// For now, we'll use a dummy implementation that needs to be completed
|
||||
|
||||
|
||||
// In a real implementation, get user_id from current user context
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
self.calendar_storage.create_calendar(calendar, user_id).await
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
self.calendar_storage
|
||||
.create_calendar(calendar, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_calendar(&self, calendar_id: &str, update: UpdateCalendarDto) -> Result<CalendarDto, DomainError> {
|
||||
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
update: UpdateCalendarDto,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
// In a real implementation, we would:
|
||||
// 1. Get the current user ID from middleware
|
||||
// 2. Verify that the user has access to this calendar
|
||||
// 3. Update the calendar if they have permission
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar"
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
self.calendar_storage.update_calendar(calendar_id, update).await
|
||||
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar"
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
}
|
||||
|
||||
|
||||
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Get the calendar
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
// Check if user has access or if calendar is public
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar"
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
|
||||
async fn list_my_calendars(&self) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
||||
}
|
||||
|
||||
|
||||
async fn list_shared_calendars(&self) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
self.calendar_storage.list_calendars_shared_with_user(user_id).await
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
self.calendar_storage
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_public_calendars(&self, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
|
||||
self.calendar_storage.list_public_calendars(limit, offset).await
|
||||
|
||||
self.calendar_storage
|
||||
.list_public_calendars(limit, offset)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_calendar(&self, calendar_id: &str, user_id: &str, access_level: &str) -> Result<(), DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if current user has access
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
// Only the owner can share the calendar
|
||||
if calendar.owner_id != current_user_id {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings"
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Validate access_level
|
||||
match access_level {
|
||||
"read" | "write" | "owner" => {},
|
||||
_ => return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!("Invalid access level: {}. Valid values are: read, write, owner", access_level)
|
||||
)),
|
||||
"read" | "write" | "owner" => {}
|
||||
_ => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!(
|
||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||
access_level
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.calendar_storage.share_calendar(calendar_id, user_id, access_level).await
|
||||
|
||||
self.calendar_storage
|
||||
.share_calendar(calendar_id, user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if current user has access
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
// Only the owner can change sharing settings
|
||||
if calendar.owner_id != current_user_id {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings"
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
|
||||
self.calendar_storage.remove_calendar_sharing(calendar_id, user_id).await
|
||||
|
||||
self.calendar_storage
|
||||
.remove_calendar_sharing(calendar_id, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(&self, calendar_id: &str) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let current_user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if current user has access
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
// Only the owner can view sharing settings
|
||||
if calendar.owner_id != current_user_id {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can view sharing settings"
|
||||
"Only the calendar owner can view sharing settings",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
||||
}
|
||||
|
||||
|
||||
async fn create_event(&self, event: CreateEventDto) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar"
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.create_event(event).await
|
||||
}
|
||||
|
||||
async fn create_event_from_ical(&self, event: CreateEventICalDto) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn create_event_from_ical(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar"
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
async fn update_event(&self, event_id: &str, update: UpdateEventDto) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
update: UpdateEventDto,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Get the event to find its calendar
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update events in this calendar"
|
||||
"You don't have permission to update events in this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.update_event(event_id, update).await
|
||||
}
|
||||
|
||||
|
||||
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Get the event to find its calendar
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar"
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
|
||||
|
||||
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Get the event
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
// Check if calendar is public
|
||||
let calendar = self.calendar_storage.get_calendar(&event.calendar_id).await?;
|
||||
|
||||
let calendar = self
|
||||
.calendar_storage
|
||||
.get_calendar(&event.calendar_id)
|
||||
.await?;
|
||||
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar"
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
async fn list_events(&self, calendar_id: &str, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
|
||||
async fn list_events(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
// Check if calendar is public
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar"
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Use pagination if provided
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
|
||||
self.calendar_storage.list_events_by_calendar_paginated(calendar_id, limit, offset).await
|
||||
|
||||
self.calendar_storage
|
||||
.list_events_by_calendar_paginated(calendar_id, limit, offset)
|
||||
.await
|
||||
} else {
|
||||
self.calendar_storage.list_events_by_calendar(calendar_id).await
|
||||
self.calendar_storage
|
||||
.list_events_by_calendar(calendar_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
let user_id = "current_user_id"; // This should come from middleware
|
||||
|
||||
// Check if user has access to the calendar
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
|
||||
// Check if calendar is public
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
|
||||
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar"
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
}
|
||||
|
||||
self.calendar_storage.get_events_in_time_range(calendar_id, &start, &end).await
|
||||
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
// ─── User-contextualized variants (for CalDAV protocol handler) ──
|
||||
|
||||
async fn create_calendar_for_user(&self, calendar: CreateCalendarDto, user_id: &str) -> Result<CalendarDto, DomainError> {
|
||||
self.calendar_storage.create_calendar(calendar, user_id).await
|
||||
|
||||
async fn create_calendar_for_user(
|
||||
&self,
|
||||
calendar: CreateCalendarDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
self.calendar_storage
|
||||
.create_calendar(calendar, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_calendar_for_user(&self, calendar_id: &str, update: UpdateCalendarDto, user_id: &str) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
async fn update_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
update: UpdateCalendarDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to update this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.update_calendar(calendar_id, update).await
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
async fn delete_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to delete this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
}
|
||||
|
||||
async fn get_calendar_for_user(&self, calendar_id: &str, user_id: &str) -> Result<CalendarDto, DomainError> {
|
||||
|
||||
async fn get_calendar_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to view this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
}
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_my_calendars_for_user(&self, user_id: &str) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
|
||||
async fn list_my_calendars_for_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
||||
}
|
||||
|
||||
async fn list_events_for_user(&self, calendar_id: &str, limit: Option<i64>, offset: Option<i64>, user_id: &str) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
async fn list_events_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to view events in this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
}
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
self.calendar_storage.list_events_by_calendar_paginated(calendar_id, limit, offset).await
|
||||
self.calendar_storage
|
||||
.list_events_by_calendar_paginated(calendar_id, limit, offset)
|
||||
.await
|
||||
} else {
|
||||
self.calendar_storage.list_events_by_calendar(calendar_id).await
|
||||
self.calendar_storage
|
||||
.list_events_by_calendar(calendar_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_events_in_range_for_user(&self, calendar_id: &str, start: DateTime<Utc>, end: DateTime<Utc>, user_id: &str) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self.calendar_storage.check_calendar_access(calendar_id, user_id).await?;
|
||||
|
||||
async fn get_events_in_range_for_user(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to view events in this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.get_events_in_time_range(calendar_id, &start, &end).await
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_event_from_ical_for_user(&self, event: CreateEventICalDto, user_id: &str) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
|
||||
async fn create_event_from_ical_for_user(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: &str,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to add events to this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
async fn delete_event_for_user(&self, event_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
|
||||
async fn delete_event_for_user(
|
||||
&self,
|
||||
event_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self.calendar_storage.check_calendar_access(&event.calendar_id, user_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(ErrorKind::AccessDenied, "Calendar", "You don't have permission to delete events in this calendar"));
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::application::ports::favorites_ports::{FavoritesUseCase, FavoritesRepositoryPort};
|
||||
use crate::application::dtos::favorites_dto::FavoriteItemDto;
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
@@ -26,13 +26,20 @@ impl FavoritesUseCase for FavoritesService {
|
||||
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>> {
|
||||
info!("Getting favorites for user: {}", user_id);
|
||||
let favorites = self.repo.get_favorites(user_id).await?;
|
||||
info!("Retrieved {} favorites for user {}", favorites.len(), user_id);
|
||||
info!(
|
||||
"Retrieved {} favorites for user {}",
|
||||
favorites.len(),
|
||||
user_id
|
||||
);
|
||||
Ok(favorites)
|
||||
}
|
||||
|
||||
/// Add an item to user's favorites
|
||||
async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
|
||||
info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id);
|
||||
info!(
|
||||
"Adding {} '{}' to favorites for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
@@ -43,25 +50,48 @@ impl FavoritesUseCase for FavoritesService {
|
||||
}
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id);
|
||||
info!(
|
||||
"Successfully added {} '{}' to favorites for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an item from user's favorites
|
||||
async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id);
|
||||
let removed = self.repo.remove_favorite(user_id, item_id, item_type).await?;
|
||||
async fn remove_from_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<bool> {
|
||||
info!(
|
||||
"Removing {} '{}' from favorites for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
let removed = self
|
||||
.repo
|
||||
.remove_favorite(user_id, item_id, item_type)
|
||||
.await?;
|
||||
info!(
|
||||
"{} {} '{}' from favorites for user {}",
|
||||
if removed { "Successfully removed" } else { "Did not find" },
|
||||
item_type, item_id, user_id
|
||||
if removed {
|
||||
"Successfully removed"
|
||||
} else {
|
||||
"Did not find"
|
||||
},
|
||||
item_type,
|
||||
item_id,
|
||||
user_id
|
||||
);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Check if an item is in user's favorites
|
||||
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id);
|
||||
info!(
|
||||
"Checking if {} '{}' is favorite for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
self.repo.is_favorite(user_id, item_id, item_type).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::DomainError;
|
||||
use tracing::{debug, info, warn, error};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Service for file management operations (move, delete).
|
||||
///
|
||||
@@ -76,9 +76,14 @@ impl FileManagementService {
|
||||
|
||||
/// Decrement dedup reference count; log result.
|
||||
async fn decrement_dedup_ref(&self, hash: &str) {
|
||||
let Some(dedup) = &self.dedup_service else { return };
|
||||
let Some(dedup) = &self.dedup_service else {
|
||||
return;
|
||||
};
|
||||
match dedup.remove_reference(hash).await {
|
||||
Ok(true) => info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]),
|
||||
Ok(true) => info!(
|
||||
"🗑️ DEDUP: Blob {} deleted (no more references)",
|
||||
&hash[..12]
|
||||
),
|
||||
Ok(false) => debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]),
|
||||
Err(e) => warn!("⚠️ DEDUP: Failed to decrement reference: {}", e),
|
||||
}
|
||||
@@ -92,12 +97,19 @@ impl FileManagementUseCase for FileManagementService {
|
||||
file_id: &str,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
|
||||
info!(
|
||||
"Moving file with ID: {} to folder: {:?}",
|
||||
file_id, folder_id
|
||||
);
|
||||
|
||||
let moved_file = self.file_repository.move_file(file_id, folder_id).await.map_err(|e| {
|
||||
error!("Error moving file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
let moved_file = self
|
||||
.file_repository
|
||||
.move_file(file_id, folder_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error moving file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"File moved successfully: {} (ID: {}) to folder: {:?}",
|
||||
@@ -109,17 +121,17 @@ impl FileManagementUseCase for FileManagementService {
|
||||
Ok(FileDto::from(moved_file))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<FileDto, DomainError> {
|
||||
info!("Renaming file with ID: {} to \"{}\"", file_id, new_name);
|
||||
|
||||
let renamed_file = self.file_repository.rename_file(file_id, new_name).await.map_err(|e| {
|
||||
error!("Error renaming file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
let renamed_file = self
|
||||
.file_repository
|
||||
.rename_file(file_id, new_name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error renaming file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"File renamed successfully: {} (ID: {})",
|
||||
@@ -135,11 +147,7 @@ impl FileManagementUseCase for FileManagementService {
|
||||
}
|
||||
|
||||
/// Smart delete: trash-first with dedup reference cleanup.
|
||||
async fn delete_with_cleanup(
|
||||
&self,
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
async fn delete_with_cleanup(&self, id: &str, user_id: &str) -> Result<bool, DomainError> {
|
||||
// Step 1: Compute content hash for dedup tracking
|
||||
let content_hash = self.compute_content_hash(id).await;
|
||||
|
||||
@@ -175,4 +183,4 @@ impl FileManagementUseCase for FileManagementService {
|
||||
|
||||
Ok(false) // permanently deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::cache_ports::{ContentCachePort, WriteBehindCachePort};
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort};
|
||||
use crate::application::ports::transcode_ports::{ImageTranscodePort, OutputFormat};
|
||||
use crate::common::errors::DomainError;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -114,7 +114,10 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
}
|
||||
}
|
||||
|
||||
Err(DomainError::not_found("File", format!("not found at path: {}", path)))
|
||||
Err(DomainError::not_found(
|
||||
"File",
|
||||
format!("not found at path: {}", path),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
|
||||
@@ -150,44 +153,69 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
|
||||
// ── Tier 0: Write-behind cache ───────────────────────
|
||||
if let Some(wb) = &self.write_behind
|
||||
&& let Some(pending) = wb.get_pending(id).await {
|
||||
debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file_name, pending.len());
|
||||
let (data, mime) = if do_transcode {
|
||||
if let Some((t, m)) = self.try_transcode(id, &pending, &mime_type, file_size, true).await {
|
||||
(t, m)
|
||||
} else {
|
||||
(pending, mime_type.clone())
|
||||
}
|
||||
&& let Some(pending) = wb.get_pending(id).await
|
||||
{
|
||||
debug!(
|
||||
"⚡ TIER 0 Write-Behind HIT: {} ({} bytes)",
|
||||
file_name,
|
||||
pending.len()
|
||||
);
|
||||
let (data, mime) = if do_transcode {
|
||||
if let Some((t, m)) = self
|
||||
.try_transcode(id, &pending, &mime_type, file_size, true)
|
||||
.await
|
||||
{
|
||||
(t, m)
|
||||
} else {
|
||||
(pending, mime_type.clone())
|
||||
};
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
}
|
||||
} else {
|
||||
(pending, mime_type.clone())
|
||||
};
|
||||
return Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data,
|
||||
mime_type: mime,
|
||||
was_transcoded: do_transcode,
|
||||
}));
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||
if file_size < CACHE_THRESHOLD {
|
||||
// Check content cache first
|
||||
if let Some(cache) = &self.content_cache
|
||||
&& let Some((cached, _etag, _ct)) = cache.get(id).await {
|
||||
debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file_name, cached.len());
|
||||
if do_transcode
|
||||
&& let Some((t, m)) = self.try_transcode(id, &cached, &mime_type, file_size, true).await {
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: t,
|
||||
mime_type: m,
|
||||
was_transcoded: true,
|
||||
}));
|
||||
}
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
&& let Some((cached, _etag, _ct)) = cache.get(id).await
|
||||
{
|
||||
debug!(
|
||||
"🔥 TIER 1 Cache HIT: {} ({} bytes)",
|
||||
file_name,
|
||||
cached.len()
|
||||
);
|
||||
if do_transcode
|
||||
&& let Some((t, m)) = self
|
||||
.try_transcode(id, &cached, &mime_type, file_size, true)
|
||||
.await
|
||||
{
|
||||
return Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data: t,
|
||||
mime_type: m,
|
||||
was_transcoded: true,
|
||||
},
|
||||
));
|
||||
}
|
||||
return Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data: cached,
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}));
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Cache miss – load from disk
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name);
|
||||
@@ -197,27 +225,47 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
// Store in cache
|
||||
if let Some(cache) = &self.content_cache {
|
||||
let etag = format!("\"{}-{}\"", id, modified_at);
|
||||
cache.put(id.to_string(), content_bytes.clone(), etag, mime_type.clone()).await;
|
||||
cache
|
||||
.put(
|
||||
id.to_string(),
|
||||
content_bytes.clone(),
|
||||
etag,
|
||||
mime_type.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if do_transcode
|
||||
&& let Some((t, m)) = self.try_transcode(id, &content_bytes, &mime_type, file_size, true).await {
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
&& let Some((t, m)) = self
|
||||
.try_transcode(id, &content_bytes, &mime_type, file_size, true)
|
||||
.await
|
||||
{
|
||||
return Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data: t,
|
||||
mime_type: m,
|
||||
was_transcoded: true,
|
||||
}));
|
||||
}
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: content_bytes,
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}));
|
||||
},
|
||||
));
|
||||
}
|
||||
return Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data: content_bytes,
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// ── Tier 2: MMAP (10–100 MB) ────────────────────────
|
||||
if file_size < MMAP_THRESHOLD {
|
||||
info!("🗺️ TIER 2 MMAP: {} ({} MB)", file_name, file_size / (1024 * 1024));
|
||||
info!(
|
||||
"🗺️ TIER 2 MMAP: {} ({} MB)",
|
||||
file_name,
|
||||
file_size / (1024 * 1024)
|
||||
);
|
||||
match self.file_read.get_file_mmap(id).await {
|
||||
Ok(mmap_content) => {
|
||||
return Ok((dto, OptimizedFileContent::Mmap(mmap_content)));
|
||||
@@ -230,17 +278,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
}
|
||||
|
||||
// ── Tier 3: Streaming (≥100 MB) ─────────────────────
|
||||
info!("📡 TIER 3 STREAMING: {} ({} MB)", file_name, file_size / (1024 * 1024));
|
||||
info!(
|
||||
"📡 TIER 3 STREAMING: {} ({} MB)",
|
||||
file_name,
|
||||
file_size / (1024 * 1024)
|
||||
);
|
||||
match self.file_read.get_file_stream(id).await {
|
||||
Ok(stream) => Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))),
|
||||
Err(e) => {
|
||||
warn!("Streaming failed, last-resort content load: {}", e);
|
||||
let content = self.file_read.get_file_content(id).await?;
|
||||
Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: Bytes::from(content),
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}))
|
||||
Ok((
|
||||
dto,
|
||||
OptimizedFileContent::Bytes {
|
||||
data: Bytes::from(content),
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,4 +309,4 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use std::pin::Pin;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy};
|
||||
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
|
||||
use crate::application::ports::cache_ports::WriteBehindCachePort;
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -47,7 +47,8 @@ pub struct FileUploadService {
|
||||
/// Optional dedup service for content-addressable storage
|
||||
dedup: Option<Arc<dyn DedupPort>>,
|
||||
/// Optional storage usage tracking
|
||||
storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
|
||||
storage_usage_service:
|
||||
Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
|
||||
}
|
||||
|
||||
impl FileUploadService {
|
||||
@@ -92,7 +93,10 @@ impl FileUploadService {
|
||||
/// Run dedup tracking (non-fatal on failure).
|
||||
async fn run_dedup(&self, data: &[u8], content_type: &str) {
|
||||
let Some(dedup) = &self.dedup else { return };
|
||||
match dedup.store_bytes(data, Some(content_type.to_string())).await {
|
||||
match dedup
|
||||
.store_bytes(data, Some(content_type.to_string()))
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.was_deduplicated() {
|
||||
info!(
|
||||
@@ -101,7 +105,10 @@ impl FileUploadService {
|
||||
result.size()
|
||||
);
|
||||
} else {
|
||||
info!("💾 DEDUP: new content stored (hash: {})", &result.hash()[..12]);
|
||||
info!(
|
||||
"💾 DEDUP: new content stored (hash: {})",
|
||||
&result.hash()[..12]
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -119,7 +126,10 @@ impl FileUploadService {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
match service_clone.update_user_storage_usage(&username).await {
|
||||
Ok(usage) => debug!("Updated storage usage for user {} to {} bytes", username, usage),
|
||||
Ok(usage) => debug!(
|
||||
"Updated storage usage for user {} to {} bytes",
|
||||
username, usage
|
||||
),
|
||||
Err(e) => warn!("Failed to update storage usage for {}: {}", username, e),
|
||||
}
|
||||
});
|
||||
@@ -138,7 +148,10 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
let file = self.file_write.save_file(name, folder_id, content_type, content).await?;
|
||||
let file = self
|
||||
.file_write
|
||||
.save_file(name, folder_id, content_type, content)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
Ok(dto)
|
||||
@@ -170,34 +183,38 @@ impl FileUploadUseCase for FileUploadService {
|
||||
// ─── TIER 1: Write-Behind (<256 KB) ──────────────────
|
||||
if total_size < WRITE_BEHIND_THRESHOLD
|
||||
&& let Some(wb) = &self.write_behind
|
||||
&& wb.is_eligible_size(total_size) {
|
||||
let data: Bytes = if chunks.len() == 1 {
|
||||
chunks.into_iter().next().unwrap()
|
||||
} else {
|
||||
let mut combined = Vec::with_capacity(total_size);
|
||||
for chunk in chunks {
|
||||
combined.extend_from_slice(&chunk);
|
||||
}
|
||||
combined.into()
|
||||
};
|
||||
|
||||
let (file, target_path) = self
|
||||
.file_write
|
||||
.register_file_deferred(name.clone(), folder_id, content_type, total_size as u64)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
|
||||
if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await {
|
||||
return Err(DomainError::internal_error("file", format!(
|
||||
"Write-behind cache failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", name, dto.id);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
return Ok((dto, UploadStrategy::WriteBehind));
|
||||
&& wb.is_eligible_size(total_size)
|
||||
{
|
||||
let data: Bytes = if chunks.len() == 1 {
|
||||
chunks.into_iter().next().unwrap()
|
||||
} else {
|
||||
let mut combined = Vec::with_capacity(total_size);
|
||||
for chunk in chunks {
|
||||
combined.extend_from_slice(&chunk);
|
||||
}
|
||||
combined.into()
|
||||
};
|
||||
|
||||
let (file, target_path) = self
|
||||
.file_write
|
||||
.register_file_deferred(name.clone(), folder_id, content_type, total_size as u64)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
|
||||
if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await {
|
||||
return Err(DomainError::internal_error(
|
||||
"file",
|
||||
format!("Write-behind cache failed: {}", e),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)",
|
||||
name, dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
return Ok((dto, UploadStrategy::WriteBehind));
|
||||
}
|
||||
|
||||
// ─── TIER 2: Streaming (≥1 MB) ──────────────────────
|
||||
if total_size >= STREAMING_UPLOAD_THRESHOLD {
|
||||
@@ -310,4 +327,4 @@ impl FileUploadUseCase for FileUploadService {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory};
|
||||
use crate::application::services::file_upload_service::FileUploadService;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::file_management_service::FileManagementService;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory,
|
||||
};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::services::file_management_service::FileManagementService;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::file_upload_service::FileUploadService;
|
||||
|
||||
/// Factory for creating file use case implementations
|
||||
pub struct AppFileUseCaseFactory {
|
||||
@@ -16,7 +18,7 @@ impl AppFileUseCaseFactory {
|
||||
/// Creates a new factory for file use cases
|
||||
pub fn new(
|
||||
file_read_repository: Arc<dyn FileReadPort>,
|
||||
file_write_repository: Arc<dyn FileWritePort>
|
||||
file_write_repository: Arc<dyn FileWritePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_read_repository,
|
||||
@@ -29,12 +31,14 @@ impl FileUseCaseFactory for AppFileUseCaseFactory {
|
||||
fn create_file_upload_use_case(&self) -> Arc<dyn FileUploadUseCase> {
|
||||
Arc::new(FileUploadService::new(self.file_write_repository.clone()))
|
||||
}
|
||||
|
||||
|
||||
fn create_file_retrieval_use_case(&self) -> Arc<dyn FileRetrievalUseCase> {
|
||||
Arc::new(FileRetrievalService::new(self.file_read_repository.clone()))
|
||||
}
|
||||
|
||||
|
||||
fn create_file_management_use_case(&self) -> Arc<dyn FileManagementUseCase> {
|
||||
Arc::new(FileManagementService::new(self.file_write_repository.clone()))
|
||||
Arc::new(FileManagementService::new(
|
||||
self.file_write_repository.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto, FolderDto};
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::transactions::storage_transaction::StorageTransaction;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Implementation of the use case for folder operations
|
||||
pub struct FolderService {
|
||||
@@ -17,55 +19,71 @@ impl FolderService {
|
||||
pub fn new(folder_storage: Arc<dyn FolderStoragePort>) -> Self {
|
||||
Self { folder_storage }
|
||||
}
|
||||
|
||||
|
||||
/// Creates a stub implementation for testing and middleware
|
||||
pub fn new_stub() -> impl FolderUseCase {
|
||||
struct FolderServiceStub;
|
||||
|
||||
|
||||
#[async_trait]
|
||||
impl FolderUseCase for FolderServiceStub {
|
||||
async fn create_folder(&self, _dto: CreateFolderDto) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
|
||||
async fn get_folder(&self, _id: &str) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
|
||||
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError> {
|
||||
|
||||
async fn list_folders(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_pagination: &crate::application::dtos::pagination::PaginationRequestDto
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError> {
|
||||
Ok(crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||
vec![],
|
||||
0,
|
||||
10,
|
||||
0
|
||||
))
|
||||
_pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||
) -> Result<
|
||||
crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>,
|
||||
DomainError,
|
||||
> {
|
||||
Ok(
|
||||
crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||
vec![],
|
||||
0,
|
||||
10,
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _dto: RenameFolderDto) -> Result<FolderDto, DomainError> {
|
||||
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_dto: RenameFolderDto,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
|
||||
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_dto: MoveFolderDto,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FolderServiceStub
|
||||
}
|
||||
}
|
||||
@@ -79,10 +97,10 @@ impl FolderUseCase for FolderService {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Folder",
|
||||
"Folder name cannot be empty"
|
||||
"Folder name cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// If a parent_id is provided, verify it exists
|
||||
if let Some(parent_id) = &dto.parent_id {
|
||||
let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok();
|
||||
@@ -90,105 +108,147 @@ impl FolderUseCase for FolderService {
|
||||
return Err(DomainError::not_found("Folder", parent_id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create the folder
|
||||
let folder = self.folder_storage.create_folder(dto.name, dto.parent_id)
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.create_folder(dto.name, dto.parent_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to create folder: {}", e)))?;
|
||||
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to create folder: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert to DTO
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
/// Gets a folder by its ID
|
||||
async fn get_folder(&self, id: &str) -> Result<FolderDto, DomainError> {
|
||||
let folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {}: {}", id, e)))?;
|
||||
|
||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
/// Gets a folder by its path
|
||||
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
|
||||
// Convert the string path to StoragePath
|
||||
let storage_path = StoragePath::from_string(path);
|
||||
|
||||
let folder = self.folder_storage.get_folder_by_path(&storage_path)
|
||||
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.get_folder_by_path(&storage_path)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder at path: {}: {}", path, e)))?;
|
||||
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get folder at path: {}: {}", path, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let folders = self.folder_storage.list_folders(parent_id)
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders(parent_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to list folders in parent: {:?}: {}", parent_id, e)))?;
|
||||
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to list folders in parent: {:?}: {}", parent_id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert to DTOs
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
|
||||
|
||||
/// Lists folders with pagination
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError> {
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>
|
||||
{
|
||||
// Validate and adjust pagination
|
||||
let pagination = pagination.validate_and_adjust();
|
||||
|
||||
|
||||
// Get paginated folders and total count
|
||||
let (folders, total_items) = self.folder_storage.list_folders_paginated(
|
||||
parent_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true // Always include total for better UX
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to list folders with pagination in parent: {:?}: {}", parent_id, e)))?;
|
||||
|
||||
let (folders, total_items) = self
|
||||
.folder_storage
|
||||
.list_folders_paginated(
|
||||
parent_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true, // Always include total for better UX
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders with pagination in parent: {:?}: {}",
|
||||
parent_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
// The total is needed to calculate pagination
|
||||
let total = total_items.unwrap_or(folders.len());
|
||||
|
||||
|
||||
// Convert to PaginatedResponseDto
|
||||
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||
folders.into_iter().map(FolderDto::from).collect(),
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
total
|
||||
total,
|
||||
);
|
||||
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
/// Renames a folder
|
||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto) -> Result<FolderDto, DomainError> {
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: RenameFolderDto,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
// Input validation
|
||||
if dto.name.is_empty() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Folder",
|
||||
"New folder name cannot be empty"
|
||||
"New folder name cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Verify the folder exists
|
||||
let existing_folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for renaming: {}", id, e)))?;
|
||||
|
||||
let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get folder with ID: {} for renaming: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create transaction for renaming
|
||||
let mut transaction = StorageTransaction::new("rename_folder");
|
||||
|
||||
|
||||
// Main operation: rename folder
|
||||
// Clone all values to avoid lifetime issues
|
||||
let folder_storage = self.folder_storage.clone();
|
||||
let id_owned = id.to_string();
|
||||
let name_owned = dto.name.clone();
|
||||
|
||||
|
||||
// Create future with owned values
|
||||
let rename_op = async move {
|
||||
folder_storage.rename_folder(&id_owned, name_owned).await?;
|
||||
@@ -198,40 +258,50 @@ impl FolderUseCase for FolderService {
|
||||
let original_name = existing_folder.name().to_string();
|
||||
let storage = self.folder_storage.clone();
|
||||
let id_clone = id.to_string();
|
||||
|
||||
|
||||
async move {
|
||||
// In case of failure, restore the original name
|
||||
storage.rename_folder(&id_clone, original_name).await
|
||||
storage
|
||||
.rename_folder(&id_clone, original_name)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder rename: {}", e)
|
||||
))
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder rename: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add to the transaction
|
||||
transaction.add_operation(rename_op, rollback_op);
|
||||
|
||||
|
||||
// Execute transaction
|
||||
transaction.commit().await?;
|
||||
|
||||
|
||||
// Get the renamed folder
|
||||
let folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get renamed folder with ID: {}: {}", id, e)))?;
|
||||
|
||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get renamed folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
/// Moves a folder to a new parent
|
||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto) -> Result<FolderDto, DomainError> {
|
||||
// Verify the source folder exists
|
||||
let source_folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for moving: {}", id, e)))?;
|
||||
|
||||
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get folder with ID: {} for moving: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// If a parent_id is specified, verify it exists
|
||||
if let Some(parent_id) = &dto.parent_id {
|
||||
// Verify we are not trying to move the folder into itself or one of its descendants
|
||||
@@ -239,29 +309,29 @@ impl FolderUseCase for FolderService {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Folder",
|
||||
"Cannot move a folder into itself"
|
||||
"Cannot move a folder into itself",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Verify the destination exists
|
||||
let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok();
|
||||
if !parent_exists {
|
||||
return Err(DomainError::not_found("Folder", parent_id));
|
||||
}
|
||||
|
||||
|
||||
// TODO: Ideally we should verify the entire hierarchy to prevent cycles
|
||||
}
|
||||
|
||||
|
||||
// Create transaction for moving
|
||||
let mut transaction = StorageTransaction::new("move_folder");
|
||||
|
||||
|
||||
// Main operation: move folder
|
||||
// Clone all values to avoid lifetime issues
|
||||
let folder_storage = self.folder_storage.clone();
|
||||
let id_owned = id.to_string();
|
||||
// Get parent ID as owned string or None
|
||||
let parent_id_owned = dto.parent_id.as_ref().map(|p| p.to_string());
|
||||
|
||||
|
||||
// Create future with owned values
|
||||
let move_op = async move {
|
||||
// Convert Option<String> to Option<&str>
|
||||
@@ -273,45 +343,58 @@ impl FolderUseCase for FolderService {
|
||||
let original_parent_id = source_folder.parent_id().map(String::from);
|
||||
let storage = self.folder_storage.clone();
|
||||
let id_clone = id.to_string();
|
||||
|
||||
|
||||
async move {
|
||||
// In case of failure, restore the original location
|
||||
storage.move_folder(&id_clone, original_parent_id.as_deref()).await
|
||||
storage
|
||||
.move_folder(&id_clone, original_parent_id.as_deref())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder move: {}", e)
|
||||
))
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Failed to rollback folder move: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add to the transaction
|
||||
transaction.add_operation(move_op, rollback_op);
|
||||
|
||||
|
||||
// Execute transaction
|
||||
transaction.commit().await?;
|
||||
|
||||
|
||||
// Get the moved folder
|
||||
let folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get moved folder with ID: {}: {}", id, e)))?;
|
||||
|
||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get moved folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
|
||||
/// Deletes a folder
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
||||
// Verify the folder exists
|
||||
let _folder = self.folder_storage.get_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to get folder with ID: {} for deletion: {}", id, e)))?;
|
||||
|
||||
let _folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to get folder with ID: {} for deletion: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// In a real implementation, we could verify permissions, dependencies, etc.
|
||||
|
||||
|
||||
// Delete the folder
|
||||
self.folder_storage.delete_folder(id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderStorage", format!("Failed to delete folder with ID: {}: {}", id, e)))
|
||||
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to delete folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::services::i18n_service::{I18nService, I18nResult, Locale};
|
||||
use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale};
|
||||
|
||||
/// Service for i18n operations
|
||||
pub struct I18nApplicationService {
|
||||
@@ -11,65 +11,67 @@ impl I18nApplicationService {
|
||||
/// Creates a dummy service for testing
|
||||
pub fn dummy() -> Self {
|
||||
struct DummyI18nService;
|
||||
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl I18nService for DummyI18nService {
|
||||
async fn translate(&self, _key: &str, _locale: Locale) -> I18nResult<String> {
|
||||
Ok("DUMMY_TRANSLATION".to_string())
|
||||
}
|
||||
|
||||
|
||||
async fn load_translations(&self, _locale: Locale) -> I18nResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn available_locales(&self) -> Vec<Locale> {
|
||||
vec![Locale::English, Locale::Spanish]
|
||||
}
|
||||
|
||||
|
||||
async fn is_supported(&self, _locale: Locale) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
Self { i18n_service: Arc::new(DummyI18nService) }
|
||||
|
||||
Self {
|
||||
i18n_service: Arc::new(DummyI18nService),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Creates a new i18n application service
|
||||
pub fn new(i18n_service: Arc<dyn I18nService>) -> Self {
|
||||
Self { i18n_service }
|
||||
}
|
||||
|
||||
|
||||
/// Get a translation for a key and locale
|
||||
pub async fn translate(&self, key: &str, locale: Option<Locale>) -> I18nResult<String> {
|
||||
let locale = locale.unwrap_or(Locale::default());
|
||||
self.i18n_service.translate(key, locale).await
|
||||
}
|
||||
|
||||
|
||||
/// Load translations for a locale
|
||||
pub async fn load_translations(&self, locale: Locale) -> I18nResult<()> {
|
||||
self.i18n_service.load_translations(locale).await
|
||||
}
|
||||
|
||||
|
||||
/// Load translations for all available locales
|
||||
pub async fn load_all_translations(&self) -> Vec<(Locale, I18nResult<()>)> {
|
||||
let locales = self.i18n_service.available_locales().await;
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
for locale in locales {
|
||||
let result = self.i18n_service.load_translations(locale).await;
|
||||
results.push((locale, result));
|
||||
}
|
||||
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
|
||||
/// Get available locales
|
||||
pub async fn available_locales(&self) -> Vec<Locale> {
|
||||
self.i18n_service.available_locales().await
|
||||
}
|
||||
|
||||
|
||||
/// Check if a locale is supported
|
||||
pub async fn is_supported(&self, locale: Locale) -> bool {
|
||||
self.i18n_service.is_supported(locale).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub mod trash_service;
|
||||
mod trash_service_test;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
pub use file_upload_service::FileUploadService;
|
||||
pub use file_retrieval_service::FileRetrievalService;
|
||||
pub use file_management_service::FileManagementService;
|
||||
pub use file_retrieval_service::FileRetrievalService;
|
||||
pub use file_upload_service::FileUploadService;
|
||||
pub use file_use_case_factory::AppFileUseCaseFactory;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::application::ports::recent_ports::{RecentItemsUseCase, RecentItemsRepositoryPort};
|
||||
use crate::application::dtos::recent_dto::RecentItemDto;
|
||||
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
/// Implementation of the use case for managing recent items.
|
||||
///
|
||||
@@ -27,17 +27,35 @@ impl RecentService {
|
||||
#[async_trait]
|
||||
impl RecentItemsUseCase for RecentService {
|
||||
/// Get recent items for a user
|
||||
async fn get_recent_items(&self, user_id: &str, limit: Option<i32>) -> Result<Vec<RecentItemDto>> {
|
||||
async fn get_recent_items(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: Option<i32>,
|
||||
) -> Result<Vec<RecentItemDto>> {
|
||||
info!("Getting recent items for user: {}", user_id);
|
||||
let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items);
|
||||
let limit_value = limit
|
||||
.unwrap_or(self.max_recent_items)
|
||||
.min(self.max_recent_items);
|
||||
let items = self.repo.get_recent_items(user_id, limit_value).await?;
|
||||
info!("Retrieved {} recent items for user {}", items.len(), user_id);
|
||||
info!(
|
||||
"Retrieved {} recent items for user {}",
|
||||
items.len(),
|
||||
user_id
|
||||
);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Record access to an item
|
||||
async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
|
||||
info!("Recording access to {} '{}' for user {}", item_type, item_id, user_id);
|
||||
async fn record_item_access(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<()> {
|
||||
info!(
|
||||
"Recording access to {} '{}' for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
@@ -50,18 +68,35 @@ impl RecentItemsUseCase for RecentService {
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
|
||||
info!("Successfully recorded access to {} '{}' for user {}", item_type, item_id, user_id);
|
||||
info!(
|
||||
"Successfully recorded access to {} '{}' for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an item from recent
|
||||
async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Removing {} '{}' from recent for user {}", item_type, item_id, user_id);
|
||||
async fn remove_from_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<bool> {
|
||||
info!(
|
||||
"Removing {} '{}' from recent for user {}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
let removed = self.repo.remove_item(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
"{} {} '{}' from recent items for user {}",
|
||||
if removed { "Successfully removed" } else { "Not found" },
|
||||
item_type, item_id, user_id
|
||||
if removed {
|
||||
"Successfully removed"
|
||||
} else {
|
||||
"Not found"
|
||||
},
|
||||
item_type,
|
||||
item_id,
|
||||
user_id
|
||||
);
|
||||
Ok(removed)
|
||||
}
|
||||
@@ -73,4 +108,4 @@ impl RecentItemsUseCase for RecentService {
|
||||
info!("Cleared all recent items for user {}", user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::Mutex;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time;
|
||||
|
||||
use crate::common::errors::Result;
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
|
||||
/**
|
||||
* Search service implementation for files and folders.
|
||||
*
|
||||
*
|
||||
* This service implements the advanced search functionality that allows
|
||||
* users to find files and folders based on various criteria
|
||||
* such as name, type, date and size. It also includes a cache to improve
|
||||
@@ -24,16 +24,16 @@ use crate::application::ports::storage_ports::FileReadPort;
|
||||
pub struct SearchService {
|
||||
/// Repository for file operations
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
|
||||
|
||||
/// Repository for folder operations
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
|
||||
|
||||
/// Search results cache with expiration time
|
||||
search_cache: Arc<Mutex<HashMap<SearchCacheKey, CachedSearchResult>>>,
|
||||
|
||||
|
||||
/// Cache validity duration in seconds
|
||||
cache_ttl: u64,
|
||||
|
||||
|
||||
/// Maximum cache size (number of stored results)
|
||||
max_cache_size: usize,
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub struct SearchService {
|
||||
struct SearchCacheKey {
|
||||
/// Serialized representation of the search criteria
|
||||
criteria_hash: String,
|
||||
|
||||
|
||||
/// User ID (to isolate searches between users)
|
||||
user_id: String,
|
||||
}
|
||||
@@ -52,7 +52,7 @@ struct SearchCacheKey {
|
||||
struct CachedSearchResult {
|
||||
/// Search results
|
||||
results: SearchResultsDto,
|
||||
|
||||
|
||||
/// Time when the cache entry was created
|
||||
timestamp: Instant,
|
||||
}
|
||||
@@ -60,7 +60,7 @@ struct CachedSearchResult {
|
||||
impl SearchService {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
*
|
||||
* @param file_repository Repository for file operations
|
||||
* @param folder_repository Repository for folder operations
|
||||
* @param cache_ttl Cache time-to-live in seconds (0 to disable)
|
||||
@@ -79,18 +79,18 @@ impl SearchService {
|
||||
cache_ttl,
|
||||
max_cache_size,
|
||||
};
|
||||
|
||||
|
||||
// Start cache cleanup task if TTL > 0
|
||||
if cache_ttl > 0 {
|
||||
Self::start_cache_cleanup_task(search_service.search_cache.clone(), cache_ttl);
|
||||
}
|
||||
|
||||
|
||||
search_service
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts an asynchronous task to clean up expired cache entries.
|
||||
*
|
||||
*
|
||||
* @param cache_ref Reference to the shared cache
|
||||
* @param ttl_seconds TTL in seconds
|
||||
*/
|
||||
@@ -101,21 +101,21 @@ impl SearchService {
|
||||
tokio::spawn(async move {
|
||||
let cleanup_interval = Duration::from_secs(ttl_seconds / 2);
|
||||
let ttl = Duration::from_secs(ttl_seconds);
|
||||
|
||||
|
||||
loop {
|
||||
time::sleep(cleanup_interval).await;
|
||||
|
||||
|
||||
// Acquire lock and clean up expired entries
|
||||
if let Ok(mut cache) = cache_ref.lock() {
|
||||
let now = Instant::now();
|
||||
|
||||
|
||||
// Identify expired entries
|
||||
let expired_keys: Vec<SearchCacheKey> = cache
|
||||
.iter()
|
||||
.filter(|(_, result)| now.duration_since(result.timestamp) > ttl)
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
|
||||
|
||||
// Remove expired entries
|
||||
for key in expired_keys {
|
||||
cache.remove(&key);
|
||||
@@ -124,10 +124,10 @@ impl SearchService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a cache key from the search criteria.
|
||||
*
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @param user_id User ID (to isolate cache between users)
|
||||
* @return Cache key
|
||||
@@ -135,16 +135,16 @@ impl SearchService {
|
||||
fn create_cache_key(&self, criteria: &SearchCriteriaDto, user_id: &str) -> SearchCacheKey {
|
||||
// Serialize criteria to generate a hash
|
||||
let criteria_str = serde_json::to_string(criteria).unwrap_or_default();
|
||||
|
||||
|
||||
SearchCacheKey {
|
||||
criteria_hash: criteria_str,
|
||||
user_id: user_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attempts to retrieve results from the cache.
|
||||
*
|
||||
*
|
||||
* @param key Cache key
|
||||
* @return Optionally, the results if they exist and have not expired
|
||||
*/
|
||||
@@ -153,24 +153,25 @@ impl SearchService {
|
||||
if self.cache_ttl == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
if let Ok(cache) = self.search_cache.lock()
|
||||
&& let Some(cached_result) = cache.get(key) {
|
||||
let now = Instant::now();
|
||||
let ttl = Duration::from_secs(self.cache_ttl);
|
||||
|
||||
// Check if the entry has expired
|
||||
if now.duration_since(cached_result.timestamp) < ttl {
|
||||
return Some(cached_result.results.clone());
|
||||
}
|
||||
&& let Some(cached_result) = cache.get(key)
|
||||
{
|
||||
let now = Instant::now();
|
||||
let ttl = Duration::from_secs(self.cache_ttl);
|
||||
|
||||
// Check if the entry has expired
|
||||
if now.duration_since(cached_result.timestamp) < ttl {
|
||||
return Some(cached_result.results.clone());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Stores results in the cache.
|
||||
*
|
||||
*
|
||||
* @param key Cache key
|
||||
* @param results Results to store
|
||||
*/
|
||||
@@ -179,45 +180,56 @@ impl SearchService {
|
||||
if self.cache_ttl == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if let Ok(mut cache) = self.search_cache.lock() {
|
||||
// If the cache is full, remove the oldest entry
|
||||
if cache.len() >= self.max_cache_size
|
||||
&& let Some((oldest_key, _)) = cache
|
||||
.iter()
|
||||
.min_by_key(|(_, result)| result.timestamp) {
|
||||
let key_to_remove = oldest_key.clone();
|
||||
cache.remove(&key_to_remove);
|
||||
}
|
||||
|
||||
&& let Some((oldest_key, _)) =
|
||||
cache.iter().min_by_key(|(_, result)| result.timestamp)
|
||||
{
|
||||
let key_to_remove = oldest_key.clone();
|
||||
cache.remove(&key_to_remove);
|
||||
}
|
||||
|
||||
// Store the new result
|
||||
cache.insert(key, CachedSearchResult {
|
||||
results,
|
||||
timestamp: Instant::now(),
|
||||
});
|
||||
cache.insert(
|
||||
key,
|
||||
CachedSearchResult {
|
||||
results,
|
||||
timestamp: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filters files according to the search criteria.
|
||||
*
|
||||
*
|
||||
* @param files List of files to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Files that match the criteria
|
||||
*/
|
||||
fn filter_files(&self, files: Vec<FileDto>, criteria: &SearchCriteriaDto) -> Vec<FileDto> {
|
||||
files.into_iter()
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !file.name.to_lowercase().contains(&name_query.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& !file
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by file type (extension)
|
||||
if let Some(file_types) = &criteria.file_types {
|
||||
if let Some(extension) = file.name.split('.').next_back() {
|
||||
if !file_types.iter().any(|ext| ext.eq_ignore_ascii_case(extension)) {
|
||||
if !file_types
|
||||
.iter()
|
||||
.any(|ext| ext.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
@@ -225,91 +237,110 @@ impl SearchService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& file.created_at < created_after {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& file.created_at > created_before {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& file.modified_at < modified_after {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& file.modified_at > modified_before {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by size
|
||||
if let Some(min_size) = criteria.min_size
|
||||
&& file.size < min_size {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.size < min_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(max_size) = criteria.max_size
|
||||
&& file.size > max_size {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& file.size > max_size
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filters folders according to the search criteria.
|
||||
*
|
||||
*
|
||||
* @param folders List of folders to filter
|
||||
* @param criteria Search criteria
|
||||
* @return Folders that match the criteria
|
||||
*/
|
||||
fn filter_folders(&self, folders: Vec<FolderDto>, criteria: &SearchCriteriaDto) -> Vec<FolderDto> {
|
||||
folders.into_iter()
|
||||
fn filter_folders(
|
||||
&self,
|
||||
folders: Vec<FolderDto>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
) -> Vec<FolderDto> {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter(|folder| {
|
||||
// Filter by name
|
||||
if let Some(name_query) = &criteria.name_contains
|
||||
&& !folder.name.to_lowercase().contains(&name_query.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& !folder
|
||||
.name
|
||||
.to_lowercase()
|
||||
.contains(&name_query.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by creation date
|
||||
if let Some(created_after) = criteria.created_after
|
||||
&& folder.created_at < created_after {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& folder.created_at < created_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(created_before) = criteria.created_before
|
||||
&& folder.created_at > created_before {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& folder.created_at > created_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by modification date
|
||||
if let Some(modified_after) = criteria.modified_after
|
||||
&& folder.modified_at < modified_after {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& folder.modified_at < modified_after
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(modified_before) = criteria.modified_before
|
||||
&& folder.modified_at > modified_before {
|
||||
return false;
|
||||
}
|
||||
|
||||
&& folder.modified_at > modified_before
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Implementation of recursive search through folders.
|
||||
*
|
||||
*
|
||||
* @param current_folder_id ID of the current folder
|
||||
* @param criteria Search criteria
|
||||
* @param found_files Files found so far
|
||||
@@ -323,43 +354,39 @@ impl SearchService {
|
||||
found_folders: &mut Vec<FolderDto>,
|
||||
) -> Result<()> {
|
||||
Box::pin(async move {
|
||||
// List files in the current folder
|
||||
let files = self.file_repository.list_files(current_folder_id).await?;
|
||||
|
||||
// Filter files according to criteria and add them to the results
|
||||
let filtered_files = self.filter_files(
|
||||
files.into_iter().map(FileDto::from).collect(),
|
||||
criteria
|
||||
);
|
||||
found_files.extend(filtered_files);
|
||||
|
||||
// If the search is recursive, process subfolders
|
||||
if criteria.recursive {
|
||||
// List subfolders
|
||||
let folders = self.folder_repository.list_folders(current_folder_id).await?;
|
||||
|
||||
// Filter folders according to criteria and add them to the results
|
||||
let filtered_folders: Vec<FolderDto> = self.filter_folders(
|
||||
folders.into_iter().map(FolderDto::from).collect(),
|
||||
criteria
|
||||
);
|
||||
|
||||
// Add filtered folders to the results
|
||||
found_folders.extend(filtered_folders.iter().cloned());
|
||||
|
||||
// Search recursively in each subfolder
|
||||
for folder in filtered_folders {
|
||||
self.search_recursive(
|
||||
Some(&folder.id),
|
||||
criteria,
|
||||
found_files,
|
||||
found_folders,
|
||||
).await?;
|
||||
// List files in the current folder
|
||||
let files = self.file_repository.list_files(current_folder_id).await?;
|
||||
|
||||
// Filter files according to criteria and add them to the results
|
||||
let filtered_files =
|
||||
self.filter_files(files.into_iter().map(FileDto::from).collect(), criteria);
|
||||
found_files.extend(filtered_files);
|
||||
|
||||
// If the search is recursive, process subfolders
|
||||
if criteria.recursive {
|
||||
// List subfolders
|
||||
let folders = self
|
||||
.folder_repository
|
||||
.list_folders(current_folder_id)
|
||||
.await?;
|
||||
|
||||
// Filter folders according to criteria and add them to the results
|
||||
let filtered_folders: Vec<FolderDto> = self
|
||||
.filter_folders(folders.into_iter().map(FolderDto::from).collect(), criteria);
|
||||
|
||||
// Add filtered folders to the results
|
||||
found_folders.extend(filtered_folders.iter().cloned());
|
||||
|
||||
// Search recursively in each subfolder
|
||||
for folder in filtered_folders {
|
||||
self.search_recursive(Some(&folder.id), criteria, found_files, found_folders)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}).await
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +394,7 @@ impl SearchService {
|
||||
impl SearchUseCase for SearchService {
|
||||
/**
|
||||
* Performs a search based on the specified criteria.
|
||||
*
|
||||
*
|
||||
* @param criteria Search criteria
|
||||
* @return Search results
|
||||
*/
|
||||
@@ -375,36 +402,37 @@ impl SearchUseCase for SearchService {
|
||||
// TODO: Get user ID from the authentication context
|
||||
let user_id = "default-user";
|
||||
let cache_key = self.create_cache_key(&criteria, user_id);
|
||||
|
||||
|
||||
// Try to get results from the cache
|
||||
if let Some(cached_results) = self.get_from_cache(&cache_key) {
|
||||
return Ok(cached_results);
|
||||
}
|
||||
|
||||
|
||||
// Initialize collections for results
|
||||
let mut found_files: Vec<FileDto> = Vec::new();
|
||||
let mut found_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
|
||||
// Perform search in the specified folder or at the root
|
||||
self.search_recursive(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
&mut found_files,
|
||||
&mut found_folders,
|
||||
).await?;
|
||||
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Apply pagination
|
||||
let total_count = found_files.len() + found_folders.len();
|
||||
|
||||
|
||||
// Sort by relevance or date according to criteria
|
||||
// By default, sort by modification date (most recent first)
|
||||
found_files.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
found_folders.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
|
||||
|
||||
// Apply limit and offset for pagination
|
||||
let start_idx = criteria.offset.min(total_count);
|
||||
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
||||
|
||||
|
||||
let paginated_items: Vec<(bool, usize)> = (start_idx..end_idx)
|
||||
.map(|i| {
|
||||
if i < found_folders.len() {
|
||||
@@ -414,11 +442,11 @@ impl SearchUseCase for SearchService {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
// Extract paginated items
|
||||
let mut paginated_folders = Vec::new();
|
||||
let mut paginated_files = Vec::new();
|
||||
|
||||
|
||||
for (is_folder, idx) in paginated_items {
|
||||
if is_folder {
|
||||
if idx < found_folders.len() {
|
||||
@@ -428,7 +456,7 @@ impl SearchUseCase for SearchService {
|
||||
paginated_files.push(found_files[idx].clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create results object
|
||||
let search_results = SearchResultsDto::new(
|
||||
paginated_files,
|
||||
@@ -437,16 +465,16 @@ impl SearchUseCase for SearchService {
|
||||
criteria.offset,
|
||||
Some(total_count),
|
||||
);
|
||||
|
||||
|
||||
// Store in cache
|
||||
self.store_in_cache(cache_key, search_results.clone());
|
||||
|
||||
|
||||
Ok(search_results)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clears the search results cache.
|
||||
*
|
||||
*
|
||||
* @return Result indicating success
|
||||
*/
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
@@ -462,18 +490,18 @@ impl SearchService {
|
||||
/// Creates a stub version of the service for testing
|
||||
pub fn new_stub() -> impl SearchUseCase {
|
||||
struct SearchServiceStub;
|
||||
|
||||
|
||||
#[async_trait]
|
||||
impl SearchUseCase for SearchServiceStub {
|
||||
async fn search(&self, _criteria: SearchCriteriaDto) -> Result<SearchResultsDto> {
|
||||
Ok(SearchResultsDto::empty())
|
||||
}
|
||||
|
||||
|
||||
async fn clear_search_cache(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SearchServiceStub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ impl From<ShareServiceError> for DomainError {
|
||||
ShareServiceError::ItemNotFound(s) => DomainError::not_found("Item", s),
|
||||
ShareServiceError::AccessDenied(s) => DomainError::access_denied("Share", s),
|
||||
ShareServiceError::InvalidPassword(s) => DomainError::access_denied("Share", s),
|
||||
ShareServiceError::Expired => DomainError::access_denied("Share", "Share has expired".to_string()),
|
||||
ShareServiceError::Expired => {
|
||||
DomainError::access_denied("Share", "Share has expired".to_string())
|
||||
}
|
||||
ShareServiceError::Repository(s) => DomainError::internal_error("Share", s),
|
||||
ShareServiceError::InvalidItemType(s) => DomainError::validation_error(s),
|
||||
ShareServiceError::Validation(s) => DomainError::validation_error(s),
|
||||
@@ -91,13 +93,23 @@ impl ShareService {
|
||||
self.file_repository
|
||||
.get_file(item_id) // Using the correct method from the FileStoragePort trait
|
||||
.await
|
||||
.map_err(|_| ShareServiceError::ItemNotFound(format!("File with ID {} not found", item_id)))?;
|
||||
.map_err(|_| {
|
||||
ShareServiceError::ItemNotFound(format!(
|
||||
"File with ID {} not found",
|
||||
item_id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
ShareItemType::Folder => {
|
||||
self.folder_repository
|
||||
.get_folder(item_id) // Using the correct method from the FolderStoragePort trait
|
||||
.await
|
||||
.map_err(|_| ShareServiceError::ItemNotFound(format!("Folder with ID {} not found", item_id)))?;
|
||||
.map_err(|_| {
|
||||
ShareServiceError::ItemNotFound(format!(
|
||||
"Folder with ID {} not found",
|
||||
item_id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -105,13 +117,13 @@ impl ShareService {
|
||||
|
||||
/// Password hash using Argon2id (resistant to timing attacks and GPU attacks)
|
||||
fn hash_password(&self, password: &str) -> String {
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
use rand_core::OsRng;
|
||||
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Failed to hash share password")
|
||||
@@ -167,7 +179,9 @@ impl ShareUseCase for ShareService {
|
||||
.share_repository
|
||||
.find_share_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
@@ -184,7 +198,9 @@ impl ShareUseCase for ShareService {
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
@@ -229,7 +245,9 @@ impl ShareUseCase for ShareService {
|
||||
.share_repository
|
||||
.find_share_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e)))?;
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with ID {} not found: {}", id, e))
|
||||
})?;
|
||||
|
||||
// Update permissions if provided
|
||||
if let Some(permissions_dto) = dto.permissions {
|
||||
@@ -264,7 +282,10 @@ impl ShareUseCase for ShareService {
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Convert the entity to DTO for the response
|
||||
Ok(ShareDto::from_entity(&updated_share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(
|
||||
&updated_share,
|
||||
&self.config.base_url(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_shared_link(&self, id: &str) -> Result<(), DomainError> {
|
||||
@@ -300,12 +321,7 @@ impl ShareUseCase for ShareService {
|
||||
.collect();
|
||||
|
||||
// Create the paginated result
|
||||
let paginated = PaginatedResponseDto::new(
|
||||
share_dtos,
|
||||
page,
|
||||
per_page,
|
||||
total
|
||||
);
|
||||
let paginated = PaginatedResponseDto::new(share_dtos, page, per_page, total);
|
||||
|
||||
Ok(paginated)
|
||||
}
|
||||
@@ -320,7 +336,9 @@ impl ShareUseCase for ShareService {
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
@@ -329,9 +347,7 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
// Verify the password using the infrastructure port
|
||||
match share.password_hash() {
|
||||
Some(hash) => {
|
||||
self.password_hasher.verify_password(password, hash)
|
||||
}
|
||||
Some(hash) => self.password_hasher.verify_password(password, hash),
|
||||
None => Ok(true), // No password required
|
||||
}
|
||||
}
|
||||
@@ -342,7 +358,9 @@ impl ShareUseCase for ShareService {
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e)))?;
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
@@ -365,9 +383,9 @@ impl ShareUseCase for ShareService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::share_ports::ShareStoragePort;
|
||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||
use crate::application::ports::share_ports::ShareStoragePort;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use async_trait::async_trait;
|
||||
@@ -391,12 +409,17 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl FileReadPort for MockFileRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
async fn get_file(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
if id == "test_file_id" {
|
||||
let file = crate::domain::entities::file::File::new(
|
||||
id.to_string(),
|
||||
"test.txt".to_string(),
|
||||
crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"),
|
||||
crate::domain::services::path_service::StoragePath::from_string(
|
||||
"/path/to/test.txt",
|
||||
),
|
||||
123,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
@@ -407,11 +430,14 @@ mod tests {
|
||||
Err(DomainError::not_found("File", id))
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
|
||||
async fn list_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -419,7 +445,10 @@ mod tests {
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
) -> Result<
|
||||
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
|
||||
DomainError,
|
||||
> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -428,7 +457,10 @@ mod tests {
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
) -> Result<
|
||||
Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>,
|
||||
DomainError,
|
||||
> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -436,7 +468,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
async fn get_file_path(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -447,16 +482,25 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn create_folder(
|
||||
&self,
|
||||
_name: String,
|
||||
_parent_id: Option<String>,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn get_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
if id == "test_folder_id" {
|
||||
let folder = crate::domain::entities::folder::Folder::new(
|
||||
id.to_string(),
|
||||
"test".to_string(),
|
||||
crate::domain::services::path_service::StoragePath::from_string("/path/to/test"),
|
||||
crate::domain::services::path_service::StoragePath::from_string(
|
||||
"/path/to/test",
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -466,35 +510,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &crate::domain::services::path_service::StoragePath,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError> {
|
||||
async fn list_folders(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError>
|
||||
{
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_name: String,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_parent_id: Option<&str>,
|
||||
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, DomainError> {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &crate::domain::services::path_service::StoragePath,
|
||||
) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
async fn get_folder_path(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -502,7 +573,11 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> {
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -530,91 +605,103 @@ mod tests {
|
||||
async fn save_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
|
||||
|
||||
shares.insert(share.id().to_string(), share.clone());
|
||||
tokens.insert(share.token().to_string(), share.id().to_string());
|
||||
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
|
||||
async fn find_share_by_id(&self, id: &str) -> Result<Share, DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
shares.get(id)
|
||||
|
||||
shares
|
||||
.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("Share", id))
|
||||
}
|
||||
|
||||
|
||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||
let tokens = self.tokens.lock().unwrap();
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let id = tokens.get(token)
|
||||
|
||||
let id = tokens
|
||||
.get(token)
|
||||
.ok_or_else(|| DomainError::not_found("Share", token))?;
|
||||
|
||||
shares.get(id)
|
||||
|
||||
shares
|
||||
.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("Share", id.as_str()))
|
||||
}
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
|
||||
|
||||
async fn find_shares_by_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<Vec<Share>, DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
|
||||
let type_str = item_type.to_string();
|
||||
let result: Vec<Share> = shares.values()
|
||||
let result: Vec<Share> = shares
|
||||
.values()
|
||||
.filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
|
||||
|
||||
let id_str = share.id().to_string();
|
||||
if !shares.contains_key(&id_str) {
|
||||
return Err(DomainError::not_found("Share", &id_str));
|
||||
}
|
||||
|
||||
|
||||
shares.insert(id_str, share.clone());
|
||||
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
|
||||
|
||||
async fn delete_share(&self, id: &str) -> Result<(), DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
|
||||
|
||||
// Find the share to get the token
|
||||
let share = shares.get(id)
|
||||
let share = shares
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::not_found("Share", id))?;
|
||||
|
||||
|
||||
// Remove token mapping
|
||||
tokens.remove(share.token());
|
||||
|
||||
|
||||
// Remove the share
|
||||
shares.remove(id);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_shares_by_user(&self, user_id: &str, offset: usize, limit: usize) -> Result<(Vec<Share>, usize), DomainError> {
|
||||
|
||||
async fn find_shares_by_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Share>, usize), DomainError> {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let user_shares: Vec<Share> = shares.values()
|
||||
|
||||
let user_shares: Vec<Share> = shares
|
||||
.values()
|
||||
.filter(|s| s.created_by() == user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
|
||||
let total = user_shares.len();
|
||||
|
||||
|
||||
// Apply pagination
|
||||
let paginated = user_shares.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect();
|
||||
|
||||
let paginated = user_shares.into_iter().skip(offset).take(limit).collect();
|
||||
|
||||
Ok((paginated, total))
|
||||
}
|
||||
}
|
||||
@@ -622,14 +709,15 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_create_shared_link() {
|
||||
let config = Arc::new(AppConfig::default());
|
||||
|
||||
|
||||
let share_repo = Arc::new(MockShareRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository);
|
||||
let folder_repo = Arc::new(MockFolderRepository);
|
||||
let password_hasher = Arc::new(MockPasswordHasher);
|
||||
|
||||
let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
|
||||
|
||||
|
||||
let service =
|
||||
ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
|
||||
|
||||
// Test creating a file share
|
||||
let dto = CreateShareDto {
|
||||
item_id: "test_file_id".to_string(),
|
||||
@@ -642,14 +730,14 @@ mod tests {
|
||||
reshare: false,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
let result = service.create_shared_link("user123", dto).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
|
||||
let share_dto = result.unwrap();
|
||||
assert_eq!(share_dto.item_id, "test_file_id");
|
||||
assert_eq!(share_dto.item_type, "file");
|
||||
assert!(share_dto.has_password);
|
||||
assert!(share_dto.url.starts_with("http://127.0.0.1:8085/s/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
use async_trait::async_trait;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::application::ports::outbound::{FolderStoragePort, IdMappingPort, StoragePort};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::application::ports::outbound::{IdMappingPort, StoragePort, FolderStoragePort};
|
||||
|
||||
/// Storage mediator specific errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StorageMediatorError {
|
||||
#[error("Entity not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
|
||||
#[error("Entity already exists: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
|
||||
#[error("Invalid path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
|
||||
#[error("Access error: {0}")]
|
||||
AccessError(String),
|
||||
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
|
||||
#[error("Domain error: {0}")]
|
||||
DomainError(#[from] crate::common::errors::DomainError),
|
||||
}
|
||||
@@ -37,36 +37,45 @@ pub type StorageMediatorResult<T> = Result<T, StorageMediatorError>;
|
||||
pub trait StorageMediator: Send + Sync + 'static {
|
||||
/// Gets the path of a folder by its ID
|
||||
async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult<PathBuf>;
|
||||
|
||||
|
||||
/// Gets the domain path of a folder by its ID
|
||||
async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult<StoragePath>;
|
||||
|
||||
|
||||
/// Gets all details of a folder by its ID
|
||||
async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult<Folder>;
|
||||
|
||||
|
||||
/// Checks if a file exists at a specific path
|
||||
async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool>;
|
||||
|
||||
|
||||
/// Checks if a file exists at a specific domain path
|
||||
async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool>;
|
||||
|
||||
async fn file_exists_at_storage_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool>;
|
||||
|
||||
/// Checks if a folder exists at a specific path
|
||||
async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool>;
|
||||
|
||||
|
||||
/// Checks if a folder exists at a specific domain path
|
||||
async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool>;
|
||||
|
||||
async fn folder_exists_at_storage_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool>;
|
||||
|
||||
/// Resolves a relative path to absolute (legacy)
|
||||
fn resolve_path(&self, relative_path: &Path) -> PathBuf;
|
||||
|
||||
|
||||
/// Resolves a domain path to an absolute physical path
|
||||
fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf;
|
||||
|
||||
|
||||
/// Creates a directory if it does not exist (legacy)
|
||||
async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()>;
|
||||
|
||||
|
||||
/// Creates a directory if it does not exist
|
||||
async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()>;
|
||||
async fn ensure_storage_directory(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<()>;
|
||||
}
|
||||
|
||||
/// Concrete implementation of the storage mediator
|
||||
@@ -77,10 +86,18 @@ pub struct FileSystemStorageMediator {
|
||||
}
|
||||
|
||||
impl FileSystemStorageMediator {
|
||||
pub fn new(folder_storage_port: Arc<dyn FolderStoragePort>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
|
||||
Self { folder_storage_port, path_service, id_mapping }
|
||||
pub fn new(
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
path_service: Arc<dyn StoragePort>,
|
||||
id_mapping: Arc<dyn IdMappingPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
folder_storage_port,
|
||||
path_service,
|
||||
id_mapping,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Creates a stub implementation for initialization bootstrapping
|
||||
pub fn new_stub() -> StubStorageMediator {
|
||||
StubStorageMediator::new()
|
||||
@@ -109,46 +126,60 @@ impl StorageMediator for StubStorageMediator {
|
||||
// Return a stub path
|
||||
Ok(PathBuf::from("/tmp"))
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, _folder_id: &str) -> StorageMediatorResult<StoragePath> {
|
||||
|
||||
async fn get_folder_storage_path(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> StorageMediatorResult<StoragePath> {
|
||||
// Return a stub storage path
|
||||
Ok(StoragePath::root())
|
||||
}
|
||||
|
||||
|
||||
async fn get_folder(&self, _folder_id: &str) -> StorageMediatorResult<Folder> {
|
||||
// This is a stub that should never be called during initialization
|
||||
Err(StorageMediatorError::NotFound("Stub not implemented".to_string()))
|
||||
Err(StorageMediatorError::NotFound(
|
||||
"Stub not implemented".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
async fn file_exists_at_path(&self, _path: &Path) -> StorageMediatorResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn file_exists_at_storage_path(&self, _storage_path: &StoragePath) -> StorageMediatorResult<bool> {
|
||||
|
||||
async fn file_exists_at_storage_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
async fn folder_exists_at_path(&self, _path: &Path) -> StorageMediatorResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn folder_exists_at_storage_path(&self, _storage_path: &StoragePath) -> StorageMediatorResult<bool> {
|
||||
|
||||
async fn folder_exists_at_storage_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
|
||||
fn resolve_path(&self, _relative_path: &Path) -> PathBuf {
|
||||
PathBuf::from("/tmp")
|
||||
}
|
||||
|
||||
|
||||
fn resolve_storage_path(&self, _storage_path: &StoragePath) -> PathBuf {
|
||||
PathBuf::from("/tmp")
|
||||
}
|
||||
|
||||
|
||||
async fn ensure_directory(&self, _path: &Path) -> StorageMediatorResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_storage_directory(&self, _storage_path: &StoragePath) -> StorageMediatorResult<()> {
|
||||
|
||||
async fn ensure_storage_directory(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -156,112 +187,140 @@ impl StorageMediator for StubStorageMediator {
|
||||
#[async_trait]
|
||||
impl StorageMediator for FileSystemStorageMediator {
|
||||
async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult<PathBuf> {
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(folder_id)
|
||||
.await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
|
||||
// Need to get the path from folder ID
|
||||
let storage_path = self.id_mapping.get_path_by_id(folder.id()).await
|
||||
let storage_path = self
|
||||
.id_mapping
|
||||
.get_path_by_id(folder.id())
|
||||
.await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
|
||||
// Convert StoragePath to PathBuf
|
||||
let path_buf = self.path_service.resolve_path(&storage_path);
|
||||
Ok(path_buf)
|
||||
}
|
||||
|
||||
|
||||
async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult<StoragePath> {
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(folder_id)
|
||||
.await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
|
||||
// Get path by folder ID - will already be a StoragePath
|
||||
let storage_path = self.id_mapping.get_path_by_id(folder.id()).await
|
||||
let storage_path = self
|
||||
.id_mapping
|
||||
.get_path_by_id(folder.id())
|
||||
.await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
|
||||
Ok(storage_path)
|
||||
}
|
||||
|
||||
|
||||
async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult<Folder> {
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(folder_id)
|
||||
.await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
|
||||
async fn file_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool> {
|
||||
let abs_path = self.resolve_path(path);
|
||||
|
||||
|
||||
// Check if it exists as a file (not as a directory)
|
||||
let exists = abs_path.exists() && abs_path.is_file();
|
||||
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn file_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool> {
|
||||
|
||||
async fn file_exists_at_storage_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool> {
|
||||
let abs_path = self.resolve_storage_path(storage_path);
|
||||
|
||||
|
||||
// Check if it exists as a file (not as a directory)
|
||||
let exists = abs_path.exists() && abs_path.is_file();
|
||||
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
|
||||
async fn folder_exists_at_path(&self, path: &Path) -> StorageMediatorResult<bool> {
|
||||
let abs_path = self.resolve_path(path);
|
||||
|
||||
|
||||
// Check if it exists as a directory
|
||||
let exists = abs_path.exists() && abs_path.is_dir();
|
||||
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn folder_exists_at_storage_path(&self, storage_path: &StoragePath) -> StorageMediatorResult<bool> {
|
||||
|
||||
async fn folder_exists_at_storage_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<bool> {
|
||||
let abs_path = self.resolve_storage_path(storage_path);
|
||||
|
||||
|
||||
// Check if it exists as a directory
|
||||
let exists = abs_path.exists() && abs_path.is_dir();
|
||||
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
|
||||
fn resolve_path(&self, relative_path: &Path) -> PathBuf {
|
||||
// Legacy method using PathBuf
|
||||
let path_str = relative_path.to_string_lossy().to_string();
|
||||
let storage_path = StoragePath::from_string(&path_str);
|
||||
self.path_service.resolve_path(&storage_path)
|
||||
}
|
||||
|
||||
|
||||
fn resolve_storage_path(&self, storage_path: &StoragePath) -> PathBuf {
|
||||
self.path_service.resolve_path(storage_path)
|
||||
}
|
||||
|
||||
|
||||
async fn ensure_directory(&self, path: &Path) -> StorageMediatorResult<()> {
|
||||
let abs_path = self.resolve_path(path);
|
||||
|
||||
|
||||
// Create directories if they don't exist
|
||||
if !abs_path.exists() {
|
||||
tokio::fs::create_dir_all(&abs_path).await
|
||||
.map_err(|e| StorageMediatorError::AccessError(format!("Could not create directory: {}", e)))?;
|
||||
tokio::fs::create_dir_all(&abs_path).await.map_err(|e| {
|
||||
StorageMediatorError::AccessError(format!("Could not create directory: {}", e))
|
||||
})?;
|
||||
} else if !abs_path.is_dir() {
|
||||
return Err(StorageMediatorError::InvalidPath(
|
||||
format!("Path exists but is not a directory: {}", abs_path.display())
|
||||
));
|
||||
return Err(StorageMediatorError::InvalidPath(format!(
|
||||
"Path exists but is not a directory: {}",
|
||||
abs_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_storage_directory(&self, storage_path: &StoragePath) -> StorageMediatorResult<()> {
|
||||
|
||||
async fn ensure_storage_directory(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> StorageMediatorResult<()> {
|
||||
let abs_path = self.resolve_storage_path(storage_path);
|
||||
|
||||
|
||||
// Create directories if they don't exist
|
||||
if !abs_path.exists() {
|
||||
tokio::fs::create_dir_all(&abs_path).await
|
||||
.map_err(|e| StorageMediatorError::AccessError(format!("Could not create directory: {}", e)))?;
|
||||
tokio::fs::create_dir_all(&abs_path).await.map_err(|e| {
|
||||
StorageMediatorError::AccessError(format!("Could not create directory: {}", e))
|
||||
})?;
|
||||
} else if !abs_path.is_dir() {
|
||||
return Err(StorageMediatorError::InvalidPath(
|
||||
format!("Path exists but is not a directory: {}", abs_path.display())
|
||||
));
|
||||
return Err(StorageMediatorError::InvalidPath(format!(
|
||||
"Path exists but is not a directory: {}",
|
||||
abs_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use tokio::task;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::application::ports::auth_ports::UserStoragePort;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
|
||||
use tracing::{info, error, debug};
|
||||
use crate::common::errors::DomainError;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::task;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/**
|
||||
* Service for managing and updating user storage usage statistics.
|
||||
*
|
||||
*
|
||||
* This service is responsible for calculating how much storage each user
|
||||
* is using and updating this information in the user records.
|
||||
*/
|
||||
@@ -28,51 +28,63 @@ impl StorageUsageService {
|
||||
user_repository,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Calculates and updates storage usage for a specific user
|
||||
pub async fn update_user_storage_usage(&self, user_id: &str) -> Result<i64, DomainError> {
|
||||
info!("Updating storage usage for user: {}", user_id);
|
||||
|
||||
|
||||
// Get user's home folder pattern
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
let username = user.username();
|
||||
|
||||
|
||||
// Calculate storage usage for this user
|
||||
let total_usage = self.calculate_user_storage_usage(username).await?;
|
||||
|
||||
|
||||
// Update the user's storage usage in the database
|
||||
self.user_repository.update_storage_usage(user_id, total_usage).await?;
|
||||
|
||||
info!("Updated storage usage for user {} to {} bytes", user_id, total_usage);
|
||||
|
||||
self.user_repository
|
||||
.update_storage_usage(user_id, total_usage)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated storage usage for user {} to {} bytes",
|
||||
user_id, total_usage
|
||||
);
|
||||
|
||||
Ok(total_usage)
|
||||
}
|
||||
|
||||
|
||||
/// Calculates a user's storage usage based on their home folder
|
||||
async fn calculate_user_storage_usage(&self, username: &str) -> Result<i64, DomainError> {
|
||||
debug!("Calculating storage for user: {}", username);
|
||||
|
||||
// First, try to find the user's home folder
|
||||
// List all folders to locate the user's folder
|
||||
let all_folders = self.file_repository.list_files(None).await
|
||||
let all_folders = self
|
||||
.file_repository
|
||||
.list_files(None)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("File repository", e.to_string()))?;
|
||||
|
||||
|
||||
// Find the user's home folder (named "My Folder - {username}")
|
||||
let home_folder_name = format!("My Folder - {}", username);
|
||||
debug!("Looking for home folder: {}", home_folder_name);
|
||||
|
||||
|
||||
let mut total_usage: i64 = 0;
|
||||
let mut home_folder_id = None;
|
||||
|
||||
|
||||
// Find the home folder ID
|
||||
for folder in &all_folders {
|
||||
if folder.name() == home_folder_name {
|
||||
home_folder_id = Some(folder.id().to_string());
|
||||
debug!("Found home folder for user {}: ID={}", username, folder.id());
|
||||
debug!(
|
||||
"Found home folder for user {}: ID={}",
|
||||
username,
|
||||
folder.id()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If we found the home folder, calculate total size
|
||||
if let Some(folder_id) = home_folder_id {
|
||||
// Calculate recursively
|
||||
@@ -81,10 +93,10 @@ impl StorageUsageService {
|
||||
// If no home folder found, just return 0
|
||||
debug!("No home folder found for user: {}", username);
|
||||
}
|
||||
|
||||
|
||||
Ok(total_usage)
|
||||
}
|
||||
|
||||
|
||||
/// Recursively calculates the size of a folder and all its contents
|
||||
async fn calculate_folder_size(&self, folder_id: &str) -> Result<i64, DomainError> {
|
||||
// Implementation with explicit boxing to handle recursion in async functions
|
||||
@@ -93,11 +105,13 @@ impl StorageUsageService {
|
||||
folder_id: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
let mut total_size: i64 = 0;
|
||||
|
||||
|
||||
// Get files directly in this folder
|
||||
let files = repo.list_files(Some(folder_id)).await
|
||||
let files = repo
|
||||
.list_files(Some(folder_id))
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("File repository", e.to_string()))?;
|
||||
|
||||
|
||||
// Sum the size of all files
|
||||
for file in &files {
|
||||
// Skip subdirectories at this level - we'll process them separately
|
||||
@@ -105,16 +119,20 @@ impl StorageUsageService {
|
||||
// Recursively calculate subfolder size with explicit boxing
|
||||
let subfolder_id = file.id().to_string(); // Create owned copy
|
||||
let repo_clone = repo.clone(); // Clone the repository
|
||||
|
||||
|
||||
// Use Box::pin to handle recursive async call
|
||||
let subfolder_size_future = Box::pin(inner_calculate_size(repo_clone, &subfolder_id));
|
||||
|
||||
let subfolder_size_future =
|
||||
Box::pin(inner_calculate_size(repo_clone, &subfolder_id));
|
||||
|
||||
match subfolder_size_future.await {
|
||||
Ok(size) => {
|
||||
total_size += size;
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error calculating size for subfolder {}: {}", subfolder_id, e);
|
||||
error!(
|
||||
"Error calculating size for subfolder {}: {}",
|
||||
subfolder_id, e
|
||||
);
|
||||
// Continue with other folders even if one fails
|
||||
}
|
||||
}
|
||||
@@ -123,10 +141,10 @@ impl StorageUsageService {
|
||||
total_size += file.size() as i64;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
|
||||
// Start the calculation with a clone of our repository reference
|
||||
let repo_clone = Arc::clone(&self.file_repository);
|
||||
inner_calculate_size(repo_clone, folder_id).await
|
||||
@@ -148,37 +166,40 @@ impl StorageUsagePort for StorageUsageService {
|
||||
|
||||
// Get the list of all users
|
||||
let users = self.user_repository.list_users(1000, 0).await?;
|
||||
|
||||
|
||||
let mut update_tasks = Vec::new();
|
||||
|
||||
|
||||
// Process users in parallel
|
||||
for user in users {
|
||||
let user_id = user.id().to_string();
|
||||
let service_clone = self.clone();
|
||||
|
||||
|
||||
// Spawn a background task for each user
|
||||
let task = task::spawn(async move {
|
||||
match service_clone.update_user_storage_usage(&user_id).await {
|
||||
Ok(usage) => {
|
||||
debug!("Updated storage usage for user {}: {} bytes", user_id, usage);
|
||||
debug!(
|
||||
"Updated storage usage for user {}: {} bytes",
|
||||
user_id, usage
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update storage for user {}: {}", user_id, e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
update_tasks.push(task);
|
||||
}
|
||||
|
||||
|
||||
// Wait for all tasks to complete
|
||||
for task in update_tasks {
|
||||
// We don't propagate errors from individual users to avoid failing the entire batch
|
||||
let _ = task.await;
|
||||
}
|
||||
|
||||
|
||||
info!("Completed batch update of all users' storage usage");
|
||||
Ok(())
|
||||
}
|
||||
@@ -192,4 +213,4 @@ impl Clone for StorageUsageService {
|
||||
user_repository: Arc::clone(&self.user_repository),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
|
||||
/**
|
||||
* Application service for trash operations.
|
||||
*
|
||||
*
|
||||
* The TrashService implements the trash management functionality in the application layer,
|
||||
* handling movement of files and folders to trash, restoration from trash, and permanent
|
||||
* deletion. It orchestrates interactions between the domain entities and infrastructure
|
||||
* repositories while enforcing business rules like retention policies.
|
||||
*
|
||||
*
|
||||
* This service follows the Clean Architecture pattern by:
|
||||
* - Depending on application ports rather than domain/infrastructure traits
|
||||
* - Orchestrating domain operations without containing domain logic
|
||||
@@ -27,16 +27,16 @@ use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
pub struct TrashService {
|
||||
/// Repository for trash-specific operations like listing and retrieving trashed items
|
||||
trash_repository: Arc<dyn TrashRepository>,
|
||||
|
||||
|
||||
/// Port for file read operations (get file metadata)
|
||||
file_read_port: Arc<dyn FileReadPort>,
|
||||
|
||||
|
||||
/// Port for file write operations (trash, restore, delete)
|
||||
file_write_port: Arc<dyn FileWritePort>,
|
||||
|
||||
|
||||
/// Port for folder operations (get folder, trash, restore, delete)
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
|
||||
|
||||
/// Number of days items should be kept in trash before automatic cleanup
|
||||
retention_days: u32,
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl TrashService {
|
||||
fn to_dto(&self, item: TrashedItem) -> TrashedItemDto {
|
||||
// Calculate days_until_deletion before moving item fields
|
||||
let days_until_deletion = item.days_until_deletion();
|
||||
|
||||
|
||||
TrashedItemDto {
|
||||
id: item.id().to_string(),
|
||||
original_id: item.original_id().to_string(),
|
||||
@@ -86,12 +86,18 @@ impl TrashService {
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
match self.trash_repository.get_trash_item(&item_uuid, &user_uuid).await? {
|
||||
match self
|
||||
.trash_repository
|
||||
.get_trash_item(&item_uuid, &user_uuid)
|
||||
.await?
|
||||
{
|
||||
Some(item) => {
|
||||
if item.user_id() != user_uuid {
|
||||
error!(
|
||||
"User {} attempted to access trash item {} owned by {}",
|
||||
user_id, item_id, item.user_id()
|
||||
user_id,
|
||||
item_id,
|
||||
item.user_id()
|
||||
);
|
||||
return Err(DomainError::access_denied(
|
||||
"TrashItem",
|
||||
@@ -117,77 +123,84 @@ impl TrashUseCase for TrashService {
|
||||
#[instrument(skip(self))]
|
||||
async fn get_trash_items(&self, user_id: &str) -> Result<Vec<TrashedItemDto>> {
|
||||
debug!("Getting trash items for user: {}", user_id);
|
||||
|
||||
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
|
||||
let items = self.trash_repository.get_trash_items(&user_uuid).await?;
|
||||
|
||||
let dtos = items.into_iter()
|
||||
.map(|item| self.to_dto(item))
|
||||
.collect();
|
||||
|
||||
|
||||
let dtos = items.into_iter().map(|item| self.to_dto(item)).collect();
|
||||
|
||||
Ok(dtos)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()> {
|
||||
info!("Moving to trash: type={}, id={}, user={}", item_type, item_id, user_id);
|
||||
info!(
|
||||
"Moving to trash: type={}, id={}, user={}",
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
debug!("User UUID validation: {}", user_id);
|
||||
|
||||
|
||||
// Note: We do NOT call validate_user_ownership here because the item
|
||||
// is not yet in the trash. Ownership validation is only for operations
|
||||
// on already-trashed items (restore, delete_permanently).
|
||||
|
||||
|
||||
// Parse UUIDs with detailed error handling
|
||||
debug!("Validating item UUID: {}", item_id);
|
||||
let item_uuid = match Uuid::parse_str(item_id) {
|
||||
Ok(uuid) => {
|
||||
debug!("Valid item UUID: {}", uuid);
|
||||
uuid
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid item UUID: {} - Error: {}", item_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid item ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid item ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
debug!("Validating user UUID: {}", user_id);
|
||||
let user_uuid = match Uuid::parse_str(user_id) {
|
||||
Ok(uuid) => {
|
||||
debug!("Valid user UUID: {}", uuid);
|
||||
uuid
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid user UUID: {} - Error: {}", user_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid user ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid user ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
match item_type {
|
||||
"file" => {
|
||||
info!("Processing file to move to trash: {}", item_id);
|
||||
|
||||
|
||||
// Get the file to verify it exists and capture its data
|
||||
debug!("Getting file data: {}", item_id);
|
||||
let file = match self.file_read_port.get_file(item_id).await {
|
||||
Ok(file) => {
|
||||
debug!("File found: {} ({})", file.name(), item_id);
|
||||
file
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error getting file: {} - {}", item_id, e);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"File",
|
||||
format!("Error retrieving file {}: {}", item_id, e)
|
||||
format!("Error retrieving file {}: {}", item_id, e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let original_path = file.storage_path().to_string();
|
||||
debug!("Original file path: {}", original_path);
|
||||
|
||||
|
||||
// Create the trash item
|
||||
debug!("Creating TrashedItem object for the file");
|
||||
let trashed_item = TrashedItem::new(
|
||||
@@ -198,50 +211,62 @@ impl TrashUseCase for TrashService {
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id());
|
||||
|
||||
debug!(
|
||||
"TrashedItem created successfully: {} -> {}",
|
||||
file.name(),
|
||||
trashed_item.id()
|
||||
);
|
||||
|
||||
// First add to trash index to register the item
|
||||
info!("Adding file {} to trash index", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => {
|
||||
debug!("File added to trash index successfully");
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error adding file to trash index: {}", e);
|
||||
return Err(DomainError::internal_error("TrashRepository", format!("Failed to add file to trash: {}", e)));
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add file to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Then physically move the file to trash
|
||||
info!("Physically moving file to trash: {}", item_id);
|
||||
match self.file_write_port.move_to_trash(item_id).await {
|
||||
Ok(_) => {
|
||||
debug!("File physically moved to trash successfully: {}", item_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error physically moving file to trash: {} - {}", item_id, e);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error moving file {} to trash: {}", item_id, e)
|
||||
format!("Error moving file {} to trash: {}", item_id, e),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("File completely moved to trash: {}", item_id);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
"folder" => {
|
||||
// Get the folder to verify it exists and capture its data
|
||||
let folder = self.folder_storage_port.get_folder(item_id).await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
format!("Error retrieving folder {}: {}", item_id, e)
|
||||
))?;
|
||||
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
format!("Error retrieving folder {}: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let original_path = folder.storage_path().to_string();
|
||||
|
||||
|
||||
// Create the trash item
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
@@ -251,66 +276,89 @@ impl TrashUseCase for TrashService {
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
|
||||
|
||||
// First add to trash index to register the item
|
||||
debug!("Adding folder {} to trash repository", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => debug!("Successfully added folder to trash repository"),
|
||||
Err(e) => {
|
||||
error!("Failed to add folder to trash repository: {}", e);
|
||||
return Err(DomainError::internal_error("TrashRepository", format!("Failed to add folder to trash: {}", e)));
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add folder to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Then physically move the folder to trash
|
||||
self.folder_storage_port.move_to_trash(item_id).await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error moving folder {} to trash: {}", item_id, e)
|
||||
))?;
|
||||
|
||||
self.folder_storage_port
|
||||
.move_to_trash(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error moving folder {} to trash: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!("Folder moved to trash: {}", item_id);
|
||||
Ok(())
|
||||
},
|
||||
_ => Err(DomainError::validation_error(format!("Invalid item type: {}", item_type))),
|
||||
}
|
||||
_ => Err(DomainError::validation_error(format!(
|
||||
"Invalid item type: {}",
|
||||
item_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()> {
|
||||
info!("Restoring item {} for user {}", trash_id, user_id);
|
||||
|
||||
|
||||
let trash_uuid = match Uuid::parse_str(trash_id) {
|
||||
Ok(id) => {
|
||||
info!("Trash UUID parsed successfully: {}", id);
|
||||
id
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid trash ID format: {} - {}", trash_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid trash ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid trash ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let user_uuid = match Uuid::parse_str(user_id) {
|
||||
Ok(id) => {
|
||||
info!("User UUID parsed successfully: {}", id);
|
||||
id
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid user ID format: {} - {}", user_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid user ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid user ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Get the trash item
|
||||
info!("Retrieving trash item from repository: ID={}", trash_id);
|
||||
let item_result = self.trash_repository.get_trash_item(&trash_uuid, &user_uuid).await;
|
||||
let item_result = self
|
||||
.trash_repository
|
||||
.get_trash_item(&trash_uuid, &user_uuid)
|
||||
.await;
|
||||
|
||||
match item_result {
|
||||
Ok(Some(item)) => {
|
||||
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id, item.item_type(), item.original_id());
|
||||
info!(
|
||||
"Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id,
|
||||
item.item_type(),
|
||||
item.original_id()
|
||||
);
|
||||
|
||||
// Restore based on type
|
||||
match item.item_type() {
|
||||
@@ -318,16 +366,26 @@ impl TrashUseCase for TrashService {
|
||||
// Restore the file to its original location
|
||||
let file_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
|
||||
info!("Restoring file from trash: ID={}, OriginalPath={}", file_id, original_path);
|
||||
match self.file_write_port.restore_from_trash(&file_id, &original_path).await {
|
||||
|
||||
info!(
|
||||
"Restoring file from trash: ID={}, OriginalPath={}",
|
||||
file_id, original_path
|
||||
);
|
||||
match self
|
||||
.file_write_port
|
||||
.restore_from_trash(&file_id, &original_path)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully restored file from trash: {}", file_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if the error is because the file is not found
|
||||
if format!("{}", e).contains("not found") {
|
||||
info!("File not found in trash, may already have been restored: {}", file_id);
|
||||
info!(
|
||||
"File not found in trash, may already have been restored: {}",
|
||||
file_id
|
||||
);
|
||||
// We continue so we can clean up the trash entry
|
||||
} else {
|
||||
// Return error for other kinds of errors
|
||||
@@ -335,68 +393,103 @@ impl TrashUseCase for TrashService {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error restoring file {} from trash: {}", file_id, e)
|
||||
format!(
|
||||
"Error restoring file {} from trash: {}",
|
||||
file_id, e
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
TrashedItemType::Folder => {
|
||||
// Restore the folder to its original location
|
||||
let folder_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
|
||||
info!("Restoring folder from trash: ID={}, OriginalPath={}", folder_id, original_path);
|
||||
match self.folder_storage_port.restore_from_trash(&folder_id, &original_path).await {
|
||||
|
||||
info!(
|
||||
"Restoring folder from trash: ID={}, OriginalPath={}",
|
||||
folder_id, original_path
|
||||
);
|
||||
match self
|
||||
.folder_storage_port
|
||||
.restore_from_trash(&folder_id, &original_path)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully restored folder from trash: {}", folder_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if the error is because the folder is not found
|
||||
if format!("{}", e).contains("not found") {
|
||||
info!("Folder not found in trash, may already have been restored: {}", folder_id);
|
||||
info!(
|
||||
"Folder not found in trash, may already have been restored: {}",
|
||||
folder_id
|
||||
);
|
||||
// We continue so we can clean up the trash entry
|
||||
} else {
|
||||
// Return error for other kinds of errors
|
||||
error!("Error restoring folder from trash: {} - {}", folder_id, e);
|
||||
error!(
|
||||
"Error restoring folder from trash: {} - {}",
|
||||
folder_id, e
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error restoring folder {} from trash: {}", folder_id, e)
|
||||
format!(
|
||||
"Error restoring folder {} from trash: {}",
|
||||
folder_id, e
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Always remove the item from the trash index to maintain consistency
|
||||
info!("Removing item from trash index after restoration: {}", trash_id);
|
||||
match self.trash_repository.restore_from_trash(&trash_uuid, &user_uuid).await {
|
||||
info!(
|
||||
"Removing item from trash index after restoration: {}",
|
||||
trash_id
|
||||
);
|
||||
match self
|
||||
.trash_repository
|
||||
.restore_from_trash(&trash_uuid, &user_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully removed entry from trash index: {}", trash_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error removing entry from trash index: {} - {}", trash_id, e);
|
||||
error!(
|
||||
"Error removing entry from trash index: {} - {}",
|
||||
trash_id, e
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Trash",
|
||||
format!("Error removing trash entry after restoration: {}", e)
|
||||
format!("Error removing trash entry after restoration: {}", e),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
info!("Item successfully restored from trash: {}", trash_id);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Ok(None) => {
|
||||
// If the item isn't found in trash, we can just return success
|
||||
info!("Item not found in trash index, considering as already restored: {}", trash_id);
|
||||
info!(
|
||||
"Item not found in trash index, considering as already restored: {}",
|
||||
trash_id
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Something went wrong with the repository
|
||||
error!("Error retrieving item from trash repository: {} - {}", trash_id, e);
|
||||
error!(
|
||||
"Error retrieving item from trash repository: {} - {}",
|
||||
trash_id, e
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -404,121 +497,169 @@ impl TrashUseCase for TrashService {
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()> {
|
||||
info!("Permanently deleting item {} for user {}", trash_id, user_id);
|
||||
|
||||
info!(
|
||||
"Permanently deleting item {} for user {}",
|
||||
trash_id, user_id
|
||||
);
|
||||
|
||||
let trash_uuid = match Uuid::parse_str(trash_id) {
|
||||
Ok(id) => {
|
||||
info!("Trash UUID parsed successfully: {}", id);
|
||||
id
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid trash ID format: {} - {}", trash_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid trash ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid trash ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let user_uuid = match Uuid::parse_str(user_id) {
|
||||
Ok(id) => {
|
||||
info!("User UUID parsed successfully: {}", id);
|
||||
id
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid user ID format: {} - {}", user_id, e);
|
||||
return Err(DomainError::validation_error(format!("Invalid user ID: {}", e)));
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid user ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Get the trash item
|
||||
info!("Retrieving trash item from repository: ID={}", trash_id);
|
||||
let item_result = self.trash_repository.get_trash_item(&trash_uuid, &user_uuid).await;
|
||||
|
||||
let item_result = self
|
||||
.trash_repository
|
||||
.get_trash_item(&trash_uuid, &user_uuid)
|
||||
.await;
|
||||
|
||||
match item_result {
|
||||
Ok(Some(item)) => {
|
||||
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id, item.item_type(), item.original_id());
|
||||
|
||||
info!(
|
||||
"Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id,
|
||||
item.item_type(),
|
||||
item.original_id()
|
||||
);
|
||||
|
||||
// Permanently delete based on type
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
// Permanently delete the file
|
||||
let file_id = item.original_id().to_string();
|
||||
|
||||
|
||||
info!("Permanently deleting file: {}", file_id);
|
||||
match self.file_write_port.delete_file_permanently(&file_id).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully deleted file permanently: {}", file_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if the file is not found - in that case, we can continue
|
||||
// because we still want to remove the item from the trash index
|
||||
if format!("{}", e).contains("not found") {
|
||||
info!("File not found, may already have been deleted: {}", file_id);
|
||||
info!(
|
||||
"File not found, may already have been deleted: {}",
|
||||
file_id
|
||||
);
|
||||
} else {
|
||||
// Return error for other types of errors
|
||||
error!("Error permanently deleting file: {} - {}", file_id, e);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error deleting file {} permanently: {}", file_id, e)
|
||||
format!(
|
||||
"Error deleting file {} permanently: {}",
|
||||
file_id, e
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
TrashedItemType::Folder => {
|
||||
// Permanently delete the folder
|
||||
let folder_id = item.original_id().to_string();
|
||||
|
||||
|
||||
info!("Permanently deleting folder: {}", folder_id);
|
||||
match self.folder_storage_port.delete_folder_permanently(&folder_id).await {
|
||||
match self
|
||||
.folder_storage_port
|
||||
.delete_folder_permanently(&folder_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully deleted folder permanently: {}", folder_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if the folder is not found - in that case, we can continue
|
||||
if format!("{}", e).contains("not found") {
|
||||
info!("Folder not found, may already have been deleted: {}", folder_id);
|
||||
info!(
|
||||
"Folder not found, may already have been deleted: {}",
|
||||
folder_id
|
||||
);
|
||||
} else {
|
||||
// Return error for other types of errors
|
||||
error!("Error permanently deleting folder: {} - {}", folder_id, e);
|
||||
error!(
|
||||
"Error permanently deleting folder: {} - {}",
|
||||
folder_id, e
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error deleting folder {} permanently: {}", folder_id, e)
|
||||
format!(
|
||||
"Error deleting folder {} permanently: {}",
|
||||
folder_id, e
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Always remove the item from trash index to maintain consistency
|
||||
info!("Removing entry from trash index: {}", trash_id);
|
||||
match self.trash_repository.delete_permanently(&trash_uuid, &user_uuid).await {
|
||||
match self
|
||||
.trash_repository
|
||||
.delete_permanently(&trash_uuid, &user_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully removed entry from trash index: {}", trash_id);
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error removing entry from trash index: {} - {}", trash_id, e);
|
||||
error!(
|
||||
"Error removing entry from trash index: {} - {}",
|
||||
trash_id, e
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Trash",
|
||||
format!("Error removing trash entry: {}", e)
|
||||
format!("Error removing trash entry: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
info!("Item permanently deleted from trash: {}", trash_id);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Ok(None) => {
|
||||
// If the item isn't found in trash, we can just return success
|
||||
info!("Item not found in trash, considering as already deleted: {}", trash_id);
|
||||
info!(
|
||||
"Item not found in trash, considering as already deleted: {}",
|
||||
trash_id
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Something went wrong with the repository
|
||||
error!("Error retrieving item from trash repository: {} - {}", trash_id, e);
|
||||
error!(
|
||||
"Error retrieving item from trash repository: {} - {}",
|
||||
trash_id, e
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -527,13 +668,13 @@ impl TrashUseCase for TrashService {
|
||||
#[instrument(skip(self))]
|
||||
async fn empty_trash(&self, user_id: &str) -> Result<()> {
|
||||
info!("Emptying trash for user {}", user_id);
|
||||
|
||||
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
|
||||
// Get all items in the user's trash
|
||||
let items = self.trash_repository.get_trash_items(&user_uuid).await?;
|
||||
|
||||
|
||||
// Permanently delete each item
|
||||
for item in items {
|
||||
match item.item_type() {
|
||||
@@ -543,21 +684,25 @@ impl TrashUseCase for TrashService {
|
||||
if let Err(e) = self.file_write_port.delete_file_permanently(&file_id).await {
|
||||
error!("Error permanently deleting file {}: {}", file_id, e);
|
||||
}
|
||||
},
|
||||
}
|
||||
TrashedItemType::Folder => {
|
||||
// Permanently delete the folder
|
||||
let folder_id = item.original_id().to_string();
|
||||
if let Err(e) = self.folder_storage_port.delete_folder_permanently(&folder_id).await {
|
||||
if let Err(e) = self
|
||||
.folder_storage_port
|
||||
.delete_folder_permanently(&folder_id)
|
||||
.await
|
||||
{
|
||||
error!("Error permanently deleting folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clear all trash records for this user
|
||||
self.trash_repository.clear_trash(&user_uuid).await?;
|
||||
|
||||
|
||||
info!("Trash completely emptied for user {}", user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::path::PathBuf;
|
||||
use chrono::Utc;
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{Result, DomainError};
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
|
||||
// Mock repositories for testing
|
||||
struct MockTrashRepository {
|
||||
@@ -40,7 +40,8 @@ impl TrashRepository for MockTrashRepository {
|
||||
|
||||
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let user_items = items.values()
|
||||
let user_items = items
|
||||
.values()
|
||||
.filter(|item| item.user_id() == *user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -49,7 +50,8 @@ impl TrashRepository for MockTrashRepository {
|
||||
|
||||
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let item = items.get(id)
|
||||
let item = items
|
||||
.get(id)
|
||||
.filter(|item| item.user_id() == *user_id)
|
||||
.cloned();
|
||||
Ok(item)
|
||||
@@ -58,18 +60,20 @@ impl TrashRepository for MockTrashRepository {
|
||||
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
if let Some(item) = items.get(id)
|
||||
&& item.user_id() == *user_id {
|
||||
items.remove(id);
|
||||
}
|
||||
&& item.user_id() == *user_id
|
||||
{
|
||||
items.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
if let Some(item) = items.get(id)
|
||||
&& item.user_id() == *user_id {
|
||||
items.remove(id);
|
||||
}
|
||||
&& item.user_id() == *user_id
|
||||
{
|
||||
items.remove(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,7 +86,8 @@ impl TrashRepository for MockTrashRepository {
|
||||
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let now = Utc::now();
|
||||
let expired = items.values()
|
||||
let expired = items
|
||||
.values()
|
||||
.filter(|item| item.deletion_date() <= now)
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -111,8 +116,9 @@ impl MockFileRepository {
|
||||
100,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut files = self.files.lock().unwrap();
|
||||
files.insert(id.to_string(), file);
|
||||
}
|
||||
@@ -129,7 +135,10 @@ impl FileReadPort for MockFileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> std::result::Result<Vec<File>, DomainError> {
|
||||
async fn list_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
) -> std::result::Result<Vec<File>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -140,7 +149,10 @@ impl FileReadPort for MockFileRepository {
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
) -> std::result::Result<
|
||||
Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>,
|
||||
DomainError,
|
||||
> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -149,7 +161,10 @@ impl FileReadPort for MockFileRepository {
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
) -> std::result::Result<
|
||||
Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>,
|
||||
DomainError,
|
||||
> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -183,7 +198,9 @@ impl FileWritePort for MockFileRepository {
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_stream: std::pin::Pin<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>>,
|
||||
_stream: std::pin::Pin<
|
||||
Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>,
|
||||
>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -208,7 +225,11 @@ impl FileWritePort for MockFileRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(&self, _file_id: &str, _content: Vec<u8>) -> std::result::Result<(), DomainError> {
|
||||
async fn update_file_content(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_content: Vec<u8>,
|
||||
) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -225,7 +246,7 @@ impl FileWritePort for MockFileRepository {
|
||||
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let mut trashed = self.trashed_files.lock().unwrap();
|
||||
|
||||
|
||||
if let Some(file) = files.remove(id) {
|
||||
trashed.insert(id.to_string(), file);
|
||||
Ok(())
|
||||
@@ -234,15 +255,22 @@ impl FileWritePort for MockFileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
id: &str,
|
||||
_original_path: &str,
|
||||
) -> std::result::Result<(), DomainError> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let mut trashed = self.trashed_files.lock().unwrap();
|
||||
|
||||
|
||||
if let Some(file) = trashed.remove(id) {
|
||||
files.insert(id.to_string(), file);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
|
||||
Err(DomainError::not_found(
|
||||
"File",
|
||||
format!("File {} not found in trash", id),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +279,10 @@ impl FileWritePort for MockFileRepository {
|
||||
if trashed.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
|
||||
Err(DomainError::not_found(
|
||||
"File",
|
||||
format!("File {} not found in trash", id),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,8 +306,9 @@ impl MockFolderRepository {
|
||||
name.to_string(),
|
||||
StoragePath::from_string(path),
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut folders = self.folders.lock().unwrap();
|
||||
folders.insert(id.to_string(), folder);
|
||||
}
|
||||
@@ -284,7 +316,11 @@ impl MockFolderRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> std::result::Result<Folder, DomainError> {
|
||||
async fn create_folder(
|
||||
&self,
|
||||
_name: String,
|
||||
_parent_id: Option<String>,
|
||||
) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -297,11 +333,17 @@ impl FolderRepository for MockFolderRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> std::result::Result<Folder, DomainError> {
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
async fn list_folders(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -315,11 +357,19 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result<Folder, DomainError> {
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_name: String,
|
||||
) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result<Folder, DomainError> {
|
||||
async fn move_folder(
|
||||
&self,
|
||||
_id: &str,
|
||||
_new_parent_id: Option<&str>,
|
||||
) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -327,7 +377,10 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result<bool, DomainError> {
|
||||
async fn folder_exists(
|
||||
&self,
|
||||
_storage_path: &StoragePath,
|
||||
) -> std::result::Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -338,7 +391,7 @@ impl FolderRepository for MockFolderRepository {
|
||||
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut folders = self.folders.lock().unwrap();
|
||||
let mut trashed = self.trashed_folders.lock().unwrap();
|
||||
|
||||
|
||||
if let Some(folder) = folders.remove(id) {
|
||||
trashed.insert(id.to_string(), folder);
|
||||
Ok(())
|
||||
@@ -347,15 +400,22 @@ impl FolderRepository for MockFolderRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
id: &str,
|
||||
_original_path: &str,
|
||||
) -> std::result::Result<(), DomainError> {
|
||||
let mut folders = self.folders.lock().unwrap();
|
||||
let mut trashed = self.trashed_folders.lock().unwrap();
|
||||
|
||||
|
||||
if let Some(folder) = trashed.remove(id) {
|
||||
folders.insert(id.to_string(), folder);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
|
||||
Err(DomainError::not_found(
|
||||
"Folder",
|
||||
format!("Folder {} not found in trash", id),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +424,10 @@ impl FolderRepository for MockFolderRepository {
|
||||
if trashed.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
|
||||
Err(DomainError::not_found(
|
||||
"Folder",
|
||||
format!("Folder {} not found in trash", id),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,7 +443,7 @@ mod tests {
|
||||
let trash_repo = Arc::new(MockTrashRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository::new());
|
||||
let folder_repo = Arc::new(MockFolderRepository::new());
|
||||
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
@@ -388,37 +451,59 @@ mod tests {
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
|
||||
let file_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let user_id = "550e8400-e29b-41d4-a716-446655440001";
|
||||
|
||||
|
||||
// Add a test file to the repository
|
||||
file_repo.add_test_file(file_id, "test.txt", "/test/path/test.txt");
|
||||
|
||||
|
||||
// Act
|
||||
let result = service.move_to_trash(file_id, "file", user_id).await;
|
||||
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok(), "Moving file to trash failed: {:?}", result);
|
||||
|
||||
|
||||
// Verify the file is in trash
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
|
||||
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
|
||||
|
||||
assert_eq!(
|
||||
trash_items.len(),
|
||||
1,
|
||||
"Should have exactly one item in trash"
|
||||
);
|
||||
let trash_item = &trash_items[0];
|
||||
|
||||
assert_eq!(trash_item.original_id().to_string(), file_id, "Original ID should match file ID");
|
||||
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
|
||||
assert_eq!(*trash_item.item_type(), TrashedItemType::File, "Item type should be File");
|
||||
|
||||
assert_eq!(
|
||||
trash_item.original_id().to_string(),
|
||||
file_id,
|
||||
"Original ID should match file ID"
|
||||
);
|
||||
assert_eq!(
|
||||
trash_item.user_id().to_string(),
|
||||
user_id,
|
||||
"User ID should match"
|
||||
);
|
||||
assert_eq!(
|
||||
*trash_item.item_type(),
|
||||
TrashedItemType::File,
|
||||
"Item type should be File"
|
||||
);
|
||||
assert_eq!(trash_item.name(), "test.txt", "File name should match");
|
||||
|
||||
|
||||
// Verify file is moved in file repository
|
||||
let files = file_repo.files.lock().unwrap();
|
||||
let trashed_files = file_repo.trashed_files.lock().unwrap();
|
||||
|
||||
assert!(files.get(file_id).is_none(), "File should no longer be in main storage");
|
||||
assert!(trashed_files.get(file_id).is_some(), "File should be in trash storage");
|
||||
|
||||
assert!(
|
||||
files.get(file_id).is_none(),
|
||||
"File should no longer be in main storage"
|
||||
);
|
||||
assert!(
|
||||
trashed_files.get(file_id).is_some(),
|
||||
"File should be in trash storage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -427,7 +512,7 @@ mod tests {
|
||||
let trash_repo = Arc::new(MockTrashRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository::new());
|
||||
let folder_repo = Arc::new(MockFolderRepository::new());
|
||||
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
@@ -435,29 +520,49 @@ mod tests {
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
|
||||
let folder_id = "550e8400-e29b-41d4-a716-446655440002";
|
||||
let user_id = "550e8400-e29b-41d4-a716-446655440001";
|
||||
|
||||
|
||||
// Add a test folder to the repository
|
||||
folder_repo.add_test_folder(folder_id, "test_folder", "/test/path/test_folder");
|
||||
|
||||
|
||||
// Act
|
||||
let result = service.move_to_trash(folder_id, "folder", user_id).await;
|
||||
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok(), "Moving folder to trash failed: {:?}", result);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Moving folder to trash failed: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Verify the folder is in trash
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
|
||||
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
|
||||
|
||||
assert_eq!(
|
||||
trash_items.len(),
|
||||
1,
|
||||
"Should have exactly one item in trash"
|
||||
);
|
||||
let trash_item = &trash_items[0];
|
||||
|
||||
assert_eq!(trash_item.original_id().to_string(), folder_id, "Original ID should match folder ID");
|
||||
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
|
||||
assert_eq!(*trash_item.item_type(), TrashedItemType::Folder, "Item type should be Folder");
|
||||
|
||||
assert_eq!(
|
||||
trash_item.original_id().to_string(),
|
||||
folder_id,
|
||||
"Original ID should match folder ID"
|
||||
);
|
||||
assert_eq!(
|
||||
trash_item.user_id().to_string(),
|
||||
user_id,
|
||||
"User ID should match"
|
||||
);
|
||||
assert_eq!(
|
||||
*trash_item.item_type(),
|
||||
TrashedItemType::Folder,
|
||||
"Item type should be Folder"
|
||||
);
|
||||
assert_eq!(trash_item.name(), "test_folder", "Folder name should match");
|
||||
}
|
||||
|
||||
@@ -467,7 +572,7 @@ mod tests {
|
||||
let trash_repo = Arc::new(MockTrashRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository::new());
|
||||
let folder_repo = Arc::new(MockFolderRepository::new());
|
||||
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
@@ -475,36 +580,53 @@ mod tests {
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
|
||||
let file_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let user_id = "550e8400-e29b-41d4-a716-446655440001";
|
||||
let file_path = "/test/path/test.txt";
|
||||
|
||||
|
||||
// Add a test file and move it to trash
|
||||
file_repo.add_test_file(file_id, "test.txt", file_path);
|
||||
service.move_to_trash(file_id, "file", user_id).await.unwrap();
|
||||
|
||||
service
|
||||
.move_to_trash(file_id, "file", user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Get the trash item ID
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
let trash_id = trash_items[0].id().to_string();
|
||||
|
||||
|
||||
// Act
|
||||
let result = service.restore_item(&trash_id, user_id).await;
|
||||
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok(), "Restoring file from trash failed: {:?}", result);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Restoring file from trash failed: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Verify the file is restored in file repository
|
||||
let files = file_repo.files.lock().unwrap();
|
||||
let trashed_files = file_repo.trashed_files.lock().unwrap();
|
||||
|
||||
assert!(files.get(file_id).is_some(), "File should be back in main storage");
|
||||
assert!(trashed_files.get(file_id).is_none(), "File should no longer be in trash storage");
|
||||
|
||||
|
||||
assert!(
|
||||
files.get(file_id).is_some(),
|
||||
"File should be back in main storage"
|
||||
);
|
||||
assert!(
|
||||
trashed_files.get(file_id).is_none(),
|
||||
"File should no longer be in trash storage"
|
||||
);
|
||||
|
||||
// Verify the trash item is removed
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
assert_eq!(trash_items.len(), 0, "Trash should be empty after restoration");
|
||||
assert_eq!(
|
||||
trash_items.len(),
|
||||
0,
|
||||
"Trash should be empty after restoration"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -513,7 +635,7 @@ mod tests {
|
||||
let trash_repo = Arc::new(MockTrashRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository::new());
|
||||
let folder_repo = Arc::new(MockFolderRepository::new());
|
||||
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
@@ -521,35 +643,52 @@ mod tests {
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
|
||||
let file_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let user_id = "550e8400-e29b-41d4-a716-446655440001";
|
||||
|
||||
|
||||
// Add a test file and move it to trash
|
||||
file_repo.add_test_file(file_id, "test.txt", "/test/path/test.txt");
|
||||
service.move_to_trash(file_id, "file", user_id).await.unwrap();
|
||||
|
||||
service
|
||||
.move_to_trash(file_id, "file", user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Get the trash item ID
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
let trash_id = trash_items[0].id().to_string();
|
||||
|
||||
|
||||
// Act
|
||||
let result = service.delete_permanently(&trash_id, user_id).await;
|
||||
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok(), "Deleting file permanently failed: {:?}", result);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Deleting file permanently failed: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Verify the file is permanently deleted
|
||||
let files = file_repo.files.lock().unwrap();
|
||||
let trashed_files = file_repo.trashed_files.lock().unwrap();
|
||||
|
||||
assert!(files.get(file_id).is_none(), "File should not be in main storage");
|
||||
assert!(trashed_files.get(file_id).is_none(), "File should not be in trash storage");
|
||||
|
||||
|
||||
assert!(
|
||||
files.get(file_id).is_none(),
|
||||
"File should not be in main storage"
|
||||
);
|
||||
assert!(
|
||||
trashed_files.get(file_id).is_none(),
|
||||
"File should not be in trash storage"
|
||||
);
|
||||
|
||||
// Verify the trash item is removed
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
assert_eq!(trash_items.len(), 0, "Trash should be empty after permanent deletion");
|
||||
assert_eq!(
|
||||
trash_items.len(),
|
||||
0,
|
||||
"Trash should be empty after permanent deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -558,7 +697,7 @@ mod tests {
|
||||
let trash_repo = Arc::new(MockTrashRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository::new());
|
||||
let folder_repo = Arc::new(MockFolderRepository::new());
|
||||
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
@@ -566,59 +705,85 @@ mod tests {
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
|
||||
let user_id = "550e8400-e29b-41d4-a716-446655440001";
|
||||
|
||||
|
||||
// Add multiple files and folders to trash
|
||||
let file_ids = [
|
||||
"550e8400-e29b-41d4-a716-446655440010",
|
||||
"550e8400-e29b-41d4-a716-446655440011",
|
||||
];
|
||||
|
||||
|
||||
let folder_ids = [
|
||||
"550e8400-e29b-41d4-a716-446655440020",
|
||||
"550e8400-e29b-41d4-a716-446655440021",
|
||||
];
|
||||
|
||||
|
||||
// Add test files and folders
|
||||
for (i, file_id) in file_ids.iter().enumerate() {
|
||||
file_repo.add_test_file(file_id, &format!("test{}.txt", i), &format!("/test/path/test{}.txt", i));
|
||||
service.move_to_trash(file_id, "file", user_id).await.unwrap();
|
||||
file_repo.add_test_file(
|
||||
file_id,
|
||||
&format!("test{}.txt", i),
|
||||
&format!("/test/path/test{}.txt", i),
|
||||
);
|
||||
service
|
||||
.move_to_trash(file_id, "file", user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
||||
for (i, folder_id) in folder_ids.iter().enumerate() {
|
||||
folder_repo.add_test_folder(folder_id, &format!("folder{}", i), &format!("/test/path/folder{}", i));
|
||||
service.move_to_trash(folder_id, "folder", user_id).await.unwrap();
|
||||
folder_repo.add_test_folder(
|
||||
folder_id,
|
||||
&format!("folder{}", i),
|
||||
&format!("/test/path/folder{}", i),
|
||||
);
|
||||
service
|
||||
.move_to_trash(folder_id, "folder", user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
||||
// Verify items are in trash
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
assert_eq!(trash_items.len(), 4, "Should have 4 items in trash");
|
||||
|
||||
|
||||
// Act
|
||||
let result = service.empty_trash(user_id).await;
|
||||
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok(), "Emptying trash failed: {:?}", result);
|
||||
|
||||
|
||||
// Verify all items are permanently deleted
|
||||
for file_id in &file_ids {
|
||||
let files = file_repo.files.lock().unwrap();
|
||||
let trashed_files = file_repo.trashed_files.lock().unwrap();
|
||||
assert!(files.get(*file_id).is_none(), "File should not be in main storage");
|
||||
assert!(trashed_files.get(*file_id).is_none(), "File should not be in trash storage");
|
||||
assert!(
|
||||
files.get(*file_id).is_none(),
|
||||
"File should not be in main storage"
|
||||
);
|
||||
assert!(
|
||||
trashed_files.get(*file_id).is_none(),
|
||||
"File should not be in trash storage"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
for folder_id in &folder_ids {
|
||||
let folders = folder_repo.folders.lock().unwrap();
|
||||
let trashed_folders = folder_repo.trashed_folders.lock().unwrap();
|
||||
assert!(folders.get(*folder_id).is_none(), "Folder should not be in main storage");
|
||||
assert!(trashed_folders.get(*folder_id).is_none(), "Folder should not be in trash storage");
|
||||
assert!(
|
||||
folders.get(*folder_id).is_none(),
|
||||
"Folder should not be in main storage"
|
||||
);
|
||||
assert!(
|
||||
trashed_folders.get(*folder_id).is_none(),
|
||||
"Folder should not be in trash storage"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Verify the trash is empty
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
assert_eq!(trash_items.len(), 0, "Trash should be empty after emptying");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub mod storage_transaction;
|
||||
pub mod storage_transaction;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
|
||||
/// Type for async operations and rollbacks
|
||||
type TransactionOp = Pin<Box<dyn Future<Output = Result<(), DomainError>> + Send>>;
|
||||
@@ -25,7 +25,7 @@ impl StorageTransaction {
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds an operation to the transaction with its corresponding rollback
|
||||
pub fn add_operation<F, R>(&mut self, operation: F, rollback: R)
|
||||
where
|
||||
@@ -35,7 +35,7 @@ impl StorageTransaction {
|
||||
self.operations.push(Box::new(move || Box::pin(operation)));
|
||||
self.rollbacks.push(Box::new(move || Box::pin(rollback)));
|
||||
}
|
||||
|
||||
|
||||
/// Adds an operation without rollback (for cleanup or logging)
|
||||
pub fn add_finalizer<F>(&mut self, finalizer: F)
|
||||
where
|
||||
@@ -43,58 +43,68 @@ impl StorageTransaction {
|
||||
{
|
||||
// The rollback is a no-op
|
||||
let noop = async { Ok(()) };
|
||||
|
||||
|
||||
self.operations.push(Box::new(move || Box::pin(finalizer)));
|
||||
self.rollbacks.push(Box::new(move || Box::pin(noop)));
|
||||
}
|
||||
|
||||
|
||||
/// Executes the transaction by applying all operations in order
|
||||
/// If any fails, executes rollbacks in reverse order
|
||||
pub async fn commit(mut self) -> Result<(), DomainError> {
|
||||
tracing::debug!("Starting transaction: {}", self.name);
|
||||
|
||||
|
||||
let mut completed_ops = Vec::new();
|
||||
|
||||
|
||||
// Extract operations to avoid ownership issues
|
||||
let operations = std::mem::take(&mut self.operations);
|
||||
let transaction_name = self.name.clone();
|
||||
|
||||
|
||||
// Execute operations
|
||||
for (i, op) in operations.into_iter().enumerate() {
|
||||
match op().await {
|
||||
Ok(()) => {
|
||||
completed_ops.push(i);
|
||||
tracing::trace!("Operation {} completed in transaction: {}", i, transaction_name);
|
||||
tracing::trace!(
|
||||
"Operation {} completed in transaction: {}",
|
||||
i,
|
||||
transaction_name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error in operation {} of transaction {}: {}", i, transaction_name, e);
|
||||
|
||||
tracing::error!(
|
||||
"Error in operation {} of transaction {}: {}",
|
||||
i,
|
||||
transaction_name,
|
||||
e
|
||||
);
|
||||
|
||||
// Execute rollbacks for completed operations in reverse order
|
||||
self.rollback(completed_ops).await?;
|
||||
|
||||
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Transaction",
|
||||
format!("Transaction '{}' failed: {}", transaction_name, e)
|
||||
).with_source(e));
|
||||
format!("Transaction '{}' failed: {}", transaction_name, e),
|
||||
)
|
||||
.with_source(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tracing::debug!("Transaction completed successfully: {}", transaction_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Executes rollbacks for completed operations
|
||||
async fn rollback(mut self, completed_ops: Vec<usize>) -> Result<(), DomainError> {
|
||||
tracing::warn!("Starting rollback for transaction: {}", self.name);
|
||||
|
||||
|
||||
let mut rollback_errors = Vec::new();
|
||||
|
||||
|
||||
// Extract rollbacks to avoid ownership issues
|
||||
let mut rollbacks = Vec::new();
|
||||
std::mem::swap(&mut rollbacks, &mut self.rollbacks);
|
||||
|
||||
|
||||
// Execute rollbacks in reverse order
|
||||
for i in completed_ops.into_iter().rev() {
|
||||
if i < rollbacks.len() {
|
||||
@@ -103,28 +113,38 @@ impl StorageTransaction {
|
||||
// Swap with an empty function
|
||||
let rollback = std::mem::replace(rb, Box::new(|| Box::pin(async { Ok(()) })));
|
||||
if let Err(e) = rollback().await {
|
||||
tracing::error!("Error in rollback of operation {} in transaction {}: {}",
|
||||
i, self.name, e);
|
||||
tracing::error!(
|
||||
"Error in rollback of operation {} in transaction {}: {}",
|
||||
i,
|
||||
self.name,
|
||||
e
|
||||
);
|
||||
rollback_errors.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If there were errors during rollback, report them
|
||||
if !rollback_errors.is_empty() {
|
||||
tracing::error!("Errors during transaction rollback {}: {} errors",
|
||||
self.name, rollback_errors.len());
|
||||
|
||||
tracing::error!(
|
||||
"Errors during transaction rollback {}: {} errors",
|
||||
self.name,
|
||||
rollback_errors.len()
|
||||
);
|
||||
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Transaction",
|
||||
format!("Errors during transaction '{}' rollback: {} errors",
|
||||
self.name, rollback_errors.len())
|
||||
format!(
|
||||
"Errors during transaction '{}' rollback: {} errors",
|
||||
self.name,
|
||||
rollback_errors.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
tracing::info!("Transaction rollback completed: {}", self.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user