Files
calendar/backend/src/db.rs
Connor Johnstone 28b3946e86 Add intelligent caching and auto-refresh for external calendars
Implements server-side database caching with 5-minute refresh intervals to
dramatically improve external calendar performance while keeping data fresh.

Backend changes:
- New external_calendar_cache table with ICS data storage
- Smart cache logic: serves from cache if < 5min old, fetches fresh otherwise
- Cache repository methods for get/update/clear operations
- Migration script for cache table creation

Frontend changes:
- 5-minute auto-refresh interval for background updates
- Manual refresh button (🔄) for each external calendar
- Last updated timestamps showing when each calendar was refreshed
- Centralized refresh function with proper cleanup on logout

Performance improvements:
- Initial load: instant from cache vs slow external HTTP requests
- Background updates: fresh data without user waiting
- Reduced external API calls: only when cache is stale
- Scalable: handles multiple external calendars efficiently

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 22:06:32 -04:00

488 lines
14 KiB
Rust

use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{FromRow, Result};
use std::sync::Arc;
use uuid::Uuid;
/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
pool: Arc<SqlitePool>,
}
impl Database {
/// Create a new database connection pool
pub async fn new(database_url: &str) -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self {
pool: Arc::new(pool),
})
}
/// Get a reference to the connection pool
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
/// User model representing a CalDAV user
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String, // UUID as string for SQLite
pub username: String,
pub server_url: String,
pub created_at: DateTime<Utc>,
}
impl User {
/// Create a new user with generated UUID
pub fn new(username: String, server_url: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
username,
server_url,
created_at: Utc::now(),
}
}
}
/// Session model for user sessions
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub id: String, // UUID as string
pub user_id: String, // Foreign key to User
pub token: String, // Session token
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_accessed: DateTime<Utc>,
}
impl Session {
/// Create a new session for a user
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id,
token,
created_at: now,
expires_at: now + chrono::Duration::hours(expires_in_hours),
last_accessed: now,
}
}
/// Check if the session has expired
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
/// User preferences model
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UserPreferences {
pub user_id: String,
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>, // JSON string
pub last_used_calendar: Option<String>,
pub updated_at: DateTime<Utc>,
}
/// External calendar model
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ExternalCalendar {
pub id: i32,
pub user_id: String,
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_fetched: Option<DateTime<Utc>>,
}
impl ExternalCalendar {
/// Create a new external calendar
pub fn new(user_id: String, name: String, url: String, color: String) -> Self {
let now = Utc::now();
Self {
id: 0, // Will be set by database
user_id,
name,
url,
color,
is_visible: true,
created_at: now,
updated_at: now,
last_fetched: None,
}
}
}
impl UserPreferences {
/// Create default preferences for a new user
pub fn default_for_user(user_id: String) -> Self {
Self {
user_id,
calendar_selected_date: None,
calendar_time_increment: Some(15),
calendar_view_mode: Some("month".to_string()),
calendar_theme: Some("light".to_string()),
calendar_style: Some("default".to_string()),
calendar_colors: None,
last_used_calendar: None,
updated_at: Utc::now(),
}
}
}
/// Repository for User operations
pub struct UserRepository<'a> {
db: &'a Database,
}
impl<'a> UserRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Find or create a user by username and server URL
pub async fn find_or_create(
&self,
username: &str,
server_url: &str,
) -> Result<User> {
// Try to find existing user
let existing = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE username = ? AND server_url = ?",
)
.bind(username)
.bind(server_url)
.fetch_optional(self.db.pool())
.await?;
if let Some(user) = existing {
Ok(user)
} else {
// Create new user
let user = User::new(username.to_string(), server_url.to_string());
sqlx::query(
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
)
.bind(&user.id)
.bind(&user.username)
.bind(&user.server_url)
.bind(&user.created_at)
.execute(self.db.pool())
.await?;
Ok(user)
}
}
/// Find a user by ID
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(self.db.pool())
.await
}
}
/// Repository for Session operations
pub struct SessionRepository<'a> {
db: &'a Database,
}
impl<'a> SessionRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Create a new session
pub async fn create(&self, session: &Session) -> Result<()> {
sqlx::query(
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&session.id)
.bind(&session.user_id)
.bind(&session.token)
.bind(&session.created_at)
.bind(&session.expires_at)
.bind(&session.last_accessed)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Find a session by token and update last_accessed
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
.bind(token)
.fetch_optional(self.db.pool())
.await?;
if let Some(ref s) = session {
if !s.is_expired() {
// Update last_accessed time
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
.bind(Utc::now())
.bind(&s.id)
.execute(self.db.pool())
.await?;
}
}
Ok(session)
}
/// Delete a session (logout)
pub async fn delete(&self, token: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Clean up expired sessions
pub async fn cleanup_expired(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
.bind(Utc::now())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected())
}
}
/// Repository for UserPreferences operations
pub struct PreferencesRepository<'a> {
db: &'a Database,
}
impl<'a> PreferencesRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Get user preferences, creating defaults if not exist
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
let existing = sqlx::query_as::<_, UserPreferences>(
"SELECT * FROM user_preferences WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(self.db.pool())
.await?;
if let Some(prefs) = existing {
Ok(prefs)
} else {
// Create default preferences
let prefs = UserPreferences::default_for_user(user_id.to_string());
sqlx::query(
"INSERT INTO user_preferences
(user_id, calendar_selected_date, calendar_time_increment,
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&prefs.user_id)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(&prefs.last_used_calendar)
.bind(&prefs.updated_at)
.execute(self.db.pool())
.await?;
Ok(prefs)
}
}
/// Update user preferences
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
sqlx::query(
"UPDATE user_preferences
SET calendar_selected_date = ?, calendar_time_increment = ?,
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
calendar_colors = ?, last_used_calendar = ?, updated_at = ?
WHERE user_id = ?",
)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(&prefs.last_used_calendar)
.bind(Utc::now())
.bind(&prefs.user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
}
/// Repository for ExternalCalendar operations
pub struct ExternalCalendarRepository<'a> {
db: &'a Database,
}
impl<'a> ExternalCalendarRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Get all external calendars for a user
pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> {
sqlx::query_as::<_, ExternalCalendar>(
"SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC",
)
.bind(user_id)
.fetch_all(self.db.pool())
.await
}
/// Create a new external calendar
pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> {
let result = sqlx::query(
"INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&calendar.user_id)
.bind(&calendar.name)
.bind(&calendar.url)
.bind(&calendar.color)
.bind(&calendar.is_visible)
.bind(&calendar.created_at)
.bind(&calendar.updated_at)
.execute(self.db.pool())
.await?;
Ok(result.last_insert_rowid() as i32)
}
/// Update an external calendar
pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> {
sqlx::query(
"UPDATE external_calendars
SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ?
WHERE id = ? AND user_id = ?",
)
.bind(&calendar.name)
.bind(&calendar.url)
.bind(&calendar.color)
.bind(&calendar.is_visible)
.bind(Utc::now())
.bind(id)
.bind(&calendar.user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Delete an external calendar
pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Update last_fetched timestamp
pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> {
sqlx::query(
"UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?",
)
.bind(Utc::now())
.bind(id)
.bind(user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Get cached ICS data for an external calendar
pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> {
let result = sqlx::query_as::<_, (String, DateTime<Utc>)>(
"SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?",
)
.bind(external_calendar_id)
.fetch_optional(self.db.pool())
.await?;
Ok(result)
}
/// Update cache with new ICS data
pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> {
sqlx::query(
"INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(external_calendar_id) DO UPDATE SET
ics_data = excluded.ics_data,
etag = excluded.etag,
cached_at = excluded.cached_at",
)
.bind(external_calendar_id)
.bind(ics_data)
.bind(etag)
.bind(Utc::now())
.execute(self.db.pool())
.await?;
Ok(())
}
/// Check if cache is stale (older than max_age_minutes)
pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> {
let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes);
let result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM external_calendar_cache
WHERE external_calendar_id = ? AND cached_at > ?",
)
.bind(external_calendar_id)
.bind(cutoff_time)
.fetch_one(self.db.pool())
.await?;
Ok(result == 0)
}
/// Clear cache for an external calendar
pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> {
sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?")
.bind(external_calendar_id)
.execute(self.db.pool())
.await?;
Ok(())
}
}