use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::error::{Error, Result};
use crate::moderation::decay::{ActiveSuspension, calculate_strike_state};
use crate::moderation::policy::StrikePolicy;
use crate::moderation::types::{ActionRecord, ActionType};
use crate::writer::rfc3339_from_epoch_ms;
#[derive(Debug, Serialize)]
pub(crate) struct SubjectStrikeStateOut {
#[serde(rename = "currentStrikeCount")]
pub current_strike_count: u32,
#[serde(rename = "rawTotal")]
pub raw_total: u32,
#[serde(rename = "decayedCount")]
pub decayed_count: u32,
#[serde(rename = "revokedCount")]
pub revoked_count: u32,
#[serde(rename = "goodStanding")]
pub good_standing: bool,
#[serde(rename = "activeSuspension", skip_serializing_if = "Option::is_none")]
pub active_suspension: Option<ActiveSuspensionOut>,
#[serde(
rename = "decayWindowRemainingDays",
skip_serializing_if = "Option::is_none"
)]
pub decay_window_remaining_days: Option<u32>,
#[serde(rename = "lastActionAt", skip_serializing_if = "Option::is_none")]
pub last_action_at: Option<String>,
#[serde(rename = "activeLabels")]
pub active_labels: Vec<ActiveLabelOut>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ActiveLabelOut {
pub val: String,
#[serde(rename = "actionId")]
pub action_id: i64,
#[serde(rename = "actionType")]
pub action_type: String,
#[serde(rename = "reasonCodes")]
pub reason_codes: Vec<String>,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ActiveSuspensionOut {
#[serde(rename = "actionType")]
pub action_type: String,
#[serde(rename = "effectiveAt")]
pub effective_at: String,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
pub(crate) async fn subject_has_history(
pool: &sqlx::Pool<sqlx::Sqlite>,
subject_did: &str,
) -> Result<bool> {
let row = sqlx::query!(
r#"SELECT EXISTS(SELECT 1 FROM subject_actions WHERE subject_did = ?1) AS "exists!: i64""#,
subject_did,
)
.fetch_one(pool)
.await?;
Ok(row.exists != 0)
}
pub(crate) async fn load_action_history(
pool: &sqlx::Pool<sqlx::Sqlite>,
subject_did: &str,
) -> Result<Vec<ActionRecord>> {
let rows = sqlx::query!(
"SELECT action_type, strike_value_applied, was_dampened,
effective_at, expires_at, revoked_at
FROM subject_actions
WHERE subject_did = ?1
ORDER BY id ASC",
subject_did,
)
.fetch_all(pool)
.await?;
let mut out = Vec::with_capacity(rows.len());
for r in rows {
let action_type = ActionType::from_db_str(&r.action_type).ok_or_else(|| {
Error::Signing(format!(
"subject_actions row has invalid action_type {:?}",
r.action_type
))
})?;
let strike_value_applied = u32::try_from(r.strike_value_applied).map_err(|_| {
Error::Signing(format!(
"subject_actions strike_value_applied {} out of u32 range",
r.strike_value_applied
))
})?;
out.push(ActionRecord {
strike_value_applied,
effective_at: epoch_ms_to_systemtime(r.effective_at),
revoked_at: r.revoked_at.map(epoch_ms_to_systemtime),
action_type,
expires_at: r.expires_at.map(epoch_ms_to_systemtime),
was_dampened: r.was_dampened != 0,
});
}
Ok(out)
}
pub(crate) async fn build_strike_state_view(
pool: &sqlx::Pool<sqlx::Sqlite>,
subject_did: &str,
service_did: &str,
policy: &StrikePolicy,
now: SystemTime,
) -> Result<SubjectStrikeStateOut> {
let history = load_action_history(pool, subject_did).await?;
let st = calculate_strike_state(&history, policy, now);
let last_action_at = history
.iter()
.rev()
.find(|a| a.action_type.contributes_strikes() && a.revoked_at.is_none())
.map(|a| a.effective_at);
let decay_window_remaining_days: Option<u32> = if st.current_count == 0 {
None
} else {
last_action_at.map(|t| {
let elapsed_days = now.duration_since(t).unwrap_or(Duration::ZERO).as_secs() / 86_400;
let window = policy.decay_window_days as u64;
window.saturating_sub(elapsed_days) as u32
})
};
let last_action_at_rfc = last_action_at
.map(systemtime_to_rfc3339)
.transpose()
.unwrap_or(None);
let active_suspension = st
.active_suspension
.as_ref()
.map(project_active_suspension)
.transpose()?;
let active_labels = load_active_labels(pool, subject_did, service_did).await?;
Ok(SubjectStrikeStateOut {
current_strike_count: st.current_count,
raw_total: st.raw_total,
decayed_count: st.decayed_count,
revoked_count: st.revoked_count,
good_standing: st.good_standing,
active_suspension,
decay_window_remaining_days,
last_action_at: last_action_at_rfc,
active_labels,
})
}
pub(crate) async fn load_active_labels(
pool: &sqlx::Pool<sqlx::Sqlite>,
subject_did: &str,
service_did: &str,
) -> Result<Vec<ActiveLabelOut>> {
let actions = sqlx::query!(
r#"SELECT
id AS "id!: i64",
subject_uri,
action_type AS "action_type!: String",
expires_at,
reason_codes AS "reason_codes!: String",
emitted_label_uri AS "emitted_label_uri!: String"
FROM subject_actions
WHERE subject_did = ?1
AND revoked_at IS NULL
AND emitted_label_uri IS NOT NULL
ORDER BY id DESC"#,
subject_did,
)
.fetch_all(pool)
.await?;
let mut out = Vec::with_capacity(actions.len());
for a in actions {
let label_uri = a
.subject_uri
.clone()
.unwrap_or_else(|| subject_did.to_string());
let latest = sqlx::query_scalar!(
r#"SELECT neg AS "neg!: i64"
FROM labels
WHERE src = ?1 AND uri = ?2 AND val = ?3
ORDER BY seq DESC
LIMIT 1"#,
service_did,
label_uri,
a.emitted_label_uri,
)
.fetch_optional(pool)
.await?;
match latest {
Some(neg) if neg != 0 => continue, None => continue, _ => {}
}
let reason_codes: Vec<String> = sqlx::query_scalar!(
"SELECT reason_code FROM subject_action_reason_labels
WHERE action_id = ?1
ORDER BY reason_code ASC",
a.id,
)
.fetch_all(pool)
.await?;
let _ = a.reason_codes;
let expires_at = match a.expires_at {
Some(ms) => Some(rfc3339_from_epoch_ms(ms)?),
None => None,
};
out.push(ActiveLabelOut {
val: a.emitted_label_uri,
action_id: a.id,
action_type: a.action_type,
reason_codes,
expires_at,
});
}
Ok(out)
}
fn project_active_suspension(s: &ActiveSuspension) -> Result<ActiveSuspensionOut> {
let effective_at = systemtime_to_rfc3339(s.effective_at)?;
let expires_at = s.expires_at.map(systemtime_to_rfc3339).transpose()?;
Ok(ActiveSuspensionOut {
action_type: s.action_type.as_db_str().to_string(),
effective_at,
expires_at,
})
}
fn epoch_ms_to_systemtime(ms: i64) -> SystemTime {
if ms >= 0 {
UNIX_EPOCH + Duration::from_millis(ms as u64)
} else {
UNIX_EPOCH
}
}
fn systemtime_to_rfc3339(t: SystemTime) -> Result<String> {
let ms: i64 = t
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis()
.try_into()
.unwrap_or(0);
rfc3339_from_epoch_ms(ms)
}