Skip to content

Commit 4a7e7a7

Browse files
Merge #1073
1073: Accept owner invites r=carols10cents This implements deployable chunk 2, accepting invitations of issue #924. I created a new route, `/me/crate_invitations/:crate_id` where a `PUT` request is submitted to. The request body contains the invitation information along with a boolean field `accepted`, indicating whether or not the invitation was accepted. This same route can then also be used for the decline route, a matter of toggling the `accepted` field. When an invitation is accepted, the user should receive a message where the invitation used to be acknowledging the successful addition. If an error occurs, a message should also appear indicating so.
2 parents b79e874 + b64c808 commit 4a7e7a7

10 files changed

+258
-29
lines changed
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Ember from 'ember';
2+
3+
export default Ember.Component.extend({
4+
isSuccess: false,
5+
isError: false,
6+
inviteError: 'default error message',
7+
8+
actions: {
9+
acceptInvitation(invite) {
10+
invite.set('accepted', true);
11+
invite.save()
12+
.then(() => {
13+
this.set('isSuccess', true);
14+
})
15+
.catch((error) => {
16+
this.set('isError', true);
17+
if (error.payload) {
18+
this.set('inviteError',
19+
`Error in accepting invite: ${error.payload.errors[0].detail}`
20+
);
21+
} else {
22+
this.set('inviteError', 'Error in accepting invite');
23+
}
24+
});
25+
}
26+
}
27+
});

app/models/crate-owner-invite.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export default DS.Model.extend({
44
invited_by_username: DS.attr('string'),
55
crate_name: DS.attr('string'),
66
crate_id: DS.attr('number'),
7-
created_at: DS.attr('date')
7+
created_at: DS.attr('date'),
8+
accepted: DS.attr('boolean', { defaultValue: false })
89
});

app/serializers/crate-owner-invite.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ export default DS.RESTSerializer.extend({
44
primaryKey: 'crate_id',
55
modelNameFromPayloadKey() {
66
return 'crate-owner-invite';
7+
},
8+
payloadKeyFromModelName() {
9+
return 'crate_owner_invite';
710
}
811
});

app/styles/me.scss

+8
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@
195195
@include justify-content(space-between);
196196

