use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
admin::{VISIBLE_USERS, require_manager_or_admin},
app::AppState,
error::ApiError,
login::CurrentUser,
me::{self, Range},
model::UserRole,
privacy::Policy,
};
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Member {
pub id: Uuid,
pub display_name: String,
pub email: String,
pub department: Option<String>,
pub days_recorded: i64,
pub worked_seconds: i64,
pub paused_seconds: i64,
pub last_day: Option<NaiveDate>,
pub day_open: bool,
pub last_seen_at: Option<DateTime<Utc>>,
pub agents: i64,
}
#[derive(Debug, Serialize)]
pub struct Team {
pub from: NaiveDate,
pub to: NaiveDate,
pub members: Vec<Member>,
pub privacy_level: crate::privacy::PrivacyLevel,
pub not_stored: Vec<&'static str>,
}
pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
me::validate_range(&range)?;
let is_admin = user.role == UserRole::Admin;
let members: Vec<Member> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT u.id, u.display_name, u.email, d.name AS department,
coalesce(w.days_recorded, 0)::bigint AS days_recorded,
coalesce(w.worked_seconds, 0)::bigint AS worked_seconds,
coalesce(w.paused_seconds, 0)::bigint AS paused_seconds,
w.last_day,
coalesce(o.day_open, false) AS day_open,
(SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
(SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents
FROM users u
LEFT JOIN departments d ON d.id = u.department_id
LEFT JOIN LATERAL (
-- `sum()` over bigint answers `numeric`, which does not decode
-- into an i64; the cast is outside the sum so it happens once.
SELECT count(*) AS days_recorded,
max(w.date) AS last_day,
coalesce(sum(
CASE WHEN w.ended_at IS NULL THEN 0
ELSE greatest(extract(epoch FROM (w.ended_at - w.started_at))::bigint - paused.seconds, 0)
END
), 0)::bigint AS worked_seconds,
coalesce(sum(paused.seconds), 0)::bigint AS paused_seconds
FROM workdays w
CROSS JOIN LATERAL (
-- Stored pauses where they exist; the day's own totals where a
-- narrower policy summarized them away (ADR 0011). One or the
-- other, never both, so the hours cannot be double-counted.
SELECT CASE
WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)
THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = w.id)
ELSE coalesce(w.paused_seconds, 0)::bigint
END AS seconds
) AS paused
WHERE w.user_id = u.id AND w.date BETWEEN $3 AND $4
) AS w ON true
LEFT JOIN LATERAL (
SELECT true AS day_open
FROM workdays w2
WHERE w2.user_id = u.id AND w2.date = current_date AND w2.ended_at IS NULL
LIMIT 1
) AS o ON true
WHERE u.active AND {VISIBLE_USERS}
ORDER BY u.display_name, u.email"
)))
.bind(is_admin)
.bind(user.user_id)
.bind(range.from)
.bind(range.to)
.fetch_all(&state.pool)
.await?;
let level = Policy::load(&state.pool).await?.level();
Ok(Json(Team {
from: range.from,
to: range.to,
members,
privacy_level: level,
not_stored: me::not_stored_at(level),
}))
}
pub async fn user_days(
State(state): State<AppState>,
user: CurrentUser,
Path(target): Path<Uuid>,
Query(range): Query<Range>,
) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
me::validate_range(&range)?;
if !may_read(&state.pool, &user, target).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
}
Ok(Json(me::days_for(&state.pool, target, &range).await?))
}
async fn may_read(pool: &PgPool, reader: &CurrentUser, target: Uuid) -> Result<bool, ApiError> {
let visible: Option<Uuid> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT u.id FROM users u WHERE u.id = $3 AND {VISIBLE_USERS}")))
.bind(reader.role == UserRole::Admin)
.bind(reader.user_id)
.bind(target)
.fetch_optional(pool)
.await?;
Ok(visible.is_some())
}