You've already forked AstralRinth
forked from didirus/AstralRinth
Add affiliate code revenue analytics (#4883)
* Add affiliate code revenue analytics * clean up some error handling * Add conversions to affiliate code analytics * Only include affiliate subscriptions which have an associated successful charge * wip: affiliate code clicks * affiliate code click ingest route * Add affiliate code clicks to analytics * add new cols
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::{collections::HashMap, net::Ipv4Addr, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
@@ -5,38 +7,148 @@ use crate::{
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::AffiliateCodeId,
|
||||
pats::Scopes,
|
||||
users::Badges,
|
||||
v3::affiliate_code::{AdminAffiliateCode, AffiliateCode},
|
||||
analytics::AffiliateCodeClick, ids::AffiliateCodeId, pats::Scopes,
|
||||
users::Badges, v3::affiliate_code::AffiliateCode,
|
||||
},
|
||||
queue::{analytics::AnalyticsQueue, session::AuthQueue},
|
||||
routes::analytics::FILTERED_HEADERS,
|
||||
util::{
|
||||
date::get_current_tenths_of_ms, env::parse_strings_from_var,
|
||||
error::Context,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
};
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, put, web};
|
||||
use ariadne::ids::UserId;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tracing::trace;
|
||||
use url::Url;
|
||||
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("affiliate")
|
||||
.route("", web::get().to(get_all))
|
||||
.route("", web::put().to(create))
|
||||
.route("/{id}", web::get().to(get))
|
||||
.route("/{id}", web::delete().to(delete))
|
||||
.route("/{id}", web::patch().to(patch)),
|
||||
);
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(ingest_click)
|
||||
.service(get_all)
|
||||
.service(create)
|
||||
.service(get)
|
||||
.service(delete)
|
||||
.service(patch);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct IngestClick {
|
||||
pub url: Url,
|
||||
pub affiliate_code_id: AffiliateCodeId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/ingest-click")]
|
||||
async fn ingest_click(
|
||||
req: HttpRequest,
|
||||
web::Json(ingest_click): web::Json<IngestClick>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await
|
||||
.map(|(_, user)| user)
|
||||
.ok();
|
||||
let conn_info = req.connection_info().peer_addr().map(|x| x.to_string());
|
||||
|
||||
let url = ingest_click.url;
|
||||
let domain = url.host_str().ok_or_else(|| {
|
||||
ApiError::InvalidInput("invalid page view URL specified!".to_string())
|
||||
})?;
|
||||
let url_origin = url.origin().ascii_serialization();
|
||||
|
||||
let is_valid_url_origin =
|
||||
parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|origin| origin == "*" || url_origin == *origin);
|
||||
|
||||
if !is_valid_url_origin {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"invalid page view URL specified!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let exists = sqlx::query!(
|
||||
"
|
||||
SELECT 1 AS exists FROM affiliate_codes WHERE id = $1
|
||||
",
|
||||
DBAffiliateCodeId::from(ingest_click.affiliate_code_id) as _
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to check if code exists")?;
|
||||
if exists.is_none() {
|
||||
// don't allow enumerating affiliate codes
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let headers = req
|
||||
.headers()
|
||||
.into_iter()
|
||||
.map(|(key, val)| {
|
||||
(
|
||||
key.to_string().to_lowercase(),
|
||||
val.to_str().unwrap_or_default().to_string(),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<String, String>>();
|
||||
|
||||
let ip = crate::util::ip::convert_to_ip_v6(
|
||||
if let Some(header) = headers.get("cf-connecting-ip") {
|
||||
header
|
||||
} else {
|
||||
conn_info.as_deref().unwrap_or_default()
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped());
|
||||
|
||||
let click = AffiliateCodeClick {
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
domain: domain.to_string(),
|
||||
user_id: user.map(|user| user.id.0).unwrap_or_default(),
|
||||
affiliate_code_id: ingest_click.affiliate_code_id.0,
|
||||
ip,
|
||||
country: headers
|
||||
.get("cf-ipcountry")
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or_default(),
|
||||
user_agent: headers.get("user-agent").cloned().unwrap_or_default(),
|
||||
headers: headers
|
||||
.into_iter()
|
||||
.filter(|x| !FILTERED_HEADERS.contains(&&*x.0))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
trace!("Ingested affiliate code click {click:?}");
|
||||
analytics_queue.add_affiliate_code_click(click);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(Vec<AffiliateCode>)))
|
||||
)]
|
||||
#[get("")]
|
||||
async fn get_all(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<web::Json<Vec<AffiliateCode>>, ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -47,21 +159,24 @@ async fn get_all(
|
||||
.await?;
|
||||
|
||||
if user.role.is_admin() {
|
||||
let codes = DBAffiliateCode::get_all(&**pool).await?;
|
||||
let codes = DBAffiliateCode::get_all(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to get all affiliate codes")?;
|
||||
let codes = codes
|
||||
.into_iter()
|
||||
.map(AdminAffiliateCode::from)
|
||||
.map(|code| AffiliateCode::from(code, true))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(HttpResponse::Ok().json(codes))
|
||||
Ok(web::Json(codes))
|
||||
} else if user.badges.contains(Badges::AFFILIATE) {
|
||||
let codes =
|
||||
DBAffiliateCode::get_by_affiliate(DBUserId::from(user.id), &**pool)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to get all affiliate codes")?;
|
||||
let codes = codes
|
||||
.into_iter()
|
||||
.map(AffiliateCode::from)
|
||||
.map(|code| AffiliateCode::from(code, false))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(HttpResponse::Ok().json(codes))
|
||||
Ok(web::Json(codes))
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to view affiliate codes!".to_string(),
|
||||
@@ -69,19 +184,23 @@ async fn get_all(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateRequest {
|
||||
affiliate: Option<UserId>,
|
||||
source_name: String,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateRequest {
|
||||
pub affiliate: Option<UserId>,
|
||||
pub source_name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(AffiliateCode)))
|
||||
)]
|
||||
#[put("")]
|
||||
async fn create(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<CreateRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<web::Json<AffiliateCode>, ApiError> {
|
||||
let (_, creator) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -135,24 +254,29 @@ async fn create(
|
||||
affiliate: affiliate_id,
|
||||
source_name: body.source_name.clone(),
|
||||
};
|
||||
code.insert(&mut *transaction).await?;
|
||||
code.insert(&mut *transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert affiliate code")?;
|
||||
|
||||
transaction.commit().await?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
if is_admin {
|
||||
Ok(HttpResponse::Created().json(AdminAffiliateCode::from(code)))
|
||||
} else {
|
||||
Ok(HttpResponse::Created().json(AffiliateCode::from(code)))
|
||||
}
|
||||
Ok(web::Json(AffiliateCode::from(code, is_admin)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(AffiliateCode)))
|
||||
)]
|
||||
#[get("/{id}")]
|
||||
async fn get(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<web::Json<AffiliateCode>, ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -172,11 +296,7 @@ async fn get(
|
||||
let is_owner = model.affiliate == DBUserId::from(user.id);
|
||||
|
||||
if is_admin || is_owner {
|
||||
if is_admin {
|
||||
Ok(HttpResponse::Ok().json(AdminAffiliateCode::from(model)))
|
||||
} else {
|
||||
Ok(HttpResponse::Ok().json(AffiliateCode::from(model)))
|
||||
}
|
||||
Ok(web::Json(AffiliateCode::from(model, is_admin)))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
@@ -185,13 +305,15 @@ async fn get(
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}")]
|
||||
async fn delete(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<(), ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -214,7 +336,7 @@ async fn delete(
|
||||
let result =
|
||||
DBAffiliateCode::remove(affiliate_code_id, &**pool).await?;
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
@@ -226,11 +348,13 @@ async fn delete(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchRequest {
|
||||
source_name: String,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PatchRequest {
|
||||
pub source_name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[patch("/{id}")]
|
||||
async fn patch(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
@@ -238,7 +362,7 @@ async fn patch(
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<PatchRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<(), ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
@@ -273,7 +397,8 @@ async fn patch(
|
||||
&body.source_name,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to update affiliate code source name")?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config)
|
||||
.configure(external_notifications::config)
|
||||
.configure(affiliate::config)
|
||||
.configure(mural::config),
|
||||
);
|
||||
}
|
||||
@@ -43,5 +42,10 @@ pub fn utoipa_config(
|
||||
utoipa_actix_web::scope("/_internal/moderation")
|
||||
.wrap(default_cors())
|
||||
.configure(moderation::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/affiliate")
|
||||
.wrap(default_cors())
|
||||
.configure(affiliate::config),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user