diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 120f7e9..3958f09 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -51,6 +51,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp"); pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp"); pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp"); pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp"); +pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp"); pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); @@ -349,6 +350,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins); write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins); write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins); + write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins); write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins); write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index bab0525..13b6b64 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -17,6 +17,7 @@ version = "1.0.0" "general:link.stats" = "Stats" "general:link.search" = "Search" "general:link.journals" = "Journals" +"general:link.achievements" = "Achievements" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index bcdb77d..75f9620 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1083,6 +1083,10 @@ ("href" "/journals/0/0") (icon (text "notebook")) (str (text "general:link.journals"))) + (a + ("href" "/achievements") + (icon (text "award")) + (str (text "general:link.achievements"))) (a ("href" "/settings") (text "{{ icon \"settings\" }}") diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp new file mode 100644 index 0000000..4b21b5d --- /dev/null +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -0,0 +1,42 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Achievements - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"achievements\") }}") +(main + ("class" "flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "coffee")) + (span (text "Welcome to {{ config.name }}!"))) + (div + ("class" "card no_p_margin") + (p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!")) + (p (text "You'll find out what each achievement is when you get it, so look around!")))) + + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center justify-between gap-2") + (span + ("class" "flex items-center gap-2") + (icon (text "award")) + (span (str (text "general:link.achievements"))))) + (div + ("class" "card lowered flex flex-col gap-4") + (text "{% for achievement in achievements %}") + (div + ("class" "w-full card-nest") + (div + ("class" "card small flex items-center gap-2 {% if achievement[2] == 'Uncommon' -%} green {%- elif achievement[2] == 'Rare' -%} purple {%- endif %}") + (icon (text "award")) + (text "{{ achievement[0] }}")) + (div + ("class" "card flex flex-col gap-2") + (span ("class" "no_p_margin") (text "{{ achievement[1]|markdown|safe }}")) + (hr) + (span ("class" "fade") (text "Unlocked: ") (span ("class" "date") (text "{{ achievement[3].unlocked }}"))))) + (text "{% endfor %}")))) +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 3ee8b54..baf2f54 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -21,7 +21,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, model::{ - auth::{InviteCode, Token, UserSettings}, + auth::{AchievementName, InviteCode, Token, UserSettings}, moderation::AuditLogEntry, oauth, permissions::FinePermission, @@ -151,6 +151,14 @@ pub async fn update_user_settings_request( req.theme_lit = format!("{}%", req.theme_lit) } + // award achievement + if let Err(e) = data + .add_achievement(&user, AchievementName::EditSettings.into()) + .await + { + return Json(e.into()); + } + // ... match data.update_user_settings(id, req).await { Ok(_) => Json(ApiReturn { diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index cef005c..a507e49 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -11,7 +11,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; use tetratto_core::model::{ - auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow}, + auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, oauth, }; @@ -59,6 +59,13 @@ pub async fn follow_request( return Json(e.into()); }; + if let Err(e) = data + .add_achievement(&user, AchievementName::FollowUser.into()) + .await + { + return Json(e.into()); + } + Json(ApiReturn { ok: true, message: "User followed".to_string(), diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 70529ed..e1fb89e 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -7,6 +7,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, + auth::AchievementName, communities::{Poll, PollVote, Post}, oauth, permissions::FinePermission, @@ -178,6 +179,41 @@ pub async fn create_request( } } + // achievements + if let Err(e) = data + .add_achievement(&user, AchievementName::CreatePost.into()) + .await + { + return Json(e.into()); + } + + if user.post_count >= 49 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create50Posts.into()) + .await + { + return Json(e.into()); + } + } + + if user.post_count >= 99 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create100Posts.into()) + .await + { + return Json(e.into()); + } + } + + if user.post_count >= 999 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create1000Posts.into()) + .await + { + return Json(e.into()); + } + } + // return Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 5e9de0d..c469512 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -7,7 +7,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, - auth::IpBlock, + auth::{AchievementName, IpBlock}, communities::{CommunityReadAccess, Question}, oauth, permissions::FinePermission, @@ -50,6 +50,16 @@ pub async fn create_request( return Json(Error::NotAllowed.into()); } + // award achievement + if let Some(ref user) = user { + if let Err(e) = data + .add_achievement(user, AchievementName::CreateQuestion.into()) + .await + { + return Json(e.into()); + } + } + // ... let mut props = Question::new( if let Some(ref ua) = user { ua.id } else { 0 }, diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 45ac04f..e2f9404 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -15,6 +15,7 @@ use crate::{ use tetratto_core::{ database::NAME_REGEX, model::{ + auth::AchievementName, journals::{Journal, JournalPrivacyPermission}, oauth, permissions::FinePermission, @@ -106,11 +107,22 @@ pub async fn create_request( .create_journal(Journal::new(user.id, props.title)) .await { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Journal created".to_string(), - payload: Some(x.id.to_string()), - }), + Ok(x) => { + // award achievement + if let Err(e) = data + .add_achievement(&user, AchievementName::CreateJournal.into()) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "Journal created".to_string(), + payload: Some(x.id.to_string()), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index f11c116..44f54aa 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -441,6 +441,34 @@ pub async fn requests_request( Ok(Html(data.1.render("misc/requests.html", &context).unwrap())) } +/// `/achievements` +pub async fn achievements_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let achievements = data.0.fill_achievements(user.achievements.clone()); + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("achievements", &achievements); + + // return + Ok(Html( + data.1.render("misc/achievements.html", &context).unwrap(), + )) +} + /// `/doc/{file_name}` pub async fn markdown_document_request( jar: CookieJar, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index a2ca470..909fa2d 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -45,6 +45,7 @@ pub fn routes() -> Router { // misc .route("/notifs", get(misc::notifications_request)) .route("/requests", get(misc::requests_request)) + .route("/achievements", get(misc::achievements_request)) .route("/doc/{*file_name}", get(misc::markdown_document_request)) .fallback_service(get(misc::not_found)) // mod diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4037db7..4baeef0 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,6 +1,6 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::UserConnections; +use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; use crate::model::permissions::SecondaryPermission; @@ -111,6 +111,7 @@ impl DataManager { associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(), invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), + achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), } } @@ -266,7 +267,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)", params![ &(data.id as i64), &(data.created as i64), @@ -291,6 +292,7 @@ impl DataManager { &serde_json::to_string(&data.associated).unwrap(), &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), + &serde_json::to_string(&data.achievements).unwrap(), ] ); @@ -707,6 +709,66 @@ impl DataManager { Ok(()) } + /// Add an achievement to a user. + /// + /// Still returns `Ok` if the user already has the achievement. + pub async fn add_achievement(&self, user: &User, achievement: Achievement) -> Result<()> { + if user + .achievements + .iter() + .find(|x| x.name == achievement.name) + .is_some() + { + return Ok(()); + } + + // send notif + self.create_notification(Notification::new( + "You've earned a new achievement!".to_string(), + format!( + "You've earned the \"{}\" [achievement](/achievements)!", + achievement.name.title() + ), + user.id, + )) + .await?; + + // add achievement + let mut user = user.clone(); + user.achievements.push(achievement); + self.update_user_achievements(user.id, user.achievements) + .await?; + + Ok(()) + } + + /// Fill achievements with their title and description. + /// + /// # Returns + /// `(name, description, rarity, achievement)` + pub fn fill_achievements( + &self, + mut list: Vec, + ) -> Vec<(String, String, AchievementRarity, Achievement)> { + let mut out = Vec::new(); + + // sort by unlocked desc + list.sort_by(|a, b| a.unlocked.cmp(&b.unlocked)); + list.reverse(); + + // ... + for x in list { + out.push(( + x.name.title().to_string(), + x.name.description().to_string(), + x.name.rarity(), + x, + )) + } + + out + } + /// Validate a given TOTP code for the given profile. pub fn check_totp(&self, ua: &User, code: &str) -> bool { let totp = ua.totp(Some( @@ -857,6 +919,7 @@ impl DataManager { auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 467d00a..9cb0851 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -20,5 +20,6 @@ CREATE TABLE IF NOT EXISTS users ( stripe_id TEXT NOT NULL, grants TEXT NOT NULL, associated TEXT NOT NULL, - secondary_permissions INT NOT NULL + secondary_permissions INT NOT NULL, + achievements TEXT NOT NULL ) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index d38a576..4617770 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -58,6 +58,9 @@ pub struct User { /// Secondary permissions because the regular permissions struct ran out of possible bits. #[serde(default)] pub secondary_permissions: SecondaryPermission, + /// Users collect achievements through little actions across the site. + #[serde(default)] + pub achievements: Vec, } pub type UserConnections = @@ -297,6 +300,7 @@ impl User { associated: Vec::new(), invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, + achievements: Vec::new(), } } @@ -470,6 +474,92 @@ pub struct ExternalConnectionData { pub data: HashMap, } +/// The total number of achievements needed to 100% Tetratto! +pub const ACHIEVEMENTS: usize = 8; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AchievementName { + /// Create your first post. + CreatePost, + /// Follow somebody. + FollowUser, + /// Create your 50th post. + Create50Posts, + /// Create your 100th post. + Create100Posts, + /// Create your 1000th post. + Create1000Posts, + /// Ask your first question. + CreateQuestion, + /// Edit your settings. + EditSettings, + /// Create your first journal. + CreateJournal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AchievementRarity { + Common, + Uncommon, + Rare, +} + +impl AchievementName { + pub fn title(&self) -> &str { + match self { + Self::CreatePost => "Dear friends,", + Self::FollowUser => "Virtual connections...", + Self::Create50Posts => "Hello, world!", + Self::Create100Posts => "It's my world", + Self::Create1000Posts => "Timeline domination", + Self::CreateQuestion => "Big questions...", + Self::EditSettings => "Just how I like it!", + Self::CreateJournal => "Dear diary...", + } + } + + pub fn description(&self) -> &str { + match self { + Self::CreatePost => "Create your first post!", + Self::FollowUser => "Follow somebody!", + Self::Create50Posts => "Create your 50th post.", + Self::Create100Posts => "Create your 100th post.", + Self::Create1000Posts => "Create your 1000th post.", + Self::CreateQuestion => "Ask your first question!", + Self::EditSettings => "Edit your settings.", + Self::CreateJournal => "Create your first journal.", + } + } + + pub fn rarity(&self) -> AchievementRarity { + match self { + Self::CreatePost => AchievementRarity::Common, + Self::FollowUser => AchievementRarity::Common, + Self::Create50Posts => AchievementRarity::Uncommon, + Self::Create100Posts => AchievementRarity::Uncommon, + Self::Create1000Posts => AchievementRarity::Rare, + Self::CreateQuestion => AchievementRarity::Common, + Self::EditSettings => AchievementRarity::Common, + Self::CreateJournal => AchievementRarity::Uncommon, + } + } +} + +impl Into for AchievementName { + fn into(self) -> Achievement { + Achievement { + name: self, + unlocked: unix_epoch_timestamp(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Achievement { + pub name: AchievementName, + pub unlocked: usize, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Notification { pub id: usize, diff --git a/sql_changes/users_achievements.sql b/sql_changes/users_achievements.sql new file mode 100644 index 0000000..2eadcf4 --- /dev/null +++ b/sql_changes/users_achievements.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN achievements TEXT NOT NULL DEFAULT '[]';