kasl-server 0.21.1

Team server for kasl: collects work-time data from employees' kasl agents and turns it into dashboards, reports, and personal pages
Documentation
//! What a manager may read about other people: `GET /api/v1/team/days` and
//! `GET /api/v1/users/{id}/days`.
//!
//! This is where the server starts answering for someone other than the caller,
//! so the permission is the subject of this module rather than a detail inside
//! it. Two rules, both settled before the code:
//!
//! * **The visibility rule lives in one place.** [`admin::VISIBLE_USERS`] is
//!   pasted into every query here. A second copy of "who may see whom" is a
//!   second chance for one of them to widen, and a leak of this kind is
//!   invisible to the person leaked about.
//! * **A summary, not a pile of days.** The dashboard shows a row per person;
//!   the timeline of one day belongs to the drill-down, which reuses the shape
//!   `/me/days` already answers. A single endpoint carrying every pause of
//!   twenty people for a month would ship a payload nobody on that screen
//!   reads.
//!
//! A person the reader may see is listed **even with nothing recorded**. The
//! employee whose agent has never reported is exactly who a manager needs to
//! notice, and dropping them from the table hides the case the dashboard exists
//! for - the same "emptiness lies by default" defect the privacy work fixed at
//! ingest (ADR 0011).

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,
    calendar::{Calendar, Norm},
    error::ApiError,
    heartbeat::{self, Live},
    login::CurrentUser,
    me::{self, Range},
    model::UserRole,
    privacy::Policy,
};
use rust_decimal::Decimal;

/// One person's period, as the dashboard's table shows it.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Member {
    pub id: Uuid,
    pub display_name: String,
    pub email: String,
    pub department: Option<String>,
    /// Days worked in the range. Zero is a real answer.
    ///
    /// Days the person was away are **not** counted here - they have their own
    /// figure below, and a field that mixed them would make "four days, twenty
    /// hours" describe somebody who worked two of them. The pair is what the
    /// row reads from.
    pub days_recorded: i64,
    /// Seconds worked across the range: the span of each finished day less
    /// what was paused in it. Open days contribute nothing - a day still
    /// running has no total to add (the same rule `/me/days` follows).
    pub worked_seconds: i64,
    pub paused_seconds: i64,
    /// The most recent date with a workday, so "no data" can be told from
    /// "nothing since the 12th".
    pub last_day: Option<NaiveDate>,
    /// Whether a day is open right now on the employee's own calendar.
    pub day_open: bool,
    /// When any of this person's agents last delivered anything.
    ///
    /// The honest half of "who is working now": the server knows when it last
    /// heard from a machine, not whether someone is at it. Live status needs
    /// heartbeats, which is its own milestone.
    pub last_seen_at: Option<DateTime<Utc>>,
    /// Live agent tokens. Zero explains a silent row without guessing.
    pub agents: i64,
    /// This person's share of a full day. Carried so a row that is half the
    /// team's hours can be read as half time rather than as half-hearted.
    pub work_rate: Decimal,
    /// What the range asked of this person, in seconds: the calendar at their
    /// rate, with the days they were away taken out (ADR 0017).
    ///
    /// Not a column of the query: the calendar is one set of rows for the
    /// whole team, so the norm is computed once per person in Rust rather than
    /// joined per row.
    #[sqlx(default)]
    pub norm_seconds: i64,
    /// Days in the range the person was on leave or ill, so a row short of its
    /// norm can be read without opening it.
    #[sqlx(default)]
    pub days_away: i64,
}

/// The team's period.
#[derive(Debug, Serialize)]
pub struct Team {
    pub from: NaiveDate,
    pub to: NaiveDate,
    pub members: Vec<Member>,
    /// The level in force, so the dashboard can caveat its own figures the way
    /// the personal page does.
    pub privacy_level: crate::privacy::PrivacyLevel,
    pub not_stored: Vec<&'static str>,
    /// The installation's full day, in hours. The one figure every norm in the
    /// table is computed from, stated once rather than per row.
    pub standard_hours: Decimal,
}

