Skip to content

Commit

Permalink
Added subscription update and getting user details
Browse files Browse the repository at this point in the history
  • Loading branch information
rimutaka committed Oct 29, 2024
1 parent 89c09c9 commit 1df65ec
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 61 deletions.
65 changes: 39 additions & 26 deletions rust/lambdas/user-handler/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use aws_lambda_events::{
use bitie_types::{
ddb::fields,
jwt,
lambda::text_response,
lambda::{json_response, text_response},
// question::{Question, QuestionFormat},
topic::Topic,
};
Expand Down Expand Up @@ -58,6 +58,7 @@ pub(crate) async fn my_handler(
};
info!("Method: {}", method);

// can only proceed if the user is authenticated with an email
let email = match get_email_from_token(&event.payload.headers) {
Some(v) => v,
None => {
Expand All @@ -66,39 +67,51 @@ pub(crate) async fn my_handler(
}
};

// topics param is required for get queries
// topics param is optional
let topics = match event.payload.query_string_parameters.get(fields::TOPICS) {
Some(v) if !v.trim().is_empty() => v
.trim()
.to_lowercase()
.split('.')
.filter_map(|t| {
let t = t.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
})
.collect::<Vec<String>>(),
// ?topic= is present, but is empty -> unsubscribe from all topics
Some(v) if v.trim().is_empty() => {
info!("Empty list of topics in the query string");
Some(Vec::new())
}
Some(v) => {
let topics = v
.trim()
.to_lowercase()
.split('.')
.filter_map(|t| {
let t = t.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
})
.collect::<Vec<String>>();
info!("Found topics in the query string");
Some(Topic::filter_valid_topics(topics))
}

_ => {
info!("No topic found in the query string");
return text_response(Some("No topic found in the query string".to_string()), 400);
None => {
info!("No topics param in the query string");
None
}
};
let topics = Topic::filter_valid_topics(topics);

//decide on the action depending on the HTTP method
match method {
Method::GET => {
if topics.is_empty() {
text_response(Some("No valid topics found".to_string()), 400)
} else {
match user::update_subscription(email, topics).await {
Ok(_) => text_response(None, 204),
Err(e) => text_response(Some(e.to_string()), 400),
}
// get the user or update the user subscription
let user = match topics {
Some(v) => user::update_subscription(email, v).await,
None => user::get_user(email).await,
};

// return the right response
match user {
Ok(Some(v)) => json_response(Some(&v), 200),
Ok(None) => text_response(Some("User not found".to_owned()), 404),
Err(e) => text_response(Some(e.to_string()), 400),
}
}

Expand Down
112 changes: 100 additions & 12 deletions rust/lambdas/user-handler/src/user.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
use anyhow::Error;
use aws_sdk_dynamodb::{types::AttributeValue, Client};
use bitie_types::{
ddb::fields,
ddb::tables,
// user::{AnswerState, AskedQuestion, User},
use aws_sdk_dynamodb::{
types::{AttributeValue, ReturnValue},
Client,
};
use chrono::Utc;
use tracing::{error, info};
use bitie_types::{ddb::fields, ddb::tables, user::User};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tracing::{error, info, warn};

/// Save a user in the main user table.
/// Replaces existing records unconditionally.
pub(crate) async fn update_subscription(email: String, topics: Vec<String>) -> Result<(), Error> {
pub(crate) async fn update_subscription(email: String, topics: Vec<String>) -> Result<Option<User>, Error> {
info!("Updating user sub: {}", email);

let client = Client::new(&aws_config::load_from_env().await);
Expand All @@ -37,16 +37,104 @@ pub(crate) async fn update_subscription(email: String, topics: Vec<String>) -> R
[":", fields::UPDATED].concat(),
AttributeValue::S(Utc::now().to_rfc3339()),
)
.return_values(ReturnValue::AllNew)
.send()
.await
{
Ok(_) => {
info!("User subs saved in DDB");
Ok(())
}
Ok(v) => query_output_to_user(v.attributes, email),
Err(e) => {
error!("Failed to save user subs {}: {:?}", email, e);
Err(Error::msg("Failed to save question".to_string()))
}
}
}

/// Get a user from the main user table.
pub(crate) async fn get_user(email: String) -> Result<Option<User>, Error> {
info!("Getting user: {}", email);

let client = Client::new(&aws_config::load_from_env().await);

match client
.query()
.table_name(tables::USERS)
.key_condition_expression("#email = :email")
.expression_attribute_names("#email", fields::EMAIL)
.expression_attribute_values(":email", AttributeValue::S(email.clone()))
.send()
.await
{
Ok(v) => match v.items {
// extract a single item from the response - there should be only one
Some(items) => {
// check how many items there are
if items.len() > 1 {
// should not happen, but carry on anyway
warn!("Found multiple records for {email}. Returning one only.");
}
query_output_to_user(items.into_iter().next(), email)
}
None => {
warn!("No query response for {email}");
Ok(None)
}
},
Err(e) => {
info!("Query for {email} failed: {:?}", e);
Err(Error::msg("DDB error".to_string()))
}
}
}

/// A reusable part of converting DDB output into User.
fn query_output_to_user(
query_output: Option<HashMap<String, AttributeValue>>,
email: String,
) -> Result<Option<User>, Error> {
/// A generic error to return back to the caller.
const INVALID_USER: &str = "Invalid user in DDB";

// process a single item
if let Some(item) = query_output {
let unsubscribe = match item.get(fields::UNSUBSCRIBE) {
Some(AttributeValue::S(v)) => v.clone(),
_ => {
warn!("Invalid user {email}: missing unsubscribe attribute");
return Err(Error::msg(INVALID_USER.to_string()));
}
};

let topics = match item.get(fields::TOPICS) {
Some(AttributeValue::Ss(v)) => v.clone(),

_ => Vec::new(),
};

let updated = match item.get(fields::UPDATED) {
Some(AttributeValue::S(v)) => match DateTime::parse_from_rfc3339(v) {
Ok(v) => v.with_timezone(&Utc),
Err(e) => {
warn!("Invalid updated field: {v},{:?}", e);
return Err(Error::msg(INVALID_USER.to_string()));
}
},
_ => {
warn!("Invalid user {email}: missing unsubscribe attribute");
return Err(Error::msg(INVALID_USER.to_string()));
}
};

info!("Returning user dets");
Ok(Some(User {
email,
topics,
questions: Vec::new(),
unsubscribe,
updated,
}))
} else {
// should not happen, but carry on anyway
warn!("No items in query response for {email}");
Ok(None)
}
}
5 changes: 5 additions & 0 deletions rust/types/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ pub struct User {
/// The list of asked questions
#[serde(default = "Vec::new")]
pub questions: Vec<AskedQuestion>,
/// A unique string to use an unsubscribe key
/// A shortened base58 encoded UUID
pub unsubscribe: String,
/// When the subscription was last updated
pub updated: DateTime<Utc>,
}

// Convert it into c2024-01-01T00:00:00Z format
Expand Down
73 changes: 65 additions & 8 deletions vue/src/components/SubscriptionForm.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<template>
<div class="card mt-8">
<h3>Select your topics of interest</h3>
<TopicsList />
<TransitionSlot>
<TopicsList :key="user?.updated" />
</TransitionSlot>

<p class="text-center text-red-600 text-sm" :class="topicsReminder && !canSubscribe ? 'visible': 'invisible'">Select at least one topic</p>
<p class="text-center text-red-600 text-sm" :class="topicsReminder && !canSubscribe ? 'visible' : 'invisible'">Select at least one topic</p>

<div class="mt-4 mb-12">
<div class="text-center">
<Button label="Subscribe" icon="pi pi-envelope" raised rounded class="font-bold px-8 py-4 md:me-4 mb-2 whitespace-nowrap" @click="subscribe" />
<Button :label="user?.topics.length ? 'Update subscription' : `Subscribe`" icon="pi pi-envelope" raised rounded class="font-bold px-8 py-4 md:me-4 mb-2 whitespace-nowrap" @click="subscribe" />
</div>
<p class="w-full text-center mt-2 mb-4 text-sm">or</p>

Expand All @@ -28,14 +30,15 @@ import { computed, ref, watchEffect } from "vue";
import { storeToRefs } from 'pinia'
import { useMainStore } from '@/store';
import { USER_HANDLER_URL, TOKEN_HEADER_NAME, URL_PARAM_TOPICS, URL_PARAM_LIST_SEPARATOR, findTopicById } from "@/constants";
import type { User } from "@/constants";
import Button from 'primevue/button';
import TopicsList from './TopicsList.vue';
import TransitionSlot from "./TransitionSlot.vue";
import SampleQuestion from "./SampleQuestion.vue";
const store = useMainStore();
const { selectedTopics, lastSelectedTopic, token } = storeToRefs(store);
const { selectedTopics, lastSelectedTopic, token, email, user } = storeToRefs(store);
const topicsReminder = ref(false); // true if attempted to subscribe without selecting topics to show a prompt
const sampleQuestionTopic = ref<string | undefined>();
Expand All @@ -44,12 +47,12 @@ const canSubscribe = computed(() => selectedTopics.value.length > 0);
/// Show a random question from the selected topics or all topics
function showRandomQuestion() {
// if no topics are selected, show a prompt and return
if (!canSubscribe.value) {
// if no topics are selected, show a prompt and return
if (!canSubscribe.value) {
topicsReminder.value = true;
return;
}
console.log("showRandomQuestion", lastSelectedTopic.value);
sampleQuestionTopic.value = lastSelectedTopic.value;
}
Expand Down Expand Up @@ -83,7 +86,20 @@ async function subscribe() {
// a successful response should contain the saved question
// an error may contain JSON or plain text, depending on where the errror occurred
if (response.status === 204) { console.log("Subscribed successfully"); }
if (response.status === 200) {
console.log("Subscribed successfully");
try {
user.value = <User>await response.json();
console.log(user.value);
// set selected topics to user's topics
selectedTopics.value = user.value.topics;
} catch (error) {
console.error(error);
}
}
else {
console.error("Failed to subscribe: ", response.status);
}
Expand All @@ -92,4 +108,45 @@ async function subscribe() {
}
}
watchEffect(async () => {
console.log(`Fetching user details for: ${email.value}`);
// only fetch if topic is set
if (!email.value) return;
// redirect the user to login with the list of topics as a parameter
if (!token.value) {
console.log("No token found.");
return; // unreachable code
}
try {
const response = await fetch(USER_HANDLER_URL, {
headers: {
[TOKEN_HEADER_NAME]: token.value,
},
});
console.log(`Fetched. Status: ${response.status}`);
// a successful response should contain the saved question
// an error may contain JSON or plain text, depending on where the errror occurred
if (response.status === 200) {
try {
user.value = <User>await response.json();
console.log(user.value);
// set selected topics to user's topics
selectedTopics.value = user.value.topics;
} catch (error) {
console.error(error);
}
} else {
console.error("Failed to get user. Status: ", response.status);
}
} catch (error) {
console.error("Failed to get user.", error);
}
});
</script>
Loading

0 comments on commit 1df65ec

Please sign in to comment.