grabbit 46d8af6084 🎉 Epic 2 Milestone: Successfully completed the final story of Epic 2: Commercialization & Core User Experience with full-stack date filtering functionality.
📋 What Was Accomplished

  Backend Changes:
  -  Enhanced API Endpoint: Updated GET /api/v1/events to accept optional date query parameter
  -  Input Validation: Added YYYY-MM-DD format validation to PaginationQueryDto
  -  Database Filtering: Implemented timezone-aware date filtering in EventsService
  -  Controller Integration: Updated EventsController to pass date parameter to service

  Frontend Changes:
  -  Date Picker Component: Created reusable DatePicker component following project design system
  -  Gallery UI Enhancement: Integrated date picker into gallery page with clear labeling
  -  State Management: Implemented reactive date state with automatic re-fetching
  -  Clear Filter Functionality: Added "Clear Filter" button for easy reset
  -  Enhanced UX: Improved empty states for filtered vs unfiltered views

  🔍 Technical Implementation

  API Design:
  GET /api/v1/events?date=2025-08-02&limit=20&cursor=xxx

  Key Files Modified:
  - meteor-web-backend/src/events/dto/pagination-query.dto.ts
  - meteor-web-backend/src/events/events.service.ts
  - meteor-web-backend/src/events/events.controller.ts
  - meteor-frontend/src/components/ui/date-picker.tsx (new)
  - meteor-frontend/src/app/gallery/page.tsx
  - meteor-frontend/src/hooks/use-events.ts
  - meteor-frontend/src/services/events.ts

   All Acceptance Criteria Met

  1.  Backend API Enhancement: Accepts optional date parameter
  2.  Date Filtering Logic: Returns events for specific calendar date
  3.  Date Picker UI: Clean, accessible interface component
  4.  Automatic Re-fetching: Immediate data updates on date selection
  5.  Filtered Display: Correctly shows only events for selected date
  6.  Clear Filter: One-click reset to view all events

  🧪 Quality Assurance

  -  Backend Build: Successful compilation with no errors
  -  Frontend Build: Successful Next.js build with no warnings
  -  Linting: All ESLint checks pass
  -  Functionality: Feature working as specified

  🎉 Epic 2 Complete!

  With Story 2.9 completion, Epic 2: Commercialization & Core User Experience is now DONE!

  Epic 2 Achievements:
  - 🔐 Full-stack device status monitoring
  - 💳 Robust payment and subscription system
  - 🛡️ Subscription-based access control
  - 📊 Enhanced data browsing with detail pages
  - 📅 Date-based event filtering
2025-08-03 10:30:29 +08:00

