Skip to content

Commit 5d0b2e4

Browse files
committed
backend: Add user email preference storing
This commit is the first step in the process to send crate owners an email notification when a new version of one of their crates is published. A database migration adds a `email_notifications` column to the `crate_owners` table, and thus the property was added to the corresponding `CrateOwner` struct. This new property is defaulted to `true`. Because a user may not want to receive a version publish notification for all of their crates, an API endpoint was added to allow them to toggle email notifications for each crate. The front end implementation will be in a forthcoming commit, as well as the actual sending of these notifications.
1 parent 21e04f3 commit 5d0b2e4

File tree

12 files changed

+221
-6
lines changed

12 files changed

+221
-6
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE crate_owners DROP COLUMN email_notifications;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE crate_owners ADD COLUMN email_notifications BOOLEAN NOT NULL DEFAULT TRUE;

src/controllers/crate_owner_invitation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ fn accept_invite(
6969
owner_id: user_id,
7070
created_by: pending_crate_owner.invited_by_user_id,
7171
owner_kind: OwnerKind::User as i32,
72+
email_notifications: true,
7273
})
7374
.on_conflict(crate_owners::table.primary_key())
7475
.do_update()

src/controllers/user/me.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ use crate::email;
55
use crate::util::bad_request;
66
use crate::util::errors::CargoError;
77

8-
use crate::models::{Email, Follow, NewEmail, User, Version};
9-
use crate::schema::{crates, emails, follows, users, versions};
10-
use crate::views::{EncodableMe, EncodableVersion};
8+
use crate::models::{CrateOwner, Email, Follow, NewEmail, User, Version};
9+
use crate::schema::{crate_owners, crates, emails, follows, users, versions};
10+
use crate::views::{EncodableMe, EncodableVersion, OwnedCrate};
1111

1212
/// Handles the `GET /me` route.
1313
pub fn me(req: &mut dyn Request) -> CargoResult<Response> {
@@ -22,11 +22,11 @@ pub fn me(req: &mut dyn Request) -> CargoResult<Response> {
2222
// perhaps adding `req.mut_extensions().insert(user)` to the
2323
// update_user route, however this somehow does not seem to work
2424

25-
let id = req.user()?.id;
25+
let user_id = req.user()?.id;
2626
let conn = req.db_conn()?;
2727

2828
let (user, verified, email, verification_sent) = users::table
29-
.find(id)
29+
.find(user_id)
3030
.left_join(emails::table)
3131
.select((
3232
users::all_columns,
@@ -36,12 +36,27 @@ pub fn me(req: &mut dyn Request) -> CargoResult<Response> {
3636
))
3737
.first::<(User, Option<bool>, Option<String>, bool)>(&*conn)?;
3838

39+
let owned_crates = crate_owners::table
40+
.inner_join(crates::table)
41+
.filter(crate_owners::owner_id.eq(user_id))
42+
.select((crates::id, crates::name, crate_owners::email_notifications))
43+
.order(crates::name.asc())
44+
.load(&*conn)?
45+
.into_iter()
46+
.map(|(id, name, email_notifications)| OwnedCrate {
47+
id,
48+
name,
49+
email_notifications,
50+
})
51+
.collect();
52+
3953
let verified = verified.unwrap_or(false);
4054
let verification_sent = verified || verification_sent;
4155
let user = User { email, ..user };
4256

4357
Ok(req.json(&EncodableMe {
4458
user: user.encodable_private(verified, verification_sent),
59+
owned_crates,
4560
}))
4661
}
4762

@@ -212,3 +227,38 @@ pub fn regenerate_token_and_send(req: &mut dyn Request) -> CargoResult<Response>
212227
}
213228
Ok(req.json(&R { ok: true }))
214229
}
230+
231+
/// Handles `PUT /me/email_notifications` route
232+
pub fn update_email_notifications(req: &mut dyn Request) -> CargoResult<Response> {
233+
use self::crate_owners::dsl::*;
234+
use diesel::update;
235+
236+
#[derive(Deserialize)]
237+
struct CrateEmailNotifications {
238+
id: i32,
239+
email_notifications: bool,
240+
}
241+
242+
let mut body = String::new();
243+
req.body().read_to_string(&mut body)?;
244+
let updates: Vec<CrateEmailNotifications> =
245+
serde_json::from_str(&body).map_err(|_| human("invalid json request"))?;
246+
247+
let user = req.user()?;
248+
let conn = req.db_conn()?;
249+
250+
for to_update in updates {
251+
update(CrateOwner::belonging_to(user))
252+
.set(email_notifications.eq(to_update.email_notifications))
253+
.filter(crate_id.eq(to_update.id))
254+
.filter(email_notifications.ne(to_update.email_notifications))
255+
.execute(&*conn)
256+
.map_err(|_| human("Error updating crate owner email notifications"))?;
257+
}
258+
259+
#[derive(Serialize)]
260+
struct R {
261+
ok: bool,
262+
}
263+
Ok(req.json(&R { ok: true }))
264+
}

