1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
//! Profile services.

use crate::api::discord::{self, global_discord_client};
use crate::api::rcos::users::edit_profile::{EditProfileContext, SaveProfileEdits};
use crate::api::rcos::users::profile::{
    profile::{ProfileTarget, ResponseData},
    Profile,
};
use crate::api::rcos::users::UserRole;
use crate::env::global_config;
use crate::error::TelescopeError;
use crate::templates::page::Page;
use crate::templates::tags::Tags;
use crate::templates::Template;
use crate::web::services::auth::identity::{AuthenticationCookie, Identity};
use actix_web::web::{Form, Path, ServiceConfig};
use actix_web::{http::header::LOCATION, HttpRequest, HttpResponse};
use chrono::{Datelike, Local};
use serenity::model::guild::Member;
use serenity::model::user::User;
use std::collections::HashMap;
use uuid::Uuid;

/// The path from the template directory to the profile template.
const TEMPLATE_NAME: &'static str = "user/profile";

/// The path from the templates directory to the user settings form template.
const SETTINGS_FORM: &'static str = "user/settings";

/// Register services into actix app.
pub fn register(config: &mut ServiceConfig) {
    config
        .service(profile)
        .service(settings)
        .service(save_changes);
}

/// User profile service. The target's user ID is in the path.
#[get("/user/{id}")]
async fn profile(
    req: HttpRequest,
    identity: Identity,
    Path(id): Path<Uuid>,
) -> Result<Page, TelescopeError> {
    // Get the viewer's user ID.
    let viewer: Option<Uuid> = identity.get_user_id().await?;

    // Get the user's profile information (and viewer info) from the RCOS API.
    let response: ResponseData = Profile::for_user(id, viewer).await?;

    // Throw an error if there is no user.
    if response.target.is_none() {
        return Err(TelescopeError::resource_not_found(
            "User Not Found",
            "Could not find a user by this user ID.",
        ));
    }

    // Create the profile template to send back to the viewer.
    let mut template: Template = Template::new(TEMPLATE_NAME);
    template["data"] = json!(&response);

    // Get the target user's info.
    let target_user: &ProfileTarget = response.target.as_ref().unwrap();
    // And use it to make the page title
    let page_title: String = format!("{} {}", target_user.first_name, target_user.last_name);

    // Get the target user's discord info.
    let target_discord_id: Option<&str> = target_user
        .discord
        .first()
        .map(|obj| obj.account_id.as_str());

    // If the discord ID exists, and is properly formatted.
    if let Some(target_discord_id) = target_discord_id.and_then(|s| s.parse::<u64>().ok()) {
        // Get target user info.
        let target_user: Result<User, serenity::Error> =
            global_discord_client().get_user(target_discord_id).await;

        // Check to make sure target user info was available.
        match target_user {
            // Issue retrieving target user info
            Err(e) => {
                // Log an error and set a flag for the template.
                warn!("Could not get target user account for Discord user ID {}. Account may have been deleted. Internal error: {}", target_discord_id, e);
                template["discord"]["target"] = json!({"errored": true});

                // Return early if there was an error.
                // Otherwise we can go forward and check for the user's membership in the RCOS
                // Discord server.
                return template.in_page(&req, page_title).await;
            }

            // User returned successfully.
            Ok(u) => {
                // Add the discord info to the template.
                template["discord"]["target"] = json!({
                    "response": &u,
                    "resolved": {
                        "face": u.face(),
                        "tag": u.tag(),
                    }
                });
            }
        }

        // Check if the target user is in the RCOS Discord.
        // First parse the RCOS Discord Guild ID.
        let rcos_discord: u64 = global_config().discord_config.rcos_guild_id();

        // Target user as member of RCOS discord.
        let membership: Option<Member> = global_discord_client()
            .get_member(rcos_discord, target_discord_id)
            .await
            .ok();

        // Get "Verified" role ID if available.
        let verified_role_id = discord::rcos_discord_verified_role_id()
            .await
            .ok()
            .flatten();

        // Check if the user has the verified role.
        let is_verified: bool = membership
            // Take as reference
            .as_ref()
            // Make tuple with other option if it's some.
            .map(|membership| verified_role_id.map(|role_id| (membership, role_id)))
            // Flatten to Option<(...)>
            .flatten()
            // Filter to membership containing verified role.
            .filter(|(membership, role_id)| membership.roles.contains(role_id))
            .is_some();

        // Add verified status to template.
        template["discord"]["target"]["is_verified"] = json!(is_verified);

        // Add Discord authentication status to template.
        template["discord"]["viewer"]["is_authenticated"] = identity
            .identity()
            .await
            .map(|cookie| json!(cookie.get_discord().is_some()))
            .unwrap_or(json!(false));
    }

    // Render the profile template and send to user.
    let mut page = template.in_page(&req, page_title.clone()).await?;

    let mut tags = Tags::default();
    tags.title = page_title.clone();
    tags.url = req.uri().to_string();
    let mut description = format!("{}\n", target_user.role);
    if target_user.rcs_id.len() > 0 {
        description.push_str("Email: ");
        description.push_str(&target_user.rcs_id[0].account_id);
        description.push_str("@rpi.edu\n");
    }
    if target_user.enrollments.len() > 0 {
        description.push_str("Enrollments:\n");
        for enrollment in &target_user.enrollments {
            let semester = &enrollment.semester;
            description.push_str(&semester.title);
            let project = enrollment.project.as_ref();
            if project.is_some() {
                let project = project.unwrap();
                description.push_str(" - ");
                if enrollment.is_coordinator {
                    description.push_str("Coordinator - ");
                }
                description.push_str(&project.title);
                if enrollment.is_project_lead {
                    description.push_str(" - Project Lead");
                }
            }
            description.push_str("\n");
        }
    }
    tags.description = description;

    page.ogp_tags = tags;

    return Ok(page);
}

