add: ProfileStyle products

This commit is contained in:
trisua 2025-08-08 23:44:45 -04:00
parent 077e9252e3
commit 95cb889080
19 changed files with 525 additions and 54 deletions

View file

@ -208,6 +208,7 @@ version = "1.0.0"
"settings:tab.billing" = "Billing"
"settings:tab.uploads" = "Uploads"
"settings:tab.invites" = "Invites"
"setttings:label.applied_configurations" = "Applied configurations"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"
@ -345,3 +346,6 @@ version = "1.0.0"
"economy:label.automail_message" = "Automail message"
"economy:action.buy" = "Buy"
"economy:label.already_purchased" = "Already purchased"
"economy:label.snippet_data" = "Snippet data"
"economy:action.apply" = "Apply"
"economy:action.unapply" = "Unapply"

View file

@ -2491,6 +2491,8 @@
(text "Create infinite Littleweb sites"))
(li
(text "Create infinite Littleweb domains"))
(li
(text "Create and sell CSS snippet products"))
(text "{% if config.security.enable_invite_codes -%}")
(li

View file

@ -150,35 +150,60 @@
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(form
(div
("class" "card flex flex_col gap_2")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(select
("id" "fulfillment_style_select")
("onchange" "mirror_fulfillment_style_select(true)")
(option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}"))
(option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}")))
(form
("class" "flex flex_col gap_2 hidden")
("id" "mail_fulfillment")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}")
(label
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save")))))
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save"))))
(form
("class" "flex flex_col gap_2 hidden")
("id" "snippet_fulfillment")
("onsubmit" "update_data_from_form(event)")
(text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}")
(div
("class" "flex flex_col gap_1")
(label
("for" "data")
(str (text "economy:label.snippet_data")))
(textarea
("name" "data")
("id" "data")
("placeholder" "data")
(text "{{ product.data }}")))
(button (str (text "general:action.save"))))))
(div
("class" "flex gap_2")
@ -347,6 +372,28 @@
});
}
async function update_data_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/data\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
data: e.target.data.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function delete_product() {
if (
!(await trigger(\"atto::confirm\", [
@ -378,7 +425,46 @@
}
}
globalThis.mirror_fulfillment_style_select = (send = false) => {
const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value;
if (selected === \"mail\") {
document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\");
if (send) {
update_method_from_form({
preventDefault: () => {},
target: document.getElementById(\"mail_fulfillment\"),
});
}
} else {
document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\");
if (send) {
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: \"ProfileStyle\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
}
}
setTimeout(() => {
mirror_use_automail();
mirror_fulfillment_style_select();
}, 150);"))
(text "{% endblock %}")

View file

@ -19,26 +19,44 @@
("class" "card lowered w_full no_p_margin")
(text "{{ product.description|markdown|safe }}"))
(text "{% if already_purchased -%}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
(text "{%- endif %}")
(div
("class" "flex gap_2 items_center")
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; price
(a
("class" "button camo lowered")
("href" "/wallet")
("target" "_blank")
(icon (text "badge-cent"))
(text "{{ product.price }}"))
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; buy button
(button
("onclick" "purchase()")
("disabled" "{{ product.stock == 0 }}")
(icon (text "piggy-bank"))
(str (text "economy:action.buy")))
(text "{% else %}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
; profile style snippets
(text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}")
(button
("onclick" "apply()")
(icon (text "check"))
(str (text "economy:action.apply")))
(text "{% else %}")
(button
("onclick" "remove()")
(icon (text "x"))
(str (text "economy:action.unapply")))
(text "{%- endif %} {%- endif %}")
; ...
(text "{%- endif %}")
(text "{% else %}")
(a
@ -69,6 +87,59 @@
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function apply() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"type\": \"StyleSnippet\",
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function remove() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{% endblock %}")

View file

@ -468,6 +468,12 @@
("class" "rhs w_full flex flex_col gap_4")
(text "{% block content %}{% endblock %}")))))
(text "{% if not use_user_theme -%}")
(text "{% for cnf in applied_configurations -%}")
(text "{{ cnf|safe }}")
(text "{%- endfor %}")
(text "{%- endif %}")
(text "{% if not is_self and profile.settings.warning -%}")
(script
(text "setTimeout(() => {

View file

@ -1162,6 +1162,26 @@
("class" "fade")
(text "This represents the site theme shown to users viewing
your profile.")))))
(text "{% if profile.applied_configurations|length > 0 -%}")
(div
("class" "card_nest")
("ui_ident" "applied_configurations")
(div
("class" "card small flex items_center gap_2")
(icon (text "cog"))
(str (text "setttings:label.applied_configurations")))
(div
("class" "card")
(p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time."))
(ul
(text "{% for cnf in profile.applied_configurations -%}")
(li
(text "{{ cnf[0] }} ")
(a
("href" "/product/{{ cnf[1] }}")
(text "{{ cnf[1] }}")))
(text "{%- endfor %}"))))
(text "{%- endif %}")
(button
("onclick" "save_settings()")
("id" "save_button")
@ -1742,6 +1762,7 @@
\"import_export\",
\"theme_preference\",
\"profile_theme\",
\"applied_configurations\",
]);
ui.generate_settings_ui(

View file

@ -3,10 +3,11 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire,
UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp,
RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole,
UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode,
UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
UpdateUserUsername,
},
State,
};
@ -24,6 +25,7 @@ use tetratto_core::{
cache::Cache,
model::{
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
economy::CoinTransferMethod,
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
@ -180,6 +182,106 @@ pub async fn update_user_settings_request(
}
}
/// Add the given applied configuration.
pub async fn add_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<AddAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
let product = match data.get_product_by_id(product_id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if data
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_err()
{
return Json(Error::NotAllowed.into());
}
// update
user.applied_configurations.push((req.r#type, product.id));
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Remove the given applied configuration.
pub async fn remove_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<RemoveAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// update
user.applied_configurations.remove(
match user
.applied_configurations
.iter()
.position(|x| x.1 == product_id)
{
Some(x) => x,
None => return Json(Error::GeneralNotFound("configuration".to_string()).into()),
},
);
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Append associations to the current user.
pub async fn append_associations_request(
jar: CookieJar,

View file

@ -25,7 +25,7 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
auth::{AchievementName, AppliedConfigType},
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
@ -333,6 +333,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request),
)
.route(
"/auth/user/{id}/applied_configuration",
post(auth::profile::add_applied_configuration_request),
)
.route(
"/auth/user/{id}/applied_configuration",
delete(auth::profile::remove_applied_configuration_request),
)
.route(
"/auth/user/{id}/role",
post(auth::profile::update_user_role_request),
@ -728,6 +736,7 @@ pub fn routes() -> Router {
"/products/{id}/description",
post(products::update_description_request),
)
.route("/products/{id}/data", post(products::update_data_request))
.route(
"/products/{id}/on_sale",
post(products::update_on_sale_request),
@ -1274,6 +1283,11 @@ pub struct UpdateProductDescription {
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductData {
pub data: String,
}
#[derive(Deserialize)]
pub struct UpdateProductOnSale {
pub on_sale: bool,
@ -1298,3 +1312,14 @@ pub struct UpdateProductMethod {
pub struct UpdateProductStock {
pub stock: i32,
}
#[derive(Deserialize)]
pub struct AddAppliedConfiguration {
pub r#type: AppliedConfigType,
pub id: String,
}
#[derive(Deserialize)]
pub struct RemoveAppliedConfiguration {
pub id: String,
}

View file

@ -1,9 +1,15 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error};
use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod},
oauth,
permissions::FinePermission,
ApiReturn, Error,
};
use super::{
CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale,
UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle,
CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod,
UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock,
UpdateProductTitle,
};
pub async fn create_request(
@ -112,6 +118,33 @@ pub async fn update_description_request(
}
}
pub async fn update_data_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(mut req): Json<UpdateProductData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
req.data = req.data.trim().to_string();
if req.data.len() > 16384 {
return Json(Error::DataTooLong("data".to_string()).into());
}
match data.update_product_data(id, &user, &req.data).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_on_sale_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -205,6 +238,12 @@ pub async fn update_method_request(
None => return Json(Error::NotAllowed.into()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle
&& !user.permissions.check(FinePermission::SUPPORTER)
{
return Json(Error::RequiresSupporter.into());
}
match data.update_product_method(id, &user, req.method).await {
Ok(_) => Json(ApiReturn {
ok: true,

View file

@ -147,12 +147,19 @@ pub async fn product_request(
false
};
let applied_configurations_mapped: Vec<usize> =
user.applied_configurations.iter().map(|x| x.1).collect();
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("product", &product);
context.insert("owner", &owner);
context.insert("already_purchased", &already_purchased);
context.insert(
"applied_configurations_mapped",
&applied_configurations_mapped,
);
// return
Ok(Html(

View file

@ -232,6 +232,7 @@ pub fn profile_context(
user: &Option<User>,
profile: &User,
communities: &Vec<Community>,
applied_configurations: Vec<String>,
is_self: bool,
is_following: bool,
is_following_you: bool,
@ -244,6 +245,7 @@ pub fn profile_context(
context.insert("is_following_you", &is_following_you);
context.insert("is_blocking", &is_blocking);
context.insert("warning_hash", &hash(profile.settings.warning.clone()));
context.insert("applied_configurations", &applied_configurations);
context.insert(
"is_supporter",
@ -376,6 +378,10 @@ pub async fn posts_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -492,6 +498,10 @@ pub async fn replies_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -604,6 +614,10 @@ pub async fn media_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -690,9 +704,13 @@ pub async fn shop_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -784,9 +802,13 @@ pub async fn outbox_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -896,6 +918,10 @@ pub async fn following_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -1005,6 +1031,10 @@ pub async fn followers_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,

View file

@ -1,15 +1,15 @@
use super::common::NAME_REGEX;
use oiseau::cache::Cache;
use crate::model::auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS,
};
use crate::model::moderation::AuditLogEntry;
use crate::model::oauth::AuthGrant;
use crate::model::permissions::SecondaryPermission;
use crate::model::{
Error, Result,
auth::{Token, User, UserSettings},
permissions::FinePermission,
permissions::{FinePermission, SecondaryPermission},
oauth::AuthGrant,
moderation::AuditLogEntry,
auth::{
Achievement, AchievementName, AchievementRarity, Notification, UserConnections,
ACHIEVEMENTS, AppliedConfigType,
},
};
use pathbufd::PathBufD;
use std::fs::{exists, remove_file};
@ -130,6 +130,7 @@ impl DataManager {
ban_expire: get!(x->30(i64)) as usize,
coins: get!(x->31(i32)),
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(),
applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(),
}
}
@ -286,7 +287,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, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)",
"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, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)",
params![
&(data.id as i64),
&(data.created as i64),
@ -321,6 +322,7 @@ impl DataManager {
&(data.ban_expire as i64),
&(data.coins as i32),
&serde_json::to_string(&data.checkouts).unwrap(),
&serde_json::to_string(&data.applied_configurations).unwrap(),
]
);
@ -1091,6 +1093,29 @@ impl DataManager {
Ok((totp.get_secret_base32(), qr, recovery))
}
/// Get all applied configurations as a vector of strings from the given user.
pub async fn get_applied_configurations(&self, user: &User) -> Result<Vec<String>> {
let mut out = Vec::new();
for config in &user.applied_configurations {
let product = match self.get_product_by_id(config.1).await {
Ok(x) => x,
Err(_) => continue,
};
out.push(match config.0 {
AppliedConfigType::StyleSnippet => {
format!(
"<style>{}</style>",
product.data.replace("<", "&lt;").replace(">", "&gt;")
)
}
})
}
Ok(out)
}
pub async fn cache_clear_user(&self, user: &User) {
self.0.1.remove(format!("atto.user:{}", user.id)).await;
self.0
@ -1119,6 +1144,7 @@ impl DataManager {
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_checkouts(Vec<String>)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
auto_method!(update_user_applied_configurations(Vec<(AppliedConfigType, usize)>)@get_user_by_id -> "UPDATE users SET applied_configurations = $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);

View file

@ -8,5 +8,6 @@ CREATE TABLE IF NOT EXISTS products (
on_sale INT NOT NULL,
price INT NOT NULL,
stock INT NOT NULL,
single_use INT NOT NULL
single_use INT NOT NULL,
data TEXT NOT NULL
)

View file

@ -31,5 +31,6 @@ CREATE TABLE IF NOT EXISTS users (
is_deactivated INT NOT NULL,
ban_expire BIGINT NOT NULL,
coins INT NOT NULL,
checkouts TEXT NOT NULL
checkouts TEXT NOT NULL,
applied_configurations TEXT NOT NULL
)

View file

@ -53,3 +53,11 @@ ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
-- transfers source
ALTER TABLE transfers
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
-- products single_use
ALTER TABLE products
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
-- users applied_configurations
ALTER TABLE users
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';

View file

@ -24,6 +24,7 @@ impl DataManager {
price: get!(x->7(i32)),
stock: get!(x->8(i32)),
single_use: get!(x->9(i32)) as i8 == 1,
data: get!(x->10(String)),
}
}
@ -106,7 +107,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
params![
&(data.id as i64),
&(data.created as i64),
@ -118,6 +119,7 @@ impl DataManager {
&data.price,
&(data.stock as i32),
&{ if data.single_use { 1 } else { 0 } },
&data.data,
]
);
@ -219,6 +221,21 @@ If your product is a purchase of goods or services, please be sure to fulfill th
// return
Ok(transfer)
}
ProductFulfillmentMethod::ProfileStyle => {
// pretty much an automail without the message
self.create_transfer(&mut transfer, true).await?;
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
"You've purchased a CSS snippet which can be applied to your profile through the product's page!".to_string(),
0,
))
.await?;
Ok(transfer)
}
}
}
@ -253,6 +270,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th
auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr);

View file

@ -29,7 +29,14 @@ impl DataManager {
.get(format!("atto.request:{}:{}", id, linked_asset))
.await
{
return Ok(serde_json::from_str(&cached).unwrap());
if let Ok(x) = serde_json::from_str(&cached) {
return Ok(x);
} else {
self.0
.1
.remove(format!("atto.request:{}:{}", id, linked_asset))
.await;
}
}
let conn = match self.0.connect().await {

View file

@ -101,11 +101,20 @@ pub struct User {
/// already applied this purchase.
#[serde(default)]
pub checkouts: Vec<String>,
/// The IDs of products to be applied to the user's profile.
#[serde(default)]
pub applied_configurations: Vec<(AppliedConfigType, usize)>,
}
pub type UserConnections =
HashMap<ConnectionService, (ExternalConnectionInfo, ExternalConnectionData)>;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum AppliedConfigType {
/// An HTML `<style>` snippet.
StyleSnippet,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ThemePreference {
Auto,
@ -417,6 +426,7 @@ impl User {
ban_expire: 0,
coins: 0,
checkouts: Vec::new(),
applied_configurations: Vec::new(),
}
}

View file

@ -10,6 +10,10 @@ pub enum ProductFulfillmentMethod {
///
/// This will leave the [`CoinTransfer`] pending until you send this mail.
ManualMail,
/// A CSS snippet which can be applied to user profiles.
///
/// Only supporters can create products like this.
ProfileStyle,
}
#[derive(Clone, Serialize, Deserialize)]
@ -31,6 +35,8 @@ pub struct Product {
pub stock: i32,
/// If this product is limited to one purchase per person.
pub single_use: bool,
/// Data for this product. Only used by snippets.
pub data: String,
}
impl Product {
@ -47,6 +53,7 @@ impl Product {
price: 0,
stock: 0,
single_use: true,
data: String::new(),
}
}
}