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    /// How many times the day was interrupted, under a privacy level that does
79    /// not store pauses one by one. `None` where the `Pause` rows answer this
80    /// themselves (ADR 0011).
81    pub paused_count: Option<i32>,
82    /// Seconds paused in total, alongside `paused_count` and on the same terms.
83    pub paused_seconds: Option<i32>,
84    pub created_at: DateTime<Utc>,
85    pub updated_at: DateTime<Utc>,
86}
87
88/// An interruption inside a workday: detected idleness or a manual break.
89#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
90pub struct Pause {
91    pub id: Uuid,
92    pub workday_id: Uuid,
93    pub started_at: DateTime<Utc>,
94    /// `None` while the pause is still running on the agent.
95    pub ended_at: Option<DateTime<Utc>>,
96    /// Seconds. Stored rather than derived: the agent merges neighbouring
97    /// pauses across a gap, so the duration is not always end minus start.
98    pub duration_seconds: Option<i32>,
99    /// Entered by the employee (the agent's `protected` flag): never merged
100    /// away and exempt from the short-pause thresholds.
101    pub manual: bool,
102    pub reason: Option<String>,
103    pub created_at: DateTime<Utc>,
104    pub updated_at: DateTime<Utc>,
105}
106
107/// A task the employee logged for a day.
108#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
109pub struct Task {
110    pub id: Uuid,
111    pub user_id: Uuid,
112    /// The agent's own row id, unique per user: the key a re-upload matches on.
113    pub agent_task_id: i32,
114    /// The agent's `task_id`, tying the same work carried across several days.
115    pub agent_group_id: i32,
116    /// The employee's local date the task belongs to.
117    pub date: NaiveDate,
118    pub recorded_at: DateTime<Utc>,
119    pub name: String,
120    pub comment: Option<String>,
121    /// Percent complete, 0..=100.
122    pub completeness: i16,
123    pub created_at: DateTime<Utc>,
124    pub updated_at: DateTime<Utc>,
125}
126
127/// A label on tasks. Scoped to one user: vocabularies are personal.
128#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
129pub struct Tag {
130    pub id: Uuid,
131    pub user_id: Uuid,
132    pub name: String,
133    pub color: Option<String>,
134    pub created_at: DateTime<Utc>,
135    pub updated_at: DateTime<Utc>,
136}
137
138/// Which period a report covers. Mirrors the `report_kind` enum.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
140#[sqlx(type_name = "report_kind", rename_all = "lowercase")]
141#[serde(rename_all = "lowercase")]
142pub enum ReportKind {
143    Daily,
144    Monthly,
145}
146
147/// The event of a report being submitted, with the figures as of that moment.
148///
149/// Not a second copy of the day: hours are recomputed from workdays and pauses
150/// whenever they are shown. What cannot be recomputed is what the employee
151/// actually submitted and when - which is what approval rests on.
152#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
153pub struct Report {
154    pub id: Uuid,
155    pub user_id: Uuid,
156    pub kind: ReportKind,
157    /// The day itself for a daily report, the first of the month for a monthly.
158    pub period_start: NaiveDate,
159    pub submitted_at: DateTime<Utc>,
160    pub worked_seconds: i32,
161    /// Percent, as the agent computes productivity.
162    pub productivity: Option<f32>,
163    pub created_at: DateTime<Utc>,
164    pub updated_at: DateTime<Utc>,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// The wire names are part of the contract with kasl agents and the web UI:
172    /// a rename here is a breaking API change, so it should fail a test, not
173    /// surface as a puzzled client.
174    #[test]
175    fn roles_and_report_kinds_serialize_in_lowercase() {
176        assert_eq!(serde_json::to_string(&UserRole::Admin).unwrap(), r#""admin""#);
177        assert_eq!(serde_json::to_string(&UserRole::Manager).unwrap(), r#""manager""#);
178        assert_eq!(serde_json::to_string(&UserRole::Employee).unwrap(), r#""employee""#);
179        assert_eq!(serde_json::to_string(&ReportKind::Daily).unwrap(), r#""daily""#);
180        assert_eq!(serde_json::to_string(&ReportKind::Monthly).unwrap(), r#""monthly""#);
181    }
182
183    #[test]
184    fn roles_round_trip_through_json() {
185        for role in [UserRole::Admin, UserRole::Manager, UserRole::Employee] {
186            let json = serde_json::to_string(&role).unwrap();
187            assert_eq!(serde_json::from_str::<UserRole>(&json).unwrap(), role);
188        }
189    }
190}