Skip to main content

kasl_server/
model.rs

1//! The domain the server stores: the shapes behind `migrations/`.
2//!
3//! These types are the Rust half of the schema. They exist now, before the
4//! ingest API that fills them, so the tables have one authoritative reading -
5//! column, type and meaning together - instead of a SQL file plus whatever a
6//! handler happens to select.
7//!
8//! Naming follows the database, and the database follows kasl: a reader who
9//! knows the agent's model recognizes this one.
10//!
11//! Nothing reads these structs yet - the ingest API and the queries behind it
12//! are the next milestones. They are allowed to sit unused rather than be
13//! written twice: the schema and its Rust reading land together, and the
14//! serialization test below already holds the wire names to the contract.
15#![allow(dead_code)]
16
17use chrono::{DateTime, NaiveDate, Utc};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21/// What a user may do. Mirrors the `user_role` enum in PostgreSQL.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
23#[sqlx(type_name = "user_role", rename_all = "lowercase")]
24#[serde(rename_all = "lowercase")]
25pub enum UserRole {
26    /// Runs the installation: accounts, agents, server settings.
27    Admin,
28    /// Sees the team (or their department) and its reports.
29    Manager,
30    /// Sees themselves.
31    Employee,
32}
33
34/// A person in the installation.
35///
36/// `password_hash` is deliberately absent: nothing outside authentication has
37/// a reason to load a verifier, and a struct that never holds one cannot leak
38/// it into a response.
39#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
40pub struct User {
41    pub id: Uuid,
42    pub email: String,
43    pub display_name: String,
44    pub role: UserRole,
45    /// Deactivated accounts keep their history and stop being accepted.
46    pub active: bool,
47    pub created_at: DateTime<Utc>,
48    pub updated_at: DateTime<Utc>,
49}
50
51/// One installed kasl reporting on a user's behalf.
52///
53/// The token itself lives only in the agent's config; the server keeps a hash.
54#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
55pub struct Agent {
56    pub id: Uuid,
57    pub user_id: Uuid,
58    pub name: String,
59    /// Set when the token is withdrawn; the row and its data stay.
60    pub revoked_at: Option<DateTime<Utc>>,
61    /// Last accepted request, for "this agent went silent" signals.
62    pub last_seen_at: Option<DateTime<Utc>>,
63    pub created_at: DateTime<Utc>,
64    pub updated_at: DateTime<Utc>,
65}
66
67/// A working day of one person.
68#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
69pub struct Workday {
70    pub id: Uuid,
71    pub user_id: Uuid,
72    /// The employee's local calendar date, as the agent recorded it - not a
73    /// date derived from `started_at` in whatever zone the server runs.
74    pub date: NaiveDate,
75    pub started_at: DateTime<Utc>,
76    /// `None` while the day is still open.
77    pub ended_at: Option<DateTime<Utc>>,
78    pub created_at: DateTime<Utc>,
79    pub updated_at: DateTime<Utc>,
80}
81
82/// An interruption inside a workday: detected idleness or a manual break.
83#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
84pub struct Pause {
85    pub id: Uuid,
86    pub workday_id: Uuid,
87    pub started_at: DateTime<Utc>,
88    /// `None` while the pause is still running on the agent.
89    pub ended_at: Option<DateTime<Utc>>,
90    /// Seconds. Stored rather than derived: the agent merges neighbouring
91    /// pauses across a gap, so the duration is not always end minus start.
92    pub duration_seconds: Option<i32>,
93    /// Entered by the employee (the agent's `protected` flag): never merged
94    /// away and exempt from the short-pause thresholds.
95    pub manual: bool,
96    pub reason: Option<String>,
97    pub created_at: DateTime<Utc>,
98    pub updated_at: DateTime<Utc>,
99}
100
101/// A task the employee logged for a day.
102#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
103pub struct Task {
104    pub id: Uuid,
105    pub user_id: Uuid,
106    /// The agent's own row id, unique per user: the key a re-upload matches on.
107    pub agent_task_id: i32,
108    /// The agent's `task_id`, tying the same work carried across several days.
109    pub agent_group_id: i32,
110    /// The employee's local date the task belongs to.
111    pub date: NaiveDate,
112    pub recorded_at: DateTime<Utc>,
113    pub name: String,
114    pub comment: Option<String>,
115    /// Percent complete, 0..=100.
116    pub completeness: i16,
117    pub created_at: DateTime<Utc>,
118    pub updated_at: DateTime<Utc>,
119}
120
121/// A label on tasks. Scoped to one user: vocabularies are personal.
122#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
123pub struct Tag {
124    pub id: Uuid,
125    pub user_id: Uuid,
126    pub name: String,
127    pub color: Option<String>,
128    pub created_at: DateTime<Utc>,
129    pub updated_at: DateTime<Utc>,
130}
131
132/// Which period a report covers. Mirrors the `report_kind` enum.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
134#[sqlx(type_name = "report_kind", rename_all = "lowercase")]
135#[serde(rename_all = "lowercase")]
136pub enum ReportKind {
137    Daily,
138    Monthly,
139}
140
141/// The event of a report being submitted, with the figures as of that moment.
142///
143/// Not a second copy of the day: hours are recomputed from workdays and pauses
144/// whenever they are shown. What cannot be recomputed is what the employee
145/// actually submitted and when - which is what approval rests on.
146#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
147pub struct Report {
148    pub id: Uuid,
149    pub user_id: Uuid,
150    pub kind: ReportKind,
151    /// The day itself for a daily report, the first of the month for a monthly.
152    pub period_start: NaiveDate,
153    pub submitted_at: DateTime<Utc>,
154    pub worked_seconds: i32,
155    /// Percent, as the agent computes productivity.
156    pub productivity: Option<f32>,
157    pub created_at: DateTime<Utc>,
158    pub updated_at: DateTime<Utc>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    /// The wire names are part of the contract with kasl agents and the web UI:
166    /// a rename here is a breaking API change, so it should fail a test, not
167    /// surface as a puzzled client.
168    #[test]
169    fn roles_and_report_kinds_serialize_in_lowercase() {
170        assert_eq!(serde_json::to_string(&UserRole::Admin).unwrap(), r#""admin""#);
171        assert_eq!(serde_json::to_string(&UserRole::Manager).unwrap(), r#""manager""#);
172        assert_eq!(serde_json::to_string(&UserRole::Employee).unwrap(), r#""employee""#);
173        assert_eq!(serde_json::to_string(&ReportKind::Daily).unwrap(), r#""daily""#);
174        assert_eq!(serde_json::to_string(&ReportKind::Monthly).unwrap(), r#""monthly""#);
175    }
176
177    #[test]
178    fn roles_round_trip_through_json() {
179        for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
180            let json = serde_json::to_string(&role).unwrap();
181            assert_eq!(serde_json::from_str::<UserRole>(&json).unwrap(), role);
182        }
183    }
184}