src/models/krate.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ impl<'a> NewCrate<'a> {
195195
owner_id: user_id,
196196
created_by: user_id,
197197
owner_kind: OwnerKind::User as i32,
198+
email_notifications: true,
198199
};
199200
diesel::insert_into(crate_owners::table)
200201
.values(&owner)
@@ -455,6 +456,7 @@ impl Crate {
455456
owner_id: owner.id(),
456457
created_by: req_user.id,
457458
owner_kind: OwnerKind::Team as i32,
459+
email_notifications: true,
458460
})
459461
.on_conflict(crate_owners::table.primary_key())
460462
.do_update()

src/models/owner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub struct CrateOwner {
1919
pub owner_id: i32,
2020
pub created_by: i32,
2121
pub owner_kind: i32,
22+
pub email_notifications: bool,
2223
}
2324

2425
#[derive(Debug, Clone, Copy)]

src/router.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ pub fn build_router(app: &App) -> R404 {
8989
"/me/crate_owner_invitations/:crate_id",
9090
C(crate_owner_invitation::handle_invite),
9191
);
92+
api_router.put(
93+
"/me/email_notifications",
94+
C(user::me::update_email_notifications),
95+
);
9296
api_router.get("/summary", C(krate::metadata::summary));
9397
api_router.put("/confirm/:email_token", C(user::me::confirm_user_email));
9498
api_router.put(

src/schema.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,12 @@ table! {
261261
///
262262
/// (Automatically generated by Diesel.)
263263
owner_kind -> Int4,
264+
/// The `email_notifications` column of the `crate_owners` table.
265+
///
266+
/// Its SQL type is `Bool`.
267+
///
268+
/// (Automatically generated by Diesel.)
269+
email_notifications -> Bool,
264270
}
265271
}
266272

src/tasks/dump_db/dump-db.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ created_by = "private"
6868
deleted = "private"
6969
updated_at = "public"
7070
owner_kind = "public"
71+
email_notifications = "private"
7172

7273
[crates.columns]
7374
id = "public"

src/tests/all.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ fn add_team_to_crate(t: &Team, krate: &Crate, u: &User, conn: &PgConnection) ->
242242
owner_id: t.id,
243243
created_by: u.id,
244244
owner_kind: 1, // Team owner kind is 1 according to owner.rs
245+
email_notifications: true,
245246
};
246247

247248
diesel::insert_into(crate_owners::table)

src/tests/user.rs

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ use crate::{
66
};
77
use cargo_registry::{
88
models::{Email, NewUser, User},
9-
views::{EncodablePrivateUser, EncodablePublicUser, EncodableVersion},
9+
schema::crate_owners,
10+
views::{EncodablePrivateUser, EncodablePublicUser, EncodableVersion, OwnedCrate},
1011
};
1112

