Skip to main content

max / goingson

6.6 KB · 189 lines History Blame Raw
1 //! SQLite implementation of the WeeklyReviewRepository.
2 //!
3 //! Provides weekly review tracking functionality including:
4 //! - Getting reviews for specific weeks
5 //! - Creating/updating reviews
6 //! - Checking if current week is completed
7
8 use async_trait::async_trait;
9 use chrono::{Datelike, NaiveDate, Utc};
10 use sqlx::SqlitePool;
11 use goingson_core::{CoreError, Result, UserId, WeeklyReview, WeeklyReviewId, WeeklyReviewRepository};
12
13 use crate::utils::{format_datetime, parse_datetime, parse_uuid};
14
15 /// SQLite-backed implementation of [`WeeklyReviewRepository`].
16 ///
17 /// Tracks weekly review completion with notes. Reviews are keyed by
18 /// the Monday of each ISO week.
19 pub struct SqliteWeeklyReviewRepository {
20 pool: SqlitePool,
21 }
22
23 impl SqliteWeeklyReviewRepository {
24 /// Creates a new repository instance with the given connection pool.
25 #[tracing::instrument(skip_all)]
26 pub fn new(pool: SqlitePool) -> Self {
27 Self { pool }
28 }
29 }
30
31 /// Gets the Monday of the current ISO week.
32 fn current_week_start() -> NaiveDate {
33 let today = Utc::now().date_naive();
34 // NaiveDate::week returns the ISO week, which starts on Monday
35 let days_from_monday = today.weekday().num_days_from_monday();
36 today - chrono::Duration::days(days_from_monday as i64)
37 }
38
39 #[derive(sqlx::FromRow)]
40 struct WeeklyReviewRow {
41 id: String,
42 user_id: String,
43 week_start_date: String,
44 completed_at: String,
45 notes: String,
46 vacation_days: String,
47 }
48
49 /// Parse comma-separated day indices into Vec<u8>.
50 fn parse_vacation_days(s: &str) -> Vec<u8> {
51 if s.is_empty() {
52 return Vec::new();
53 }
54 s.split(',')
55 .filter_map(|d| d.trim().parse::<u8>().ok())
56 .filter(|&d| d <= 6)
57 .collect()
58 }
59
60 /// Serialize Vec<u8> into comma-separated string.
61 fn serialize_vacation_days(days: &[u8]) -> String {
62 days.iter()
63 .filter(|&&d| d <= 6)
64 .map(|d| d.to_string())
65 .collect::<Vec<_>>()
66 .join(",")
67 }
68
69 impl TryFrom<WeeklyReviewRow> for WeeklyReview {
70 type Error = CoreError;
71
72 fn try_from(row: WeeklyReviewRow) -> Result<Self> {
73 Ok(WeeklyReview {
74 id: parse_uuid(&row.id)?.into(),
75 user_id: parse_uuid(&row.user_id)?.into(),
76 week_start_date: NaiveDate::parse_from_str(&row.week_start_date, "%Y-%m-%d")
77 .map_err(|_| CoreError::parse("Invalid date"))?,
78 completed_at: parse_datetime(&row.completed_at)?,
79 notes: row.notes,
80 vacation_days: parse_vacation_days(&row.vacation_days),
81 })
82 }
83 }
84
85 #[async_trait]
86 impl WeeklyReviewRepository for SqliteWeeklyReviewRepository {
87 #[tracing::instrument(skip_all)]
88 async fn get_for_week(&self, user_id: UserId, week_start: NaiveDate) -> Result<Option<WeeklyReview>> {
89 let user_id_str = user_id.to_string();
90 let week_start_str = week_start.format("%Y-%m-%d").to_string();
91
92 let row: Option<WeeklyReviewRow> = sqlx::query_as(
93 "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days
94 FROM weekly_reviews
95 WHERE user_id = ? AND week_start_date = ?"
96 )
97 .bind(&user_id_str)
98 .bind(&week_start_str)
99 .fetch_optional(&self.pool)
100 .await
101 .map_err(CoreError::database)?;
102
103 row.map(WeeklyReview::try_from).transpose()
104 }
105
106 #[tracing::instrument(skip_all)]
107 async fn upsert(&self, user_id: UserId, week_start: NaiveDate, notes: &str) -> Result<WeeklyReview> {
108 let user_id_str = user_id.to_string();
109 let week_start_str = week_start.format("%Y-%m-%d").to_string();
110 let now = Utc::now();
111 let completed_at_str = format_datetime(&now);
112
113 // Atomic upsert on the (user_id, week_start_date) unique key. Avoids the
114 // get-then-insert/update race that could double-insert under a
115 // concurrent first write (sync apply + a UI write). vacation_days and
116 // the original id are preserved by only updating notes + completed_at.
117 sqlx::query(
118 "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes)
119 VALUES (?, ?, ?, ?, ?)
120 ON CONFLICT(user_id, week_start_date)
121 DO UPDATE SET notes = excluded.notes, completed_at = excluded.completed_at",
122 )
123 .bind(WeeklyReviewId::new().to_string())
124 .bind(&user_id_str)
125 .bind(&week_start_str)
126 .bind(&completed_at_str)
127 .bind(notes)
128 .execute(&self.pool)
129 .await
130 .map_err(CoreError::database)?;
131
132 // Re-read to return the persisted row (its id may predate this call).
133 self.get_for_week(user_id, week_start)
134 .await?
135 .ok_or_else(|| CoreError::database_msg("weekly review vanished after upsert"))
136 }
137
138 #[tracing::instrument(skip_all)]
139 async fn is_current_week_completed(&self, user_id: UserId) -> Result<bool> {
140 let week_start = current_week_start();
141 let review = self.get_for_week(user_id, week_start).await?;
142 Ok(review.is_some())
143 }
144
145 #[tracing::instrument(skip_all)]
146 async fn set_vacation_days(&self, user_id: UserId, week_start: NaiveDate, days: &[u8]) -> Result<()> {
147 let user_id_str = user_id.to_string();
148 let week_start_str = week_start.format("%Y-%m-%d").to_string();
149 let vacation_str = serialize_vacation_days(days);
150 let now = format_datetime(&Utc::now());
151
152 // Atomic upsert: insert a vacation-only row or update the existing
153 // week's vacation_days, without the get-then-write race.
154 sqlx::query(
155 "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days)
156 VALUES (?, ?, ?, ?, '', ?)
157 ON CONFLICT(user_id, week_start_date) DO UPDATE SET vacation_days = excluded.vacation_days",
158 )
159 .bind(WeeklyReviewId::new().to_string())
160 .bind(&user_id_str)
161 .bind(&week_start_str)
162 .bind(&now)
163 .bind(&vacation_str)
164 .execute(&self.pool)
165 .await
166 .map_err(CoreError::database)?;
167
168 Ok(())
169 }
170
171 #[tracing::instrument(skip_all)]
172 async fn list_all(&self, user_id: UserId) -> Result<Vec<WeeklyReview>> {
173 let user_id_str = user_id.to_string();
174
175 let rows: Vec<WeeklyReviewRow> = sqlx::query_as(
176 "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days
177 FROM weekly_reviews
178 WHERE user_id = ?
179 ORDER BY week_start_date ASC",
180 )
181 .bind(&user_id_str)
182 .fetch_all(&self.pool)
183 .await
184 .map_err(CoreError::database)?;
185
186 rows.into_iter().map(WeeklyReview::try_from).collect()
187 }
188 }
189