Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion crates/api/src/agent/confirmation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,8 +671,15 @@ async fn describe(
)
}
"mergeMentors" => {
let input: crate::MentorMerge = parse(input)?;
let crate::PreviewedMerge {
pair: input,
discarded_drafts,
} = parse(input)?;
let drafts = mentor::merge::conflicting_drafts(conn, input.keep, input.other).await?;
// What the user agrees to must be what the merge then checks it discards.
if drafts != discarded_drafts {
return Err(mentor::merge::MergeError::Changed.into());
}
let canceled = sqlx::query_scalar!(
r#"SELECT count(*) AS "count!: i64" FROM email
WHERE mentor_id IN (?1, ?2) AND task_id IS NOT NULL
Expand Down
42 changes: 34 additions & 8 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,8 @@ operations! {
"setMentorNote" [none] => set_mentor_note(MentorNote) -> ();
/// 添加一位导师,返回新导师的 id。
"addMentor" [none] => add_mentor(mentor::MentorForm) -> MentorId;
/// 更新指定导师的资料。
/// 更新指定导师的资料。before 是编辑前 getMentor 读到的 name、aliases、emails、
/// affiliations、homepages、research;这之后资料有了变化就不保存,重新读取后再改。
"updateMentor" [confirm_if "更改已批准邮件的收件地址时"] =>
update_mentor(MentorEdit) -> mentor::MentorDetail;
/// 设置联系导师使用的邮箱,mailboxId 为 null 时取消指定。
Expand All @@ -654,8 +655,9 @@ operations! {
add_mentor_email(MentorAddress) -> mentor::MentorDetail;
/// 预览把 other 导师并入 keep 导师的结果。
"previewMerge" [none] => preview_merge(MentorMerge) -> mentor::merge::Preview;
/// 把 other 导师合并到 keep 导师。
"mergeMentors" [confirm] => merge_mentors(MentorMerge) -> ();
/// 把 other 导师合并到 keep 导师。discardedDrafts 是 previewMerge 给出的;这之后要删的草稿
/// 有了变化就不合并,重新预览后再合并。
"mergeMentors" [confirm] => merge_mentors(PreviewedMerge) -> ();
/// 按筛选和分页条件读取回收站导师。
"listTrash" [none] => list_trash(mentor::trash::TrashQuery) -> mentor::MentorPage;
/// 将指定 id 列表中的导师移入回收站。
Expand Down Expand Up @@ -710,7 +712,7 @@ operations! {
"个人资料" {
/// 读取学生个人资料。
"getProfile" [none] => get_profile(Nothing) -> profile::Profile;
/// 保存学生个人资料。
/// 保存学生个人资料,只改传了的字段,空字符串清空。
"updateProfile" [none] => update_profile(profile::ProfileForm) -> profile::Profile;
/// 标记首次使用引导已完成。
"finishOnboarding" [none] => finish_onboarding(Nothing) -> ();
Expand Down Expand Up @@ -978,6 +980,8 @@ pub struct MentorIds {
#[derive(Debug, Deserialize, TS)]
pub struct MentorEdit {
pub id: i64,
/// The fields as they were when editing began.
pub before: mentor::MentorFields,
#[serde(flatten)]
pub form: mentor::MentorForm,
}
Expand All @@ -1002,6 +1006,15 @@ pub struct MentorMerge {
pub other: i64,
}

/// A merge as its preview showed it.
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct PreviewedMerge {
#[serde(flatten)]
pub pair: MentorMerge,
pub discarded_drafts: Vec<email::DraftKind>,
}

#[derive(Debug, Deserialize, TS)]
pub struct MentorNote {
pub id: i64,
Expand Down Expand Up @@ -1076,8 +1089,17 @@ async fn update_mentor(
input: MentorEdit,
) -> Result<mentor::MentorDetail, CallError> {
let fields = input.form.check().map_err(CallError::invalid)?;
let applied = mentor::update(&ctx.db, input.id, &fields, now()).await?;
require_applied(applied, NO_MENTOR)?;
match mentor::edit(&ctx.db, input.id, &input.before, &fields, now()).await? {
mentor::Edited::Saved => {}
mentor::Edited::Changed => {
return Err(CallError::invalid(
"打开编辑之后,这位导师的资料在别处有了变化(比如合并、导入或导师库更新)。\
关掉后重新打开,再改一次。"
.into(),
));
}
mentor::Edited::Gone => return Err(CallError::not_found(NO_MENTOR)),
}
look_up(ctx, input.id).await?;
get_mentor(ctx, MentorId { id: input.id }).await
}
Expand Down Expand Up @@ -1123,8 +1145,9 @@ async fn preview_merge(
Ok(mentor::merge::preview(&ctx.db, input.keep, input.other).await?)
}

async fn merge_mentors(ctx: &Context, input: MentorMerge) -> Result<(), CallError> {
mentor::merge::merge(&ctx.db, input.keep, input.other, now()).await?;
async fn merge_mentors(ctx: &Context, input: PreviewedMerge) -> Result<(), CallError> {
let MentorMerge { keep, other } = input.pair;
mentor::merge::merge(&ctx.db, keep, other, &input.discarded_drafts, now()).await?;
ctx.reply_processor.wake();
ctx.todo_reminders.wake();
Ok(())
Expand Down Expand Up @@ -1278,6 +1301,9 @@ impl From<mentor::merge::MergeError> for CallError {
"{name}老师还有 {count} 封邮件会发出(待发送、发送中或发送失败)。等它们发出,\
或改回草稿后再合并。"
)),
mentor::merge::MergeError::Changed => {
CallError::invalid("预览之后两位导师的草稿有了变化,请看过新的预览再合并。".into())
}
mentor::merge::MergeError::Db(e) => e.into(),
}
}
Expand Down
69 changes: 59 additions & 10 deletions crates/api/tests/agent_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,16 @@ async fn empty_save_does_not_delete_a_draft_whose_attachment_was_swapped(db: Sql
}

/// A paused task holding one approved first email to the mentor.
/// The profile dialog's save of the mentor with only its emails changed.
async fn emails_edit(ctx: &Context, id: i64, emails: &[&str]) -> Value {
let m = mentor::get(&ctx.db, id).await.unwrap().unwrap();
let before = json!({"name":m.name, "aliases":m.aliases, "emails":m.emails,
"affiliations":m.affiliations, "homepages":m.homepages, "research":m.research});
json!({"id":id, "before":before, "name":m.name, "aliases":m.aliases, "emails":emails,
"affiliations":m.affiliations, "homepages":m.homepages,
"research":m.research.unwrap_or_default()})
}

async fn paused_task(ctx: &Context, mentor: i64) -> (i64, i64) {
let task = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO task (name, kind, writer, schedule, state, created_at)
Expand Down Expand Up @@ -672,8 +682,7 @@ async fn resume_task_rechecks_the_pending_recipient_address(db: SqlitePool) {
call(
&ctx,
"updateMentor",
json!({"id":mentor, "name":"王伟", "emails":["new@example.invalid"],
"aliases":[], "affiliations":[], "homepages":[], "research":""}),
emails_edit(&ctx, mentor, &["new@example.invalid"]).await,
)
.await
.unwrap();
Expand Down Expand Up @@ -975,8 +984,7 @@ async fn recipient_edits_recheck_schedules_including_unassigned_task_mail(db: Sq
.await
.unwrap();
let input = if op == "updateMentor" {
json!({"id":mentor, "name":name, "emails":[after], "aliases":[],
"affiliations":[], "homepages":[], "research":""})
emails_edit(&ctx, mentor, &[&after]).await
} else {
let candidate = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO candidate (name, emails, review, enrich)
Expand Down Expand Up @@ -1653,8 +1661,7 @@ async fn resume_mailbox_rechecks_recipients_and_deleting_a_task_rechecks_its_ema
call(
&ctx,
"updateMentor",
json!({"id":mentor, "name":"王伟", "emails":["after@example.invalid"],
"aliases":[], "affiliations":[], "homepages":[], "research":""}),
emails_edit(&ctx, mentor, &["after@example.invalid"]).await,
)
.await
.unwrap();
Expand Down Expand Up @@ -1839,10 +1846,7 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP
.unwrap();
let decisions = json!({"take":[], "matches":[], "restore":[]});
let input = match op {
"updateMentor" => json!({
"id":mentor, "name":name, "emails":[after], "aliases":[],
"affiliations":[], "homepages":[], "research":""
}),
"updateMentor" => emails_edit(&ctx, mentor, &[&after]).await,
"addMentorEmail" => json!({"id":mentor, "address":after}),
"importMentors" => json!({
"fileName":"mentors.csv",
Expand Down Expand Up @@ -1901,6 +1905,12 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP
.execute(&ctx.db)
.await
.unwrap();
// The safe edit changed the emails the first one began from.
let input = if op == "updateMentor" {
emails_edit(&ctx, mentor, &[&after]).await
} else {
input
};
call_agent(&ctx, op, input, |_| {}, pending())
.await
.unwrap();
Expand Down Expand Up @@ -2107,3 +2117,42 @@ async fn confirmation_detects_recipient_changes_after_the_first_forty_characters
async fn confirmation_detects_a_change_to_only_the_sixth_mentor(db: SqlitePool) {
refuses_changed_candidate_recipient(db, 6, "shown@a.edu", "unshown@b.edu").await;
}

#[sqlx::test(migrator = "db::MIGRATOR")]
async fn a_merge_is_confirmed_only_with_the_drafts_it_discards(db: SqlitePool) {
let ctx = Context::new(db, net::Net::new(vec![]).unwrap(), std::path::Path::new(""));
let mut ids = Vec::new();
for name in ["王伟", "王伟伟"] {
let id = mentor::create(
&ctx.db,
&mentor::MentorFields {
name: name.into(),
..Default::default()
},
0,
)
.await
.unwrap();
email::save_first_draft(&ctx.db, id, "申请", "草稿", &[], 0)
.await
.unwrap();
ids.push(id);
}
// A preview from before the other mentor's draft: the user would agree to discarding none.
let error = call_agent(
&ctx,
"mergeMentors",
json!({"keep":ids[0], "other":ids[1], "discardedDrafts":[]}),
|_| panic!("a merge whose preview is out of date was put to the user"),
pending(),
)
.await
.unwrap_err();
assert!(matches!(error.code, ErrorCode::Invalid));
refused(
&ctx,
"mergeMentors",
json!({"keep":ids[0], "other":ids[1], "discardedDrafts":["first"]}),
)
.await;
}
2 changes: 1 addition & 1 deletion crates/email/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ pub async fn attached(conn: &mut SqliteConnection, email_id: i64) -> Result<Vec<
}

/// The standalone draft an attachment picker belongs to.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, sqlx::Type)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "snake_case")]
pub enum DraftKind {
Expand Down
2 changes: 1 addition & 1 deletion crates/email/tests/todos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async fn manual_edits_completion_merge_and_permanent_deletion(db: SqlitePool) {
todos::update(&db, id, &form("填报名表", None, None).check().unwrap(), 4)
.await
.unwrap();
mentor::merge::merge(&db, keep, m, 5).await.unwrap();
mentor::merge::merge(&db, keep, m, &[], 5).await.unwrap();
assert!(todos::for_mentor(&db, m).await.unwrap().is_empty());
let saved = todos::for_mentor(&db, keep).await.unwrap().remove(0);
assert_eq!(saved.id, id);
Expand Down
4 changes: 3 additions & 1 deletion crates/library/tests/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,9 @@ async fn merged_mentors_keep_library_records_and_pending_choices(db: SqlitePool)
let other = mentor_id(&db, "mentor_second").await;
second.preview.units[0].title = Some("教授".into());
change(&db, second, 2).await;
mentor::merge::merge(&db, keep, other, 300).await.unwrap();
mentor::merge::merge(&db, keep, other, &[], 300)
.await
.unwrap();
assert_eq!(mentor_id(&db, "mentor_second").await, keep);
let groups = updates::list(&db).await.unwrap();
assert_eq!(groups[0].differences[0].id, keep);
Expand Down
2 changes: 1 addition & 1 deletion crates/matching/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ mod tests {
enqueue(&db, &[id], true, true, 1).await.unwrap();
let claim = next(&db).await.unwrap().unwrap();
if merged {
mentor::merge::merge(&db, keep, id, 2).await.unwrap();
mentor::merge::merge(&db, keep, id, &[], 2).await.unwrap();
} else {
mentor::trash::trash(&db, &[id], 2).await.unwrap();
mentor::trash::delete(&db, &[id]).await.unwrap();
Expand Down
12 changes: 6 additions & 6 deletions crates/matching/tests/matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ async fn setup(db: &SqlitePool, address: &str, n: usize) -> Vec<i64> {
profile::update(
db,
&profile::ProfileForm {
name: "学生".into(),
research_interests: "视觉".into(),
rewrite_preference: "".into(),
name: Some("学生".into()),
research_interests: Some("视觉".into()),
rewrite_preference: Some("".into()),
},
1,
)
Expand Down Expand Up @@ -413,9 +413,9 @@ async fn absent_model_consent_and_student_basis_are_explained_before_calling(db:
profile::update(
&db,
&profile::ProfileForm {
name: "".into(),
research_interests: "".into(),
rewrite_preference: "".into(),
name: Some("".into()),
research_interests: Some("".into()),
rewrite_preference: Some("".into()),
},
30,
)
Expand Down
31 changes: 31 additions & 0 deletions crates/mentor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,37 @@ pub async fn update<'c>(
Ok(true)
}

/// What became of an edit made in a form.
#[derive(Debug, PartialEq, Eq)]
pub enum Edited {
Saved,
/// The fields are no longer those the form started from, as after a merge, an import or a
/// library update; saving would undo that.
Changed,
Gone,
}

/// Saves an edit of the fields `before` showed, unless they have changed since.
pub async fn edit(
db: &SqlitePool,
id: i64,
before: &MentorFields,
after: &MentorFields,
now: i64,
) -> Result<Edited, SaveError> {
let mut tx = db.begin_with("BEGIN IMMEDIATE").await?;
let current = export::profiles(&mut tx, &[id]).await?;
let Some(current) = current.get(&id) else {
return Ok(Edited::Gone);
};
if current.fields != *before {
return Ok(Edited::Changed);
}
update(&mut *tx, id, after, now).await?;
tx.commit().await?;
Ok(Edited::Saved)
}

/// Writes the mentor's lists in the given order. An address the mentor keeps keeps its row, so
/// that it is not looked up in the mailboxes again; one another mentor has fails the save.
async fn save_lists(
Expand Down
Loading
Loading