/// Create a form template for the user settings page.
fn make_settings_form() -> Template {
    // Create the base form.
    let mut form = Template::new(SETTINGS_FORM);

    // The max entry year should always be the current year.
    form.fields = json!({
        "max_entry_year": Local::today().year()
    });

    return form;
}

/// Get the viewer's user ID and make a profile edit form for them.
async fn get_context_and_make_form(
    auth: &AuthenticationCookie,
) -> Result<Template, TelescopeError> {
    // Get viewer's user ID. You have to be authenticated to edit your own profile.
    let viewer: Uuid = auth.get_user_id_or_error().await?;
    // Get the context for the edit form.
    let context = EditProfileContext::get(viewer).await?;
    // Ensure that the context exists.
    if context.is_none() {
        // Use an ISE since we should be able to get an edit context as long as there is an
        // authenticated user.
        return Err(TelescopeError::ise(format!(
            "Could not get edit context for user ID {}.",
            viewer
        )));
    }

    // Unwrap the context.
    let context = context.unwrap();

    // Create the form to edit the profile.
    let mut form: Template = make_settings_form();
    // Add the context to the form.
    form["context"] = json!(&context);
    // Add user id to the form for the cancel button
    form["user_id"] = json!(&viewer);

    // Add the list of roles (and whether the current role can switch to them).
    let role_list = UserRole::ALL_ROLES
        .iter()
        // Add availability data
        .map(|role| (*role, UserRole::can_switch_to(context.role, *role)))
        // Collect into map
        .collect::<HashMap<_, _>>();

    // Add to form.
    form["roles"] = json!(role_list);

    // Disable student role if the current role is external and there is no RCS ID in the context.
    if context.role.is_external() && context.rcs_id.first().is_none() {
        form["roles"]["student"] = json!(false);
    }

    return Ok(form);
}

/// User settings form.
#[get("/edit_profile")]
async fn settings(req: HttpRequest, auth: AuthenticationCookie) -> Result<Page, TelescopeError> {
    get_context_and_make_form(&auth)
        .await?
        .in_page(&req, "Edit Profile")
        .await
}

/// Edits to the user's profile submitted through the form.
#[derive(Clone, Serialize, Deserialize, Debug)]
struct ProfileEdits {
    first_name: String,
    last_name: String,
    role: UserRole,

    /// Entry year for RPI students.
    #[serde(default)]
    cohort: String,
}

/// Submission endpoint for the user settings form.
#[post("/edit_profile")]
async fn save_changes(
    req: HttpRequest,
    auth: AuthenticationCookie,
    Form(ProfileEdits {
        first_name,
        last_name,
        role,
        cohort,
    }): Form<ProfileEdits>,
) -> Result<HttpResponse, TelescopeError> {
    // Get authenticated user ID. This API call gets duplicated in the context creation unfortunately.
    let user_id = auth.get_user_id_or_error().await?;

    // Pass most of the handling here to the GET handler. This will get the context and make
    // and fill the form.
    let mut form: Template = get_context_and_make_form(&auth).await?;

    // Convert the cohort to a number or default to no cohort input. This should be checked client side.
    let cohort: Option<i64> = cohort.parse::<i64>().ok();

    // Check if user is allowed to set their cohort and if it is within the valid range.
    if cohort.is_some() {
        if auth.get_rcs_id().await.unwrap().is_none() {
            form["issues"]["cohort"] = json!("Please link RPI CAS before setting this.");
        }
        let cohort_int = cohort.unwrap();
        let year: i64 = Local::today().year() as i64;
        if cohort_int < 1824 || cohort_int > year {
            form["issues"]["cohort"] = json!(format!("Year must be between 1824 and {}", year));
        }
    }

    // Check that the current user role can switch to the submitted role.
    // First get the json version of the role as a string.
    let role_json: String = json!(role)
        .as_str()
        .expect("Role serialized to JSON string")
        .to_string();
    // Then index into the available roles on the context with the selected role to check availability.
    if form["roles"][&role_json] != json!(true) {
        return Err(TelescopeError::BadRequest {
            header: "Invalid Role Selection".into(),
            message: "The selected role is not available at this time".into(),
            show_status_code: false,
        });
    }

    // Fill the form with the submitted info.
    form["context"]["first_name"] = json!(&first_name);
    form["context"]["last_name"] = json!(&last_name);
    form["context"]["cohort"] = json!(&cohort);
    form["context"]["role"] = json!(role);

    // Error if first or last name is empty.
    if first_name.trim().is_empty() {
        form["issues"]["first_name"] = json!("Cannot be empty.");
    }

    if last_name.trim().is_empty() {
        form["issues"]["last_name"] = json!("Cannot be empty.");
    }

    if form["issues"] != json!(null) {
        let page = form.in_page(&req, "Edit Profile").await?;
        return Err(TelescopeError::InvalidForm(page));
    }

    // Execute GraphQL mutation to save changes.
    let user_id = SaveProfileEdits::execute(user_id, first_name, last_name, cohort, role)
        .await?
        .ok_or(TelescopeError::ise(
            "Could not save changes -- user not found.",
        ))?;

    // On success, redirect to user's profile.
    return Ok(HttpResponse::Found()
        .header(LOCATION, format!("/user/{}", user_id))
        .finish());
}