Skip to content

Commit

Permalink
feat: allow creators to reset a quest instance (#175)
Browse files Browse the repository at this point in the history
This PR: 
- adds function to DB interface to remove events that made progress on a
quest instance
- adds function to DB interface to remove a quest instance from the
completed quest instances table
- adds a new endpoint to allow the creators to reset a quest instance
state to the start
  • Loading branch information
lauti7 authored Jun 5, 2024
1 parent b715000 commit d060f80
Show file tree
Hide file tree
Showing 16 changed files with 333 additions and 53 deletions.
4 changes: 2 additions & 2 deletions crates/benchmark/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub async fn handle_client(
let auth_chain = identity.sign_payload(format!("{method}:{path}:{now}:{metadata}"));
let signed_headers = format!(
r#"{{"X-Identity-Auth-Chain-0": {}, "X-Identity-Auth-Chain-1": {}, "X-Identity-Auth-Chain-2": {}, "X-Identity-Timestamp": {}, "X-Identity-Metadata": {} }}"#,
serde_json::to_string(auth_chain.get(0).unwrap()).unwrap(),
serde_json::to_string(auth_chain.first().unwrap()).unwrap(),
serde_json::to_string(auth_chain.get(1).unwrap()).unwrap(),
serde_json::to_string(auth_chain.get(2).unwrap()).unwrap(),
now,
Expand Down Expand Up @@ -131,7 +131,7 @@ pub fn get_signed_headers(
vec![
(
"X-Identity-Auth-Chain-0".to_string(),
serde_json::to_string(authchain.get(0).unwrap()).unwrap(),
serde_json::to_string(authchain.first().unwrap()).unwrap(),
),
(
"X-Identity-Auth-Chain-1".to_string(),
Expand Down
6 changes: 6 additions & 0 deletions crates/db/src/core/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub trait QuestsDatabase: Send + Sync + CloneDatabase {
async fn complete_quest_instance(&self, quest_instance_id: &str) -> DBResult<String>;
async fn is_completed_instance(&self, quest_instance_id: &str) -> DBResult<bool>;

async fn remove_instance_from_completed_instances(
&self,
quest_instance_id: &str,
) -> DBResult<()>;

async fn get_quest_instance(&self, id: &str) -> DBResult<QuestInstance>;
async fn is_active_quest_instance(&self, quest_instance_id: &str) -> DBResult<bool>;
async fn get_active_user_quest_instances(
Expand All @@ -50,6 +55,7 @@ pub trait QuestsDatabase: Send + Sync + CloneDatabase {

async fn add_event(&self, event: &AddEvent, quest_instance_id: &str) -> DBResult<()>;
async fn get_events(&self, quest_instance_id: &str) -> DBResult<Vec<Event>>;
async fn remove_events(&self, quest_instance_id: &str) -> DBResult<()>;

async fn add_reward_hook_to_quest(
&self,
Expand Down
55 changes: 47 additions & 8 deletions crates/db/src/core/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ pub async fn quest_database_works<DB: QuestsDatabase>(db: &DB, quest: CreateQues
// creators quests should be ONE because query returns current versions (activated and deactivated) and not old versions
let quests_by_creator = db.get_quests_by_creator_id("0xA", 0, 50).await.unwrap();
assert_eq!(quests_by_creator.len(), 1);
assert_eq!(quests_by_creator.get(0).unwrap().id, new_quest_id);
assert!(quests_by_creator.get(0).unwrap().active);
assert_eq!(quests_by_creator.first().unwrap().id, new_quest_id);
assert!(quests_by_creator.first().unwrap().active);
let create_deactivated_quest = CreateQuest {
name: "DEACTIVATED_CREATOR_QUEST",
description: quest.description,
Expand All @@ -101,18 +101,17 @@ pub async fn quest_database_works<DB: QuestsDatabase>(db: &DB, quest: CreateQues
db.deactivate_quest(&deactivated_quest).await.unwrap();
// creators quests should be TWO because query returns current versions (activated and deactivated) and not old versions
let quests_by_creator = db.get_quests_by_creator_id("0xA", 0, 50).await.unwrap();
println!("quests {quests_by_creator:?}");
assert_eq!(quests_by_creator.len(), 2);
// order by desc
assert_eq!(quests_by_creator.get(0).unwrap().id, deactivated_quest);
assert!(!quests_by_creator.get(0).unwrap().active);
assert_eq!(quests_by_creator.first().unwrap().id, deactivated_quest);
assert!(!quests_by_creator.first().unwrap().active);
assert_eq!(quests_by_creator.get(1).unwrap().id, new_quest_id);
assert!(quests_by_creator.get(1).unwrap().active);

// new quest old versions
let old_versions = db.get_old_quest_versions(&new_quest_id).await.unwrap();
assert_eq!(old_versions.len(), 1);
assert_eq!(old_versions.get(0).unwrap(), &quest_id);
assert_eq!(old_versions.first().unwrap(), &quest_id);

// old quest is still there
let get_old_quest = db.get_quest(&quest_id).await.unwrap();
Expand Down Expand Up @@ -233,9 +232,49 @@ pub async fn quest_database_works<DB: QuestsDatabase>(db: &DB, quest: CreateQues

let quest_w_reward_items = db.get_quest_reward_items(&quest_w_reward_id).await.unwrap();
assert_eq!(quest_reward_items.len(), 1);
assert_eq!(quest_w_reward_items.get(0).unwrap().name, "SunGlasses");
assert_eq!(quest_w_reward_items.first().unwrap().name, "SunGlasses");
assert_eq!(
quest_w_reward_items.get(0).unwrap().image_link,
quest_w_reward_items.first().unwrap().image_link,
"https://github.com/decentraland"
);

let new_quest_instance_id = db.start_quest(&quest_id, "0xD").await.unwrap();

db.add_event(
&AddEvent {
id: uuid::Uuid::new_v4().to_string(),
user_address: "0xD",
event: vec![0],
},
&new_quest_instance_id,
)
.await
.unwrap();

let events = db.get_events(&new_quest_instance_id).await.unwrap();
assert_eq!(events.len(), 1);

db.complete_quest_instance(&new_quest_instance_id)
.await
.unwrap();

let is_completed = db
.is_completed_instance(&new_quest_instance_id)
.await
.unwrap();
assert!(is_completed);

// test remove events
db.remove_events(&new_quest_instance_id).await.unwrap();
let events = db.get_events(&new_quest_instance_id).await.unwrap();
assert_eq!(events.len(), 0);
// test remove from completed quest instances
db.remove_instance_from_completed_instances(&new_quest_instance_id)
.await
.unwrap();
let is_not_completed = db
.is_completed_instance(&new_quest_instance_id)
.await
.unwrap();
assert!(!is_not_completed);
}
23 changes: 23 additions & 0 deletions crates/db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,19 @@ impl QuestsDatabase for Database {
.map(|_| id)
}

async fn remove_instance_from_completed_instances(
&self,
quest_instance_id: &str,
) -> DBResult<()> {
sqlx::query("DELETE FROM completed_quest_instances WHERE quest_instance_id = $1")
.bind(parse_str_to_uuid(quest_instance_id)?)
.execute(&self.pool)
.await
.map_err(|err| DBError::GetQuestEventsFailed(Box::new(err)))?;

Ok(())
}

async fn is_completed_instance(&self, quest_instance_id: &str) -> DBResult<bool> {
let quest_instance_exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM completed_quest_instances WHERE quest_instance_id = $1)",
Expand Down Expand Up @@ -513,6 +526,16 @@ impl QuestsDatabase for Database {
Ok(events)
}

async fn remove_events(&self, quest_instance_id: &str) -> DBResult<()> {
sqlx::query("DELETE FROM events WHERE quest_instance_id = $1")
.bind(parse_str_to_uuid(quest_instance_id)?)
.execute(&self.pool)
.await
.map_err(|err| DBError::GetQuestEventsFailed(Box::new(err)))?;

Ok(())
}

async fn add_reward_hook_to_quest(
&self,
quest_id: &str,
Expand Down
16 changes: 8 additions & 8 deletions crates/protocol/src/quests/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ mod tests {
let tasks = &state.current_steps.get("A").unwrap().to_dos;
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "A_1".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -456,7 +456,7 @@ mod tests {
let tasks = &state.current_steps.get("A").unwrap().to_dos;
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "A_1".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -491,7 +491,7 @@ mod tests {
let tasks = &state.current_steps.get("A").unwrap().to_dos;
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "A_2".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -524,7 +524,7 @@ mod tests {
let subtasks = &state.current_steps.get("A").unwrap().to_dos;
assert_eq!(subtasks.len(), 1);
assert_eq!(
subtasks.get(0).unwrap(),
subtasks.first().unwrap(),
&Task {
id: "A_2".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -555,7 +555,7 @@ mod tests {
let tasks = &state.current_steps.get("B").unwrap().to_dos;
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "B_1".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -589,7 +589,7 @@ mod tests {
let tasks = &state.current_steps.get("B").unwrap().to_dos;
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "B_1".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -618,7 +618,7 @@ mod tests {
let tasks = &state.current_steps.get("B").unwrap().to_dos;
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "B_2".to_string(),
description: "".to_string(),
Expand Down Expand Up @@ -651,7 +651,7 @@ mod tests {
let tasks = &state.current_steps.get("B").unwrap().to_dos;
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks.get(0).unwrap(),
tasks.first().unwrap(),
&Task {
id: "B_2".to_string(),
description: "".to_string(),
Expand Down
3 changes: 1 addition & 2 deletions crates/server/src/api/middlewares/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ use dcl_crypto::Address;
use dcl_crypto_middleware_rs::signed_fetch::{verify, AuthMiddlewareError, VerificationOptions};
use std::collections::HashMap;

// This middlware is intended for routes where the auth is REQUIRED
pub fn dcl_auth_middleware<S, B>(
required_auth_routes: [&'static str; 5],
required_auth_routes: [&'static str; 6],
optional_auth_routes: [&'static str; 3],
) -> impl Transform<
S,
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub fn get_app_router(
"PUT:/api/quests/{quest_id}",
"GET:/api/quests/{quest_id}/stats",
"PUT:/api/quests/{quest_id}/activate",
"PATCH:/api/instances/{quest_instance}/reset",
],
[
"GET:/api/quests/{quest_id}",
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/api/routes/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ impl ResponseError for QuestError {
Self::QuestNotActivable => StatusCode::BAD_REQUEST,
Self::QuestIsNotUpdatable => StatusCode::BAD_REQUEST,
Self::QuestIsCurrentlyDeactivated => StatusCode::BAD_REQUEST,
Self::ResetQuestInstanceNotAllowed => StatusCode::FORBIDDEN,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/api/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod api_doc;
pub mod creators;
pub mod errors;
mod health;
pub mod quest_instances;
pub mod quests;

pub use errors::{query_extractor_config, ErrorResponse};
Expand All @@ -14,6 +15,7 @@ pub(crate) fn services(config: &mut ServiceConfig) {
let api_scope = web::scope("/api");
let api_scope = quests::services(api_scope);
let api_scope = creators::services(api_scope);
let api_scope = quest_instances::services(api_scope);
config.service(api_scope);

health::services(config);
Expand Down
7 changes: 7 additions & 0 deletions crates/server/src/api/routes/quest_instances/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
pub mod reset;

use actix_web::Scope;

pub fn services(api_scope: Scope) -> Scope {
api_scope.service(reset::reset_quest_instance)
}
40 changes: 40 additions & 0 deletions crates/server/src/api/routes/quest_instances/reset.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::{api::routes::quests::get_user_address_from_request, domain::quests};
use actix_web::{patch, web, HttpRequest, HttpResponse};
use quests_db::Database;
use quests_protocol::definitions::Quest;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct GetCreatorQuestsResponse {
pub quests: Vec<Quest>,
}

/// Reset a User's Quest Instance. It can only be executed by the Quest Creator
#[utoipa::path(
params(
("quest_instance" = String, description = "Quest Instance UUID")
),
responses(
(status = 204, description = "Quest Instance was reset"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Cannot reset a Quest Instance if you are not the Quest Creator"),
(status = 404, description = "Quest Instance not found"),
(status = 500, description = "Internal Server Error")
)
)]
#[patch("/instances/{quest_instance}/reset")]
pub async fn reset_quest_instance(
req: HttpRequest,
data: web::Data<Database>,
quest_instance: web::Path<String>,
) -> HttpResponse {
let db = data.into_inner();

let auth_user = get_user_address_from_request(&req).unwrap(); // unwrap here is safe

match quests::reset_quest_instance(db, &auth_user, &quest_instance).await {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => HttpResponse::from_error(err),
}
}
43 changes: 43 additions & 0 deletions crates/server/src/domain/quests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub enum QuestError {
QuestIsNotUpdatable,
#[error("Quest is currently deactivated")]
QuestIsCurrentlyDeactivated,
#[error("Cannot reset a Quest Instance if you are not the Quest Creator")]
ResetQuestInstanceNotAllowed,
}

pub async fn abandon_quest(
Expand Down Expand Up @@ -68,6 +70,47 @@ pub async fn start_quest(
Ok(db.start_quest(quest_id, user_address).await?)
}

pub async fn reset_quest_instance(
db: Arc<impl QuestsDatabase>,
auth_user_address: &str,
quest_instance_id: &str,
) -> Result<(), QuestError> {
match db.get_quest_instance(quest_instance_id).await {
Ok(instance) => match db.get_quest(&instance.quest_id).await {
Ok(quest) => {
if !auth_user_address.eq_ignore_ascii_case(&quest.creator_address) {
return Err(QuestError::ResetQuestInstanceNotAllowed);
}

// remove events to reset quest instance state
db.remove_events(quest_instance_id).await.map_err(|err| {
let err: QuestError = err.into();
err
})?;

db.remove_instance_from_completed_instances(quest_instance_id)
.await
.map_err(|err| {
let err: QuestError = err.into();
err
})?;

Ok(())
}
Err(err) => {
log::error!("Error getting quest: {:?}", err);
let err: QuestError = err.into();
Err(err)
}
},
Err(err) => {
log::error!("Error getting quest instance: {:?}", err);
let err: QuestError = err.into();
Err(err)
}
}
}

impl From<QuestStateCalculationError> for QuestError {
fn from(value: QuestStateCalculationError) -> Self {
match value {
Expand Down
3 changes: 1 addition & 2 deletions crates/server/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
pub mod quest_samples;
pub mod rewards;

use std::time::{SystemTime, UNIX_EPOCH};

Expand Down Expand Up @@ -125,7 +124,7 @@ pub fn get_signed_headers(
vec![
(
"X-Identity-Auth-Chain-0".to_string(),
serde_json::to_string(authchain.get(0).unwrap()).unwrap(),
serde_json::to_string(authchain.first().unwrap()).unwrap(),
),
(
"X-Identity-Auth-Chain-1".to_string(),
Expand Down
Loading

0 comments on commit d060f80

Please sign in to comment.