Skip to main content

kasl_server/
import.rs

1//! Importing an agent's local history.
2//!
3//! A team that adopts kasl-server late has employees who tracked their time
4//! locally for months. That history is in an ordinary SQLite file on their
5//! machine, and it should not be thrown away because the server arrived after
6//! it. This reads that file and writes the days into the server's tables.
7//!
8//! The awkward part is time. kasl stores bare wall-clock text - `datetime(...,
9//! 'localtime')`, no offset anywhere - which is unambiguous on the one machine
10//! that wrote it and meaningless to a server serving several time zones. There
11//! is nothing in the file to recover the offset from, so whoever runs the
12//! import states it, and the choice is theirs to make and to get wrong (ADR
13//! 0003, ADR 0006).
14
15use anyhow::{Context, Result, bail};
16use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone};
17use rusqlite::Connection;
18use sqlx::PgPool;
19use uuid::Uuid;
20
21/// The format kasl's `datetime()` writes: no offset, no fractional seconds.
22const AGENT_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
23
24/// One workday as it appears in the agent's database.
25#[derive(Debug)]
26pub struct AgentDay {
27    pub date: NaiveDate,
28    pub start: NaiveDateTime,
29    pub end: Option<NaiveDateTime>,
30    pub pauses: Vec<AgentPause>,
31    pub tasks: Vec<AgentTask>,
32}
33
34#[derive(Debug)]
35pub struct AgentPause {
36    pub start: NaiveDateTime,
37    pub end: Option<NaiveDateTime>,
38    pub duration_seconds: Option<i32>,
39    /// True for a break the employee entered by hand. In the agent these live
40    /// in a separate `breaks` table; here they are pauses with a flag, which is
41    /// the shape the server's schema already had.
42    pub manual: bool,
43    pub reason: Option<String>,
44}
45
46#[derive(Debug)]
47pub struct AgentTask {
48    pub agent_task_id: i32,
49    pub agent_group_id: i32,
50    pub recorded_at: NaiveDateTime,
51    pub name: String,
52    pub comment: Option<String>,
53    pub completeness: i16,
54}
55
56/// What an import did, for the operator to read back.
57#[derive(Debug, Default, PartialEq, Eq)]
58pub struct ImportSummary {
59    pub days: usize,
60    pub pauses: usize,
61    pub tasks: usize,
62    /// Tasks the employee had deleted. Counted rather than silently dropped:
63    /// "892 tasks" and "892 tasks, 14 skipped" describe different files.
64    pub skipped_deleted_tasks: usize,
65    /// Rows whose timestamps could not be parsed at all.
66    pub skipped_unreadable: usize,
67}
68
69/// Reads an agent's database into days, newest last.
70///
71/// Opened read-only: this is the employee's file, and an import must not be
72/// able to damage the thing it is copying from - not even by taking a write
73/// lock on a database the agent is still using.
74pub fn read_agent_db(path: &std::path::Path) -> Result<(Vec<AgentDay>, ImportSummary)> {
75    if !path.exists() {
76        bail!("no such file: {}", path.display());
77    }
78
79    let connection = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
80        .with_context(|| format!("failed to open {} as a SQLite database", path.display()))?;
81
82    let mut summary = ImportSummary::default();
83    let mut days = read_workdays(&connection, &mut summary)?;
84
85    let pauses = read_pauses(&connection, &mut summary)?;
86    let breaks = read_breaks(&connection, &mut summary)?;
87    let tasks = read_tasks(&connection, &mut summary)?;
88
89    // The agent has no foreign keys: it relates rows by comparing date strings,
90    // and so must this. A pause whose date matches no workday is dropped rather
91    // than inventing a day the employee never had.
92    for (date, pause) in pauses.into_iter().chain(breaks) {
93        if let Some(day) = days.iter_mut().find(|day| day.date == date) {
94            day.pauses.push(pause);
95            summary.pauses += 1;
96        }
97    }
98    for (date, task) in tasks {
99        if let Some(day) = days.iter_mut().find(|day| day.date == date) {
100            day.tasks.push(task);
101            summary.tasks += 1;
102        }
103    }
104
105    summary.days = days.len();
106    Ok((days, summary))
107}
108
109fn read_workdays(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<AgentDay>> {
110    let mut statement = connection
111        .prepare("SELECT date, start, end FROM workdays ORDER BY date")
112        .context("failed to read the workdays table; is this a kasl database?")?;
113
114    let rows = statement.query_map([], |row| {
115        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option<String>>(2)?))
116    })?;
117
118    let mut days = Vec::new();
119    for row in rows {
120        let (date, start, end) = row?;
121        let (Ok(date), Ok(start)) = (parse_date(&date), parse_time(&start)) else {
122            summary.skipped_unreadable += 1;
123            continue;
124        };
125        days.push(AgentDay {
126            date,
127            start,
128            end: end.as_deref().and_then(|end| parse_time(end).ok()),
129            pauses: Vec::new(),
130            tasks: Vec::new(),
131        });
132    }
133
134    Ok(days)
135}
136
137fn read_pauses(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentPause)>> {
138    let mut statement = connection.prepare("SELECT start, end, duration FROM pauses ORDER BY start")?;
139    let rows = statement.query_map([], |row| {
140        Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<i32>>(2)?))
141    })?;
142
143    let mut pauses = Vec::new();
144    for row in rows {
145        let (start, end, duration_seconds) = row?;
146        let Ok(start) = parse_time(&start) else {
147            summary.skipped_unreadable += 1;
148            continue;
149        };
150        pauses.push((
151            start.date(),
152            AgentPause {
153                start,
154                end: end.as_deref().and_then(|end| parse_time(end).ok()),
155                duration_seconds,
156                manual: false,
157                reason: None,
158            },
159        ));
160    }
161
162    Ok(pauses)
163}
164
165/// Manual breaks, which the agent keeps in their own table.
166///
167/// Missing in databases from before the agent's migration 6, so a failure to
168/// read it is not a failure to import: an older file simply has no breaks.
169fn read_breaks(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentPause)>> {
170    let Ok(mut statement) = connection.prepare("SELECT date, start_time, end_time, duration, reason FROM breaks ORDER BY start_time") else {
171        return Ok(Vec::new());
172    };
173
174    let rows = statement.query_map([], |row| {
175        Ok((
176            row.get::<_, String>(0)?,
177            row.get::<_, String>(1)?,
178            row.get::<_, Option<String>>(2)?,
179            row.get::<_, Option<i32>>(3)?,
180            row.get::<_, Option<String>>(4)?,
181        ))
182    })?;
183
184    let mut breaks = Vec::new();
185    for row in rows {
186        let (date, start, end, duration_seconds, reason) = row?;
187        let (Ok(date), Ok(start)) = (parse_date(&date), parse_time(&start)) else {
188            summary.skipped_unreadable += 1;
189            continue;
190        };
191        breaks.push((
192            date,
193            AgentPause {
194                start,
195                end: end.as_deref().and_then(|end| parse_time(end).ok()),
196                duration_seconds,
197                manual: true,
198                reason,
199            },
200        ));
201    }
202
203    Ok(breaks)
204}
205
206fn read_tasks(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentTask)>> {
207    // `deleted_at` arrived in the agent's migration 4; older files lack the
208    // column, so the query that filters on it is tried first and the plain one
209    // is the fallback.
210    let (sql, filters_deleted) =
211        match connection.prepare("SELECT id, task_id, timestamp, name, comment, completeness FROM tasks WHERE deleted_at IS NULL ORDER BY id") {
212            Ok(_) => (
213                "SELECT id, task_id, timestamp, name, comment, completeness FROM tasks WHERE deleted_at IS NULL ORDER BY id",
214                true,
215            ),
216            Err(_) => ("SELECT id, task_id, timestamp, name, comment, completeness FROM tasks ORDER BY id", false),
217        };
218
219    if filters_deleted {
220        let deleted: i64 = connection
221            .query_row("SELECT count(*) FROM tasks WHERE deleted_at IS NOT NULL", [], |row| row.get(0))
222            .unwrap_or(0);
223        summary.skipped_deleted_tasks = deleted.max(0) as usize;
224    }
225
226    let mut statement = connection.prepare(sql)?;
227    let rows = statement.query_map([], |row| {
228        Ok((
229            row.get::<_, i32>(0)?,
230            row.get::<_, i32>(1)?,
231            row.get::<_, String>(2)?,
232            row.get::<_, String>(3)?,
233            row.get::<_, Option<String>>(4)?,
234            row.get::<_, i32>(5)?,
235        ))
236    })?;
237
238    let mut tasks = Vec::new();
239    for row in rows {
240        let (agent_task_id, task_id, recorded_at, name, comment, completeness) = row?;
241        let Ok(recorded_at) = parse_time(&recorded_at) else {
242            summary.skipped_unreadable += 1;
243            continue;
244        };
245        tasks.push((
246            recorded_at.date(),
247            AgentTask {
248                agent_task_id,
249                // The agent stores 0 for "belongs to itself"; the server stores
250                // the task's own id, as a live upload would send.
251                agent_group_id: if task_id == 0 { agent_task_id } else { task_id },
252                recorded_at,
253                name,
254                comment,
255                // Clamped rather than refused: a value outside 0..=100 is a bug
256                // in an old agent, not a reason to lose the employee's day.
257                completeness: completeness.clamp(0, 100) as i16,
258            },
259        ));
260    }
261
262    Ok(tasks)
263}
264
265fn parse_date(raw: &str) -> Result<NaiveDate> {
266    NaiveDate::parse_from_str(raw.trim(), "%Y-%m-%d").with_context(|| format!("not a date: {raw}"))
267}
268
269fn parse_time(raw: &str) -> Result<NaiveDateTime> {
270    let raw = raw.trim();
271    NaiveDateTime::parse_from_str(raw, AGENT_TIME_FORMAT)
272        // Some rows carry fractional seconds, depending on how they were
273        // written; accept both rather than dropping the day over a decimal.
274        .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.f"))
275        .with_context(|| format!("not a timestamp: {raw}"))
276}
277
278/// Keeps only the days within an inclusive date range.
279///
280/// The range exists for the employee who changed time zones mid-history: one
281/// run per stretch, each with the offset that stretch was recorded in. Both
282/// ends are optional, and an absent one means "no bound on that side".
283pub fn within(days: Vec<AgentDay>, since: Option<NaiveDate>, until: Option<NaiveDate>) -> Vec<AgentDay> {
284    days.into_iter()
285        .filter(|day| since.is_none_or(|since| day.date >= since) && until.is_none_or(|until| day.date <= until))
286        .collect()
287}
288
289/// Applies the operator's offset to a wall-clock time from the agent.
290///
291/// A fixed offset, not a zone: there is nothing in the file to say which of two
292/// possible offsets a given day was recorded in, so one is chosen for all of
293/// them and stated plainly in the output (ADR 0006).
294pub fn at_offset(time: NaiveDateTime, offset: FixedOffset) -> DateTime<FixedOffset> {
295    // `LocalResult` cannot be ambiguous for a fixed offset: it has no
296    // transitions. The single mapping is always there.
297    offset
298        .from_local_datetime(&time)
299        .single()
300        .expect("a fixed offset maps every local time exactly once")
301}
302
303/// Writes the days into the server's tables, as the given user.
304///
305/// Each day is its own transaction, matching the batch upload: an import of a
306/// year that fails on day 200 leaves 199 days imported, and running it again
307/// is safe because a re-imported day replaces itself.
308pub async fn write_days(pool: &PgPool, user_id: Uuid, days: &[AgentDay], offset: FixedOffset) -> Result<usize> {
309    let mut written = 0;
310
311    for day in days {
312        let mut tx = pool.begin().await?;
313
314        let workday_id: Uuid = sqlx::query_scalar(
315            "INSERT INTO workdays (user_id, date, started_at, ended_at) VALUES ($1, $2, $3, $4)
316             ON CONFLICT (user_id, date) DO UPDATE SET started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at
317             RETURNING id",
318        )
319        .bind(user_id)
320        .bind(day.date)
321        .bind(at_offset(day.start, offset))
322        .bind(day.end.map(|end| at_offset(end, offset)))
323        .fetch_one(&mut *tx)
324        .await
325        .with_context(|| format!("failed to write the workday of {}", day.date))?;
326
327        sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
328            .bind(workday_id)
329            .execute(&mut *tx)
330            .await?;
331        for pause in &day.pauses {
332            sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
333                .bind(workday_id)
334                .bind(at_offset(pause.start, offset))
335                .bind(pause.end.map(|end| at_offset(end, offset)))
336                .bind(pause.duration_seconds)
337                .bind(pause.manual)
338                .bind(pause.reason.as_deref())
339                .execute(&mut *tx)
340                .await?;
341        }
342
343        for task in &day.tasks {
344            sqlx::query(
345                "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
346                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
347                 ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
348                     agent_group_id = EXCLUDED.agent_group_id,
349                     date = EXCLUDED.date,
350                     recorded_at = EXCLUDED.recorded_at,
351                     name = EXCLUDED.name,
352                     comment = EXCLUDED.comment,
353                     completeness = EXCLUDED.completeness",
354            )
355            .bind(user_id)
356            .bind(task.agent_task_id)
357            .bind(task.agent_group_id)
358            .bind(day.date)
359            .bind(at_offset(task.recorded_at, offset))
360            .bind(task.name.trim())
361            .bind(task.comment.as_deref())
362            .bind(task.completeness)
363            .execute(&mut *tx)
364            .await?;
365        }
366
367        tx.commit().await?;
368        written += 1;
369    }
370
371    Ok(written)
372}
373
374/// Finds the user an import writes for, refusing to invent one.
375///
376/// An import that created accounts would let a typo in an email address file a
377/// year of someone's history under a person who does not exist, with nothing
378/// to notice it. The account is made first, deliberately.
379pub async fn resolve_user(pool: &PgPool, email: &str) -> Result<Uuid> {
380    let user: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE lower(email) = lower($1)")
381        .bind(email)
382        .fetch_optional(pool)
383        .await?;
384
385    user.with_context(|| format!("no user with the email {email}; create the account before importing into it"))
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn reads_the_agents_timestamp_format() {
394        let time = parse_time("2026-08-14 09:12:00").expect("the agent's own format must parse");
395        assert_eq!(time.to_string(), "2026-08-14 09:12:00");
396
397        // Written by some paths with a fraction; the day should not be lost.
398        assert!(parse_time("2026-08-14 09:12:00.123").is_ok());
399        // An offset is exactly what the agent never writes; if one appears, the
400        // assumption behind this whole module is wrong and it should not parse.
401        assert!(parse_time("2026-08-14T09:12:00-03:00").is_err());
402    }
403
404    #[test]
405    fn the_operators_offset_makes_the_instant_absolute() {
406        let time = parse_time("2026-08-14 09:12:00").unwrap();
407        let offset = FixedOffset::east_opt(-3 * 3600).unwrap();
408
409        let instant = at_offset(time, offset);
410        assert_eq!(instant.to_rfc3339(), "2026-08-14T09:12:00-03:00");
411        assert_eq!(instant.naive_utc().to_string(), "2026-08-14 12:12:00", "09:12-03:00 is 12:12 UTC");
412    }
413
414    #[test]
415    fn a_different_offset_is_a_different_moment() {
416        // The reason the argument is required rather than defaulted: the same
417        // text becomes a different instant, and only the operator knows which.
418        let time = parse_time("2026-08-14 09:12:00").unwrap();
419        let west = at_offset(time, FixedOffset::east_opt(-3 * 3600).unwrap());
420        let east = at_offset(time, FixedOffset::east_opt(5 * 3600).unwrap());
421
422        assert_ne!(west.naive_utc(), east.naive_utc());
423        assert_eq!(east.naive_utc().to_string(), "2026-08-14 04:12:00");
424    }
425}