//! SQLite implementation of the WeeklyReviewRepository. //! //! Provides weekly review tracking functionality including: //! - Getting reviews for specific weeks //! - Creating/updating reviews //! - Checking if current week is completed use async_trait::async_trait; use chrono::{Datelike, NaiveDate, Utc}; use sqlx::SqlitePool; use goingson_core::{CoreError, Result, UserId, WeeklyReview, WeeklyReviewId, WeeklyReviewRepository}; use crate::utils::{format_datetime, parse_datetime, parse_uuid}; /// SQLite-backed implementation of [`WeeklyReviewRepository`]. /// /// Tracks weekly review completion with notes. Reviews are keyed by /// the Monday of each ISO week. pub struct SqliteWeeklyReviewRepository { pool: SqlitePool, } impl SqliteWeeklyReviewRepository { /// Creates a new repository instance with the given connection pool. #[tracing::instrument(skip_all)] pub fn new(pool: SqlitePool) -> Self { Self { pool } } } /// Gets the Monday of the current ISO week. fn current_week_start() -> NaiveDate { let today = Utc::now().date_naive(); // NaiveDate::week returns the ISO week, which starts on Monday let days_from_monday = today.weekday().num_days_from_monday(); today - chrono::Duration::days(days_from_monday as i64) } #[derive(sqlx::FromRow)] struct WeeklyReviewRow { id: String, user_id: String, week_start_date: String, completed_at: String, notes: String, vacation_days: String, } /// Parse comma-separated day indices into Vec. fn parse_vacation_days(s: &str) -> Vec { if s.is_empty() { return Vec::new(); } s.split(',') .filter_map(|d| d.trim().parse::().ok()) .filter(|&d| d <= 6) .collect() } /// Serialize Vec into comma-separated string. fn serialize_vacation_days(days: &[u8]) -> String { days.iter() .filter(|&&d| d <= 6) .map(|d| d.to_string()) .collect::>() .join(",") } impl TryFrom for WeeklyReview { type Error = CoreError; fn try_from(row: WeeklyReviewRow) -> Result { Ok(WeeklyReview { id: parse_uuid(&row.id)?.into(), user_id: parse_uuid(&row.user_id)?.into(), week_start_date: NaiveDate::parse_from_str(&row.week_start_date, "%Y-%m-%d") .map_err(|_| CoreError::parse("Invalid date"))?, completed_at: parse_datetime(&row.completed_at)?, notes: row.notes, vacation_days: parse_vacation_days(&row.vacation_days), }) } } #[async_trait] impl WeeklyReviewRepository for SqliteWeeklyReviewRepository { #[tracing::instrument(skip_all)] async fn get_for_week(&self, user_id: UserId, week_start: NaiveDate) -> Result> { let user_id_str = user_id.to_string(); let week_start_str = week_start.format("%Y-%m-%d").to_string(); let row: Option = sqlx::query_as( "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days FROM weekly_reviews WHERE user_id = ? AND week_start_date = ?" ) .bind(&user_id_str) .bind(&week_start_str) .fetch_optional(&self.pool) .await .map_err(CoreError::database)?; row.map(WeeklyReview::try_from).transpose() } #[tracing::instrument(skip_all)] async fn upsert(&self, user_id: UserId, week_start: NaiveDate, notes: &str) -> Result { let user_id_str = user_id.to_string(); let week_start_str = week_start.format("%Y-%m-%d").to_string(); let now = Utc::now(); let completed_at_str = format_datetime(&now); // Try to get existing review let existing = self.get_for_week(user_id, week_start).await?; let existing_vacation_days = existing.as_ref().map(|r| r.vacation_days.clone()).unwrap_or_default(); let id = if let Some(existing) = existing { // Update existing sqlx::query( "UPDATE weekly_reviews SET notes = ?, completed_at = ? WHERE id = ?" ) .bind(notes) .bind(&completed_at_str) .bind(existing.id.to_string()) .execute(&self.pool) .await .map_err(CoreError::database)?; existing.id } else { // Insert new let id = WeeklyReviewId::new(); sqlx::query( "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes) VALUES (?, ?, ?, ?, ?)" ) .bind(id.to_string()) .bind(&user_id_str) .bind(&week_start_str) .bind(&completed_at_str) .bind(notes) .execute(&self.pool) .await .map_err(CoreError::database)?; id }; Ok(WeeklyReview { id, user_id, week_start_date: week_start, completed_at: now, notes: notes.to_string(), vacation_days: existing_vacation_days, }) } #[tracing::instrument(skip_all)] async fn is_current_week_completed(&self, user_id: UserId) -> Result { let week_start = current_week_start(); let review = self.get_for_week(user_id, week_start).await?; Ok(review.is_some()) } #[tracing::instrument(skip_all)] async fn set_vacation_days(&self, user_id: UserId, week_start: NaiveDate, days: &[u8]) -> Result<()> { let user_id_str = user_id.to_string(); let week_start_str = week_start.format("%Y-%m-%d").to_string(); let vacation_str = serialize_vacation_days(days); // Check if a review row already exists for this week let existing = self.get_for_week(user_id, week_start).await?; if existing.is_some() { // Update existing row sqlx::query( "UPDATE weekly_reviews SET vacation_days = ? WHERE user_id = ? AND week_start_date = ?" ) .bind(&vacation_str) .bind(&user_id_str) .bind(&week_start_str) .execute(&self.pool) .await .map_err(CoreError::database)?; } else { // Insert a new row (review not yet completed, just vacation days) let id = WeeklyReviewId::new(); let now = format_datetime(&Utc::now()); sqlx::query( "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days) VALUES (?, ?, ?, ?, '', ?)" ) .bind(id.to_string()) .bind(&user_id_str) .bind(&week_start_str) .bind(&now) .bind(&vacation_str) .execute(&self.pool) .await .map_err(CoreError::database)?; } Ok(()) } }