/// Answers the team's hours over a range.
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;

    // `date` here is the employee's own local date, as their agent recorded it,
    // and "today" is the server's. They can differ by a day at the edges; for
    // "is a day open" that is the right approximation - the alternative needs a
    // per-person time zone the server does not store (ADR 0003).
    // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
    // constant in `admin`; every value from the request is bound below.
    let members: Vec<Member> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
        "SELECT u.id, u.display_name, u.email, d.name AS department,
                u.work_rate,
                coalesce(w.days_away, 0)::bigint AS days_away,
                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(*) FILTER (WHERE w.kind = 'work') AS days_recorded,
                    -- Days the employee told us they were away. Counted here
                    -- rather than in a second query: the same scan already has
                    -- the range's rows in hand, and the two counts partition
                    -- the days rather than overlapping.
                    count(*) FILTER (WHERE w.kind <> 'work') AS days_away,
                    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();

    // One calendar for the whole table, and one query for the leave dates.
    // The norm differs per person only by their rate and by the days they were
    // away, so nothing here needs a round trip per row.
    let calendar = Calendar::load(&state.pool, range.from, range.to).await?;
    let standard_hours = Norm::standard_hours(&state.pool).await?;
    let away = away_by_user(&state.pool, &members, &range).await?;

    let mut members = members;
    for member in &mut members {
        let norm = Norm {
            standard_hours,
            work_rate: member.work_rate,
        };
        let theirs = away.iter().filter(|(id, _)| *id == member.id).map(|(_, date)| *date).collect::<Vec<_>>();
        member.norm_seconds = norm.for_range(&calendar, range.from, range.to, &theirs);
    }

    Ok(Json(Team {
        from: range.from,
        to: range.to,
        members,
        privacy_level: level,
        not_stored: me::not_stored_at(level),
        standard_hours,
    }))
}

/// The dates in the range each listed person was away.
///
/// One query for the whole table rather than one per row, and scoped to the
/// people already listed - the visibility rule was applied when they were
/// selected, and re-deriving it here would be the second copy this module
/// exists to avoid.
async fn away_by_user(pool: &PgPool, members: &[Member], range: &Range) -> Result<Vec<(Uuid, NaiveDate)>, ApiError> {
    if members.is_empty() {
        return Ok(Vec::new());
    }
    let ids: Vec<Uuid> = members.iter().map(|member| member.id).collect();

    let rows: Vec<(Uuid, NaiveDate)> =
        sqlx::query_as("SELECT user_id, date FROM workdays WHERE user_id = ANY($1) AND date BETWEEN $2 AND $3 AND kind <> 'work'")
            .bind(&ids)
            .bind(range.from)
            .bind(range.to)
            .fetch_all(pool)
            .await?;

    Ok(rows)
}

/// Answers one person's days to someone allowed to see them.
///
/// Deliberately the same response shape as `/me/days`: the drill-down is the
/// personal screen pointed at someone else, and two shapes for one thing would
/// mean two renderers to keep in step.
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? {
        // Not "no such user": a manager probing ids should not be able to tell
        // an employee in another department from one who does not exist.
        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
    }

    Ok(Json(me::days_for(&state.pool, target, &range).await?))
}

/// What the team is doing right now: `GET /api/v1/team/live`.
///
/// Its own endpoint rather than a field on `/team/days`, because the two are
/// asked at completely different rates. The week's hours are a page load; the
/// pulse is a poll every half minute, and answering it with the week's totals
/// would make the dashboard re-run the heaviest query on the server on a timer
/// - for numbers that did not change.
///
/// Keyed by user id so the caller merges it into the table it already drew.
/// Nothing here identifies a person beyond that id: the row a manager may see
/// was decided by `/team/days`, and this endpoint applies the same clause
/// rather than a second reading of it.
#[derive(Debug, Serialize)]
pub struct LiveTeam {
    pub members: Vec<Live>,
    /// How often the caller should ask again, in seconds. The server owns the
    /// cadence - it is the side that knows the staleness threshold.
    pub poll_seconds: i64,
    /// After how many seconds of silence a pulse stops being believed, so the
    /// UI can explain "offline" with the same number the server used.
    pub stale_after_seconds: i64,
}

/// Answers the live status of everyone the reader may see.
pub async fn live(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
    require_manager_or_admin(&user)?;

    let members = heartbeat::load(&state.pool, VISIBLE_USERS, user.role == UserRole::Admin, user.user_id).await?;

    Ok(Json(LiveTeam {
        members,
        // Half the agent's interval: a dashboard that polled at exactly the
        // pulse rate would show every state one full interval late, having
        // just missed each arrival.
        poll_seconds: heartbeat::INTERVAL_SECONDS / 2,
        stale_after_seconds: heartbeat::STALE_AFTER_SECONDS,
    }))
}

/// Whether `reader` may see `target`'s data.
///
/// Asked of the database with the same clause the listing uses, rather than
/// reasoned about in Rust: the rule and the check cannot drift if they are the
/// same string.
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())
}