a lot of stuff
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m29s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m28s

This commit is contained in:
2025-10-16 00:38:03 +02:00
parent 5b420a6b62
commit aef34791f3
21 changed files with 2987 additions and 736 deletions
+202 -34
View File
@@ -205,6 +205,12 @@ pub struct BulkTagResponse {
pub removed: usize,
}
#[derive(Serialize)]
pub struct BulkCorrespondentResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Deserialize)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
@@ -218,6 +224,69 @@ pub struct AssignCorrespondentsRequest {
pub replace: bool,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Deserialize)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
#[derive(Deserialize)]
pub struct CorrespondentRoleQuery {
pub role: String,
@@ -850,40 +919,8 @@ pub async fn assign_correspondents(
return Err(AppError::bad_request("assignments must not be empty"));
}
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
for assignment in &payload.assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
));
}
let correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
let roles_vec: Vec<String> = role_set.into_iter().collect();
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let replace = payload.replace;
let user_id = user.user_id;
@@ -950,6 +987,137 @@ pub async fn assign_correspondents(
Ok(StatusCode::NO_CONTENT)
}
pub async fn bulk_assign_correspondents(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<(StatusCode, Json<BulkCorrespondentResponse>)> {
if payload.document_ids.is_empty() {
return Err(AppError::bad_request("document_ids must not be empty"));
}
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
let mut document_ids = payload.document_ids;
document_ids.sort();
document_ids.dedup();
let (normalized_pairs, correspondents_vec, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let action = payload.action;
let user_id = user.user_id;
let mut conn = state.db()?;
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign correspondents to deleted documents",
));
}
if !correspondents_vec.is_empty() {
let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec))
.load(conn)?;
if existing.len() != correspondents_vec.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
}
match action {
BulkCorrespondentAction::Add => {
let mut removed = 0;
if !roles_vec.is_empty() {
removed = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
}
let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len());
for doc_id in &document_ids {
for (correspondent_id, role) in &normalized_pairs {
new_rows.push(NewDocumentCorrespondent {
document_id: *doc_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id),
});
}
}
let assigned = if new_rows.is_empty() {
0
} else {
diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?
};
if assigned > 0 || removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((assigned, removed))
}
BulkCorrespondentAction::Remove => {
let mut removed = 0;
if !normalized_pairs.is_empty() {
let mut grouped: HashMap<String, Vec<Uuid>> = HashMap::new();
for (correspondent_id, role) in &normalized_pairs {
grouped
.entry(role.clone())
.or_default()
.push(*correspondent_id);
}
for (role, ids) in grouped {
removed += diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::role.eq(role.as_str()))
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
)
.execute(conn)?;
}
}
if removed > 0 {
diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((0, removed))
}
}
})?;
Ok((
StatusCode::OK,
Json(BulkCorrespondentResponse { assigned, removed }),
))
}
pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,