1213
use diesel::prelude::*;
@@ -25,13 +26,20 @@ pub struct UserShowPublicResponse {
2526
#[derive(Deserialize)]
2627
pub struct UserShowPrivateResponse {
2728
pub user: EncodablePrivateUser,
29+
pub owned_crates: Vec<OwnedCrate>,
2830
}
2931

3032
#[derive(Deserialize)]
3133
struct UserStats {
3234
total_downloads: i64,
3335
}
3436

37+
#[derive(Serialize)]
38+
struct EmailNotificationsUpdate {
39+
id: i32,
40+
email_notifications: bool,
41+
}
42+
3543
impl crate::util::MockCookieUser {
3644
fn show_me(&self) -> UserShowPrivateResponse {
3745
let url = "/api/v1/me";
@@ -65,6 +73,14 @@ impl crate::util::MockCookieUser {
6573
let url = format!("/api/v1/confirm/{}", email_token);
6674
self.put(&url, &[]).good()
6775
}
76+
77+
fn update_email_notifications(&self, updates: Vec<EmailNotificationsUpdate>) -> OkBool {
78+
self.put(
79+
"/api/v1/me/email_notifications",
80+
json!(updates).to_string().as_bytes(),
81+
)
82+
.good()
83+
}
6884
}
6985

7086
impl crate::util::MockAnonymousUser {
@@ -108,9 +124,14 @@ fn me() {
108124
anon.get(url).assert_forbidden();
109125

110126
let user = app.db_new_user("foo");
127+
app.db(|conn| {
128+
CrateBuilder::new("foo_my_packages", user.as_model().id).expect_build(conn);
129+
});
130+
111131
let json = user.show_me();
112132

113133
assert_eq!(json.user.email, user.as_model().email);
134+
assert_eq!(json.owned_crates.len(), 1);
114135
}
115136

116137
#[test]
@@ -569,3 +590,121 @@ fn test_existing_user_email() {
569590
assert!(!json.user.email_verified);
570591
assert!(!json.user.email_verification_sent);
571592
}
593+
594+
/* A user should be able to update the email notifications for crates they own. Only the crates that
595+
were sent in the request should be updated the the corresponding `email_notifications` value.
596+
*/
597+
#[test]
598+
fn test_update_email_notifications() {
599+
let (app, _, user) = TestApp::init().with_user();
600+
601+
let my_crates = app.db(|conn| {
602+
vec![
603+
CrateBuilder::new("test_package", user.as_model().id).expect_build(&conn),
604+
CrateBuilder::new("another_package", user.as_model().id).expect_build(&conn),
605+
]
606+
});
607+
608+
let a_id = my_crates.get(0).unwrap().id;
609+
let b_id = my_crates.get(1).unwrap().id;
610+
611+
// Update crate_a: email_notifications = false
612+
// crate_a should be false, crate_b should be true
613+
user.update_email_notifications(vec![EmailNotificationsUpdate {
614+
id: a_id,
615+
email_notifications: false,
616+
}]);
617+
let json = user.show_me();
618+
619+
assert_eq!(
620+
json.owned_crates
621+
.iter()
622+
.find(|c| c.id == a_id)
623+
.unwrap()
624+
.email_notifications,
625+
false
626+
);
627+
assert_eq!(
628+
json.owned_crates
629+
.iter()
630+
.find(|c| c.id == b_id)
631+
.unwrap()
632+
.email_notifications,
633+
true
634+
);
635+
636+
// Update crate_b: email_notifications = false
637+
// Both should be false now
638+
user.update_email_notifications(vec![EmailNotificationsUpdate {
639+
id: b_id,
640+
email_notifications: false,
641+
}]);
642+
let json = user.show_me();
643+
644+
assert_eq!(
645+
json.owned_crates
646+
.iter()
647+
.find(|c| c.id == a_id)
648+
.unwrap()
649+
.email_notifications,
650+
false
651+
);
652+
assert_eq!(
653+
json.owned_crates
654+
.iter()
655+
.find(|c| c.id == b_id)
656+
.unwrap()
657+
.email_notifications,
658+
false
659+
);
660+
661+
// Update crate_a and crate_b: email_notifications = true
662+
// Both should be true
663+
user.update_email_notifications(vec![
664+
EmailNotificationsUpdate {
665+
id: a_id,
666+
email_notifications: true,
667+
},
668+
EmailNotificationsUpdate {
669+
id: b_id,
670+
email_notifications: true,
671+
},
672+
]);
673+
let json = user.show_me();
674+
675+
json.owned_crates.iter().for_each(|c| {
676+
assert!(c.email_notifications);
677+
})
678+
}
679+
680+
/* A user should not be able to update the `email_notifications` value for a crate that is not
681+
owend by them.
682+
*/
683+
#[test]
684+
fn test_update_email_notifications_not_owned() {
685+
let (app, _, user) = TestApp::init().with_user();
686+
687+
let not_my_crate = app.db(|conn| {
688+
let u = new_user("arbitrary_username")
689+
.create_or_update(&conn)
690+
.unwrap();
691+
CrateBuilder::new("test_package", u.id).expect_build(&conn)
692+
});
693+
694+
user.update_email_notifications(vec![EmailNotificationsUpdate {
695+
id: not_my_crate.id,
696+
email_notifications: false,
697+
}]);
698+
699+
let email_notifications = app
700+
.db(|conn| {
701+
crate_owners::table
702+
.select(crate_owners::email_notifications)
703+
.filter(crate_owners::crate_id.eq(not_my_crate.id))
704+
.first::<bool>(&*conn)
705+
})
706+
.unwrap();
707+
708+
// There should be no change to the `email_notifications` value for a crate not belonging to me
709+
assert!(email_notifications);
710+
}

src/views.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,17 @@ pub struct EncodableApiTokenWithToken {
149149
pub last_used_at: Option<NaiveDateTime>,
150150
}
151151

152+
#[derive(Deserialize, Serialize, Debug)]
153+
pub struct OwnedCrate {
154+
pub id: i32,
155+
pub name: String,
156+
pub email_notifications: bool,
157+
}
158+
152159
#[derive(Serialize, Deserialize, Debug)]
153160
pub struct EncodableMe {
154161
pub user: EncodablePrivateUser,
162+
pub owned_crates: Vec<OwnedCrate>,
155163
}
156164

157165
/// The serialization format for the `User` model.

0 commit comments

Comments
 (0)