197197
.date { @include flex-grow(2); text-align: right; }
198+
.label {
199+
.small-text {
200+
font-size: 90%;
201+
}
202+
}
203+
.name {
204+
width: 200px;
205+
}
198206
}
199207
}
200208

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<div class='row'>
2+
{{#if isSuccess }}
3+
<p>Success! You've been added as an owner of crate
4+
{{#link-to 'crate' invite.crate_name}}{{invite.crate_name}}{{/link-to}}.
5+
</p>
6+
{{else}}
7+
<div class='info'>
8+
<div class='name'>
9+
<h3>
10+
{{#link-to 'crate' invite.crate_name}}
11+
{{invite.crate_name}}
12+
{{/link-to}}
13+
</h3>
14+
</div>
15+
<div class='invite'>
16+
<p>Invited by:
17+
{{#link-to 'user' invite.invited_by_username}}
18+
{{invite.invited_by_username}}
19+
{{/link-to}}
20+
</p>
21+
</div>
22+
<div class='sent'>
23+
<span class='small'>{{moment-from-now invite.created_at}}</span>
24+
</div>
25+
<div class='actions'>
26+
<button class='small yellow-button' {{action 'acceptInvitation' invite}}>Accept</button>
27+
<button class='small yellow-button'>Deny</button>
28+
</div>
29+
{{#if isError}}
30+
<div class='label'>
31+
<p class='small-text'>{{inviteError}}</p>
32+
</div>
33+
{{/if}}
34+
</div>
35+
{{/if}}
36+
</div>

app/templates/me/pending-invites.hbs

+3-25
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,9 @@
88
<div id='my-invites'>
99
<div class='white-rows'>
1010
{{#each model as |invite|}}
11-
<div class='row'>
12-
<div class='info'>
13-
<div class='name'>
14-
<h3>
15-
{{#link-to 'crate' invite.crate_name}}
16-
{{invite.crate_name}}
17-
{{/link-to}}
18-
</h3>
19-
</div>
20-
<div class='invite'>
21-
<p>Invited by:
22-
{{#link-to 'user' invite.invited_by_username}}
23-
{{invite.invited_by_username}}
24-
{{/link-to}}
25-
</p>
26-
</div>
27-
<div class='sent'>
28-
<span class='small'>{{moment-from-now invite.created_at}}</span>
29-
</div>
30-
<div class='actions'>
31-
<button class='small yellow-button'>Accept</button>
32-
<button class='small yellow-button'>Deny</button>
33-
</div>
34-
</div>
35-
</div>
11+
{{pending-owner-invite-row invite=invite}}
12+
{{else}}
13+
<p>You don't seem to have any pending invitations.</p>
3614
{{/each}}
3715
</div>
3816
</div>

src/crate_owner_invitation.rs

+76-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use conduit::{Request, Response};
22
use diesel::prelude::*;
33
use time::Timespec;
4+
use serde_json;
45

56
use db::RequestTransaction;
6-
use schema::{crate_owner_invitations, users, crates};
7+
use schema::{crate_owner_invitations, users, crates, crate_owners};
78
use user::RequestUser;
8-
use util::errors::CargoResult;
9+
use util::errors::{CargoResult, human};
910
use util::RequestUtils;
11+
use owner::{CrateOwner, OwnerKind};
1012

1113
/// The model representing a row in the `crate_owner_invitations` database table.
1214
#[derive(Clone, Copy, Debug, PartialEq, Eq, Identifiable, Queryable)]
@@ -80,3 +82,75 @@ pub fn list(req: &mut Request) -> CargoResult<Response> {
8082
}
8183
Ok(req.json(&R { crate_owner_invitations }))
8284
}
85+
86+
#[derive(Deserialize)]
87+
struct OwnerInvitation {
88+
crate_owner_invite: InvitationResponse,
89+
}
90+
91+
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
92+
pub struct InvitationResponse {
93+
pub crate_id: i32,
94+
pub accepted: bool,
95+
}
96+
97+
/// Handles the `PUT /me/crate_owner_invitations/:crate_id` route.
98+
pub fn handle_invite(req: &mut Request) -> CargoResult<Response> {
99+
100+
let conn = &*req.db_conn()?;
101+
102+
103+
let mut body = String::new();
104+
req.body().read_to_string(&mut body)?;
105+
106+
let crate_invite: OwnerInvitation = serde_json::from_str(&body).map_err(|_| {
107+
human("invalid json request")
108+
})?;
109+
110+
let crate_invite = crate_invite.crate_owner_invite;
111+
112+
if crate_invite.accepted {
113+
accept_invite(req, conn, crate_invite)
114+
} else {
115+
#[derive(Serialize)]
116+
struct R {
117+
crate_owner_invitation: InvitationResponse,
118+
}
119+
Ok(req.json(&R { crate_owner_invitation: crate_invite }))
120+
}
121+
}
122+
123+
fn accept_invite(
124+
req: &mut Request,
125+
conn: &PgConnection,
126+
crate_invite: InvitationResponse,
127+
) -> CargoResult<Response> {
128+
let user_id = req.user()?.id;
129+
use diesel::{insert, delete};
130+
let pending_crate_owner = crate_owner_invitations::table
131+
.filter(crate_owner_invitations::crate_id.eq(crate_invite.crate_id))
132+
.filter(crate_owner_invitations::invited_user_id.eq(user_id))
133+
.first::<CrateOwnerInvitation>(&*conn)?;
134+
135+
let owner = CrateOwner {
136+
crate_id: crate_invite.crate_id,
137+
owner_id: user_id,
138+
created_by: pending_crate_owner.invited_by_user_id,
139+
owner_kind: OwnerKind::User as i32,
140+
};
141+
142+
conn.transaction(|| {
143+
insert(&owner).into(crate_owners::table).execute(conn)?;
144+
delete(
145+
crate_owner_invitations::table
146+
.filter(crate_owner_invitations::crate_id.eq(crate_invite.crate_id))
147+
.filter(crate_owner_invitations::invited_user_id.eq(user_id)),
148+
).execute(conn)?;
149+
150+
#[derive(Serialize)]
151+
struct R {
152+
crate_owner_invitation: InvitationResponse,
153+
}
154+
Ok(req.json(&R { crate_owner_invitation: crate_invite }))
155+
})
156+
}

src/lib.rs

+4
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ pub fn middleware(app: Arc<App>) -> MiddlewareBuilder {
191191
"/me/crate_owner_invitations",
192192
C(crate_owner_invitation::list),
193193
);
194+
api_router.put(
195+
"/me/crate_owner_invitations/:crate_id",
196+
C(crate_owner_invitation::handle_invite),
197+
);
194198
api_router.get("/summary", C(krate::summary));
195199
api_router.put("/confirm/:email_token", C(user::confirm_user_email));
196200
api_router.put("/users/:user_id/resend", C(user::regenerate_token_and_send));

src/tests/all.rs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ extern crate dotenv;
1515
extern crate git2;
1616
extern crate semver;
1717
extern crate serde;
18+
#[macro_use]
1819
extern crate serde_json;
1920
extern crate time;
2021
extern crate url;

src/tests/owners.rs

+98-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use {CrateList, GoodCrate};
33
use cargo_registry::owner::EncodableOwner;
44
use cargo_registry::user::EncodablePublicUser;
55
use cargo_registry::crate_owner_invitation::{EncodableCrateOwnerInvitation,
6-
NewCrateOwnerInvitation};
6+
NewCrateOwnerInvitation, InvitationResponse};
77
use cargo_registry::schema::crate_owner_invitations;
88

99
use conduit::{Handler, Method};
@@ -370,3 +370,100 @@ fn invitations_list() {
370370
assert_eq!(json.crate_owner_invitations[0].crate_name, "invited_crate");
371371
assert_eq!(json.crate_owner_invitations[0].crate_id, krate.id);
372372
}
373+
374+
/* Given a user inviting a different user to be a crate
375+
owner, check that the user invited can accept their
376+
invitation, the invitation will be deleted from
377+
the invitations table, and a new crate owner will be
378+
inserted into the table for the given crate.
379+
*/
380+
#[test]
381+
fn test_accept_invitation() {
382+
#[derive(Deserialize)]
383+
struct R {
384+
crate_owner_invitations: Vec<EncodableCrateOwnerInvitation>,
385+
}
386+
387+
#[derive(Deserialize)]
388+
struct Q {
389+
users: Vec<EncodablePublicUser>,
390+
}
391+
392+
#[derive(Deserialize)]
393+
struct T {
394+
crate_owner_invitation: InvitationResponse,
395+
}
396+
397+
let (_b, app, middle) = ::app();
398+
let mut req = ::req(
399+
app.clone(),
400+
Method::Get,
401+
"/api/v1/me/crate_owner_invitations",
402+
);
403+
let (krate, user) = {
404+
let conn = app.diesel_database.get().unwrap();
405+
let owner = ::new_user("inviting_user").create_or_update(&conn).unwrap();
406+
let user = ::new_user("invited_user").create_or_update(&conn).unwrap();
407+
let krate = ::CrateBuilder::new("invited_crate", owner.id).expect_build(&conn);
408+
409+
// This should be replaced by an actual call to the route that `owner --add` hits once
410+
// that route creates an invitation.
411+
let invitation = NewCrateOwnerInvitation {
412+
invited_by_user_id: owner.id,
413+
invited_user_id: user.id,
414+
crate_id: krate.id,
415+
};
416+
diesel::insert(&invitation)
417+
.into(crate_owner_invitations::table)
418+
.execute(&*conn)
419+
.unwrap();
420+
(krate, user)
421+
};
422+
::sign_in_as(&mut req, &user);
423+
424+
let body = json!({
425+
"crate_owner_invite": {
426+
"invited_by_username": "inviting_user",
427+
"crate_name": "invited_crate",
428+
"crate_id": krate.id,
429+
"created_at": "",
430+
"accepted": true
431+
}
432+
});
433+
434+
// first check that response from inserting new crate owner
435+
// and deleting crate_owner_invitation is okay
436+
let mut response = ok_resp!(
437+
middle.call(
438+
req.with_path(&format!("api/v1/me/crate_owner_invitations/{}", krate.id))
439+
.with_method(Method::Put)
440+
.with_body(body.to_string().as_bytes()),
441+
)
442+
);
443+
444+
let json: T = ::json(&mut response);
445+
assert_eq!(json.crate_owner_invitation.accepted, true);
446+
assert_eq!(json.crate_owner_invitation.crate_id, krate.id);
447+
448+
// then check to make sure that accept_invite did what it
449+
// was supposed to
450+
// crate_owner_invitation was deleted
451+
let mut response = ok_resp!(
452+
middle.call(
453+
req.with_path("api/v1/me/crate_owner_invitations")
454+
.with_method(Method::Get)
455+
)
456+
);
457+
let json: R = ::json(&mut response);
458+
assert_eq!(json.crate_owner_invitations.len(), 0);
459+
460+
// new crate owner was inserted
461+
let mut response = ok_resp!(
462+
middle.call(
463+
req.with_path("/api/v1/crates/invited_crate/owners")
464+
.with_method(Method::Get)
465+
)
466+
);
467+
let json: Q = ::json(&mut response);
468+
assert_eq!(json.users.len(), 2);
469+
}

0 commit comments

Comments
 (0)