use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{BusError, BusResult};
#[derive(Debug, sqlx::FromRow)]
pub struct Usage {
pub attachment_bytes: i64,
pub attachment_count: i64,
pub attachment_bytes_limit: Option<i64>,
pub messages: i64,
pub note_revisions: i64,
pub task_events: i64,
pub oldest_message: Option<chrono::DateTime<chrono::Utc>>,
}
impl Usage {
pub fn percent_used(&self) -> Option<f64> {
self.attachment_bytes_limit
.filter(|l| *l > 0)
.map(|limit| (self.attachment_bytes as f64 / limit as f64) * 100.0)
}
}
pub async fn usage(pool: &PgPool, team_id: Uuid) -> BusResult<Usage> {
let usage = sqlx::query_as::<_, Usage>(
r#"
SELECT
COALESCE((SELECT sum(size_bytes) FROM attachments WHERE team_id = $1), 0)::bigint
AS attachment_bytes,
(SELECT count(*) FROM attachments WHERE team_id = $1) AS attachment_count,
(SELECT attachment_bytes_limit FROM teams WHERE id = $1) AS attachment_bytes_limit,
(SELECT count(*) FROM messages WHERE team_id = $1) AS messages,
(SELECT count(*) FROM note_revisions r
JOIN notes n ON n.id = r.note_id WHERE n.team_id = $1) AS note_revisions,
(SELECT count(*) FROM task_events e
JOIN tasks t ON t.id = e.task_id WHERE t.team_id = $1) AS task_events,
(SELECT min(created_at) FROM messages WHERE team_id = $1) AS oldest_message
"#,
)
.bind(team_id)
.fetch_one(pool)
.await?;
Ok(usage)
}
pub async fn check_attachment_quota(
conn: &mut sqlx::PgConnection,
team_id: Uuid,
incoming_bytes: i64,
) -> BusResult<()> {
let limit: Option<i64> =
sqlx::query_scalar("SELECT attachment_bytes_limit FROM teams WHERE id = $1 FOR UPDATE")
.bind(team_id)
.fetch_one(&mut *conn)
.await?;
let Some(limit) = limit else {
return Ok(()); };
let used: i64 = sqlx::query_scalar(
"SELECT COALESCE(sum(size_bytes), 0)::bigint FROM attachments WHERE team_id = $1",
)
.bind(team_id)
.fetch_one(&mut *conn)
.await?;
if used + incoming_bytes > limit {
return Err(BusError::invalid(format!(
"attachment quota exceeded: this team stores {used} of {limit} bytes and this \
file adds {incoming_bytes}. Delete the task or message carrying an old \
attachment, or ask an operator to raise the quota."
)));
}
Ok(())
}
#[derive(Debug, Default)]
pub struct PruneReport {
pub messages: u64,
pub note_revisions: u64,
pub task_events: u64,
pub attachments_freed_bytes: i64,
pub dry_run: bool,
}
pub async fn prune(
pool: &PgPool,
team_id: Uuid,
older_than_days: i64,
dry_run: bool,
) -> BusResult<PruneReport> {
if older_than_days < 1 {
return Err(BusError::invalid(
"older_than_days must be at least 1; pruning everything is not a retention policy",
));
}
let mut tx = pool.begin().await?;
let freed: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE(sum(a.size_bytes), 0)::bigint
FROM attachments a
JOIN messages m ON m.id = a.message_id
WHERE a.team_id = $1 AND m.created_at < now() - make_interval(days => $2)
"#,
)
.bind(team_id)
.bind(older_than_days as i32)
.fetch_one(&mut *tx)
.await?;
let note_revisions = sqlx::query(
r#"
DELETE FROM note_revisions r
USING notes n
WHERE r.note_id = n.id AND n.team_id = $1
AND r.created_at < now() - make_interval(days => $2)
"#,
)
.bind(team_id)
.bind(older_than_days as i32)
.execute(&mut *tx)
.await?
.rows_affected();
let task_events = sqlx::query(
r#"
DELETE FROM task_events e
USING tasks t
WHERE e.task_id = t.id AND t.team_id = $1
AND e.created_at < now() - make_interval(days => $2)
"#,
)
.bind(team_id)
.bind(older_than_days as i32)
.execute(&mut *tx)
.await?
.rows_affected();
let messages = sqlx::query(
"DELETE FROM messages WHERE team_id = $1 AND created_at < now() - make_interval(days => $2)",
)
.bind(team_id)
.bind(older_than_days as i32)
.execute(&mut *tx)
.await?
.rows_affected();
let report = PruneReport {
messages,
note_revisions,
task_events,
attachments_freed_bytes: freed,
dry_run,
};
if dry_run {
tx.rollback().await?;
} else {
tx.commit().await?;
}
Ok(report)
}