400 lines
14 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::camera::{CameraConfig, CameraSource};
use crate::storage::{StorageConfig, VideoQuality};
use crate::communication::CommunicationConfig;
/// Configuration structure for the meteor edge client
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
/// Whether the device has been successfully registered
pub registered: bool,
/// The hardware ID used for registration
pub hardware_id: String,
/// Timestamp of when registration was completed
pub registered_at: Option<String>,
/// The user profile ID this device is registered to
pub user_profile_id: Option<String>,
/// Device ID returned from the registration API
pub device_id: Option<String>,
/// JWT token for authentication with backend services
pub jwt_token: Option<String>,
}
impl Config {
/// Creates a new unregistered configuration
pub fn new(hardware_id: String) -> Self {
Self {
registered: false,
hardware_id,
registered_at: None,
user_profile_id: None,
device_id: None,
jwt_token: None,
}
}
/// Marks the configuration as registered with the given details
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String, jwt_token: String) {
self.registered = true;
self.user_profile_id = Some(user_profile_id);
self.device_id = Some(device_id);
self.jwt_token = Some(jwt_token);
self.registered_at = Some(
chrono::Utc::now().to_rfc3339()
);
}
}
/// Configuration manager handles reading and writing config files
pub struct ConfigManager {
config_path: PathBuf,
}
impl ConfigManager {
/// Creates a new configuration manager
/// Uses system-appropriate config directory or falls back to a local path
pub fn new() -> Self {
let config_path = get_config_file_path();
Self { config_path }
}
/// Creates a configuration manager with a custom path (useful for testing)
pub fn with_path<P: AsRef<Path>>(path: P) -> Self {
Self {
config_path: path.as_ref().to_path_buf(),
}
}
/// Checks if a configuration file exists
pub fn config_exists(&self) -> bool {
self.config_path.exists()
}
/// Loads configuration from the file system
pub fn load_config(&self) -> Result<Config> {
let content = fs::read_to_string(&self.config_path)
.with_context(|| format!("Failed to read config file: {:?}", self.config_path))?;
let config: Config = toml::from_str(&content)
.context("Failed to parse config file as TOML")?;
Ok(config)
}
/// Saves configuration to the file system
pub fn save_config(&self, config: &Config) -> Result<()> {
// Ensure the parent directory exists
if let Some(parent) = self.config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
let content = toml::to_string_pretty(config)
.context("Failed to serialize config to TOML")?;
fs::write(&self.config_path, content)
.with_context(|| format!("Failed to write config file: {:?}", self.config_path))?;
println!("✅ Configuration saved to: {:?}", self.config_path);
Ok(())
}
/// Gets the path where the config file is stored
pub fn get_config_path(&self) -> &Path {
&self.config_path
}
}
/// Camera-specific configuration structure for TOML parsing
#[derive(Debug, Serialize, Deserialize)]
pub struct CameraConfigToml {
pub source: String, // "device" or file path
pub device_id: Option<i32>,
pub fps: Option<f64>,
pub width: Option<i32>,
pub height: Option<i32>,
}
impl Default for CameraConfigToml {
fn default() -> Self {
Self {
source: "device".to_string(),
device_id: Some(0),
fps: Some(30.0),
width: Some(640),
height: Some(480),
}
}
}
/// Storage-specific configuration structure for TOML parsing
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageConfigToml {
pub frame_buffer_size: Option<usize>,
pub storage_path: Option<String>,
pub retention_days: Option<u32>,
pub video_quality: Option<String>, // "low", "medium", "high"
pub cleanup_interval_hours: Option<u64>,
}
impl Default for StorageConfigToml {
fn default() -> Self {
Self {
frame_buffer_size: Some(200),
storage_path: Some("./meteor_events".to_string()),
retention_days: Some(30),
video_quality: Some("medium".to_string()),
cleanup_interval_hours: Some(24),
}
}
}
/// Communication-specific configuration structure for TOML parsing
#[derive(Debug, Serialize, Deserialize)]
pub struct CommunicationConfigToml {
pub api_base_url: Option<String>,
pub retry_attempts: Option<u32>,
pub retry_delay_seconds: Option<u64>,
pub max_retry_delay_seconds: Option<u64>,
pub request_timeout_seconds: Option<u64>,
pub heartbeat_interval_seconds: Option<u64>,
}
impl Default for CommunicationConfigToml {
fn default() -> Self {
Self {
api_base_url: Some("http://localhost:3000".to_string()),
retry_attempts: Some(3),
retry_delay_seconds: Some(2),
max_retry_delay_seconds: Some(60),
request_timeout_seconds: Some(300),
heartbeat_interval_seconds: Some(300),
}
}
}
/// Top-level configuration structure with camera, storage, and communication settings
#[derive(Debug, Serialize, Deserialize)]
pub struct AppConfig {
pub camera: Option<CameraConfigToml>,
pub storage: Option<StorageConfigToml>,
pub communication: Option<CommunicationConfigToml>,
}
/// Load camera configuration from TOML file
pub fn load_camera_config() -> Result<CameraConfig> {
let config_path = get_app_config_file_path();
let camera_config = if config_path.exists() {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
let app_config: AppConfig = toml::from_str(&content)
.context("Failed to parse app config file as TOML")?;
app_config.camera.unwrap_or_default()
} else {
println!("📄 No app config file found, using default camera settings");
CameraConfigToml::default()
};
// Convert TOML config to CameraConfig
let source = if camera_config.source == "device" {
CameraSource::Device(camera_config.device_id.unwrap_or(0))
} else {
CameraSource::File(camera_config.source.clone())
};
Ok(CameraConfig {
source,
fps: camera_config.fps.unwrap_or(30.0),
width: camera_config.width,
height: camera_config.height,
})
}
/// Load storage configuration from TOML file
pub fn load_storage_config() -> Result<StorageConfig> {
let config_path = get_app_config_file_path();
let storage_config = if config_path.exists() {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
let app_config: AppConfig = toml::from_str(&content)
.context("Failed to parse app config file as TOML")?;
app_config.storage.unwrap_or_default()
} else {
println!("📄 No app config file found, using default storage settings");
StorageConfigToml::default()
};
// Convert TOML config to StorageConfig
let video_quality = match storage_config.video_quality.as_deref() {
Some("low") => VideoQuality::Low,
Some("high") => VideoQuality::High,
_ => VideoQuality::Medium, // Default and fallback for "medium"
};
let base_storage_path = PathBuf::from(
storage_config.storage_path.unwrap_or_else(|| "./meteor_events".to_string())
);
Ok(StorageConfig {
frame_buffer_size: storage_config.frame_buffer_size.unwrap_or(200),
base_storage_path,
retention_days: storage_config.retention_days.unwrap_or(30),
video_quality,
cleanup_interval_hours: storage_config.cleanup_interval_hours.unwrap_or(24),
})
}
/// Load communication configuration from TOML file
pub fn load_communication_config() -> Result<CommunicationConfig> {
let config_path = get_app_config_file_path();
let communication_config = if config_path.exists() {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read app config file: {:?}", config_path))?;
let app_config: AppConfig = toml::from_str(&content)
.context("Failed to parse app config file as TOML")?;
app_config.communication.unwrap_or_default()
} else {
println!("📄 No app config file found, using default communication settings");
CommunicationConfigToml::default()
};
// Convert TOML config to CommunicationConfig
Ok(CommunicationConfig {
api_base_url: communication_config.api_base_url.unwrap_or_else(|| "http://localhost:3000".to_string()),
retry_attempts: communication_config.retry_attempts.unwrap_or(3),
retry_delay_seconds: communication_config.retry_delay_seconds.unwrap_or(2),
max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60),
request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300),
heartbeat_interval_seconds: communication_config.heartbeat_interval_seconds.unwrap_or(300),
})
}
/// Create a sample app configuration file
pub fn create_sample_app_config() -> Result<()> {
let config_path = get_app_config_file_path();
if config_path.exists() {
println!("📄 App config file already exists at: {:?}", config_path);
return Ok(());
}
let sample_config = AppConfig {
camera: Some(CameraConfigToml::default()),
storage: Some(StorageConfigToml::default()),
communication: Some(CommunicationConfigToml::default()),
};
// Ensure the parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
let content = toml::to_string_pretty(&sample_config)
.context("Failed to serialize sample config to TOML")?;
fs::write(&config_path, content)
.with_context(|| format!("Failed to write sample config file: {:?}", config_path))?;
println!("✅ Sample app config created at: {:?}", config_path);
Ok(())
}
/// Get the path for app configuration file (separate from device config)
fn get_app_config_file_path() -> PathBuf {
// Try standard system config location first
let system_config = Path::new("/etc/meteor-client/app-config.toml");
if system_config.parent().map_or(false, |p| p.exists()) {
return system_config.to_path_buf();
}
// Fallback to user config directory
if let Some(config_dir) = dirs::config_dir() {
let user_config = config_dir.join("meteor-client").join("app-config.toml");
return user_config;
}
// Last resort: local directory
PathBuf::from("meteor-app-config.toml")
}
/// Determines the appropriate config file path based on the system
fn get_config_file_path() -> PathBuf {
// Try standard system config location first
let system_config = Path::new("/etc/meteor-client/config.toml");
if system_config.parent().map_or(false, |p| p.exists()) {
return system_config.to_path_buf();
}
// Fallback to user config directory
if let Some(config_dir) = dirs::config_dir() {
let user_config = config_dir.join("meteor-client").join("config.toml");
return user_config;
}
// Last resort: local directory
PathBuf::from("meteor-client-config.toml")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_config_creation() {
let config = Config::new("TEST_DEVICE_123".to_string());
assert!(!config.registered);
assert_eq!(config.hardware_id, "TEST_DEVICE_123");
assert!(config.user_profile_id.is_none());
assert!(config.device_id.is_none());
}
#[test]
fn test_config_mark_registered() {
let mut config = Config::new("TEST_DEVICE_123".to_string());
config.mark_registered("user-456".to_string(), "device-789".to_string(), "test-jwt-token".to_string());
assert!(config.registered);
assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456");
assert_eq!(config.device_id.as_ref().unwrap(), "device-789");
assert_eq!(config.jwt_token.as_ref().unwrap(), "test-jwt-token");
assert!(config.registered_at.is_some());
}
#[test]
fn test_config_save_and_load() -> Result<()> {
let temp_file = NamedTempFile::new()?;
let config_manager = ConfigManager::with_path(temp_file.path());
let mut config = Config::new("TEST_DEVICE_456".to_string());
config.mark_registered("user-123".to_string(), "device-456".to_string(), "test-jwt-456".to_string());
// Save config
config_manager.save_config(&config)?;
assert!(config_manager.config_exists());
// Load config
let loaded_config = config_manager.load_config()?;
assert!(loaded_config.registered);
assert_eq!(loaded_config.hardware_id, "TEST_DEVICE_456");
assert_eq!(loaded_config.user_profile_id.as_ref().unwrap(), "user-123");
assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456");
assert_eq!(loaded_config.jwt_token.as_ref().unwrap(), "test-jwt-456");
Ok(())
}
}