Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
|
|
@ -1,4 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
|
@ -7,9 +8,39 @@ use axum::{Extension, Json};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::auth::{OptionalUser, PocketBaseUser};
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::state::SharedState;
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
static INVITE_REDEMPTIONS_IN_PROGRESS: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
struct InviteRedemptionGuard {
|
||||
code: String,
|
||||
}
|
||||
|
||||
impl InviteRedemptionGuard {
|
||||
fn acquire(code: &str) -> Option<Self> {
|
||||
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !in_progress.insert(code.to_string()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
code: code.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InviteRedemptionGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
in_progress.remove(&self.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteResponse {
|
||||
|
|
@ -87,6 +118,207 @@ fn generate_invite_code() -> String {
|
|||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
fn current_unix_secs_string() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn lookup_unused_invite(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
code: &str,
|
||||
) -> Result<Option<serde_json::Value>, Response> {
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
warn!("PocketBase invite lookup failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse invite lookup response: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(body["items"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn mark_invite_used(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
invite_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), Response> {
|
||||
let resp = match state
|
||||
.http_client
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user_id,
|
||||
"used_at": current_unix_secs_string(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to mark invite as used: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase invite usage update failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn grant_license_for_invite(
|
||||
state: &AppState,
|
||||
pb_url: &str,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), Response> {
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let resp = match state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to update user subscription for admin invite: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("PocketBase user subscription update failed ({status}): {text}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_referral_checkout(
|
||||
state: &AppState,
|
||||
user: &PocketBaseUser,
|
||||
) -> Result<String, Response> {
|
||||
let count = match super::pricing::count_licensed_users(state).await {
|
||||
Ok(count) => count,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
(
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(&state.stripe_secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = match resp.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse Stripe checkout response: {err}");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
};
|
||||
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
|
||||
if checkout_url.is_empty() {
|
||||
warn!("Stripe checkout response did not include a URL");
|
||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||
}
|
||||
Ok(checkout_url)
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Failed to create Stripe checkout for referral invite ({status}): {text}");
|
||||
Err(StatusCode::BAD_GATEWAY.into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Stripe request error for referral invite: {err}");
|
||||
Err(StatusCode::BAD_GATEWAY.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license) by default,
|
||||
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
|
|
@ -319,154 +551,80 @@ pub async fn post_redeem_invite(
|
|||
}
|
||||
};
|
||||
|
||||
// Look up invite
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
let _redemption_guard = match InviteRedemptionGuard::acquire(&req.code) {
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
"Invite redemption is already in progress",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
|
||||
Ok(Some(invite)) => invite,
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
let invite_id = match invite["id"].as_str().filter(|id| !id.is_empty()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
warn!(code = %req.code, "Invite lookup returned record without id");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
let invite_type = match invite["invite_type"].as_str() {
|
||||
Some("admin") => "admin",
|
||||
Some("referral") => "referral",
|
||||
Some(other) => {
|
||||
warn!(code = %req.code, invite_type = other, "Invite has unsupported type");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
None => {
|
||||
warn!(code = %req.code, "Invite lookup returned record without invite_type");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
|
||||
Some(inv) => inv.clone(),
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let invite_id = invite["id"].as_str().unwrap_or("");
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("");
|
||||
|
||||
// Mark invite as used
|
||||
let now = {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
dur.as_secs().to_string()
|
||||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user.id,
|
||||
"used_at": now,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if invite_type == "admin" {
|
||||
// Grant license directly
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
|
||||
Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to update user subscription for admin invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
} else {
|
||||
// Referral invite — create discounted checkout with dynamic pricing
|
||||
let count = match super::pricing::count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
(
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to create Stripe checkout for referral invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed; user licensed");
|
||||
return Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let checkout_url = match create_referral_checkout(&state, &user).await {
|
||||
Ok(url) => url,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed; checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// List invites. Admins see all invites; licensed users see only their own.
|
||||
/// List invites. Users only see invites they created, including admins.
|
||||
pub async fn get_invites(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
|
|
@ -487,16 +645,9 @@ pub async fn get_invites(
|
|||
}
|
||||
};
|
||||
|
||||
let filter = if user.is_admin {
|
||||
String::new()
|
||||
} else {
|
||||
format!("created_by=\"{}\"", user.id)
|
||||
};
|
||||
|
||||
let filter = format!("created_by=\"{}\"", user.id);
|
||||
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
|
||||
if !filter.is_empty() {
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
}
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue