mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
wip
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
CREATE TABLE payouts_runs(
|
||||
period_start TIMESTAMPTZ PRIMARY KEY,
|
||||
id BIGINT PRIMARY KEY,
|
||||
-- timestamp on the 1st of a month at midnight,
|
||||
-- representing what month this run is for.
|
||||
-- if a row exists for a month, then a payout run
|
||||
-- is running/has completed for this month (see
|
||||
-- `completed_at`).
|
||||
period_start TIMESTAMPTZ NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
started_by BIGINT REFERENCES users(id)
|
||||
ON DELETE SET NULL,
|
||||
completed_at TIMESTAMPTZ,
|
||||
completed_result JSONB,
|
||||
adjustments JSONB NOT NULL
|
||||
);
|
||||
CREATE INDEX payouts_runs_period_start ON payouts_runs(period_start);
|
||||
|
||||
@@ -14,6 +14,7 @@ pub use v3::oauth_clients;
|
||||
pub use v3::organizations;
|
||||
pub use v3::pack;
|
||||
pub use v3::pats;
|
||||
pub use v3::payout_runs;
|
||||
pub use v3::payouts;
|
||||
pub use v3::projects;
|
||||
pub use v3::reports;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod oauth_clients;
|
||||
pub mod organizations;
|
||||
pub mod pack;
|
||||
pub mod pats;
|
||||
pub mod payout_runs;
|
||||
pub mod payouts;
|
||||
pub mod projects;
|
||||
pub mod reports;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use ariadne::ids::UserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::util::time::YearMonth;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PayoutRun {
|
||||
/// What period this payout run is for.
|
||||
///
|
||||
/// Payout runs are always for the period of a specific year and month -
|
||||
/// they are not associated with any specific day.
|
||||
pub period_start: YearMonth,
|
||||
/// What state this run is in.
|
||||
pub status: PayoutRunStatus,
|
||||
/// When this run started running.
|
||||
///
|
||||
/// Only accessible to admins.
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// What user started this run.
|
||||
///
|
||||
/// Only accessible to admins.
|
||||
pub started_by: Option<UserId>,
|
||||
/// When this run completed.
|
||||
///
|
||||
/// Only accessible to admins.
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// What payout adjustments were specified in this run.
|
||||
///
|
||||
/// Only accessible to admins.
|
||||
pub adjustments: Option<Vec<Adjustment>>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PayoutRunStatus {
|
||||
/// We are still waiting on the ad provider to issue payouts to us.
|
||||
Pending,
|
||||
/// The ad provider should have issued payouts to us by now, and we will
|
||||
/// soon run the payouts.
|
||||
Review,
|
||||
/// Payouts run is currently being performed.
|
||||
Running,
|
||||
/// Payouts run is complete and payouts have been distributed to users.
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct Adjustment {}
|
||||
@@ -13,6 +13,7 @@ pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod mural;
|
||||
pub mod pats;
|
||||
pub mod payouts;
|
||||
pub mod search;
|
||||
pub mod server_ping;
|
||||
pub mod session;
|
||||
@@ -32,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(session::config)
|
||||
.configure(flows::config)
|
||||
.configure(pats::config)
|
||||
.configure(payouts::config)
|
||||
.configure(oauth_clients::config)
|
||||
.service(web::scope("/moderation").configure(moderation::config))
|
||||
.service(web::scope("/affiliate").configure(affiliate::config))
|
||||
@@ -100,6 +102,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pats::create_pat,
|
||||
pats::edit_pat,
|
||||
pats::delete_pat,
|
||||
payouts::get,
|
||||
moderation::get_projects,
|
||||
moderation::get_project_ids,
|
||||
moderation::get_project_meta,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use actix_web::{HttpRequest, get, web};
|
||||
use chrono::{Months, Utc};
|
||||
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::models::DBUserId;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgPool, ReadOnlyPgPool};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::time::YearMonth;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get);
|
||||
}
|
||||
|
||||
/// List creator payout runs.
|
||||
#[utoipa::path(
|
||||
tag = "payouts",
|
||||
responses((status = OK, body = inline(Vec<PayoutRun>)))
|
||||
)]
|
||||
#[get("/payout-runs")]
|
||||
pub async fn get(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
ro_pool: web::Data<ReadOnlyPgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<Vec<PayoutRun>>, ApiError> {
|
||||
let is_admin = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await
|
||||
.is_ok_and(|(_, user)| user.role.is_admin());
|
||||
|
||||
let stored_runs = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
period_start,
|
||||
started_at,
|
||||
started_by,
|
||||
completed_at,
|
||||
adjustments AS "adjustments!: sqlx::types::Json<Vec<Adjustment>>"
|
||||
FROM payouts_runs
|
||||
ORDER BY period_start DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&***ro_pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch payout runs")?;
|
||||
|
||||
let newest_created = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT created
|
||||
FROM payouts_values
|
||||
ORDER BY created DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(&***ro_pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch newest payout value")?;
|
||||
|
||||
let mut stored_periods = HashSet::with_capacity(stored_runs.len());
|
||||
let mut runs = Vec::with_capacity(stored_runs.len());
|
||||
for run in stored_runs {
|
||||
let period_start = YearMonth::from_day1(run.period_start.date_naive());
|
||||
let status = if run.completed_at.is_some() {
|
||||
PayoutRunStatus::Done
|
||||
} else {
|
||||
PayoutRunStatus::Running
|
||||
};
|
||||
|
||||
stored_periods.insert(period_start);
|
||||
runs.push(PayoutRun {
|
||||
period_start,
|
||||
status,
|
||||
started_at: is_admin.then_some(run.started_at),
|
||||
started_by: is_admin
|
||||
.then_some(run.started_by.map(|id| DBUserId(id).into()))
|
||||
.flatten(),
|
||||
completed_at: is_admin.then_some(run.completed_at).flatten(),
|
||||
adjustments: is_admin.then_some(run.adjustments.0),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(newest_created) = newest_created {
|
||||
let now = Utc::now();
|
||||
let newest_period = YearMonth::from_day1(newest_created.date_naive());
|
||||
let mut period = YearMonth::from_day1(now.date_naive());
|
||||
|
||||
while period <= newest_period {
|
||||
if !stored_periods.contains(&period) {
|
||||
runs.push(PayoutRun {
|
||||
period_start: period,
|
||||
status: PayoutRunStatus::Pending,
|
||||
started_at: None,
|
||||
started_by: None,
|
||||
completed_at: None,
|
||||
adjustments: None,
|
||||
});
|
||||
}
|
||||
|
||||
if period == newest_period {
|
||||
break;
|
||||
}
|
||||
|
||||
let next_month = period
|
||||
.date()
|
||||
.checked_add_months(Months::new(1))
|
||||
.wrap_internal_err(
|
||||
"failed to calculate next payout month",
|
||||
)?;
|
||||
period = YearMonth::from_day1(next_month);
|
||||
}
|
||||
}
|
||||
|
||||
runs.sort_by_key(|run| Reverse(run.period_start));
|
||||
|
||||
Ok(web::Json(runs))
|
||||
}
|
||||
@@ -21,5 +21,6 @@ pub mod routes;
|
||||
pub mod sentry;
|
||||
pub mod tags;
|
||||
pub mod tiltify;
|
||||
pub mod time;
|
||||
pub mod validate;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct YearMonth(NaiveDate);
|
||||
|
||||
impl YearMonth {
|
||||
pub fn new(date: NaiveDate) -> Result<Self, InvalidYearMonth> {
|
||||
if date.day() == 1 {
|
||||
Ok(Self(date))
|
||||
} else {
|
||||
return Err(InvalidYearMonth);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_year_month(year: i32, month: u32) -> Option<Self> {
|
||||
NaiveDate::from_ymd_opt(year, month, 1).map(Self)
|
||||
}
|
||||
|
||||
pub fn from_day1(date: NaiveDate) -> Self {
|
||||
Self(date.with_day(1).expect("every month has a first day"))
|
||||
}
|
||||
|
||||
pub fn date(self) -> NaiveDate {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("a year-month must use the first day of its month")]
|
||||
pub struct InvalidYearMonth;
|
||||
|
||||
impl TryFrom<NaiveDate> for YearMonth {
|
||||
type Error = InvalidYearMonth;
|
||||
|
||||
fn try_from(date: NaiveDate) -> Result<Self, Self::Error> {
|
||||
Self::new(date)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<YearMonth> for NaiveDate {
|
||||
fn from(year_month: YearMonth) -> Self {
|
||||
year_month.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for YearMonth {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}", self.0.format("%Y-%m"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a valid year and month in `YYYY-MM` format")]
|
||||
pub struct ParseYearMonthError;
|
||||
|
||||
impl FromStr for YearMonth {
|
||||
type Err = ParseYearMonthError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
let mut segments = value.split('-');
|
||||
let (Some(year), Some(month), None) =
|
||||
(segments.next(), segments.next(), segments.next())
|
||||
else {
|
||||
return Err(ParseYearMonthError);
|
||||
};
|
||||
|
||||
let year = year.parse().map_err(|_| ParseYearMonthError)?;
|
||||
let month = month.parse().map_err(|_| ParseYearMonthError)?;
|
||||
Self::from_year_month(year, month).ok_or(ParseYearMonthError)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for YearMonth {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for YearMonth {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::PartialSchema for YearMonth {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::String)
|
||||
.pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$"))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::ToSchema for YearMonth {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_dates_after_the_first() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
|
||||
|
||||
assert_eq!(YearMonth::new(date), Err(InvalidYearMonth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructs_from_year_and_month() {
|
||||
let year_month = YearMonth::from_year_month(2026, 7).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
assert!(YearMonth::from_year_month(2026, 13).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructs_from_date_using_first_day() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 20).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
YearMonth::from_day1(date).date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_as_year_and_month() {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
||||
let year_month = YearMonth::new(date).unwrap();
|
||||
|
||||
assert_eq!(serde_json::to_string(&year_month).unwrap(), r#""2026-07""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_year_and_month_to_the_first() {
|
||||
let year_month: YearMonth =
|
||||
serde_json::from_str(r#""2026-07""#).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_year_and_month_to_the_first() {
|
||||
let year_month = YearMonth::from_str("2026-7").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
year_month.date(),
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
|
||||
);
|
||||
assert_eq!(year_month.to_string(), "2026-07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_serialized_formats() {
|
||||
for value in [r#""2026""#, r#""2026-07-01""#, r#""2026-13""#] {
|
||||
assert!(serde_json::from_str::<YearMonth>(value).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user