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