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}