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>
488 lines
14 KiB
Rust
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(())
|
|
}
|
|
} |