🎉 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
This commit is contained in:
parent
a04d6eba88
commit
46d8af6084
@ -21,3 +21,4 @@ retry_attempts = 3 # Number of upload retry attempts
|
|||||||
retry_delay_seconds = 2 # Initial delay between retries in seconds
|
retry_delay_seconds = 2 # Initial delay between retries in seconds
|
||||||
max_retry_delay_seconds = 60 # Maximum delay between retries in seconds
|
max_retry_delay_seconds = 60 # Maximum delay between retries in seconds
|
||||||
request_timeout_seconds = 300 # Request timeout for uploads in seconds (5 minutes)
|
request_timeout_seconds = 300 # Request timeout for uploads in seconds (5 minutes)
|
||||||
|
heartbeat_interval_seconds = 300 # Heartbeat interval in seconds (5 minutes)
|
||||||
@ -10,6 +10,13 @@ pub struct RegisterDeviceRequest {
|
|||||||
pub hardware_id: String,
|
pub hardware_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request payload for device heartbeat
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HeartbeatRequest {
|
||||||
|
pub hardware_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Response from successful device registration
|
/// Response from successful device registration
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -141,6 +148,57 @@ impl ApiClient {
|
|||||||
anyhow::bail!("Backend health check failed: HTTP {}", response.status());
|
anyhow::bail!("Backend health check failed: HTTP {}", response.status());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends a heartbeat to the backend API
|
||||||
|
pub async fn send_heartbeat(
|
||||||
|
&self,
|
||||||
|
hardware_id: String,
|
||||||
|
jwt_token: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request_payload = HeartbeatRequest { hardware_id };
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/devices/heartbeat", self.base_url);
|
||||||
|
|
||||||
|
println!("💓 Sending heartbeat to backend at: {}", url);
|
||||||
|
println!("📱 Hardware ID: {}", request_payload.hardware_id);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", jwt_token))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&request_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to send heartbeat request")?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
println!("✅ Heartbeat sent successfully!");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unable to read response body".to_string());
|
||||||
|
|
||||||
|
// Try to parse error response for better error messages
|
||||||
|
if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&response_text) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Heartbeat failed (HTTP {}): {}",
|
||||||
|
status.as_u16(),
|
||||||
|
error_response.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Heartbeat failed (HTTP {}): {}",
|
||||||
|
status.as_u16(),
|
||||||
|
response_text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -158,6 +216,17 @@ mod tests {
|
|||||||
assert!(json.contains("TEST_DEVICE_123"));
|
assert!(json.contains("TEST_DEVICE_123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_heartbeat_request_serialization() {
|
||||||
|
let request = HeartbeatRequest {
|
||||||
|
hardware_id: "TEST_DEVICE_456".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("hardwareId"));
|
||||||
|
assert!(json.contains("TEST_DEVICE_456"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_register_device_response_deserialization() {
|
fn test_register_device_response_deserialization() {
|
||||||
let json_response = r#"
|
let json_response = r#"
|
||||||
|
|||||||
@ -5,10 +5,11 @@ use tokio::time::sleep;
|
|||||||
|
|
||||||
use crate::events::{EventBus, SystemEvent, SystemStartedEvent};
|
use crate::events::{EventBus, SystemEvent, SystemStartedEvent};
|
||||||
use crate::camera::{CameraController, CameraConfig};
|
use crate::camera::{CameraController, CameraConfig};
|
||||||
use crate::config::{load_camera_config, load_storage_config, load_communication_config};
|
use crate::config::{load_camera_config, load_storage_config, load_communication_config, ConfigManager};
|
||||||
use crate::detection::{DetectionController, DetectionConfig};
|
use crate::detection::{DetectionController, DetectionConfig};
|
||||||
use crate::storage::{StorageController, StorageConfig};
|
use crate::storage::{StorageController, StorageConfig};
|
||||||
use crate::communication::{CommunicationController, CommunicationConfig};
|
use crate::communication::{CommunicationController, CommunicationConfig};
|
||||||
|
use crate::api::ApiClient;
|
||||||
|
|
||||||
/// Core application coordinator that manages the event bus and background tasks
|
/// Core application coordinator that manages the event bus and background tasks
|
||||||
pub struct Application {
|
pub struct Application {
|
||||||
@ -147,6 +148,8 @@ impl Application {
|
|||||||
// Initialize and start communication controller
|
// Initialize and start communication controller
|
||||||
println!("📡 Initializing communication controller...");
|
println!("📡 Initializing communication controller...");
|
||||||
let communication_config = load_communication_config()?;
|
let communication_config = load_communication_config()?;
|
||||||
|
let heartbeat_config = communication_config.clone(); // Clone before moving
|
||||||
|
|
||||||
let mut communication_controller = match CommunicationController::new(communication_config, self.event_bus.clone()) {
|
let mut communication_controller = match CommunicationController::new(communication_config, self.event_bus.clone()) {
|
||||||
Ok(controller) => controller,
|
Ok(controller) => controller,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -164,6 +167,16 @@ impl Application {
|
|||||||
|
|
||||||
self.background_tasks.push(communication_handle);
|
self.background_tasks.push(communication_handle);
|
||||||
|
|
||||||
|
// Initialize and start heartbeat task
|
||||||
|
println!("💓 Initializing heartbeat task...");
|
||||||
|
let heartbeat_handle = tokio::spawn(async move {
|
||||||
|
if let Err(e) = Self::run_heartbeat_task(heartbeat_config).await {
|
||||||
|
eprintln!("❌ Heartbeat task error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.background_tasks.push(heartbeat_handle);
|
||||||
|
|
||||||
// Run the main application loop
|
// Run the main application loop
|
||||||
println!("🔄 Starting main application loop...");
|
println!("🔄 Starting main application loop...");
|
||||||
self.main_loop().await?;
|
self.main_loop().await?;
|
||||||
@ -200,6 +213,60 @@ impl Application {
|
|||||||
pub fn subscriber_count(&self) -> usize {
|
pub fn subscriber_count(&self) -> usize {
|
||||||
self.event_bus.subscriber_count()
|
self.event_bus.subscriber_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Background task for sending heartbeat signals to the backend
|
||||||
|
async fn run_heartbeat_task(config: CommunicationConfig) -> Result<()> {
|
||||||
|
println!("💓 Starting heartbeat task...");
|
||||||
|
println!(" Heartbeat interval: {}s", config.heartbeat_interval_seconds);
|
||||||
|
|
||||||
|
let api_client = ApiClient::new(config.api_base_url.clone());
|
||||||
|
let config_manager = ConfigManager::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Wait for the configured interval
|
||||||
|
sleep(Duration::from_secs(config.heartbeat_interval_seconds)).await;
|
||||||
|
|
||||||
|
// Check if device is registered and has configuration
|
||||||
|
if !config_manager.config_exists() {
|
||||||
|
println!("⚠️ No device configuration found, skipping heartbeat");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_config = match config_manager.load_config() {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("❌ Failed to load device configuration: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip heartbeat if device is not registered
|
||||||
|
if !device_config.registered {
|
||||||
|
println!("⚠️ Device not registered, skipping heartbeat");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip heartbeat if no JWT token is available
|
||||||
|
let jwt_token = match device_config.jwt_token {
|
||||||
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
eprintln!("❌ No JWT token available for heartbeat authentication");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send heartbeat
|
||||||
|
match api_client.send_heartbeat(device_config.hardware_id, jwt_token).await {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("✅ Heartbeat sent successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("❌ Heartbeat failed: {}", e);
|
||||||
|
// Continue the loop - don't crash on heartbeat failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -17,6 +17,7 @@ pub struct CommunicationConfig {
|
|||||||
pub retry_delay_seconds: u64,
|
pub retry_delay_seconds: u64,
|
||||||
pub max_retry_delay_seconds: u64,
|
pub max_retry_delay_seconds: u64,
|
||||||
pub request_timeout_seconds: u64,
|
pub request_timeout_seconds: u64,
|
||||||
|
pub heartbeat_interval_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CommunicationConfig {
|
impl Default for CommunicationConfig {
|
||||||
@ -27,6 +28,7 @@ impl Default for CommunicationConfig {
|
|||||||
retry_delay_seconds: 2,
|
retry_delay_seconds: 2,
|
||||||
max_retry_delay_seconds: 60,
|
max_retry_delay_seconds: 60,
|
||||||
request_timeout_seconds: 300, // 5 minutes for large file uploads
|
request_timeout_seconds: 300, // 5 minutes for large file uploads
|
||||||
|
heartbeat_interval_seconds: 300, // 5 minutes for heartbeat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -309,5 +311,6 @@ mod tests {
|
|||||||
assert_eq!(config.retry_delay_seconds, 2);
|
assert_eq!(config.retry_delay_seconds, 2);
|
||||||
assert_eq!(config.max_retry_delay_seconds, 60);
|
assert_eq!(config.max_retry_delay_seconds, 60);
|
||||||
assert_eq!(config.request_timeout_seconds, 300);
|
assert_eq!(config.request_timeout_seconds, 300);
|
||||||
|
assert_eq!(config.heartbeat_interval_seconds, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -20,6 +20,8 @@ pub struct Config {
|
|||||||
pub user_profile_id: Option<String>,
|
pub user_profile_id: Option<String>,
|
||||||
/// Device ID returned from the registration API
|
/// Device ID returned from the registration API
|
||||||
pub device_id: Option<String>,
|
pub device_id: Option<String>,
|
||||||
|
/// JWT token for authentication with backend services
|
||||||
|
pub jwt_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@ -31,14 +33,16 @@ impl Config {
|
|||||||
registered_at: None,
|
registered_at: None,
|
||||||
user_profile_id: None,
|
user_profile_id: None,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
|
jwt_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the configuration as registered with the given details
|
/// Marks the configuration as registered with the given details
|
||||||
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String) {
|
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String, jwt_token: String) {
|
||||||
self.registered = true;
|
self.registered = true;
|
||||||
self.user_profile_id = Some(user_profile_id);
|
self.user_profile_id = Some(user_profile_id);
|
||||||
self.device_id = Some(device_id);
|
self.device_id = Some(device_id);
|
||||||
|
self.jwt_token = Some(jwt_token);
|
||||||
self.registered_at = Some(
|
self.registered_at = Some(
|
||||||
chrono::Utc::now().to_rfc3339()
|
chrono::Utc::now().to_rfc3339()
|
||||||
);
|
);
|
||||||
@ -157,6 +161,7 @@ pub struct CommunicationConfigToml {
|
|||||||
pub retry_delay_seconds: Option<u64>,
|
pub retry_delay_seconds: Option<u64>,
|
||||||
pub max_retry_delay_seconds: Option<u64>,
|
pub max_retry_delay_seconds: Option<u64>,
|
||||||
pub request_timeout_seconds: Option<u64>,
|
pub request_timeout_seconds: Option<u64>,
|
||||||
|
pub heartbeat_interval_seconds: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CommunicationConfigToml {
|
impl Default for CommunicationConfigToml {
|
||||||
@ -167,6 +172,7 @@ impl Default for CommunicationConfigToml {
|
|||||||
retry_delay_seconds: Some(2),
|
retry_delay_seconds: Some(2),
|
||||||
max_retry_delay_seconds: Some(60),
|
max_retry_delay_seconds: Some(60),
|
||||||
request_timeout_seconds: Some(300),
|
request_timeout_seconds: Some(300),
|
||||||
|
heartbeat_interval_seconds: Some(300),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -272,6 +278,7 @@ pub fn load_communication_config() -> Result<CommunicationConfig> {
|
|||||||
retry_delay_seconds: communication_config.retry_delay_seconds.unwrap_or(2),
|
retry_delay_seconds: communication_config.retry_delay_seconds.unwrap_or(2),
|
||||||
max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60),
|
max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60),
|
||||||
request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300),
|
request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300),
|
||||||
|
heartbeat_interval_seconds: communication_config.heartbeat_interval_seconds.unwrap_or(300),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,11 +366,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_config_mark_registered() {
|
fn test_config_mark_registered() {
|
||||||
let mut config = Config::new("TEST_DEVICE_123".to_string());
|
let mut config = Config::new("TEST_DEVICE_123".to_string());
|
||||||
config.mark_registered("user-456".to_string(), "device-789".to_string());
|
config.mark_registered("user-456".to_string(), "device-789".to_string(), "test-jwt-token".to_string());
|
||||||
|
|
||||||
assert!(config.registered);
|
assert!(config.registered);
|
||||||
assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456");
|
assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456");
|
||||||
assert_eq!(config.device_id.as_ref().unwrap(), "device-789");
|
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());
|
assert!(config.registered_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,7 +381,7 @@ mod tests {
|
|||||||
let config_manager = ConfigManager::with_path(temp_file.path());
|
let config_manager = ConfigManager::with_path(temp_file.path());
|
||||||
|
|
||||||
let mut config = Config::new("TEST_DEVICE_456".to_string());
|
let mut config = Config::new("TEST_DEVICE_456".to_string());
|
||||||
config.mark_registered("user-123".to_string(), "device-456".to_string());
|
config.mark_registered("user-123".to_string(), "device-456".to_string(), "test-jwt-456".to_string());
|
||||||
|
|
||||||
// Save config
|
// Save config
|
||||||
config_manager.save_config(&config)?;
|
config_manager.save_config(&config)?;
|
||||||
@ -385,6 +393,7 @@ mod tests {
|
|||||||
assert_eq!(loaded_config.hardware_id, "TEST_DEVICE_456");
|
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.user_profile_id.as_ref().unwrap(), "user-123");
|
||||||
assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456");
|
assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456");
|
||||||
|
assert_eq!(loaded_config.jwt_token.as_ref().unwrap(), "test-jwt-456");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,7 @@ mod integration_tests {
|
|||||||
retry_delay_seconds: 1,
|
retry_delay_seconds: 1,
|
||||||
max_retry_delay_seconds: 2,
|
max_retry_delay_seconds: 2,
|
||||||
request_timeout_seconds: 5,
|
request_timeout_seconds: 5,
|
||||||
|
heartbeat_interval_seconds: 300,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut communication_controller = CommunicationController::new(communication_config, event_bus.clone()).unwrap();
|
let mut communication_controller = CommunicationController::new(communication_config, event_bus.clone()).unwrap();
|
||||||
|
|||||||
@ -128,7 +128,7 @@ async fn register_device(jwt_token: String, api_url: String) -> Result<()> {
|
|||||||
// Attempt registration
|
// Attempt registration
|
||||||
println!("📡 Registering device with backend...");
|
println!("📡 Registering device with backend...");
|
||||||
let registration_response = api_client
|
let registration_response = api_client
|
||||||
.register_device(hardware_id.clone(), jwt_token)
|
.register_device(hardware_id.clone(), jwt_token.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Save configuration
|
// Save configuration
|
||||||
@ -137,6 +137,7 @@ async fn register_device(jwt_token: String, api_url: String) -> Result<()> {
|
|||||||
config.mark_registered(
|
config.mark_registered(
|
||||||
registration_response.device.user_profile_id,
|
registration_response.device.user_profile_id,
|
||||||
registration_response.device.id,
|
registration_response.device.id,
|
||||||
|
jwt_token,
|
||||||
);
|
);
|
||||||
|
|
||||||
config_manager.save_config(&config)?;
|
config_manager.save_config(&config)?;
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
Subproject commit e3a9b283b272e59a12121a99672075d5cb360b6d
|
|
||||||
62
meteor-frontend/.dockerignore
Normal file
62
meteor-frontend/.dockerignore
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Operating system files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
jest-results.xml
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.eslintcache
|
||||||
41
meteor-frontend/.gitignore
vendored
Normal file
41
meteor-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
61
meteor-frontend/Dockerfile
Normal file
61
meteor-frontend/Dockerfile
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Multi-stage Dockerfile for Next.js application
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:18-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Stage 2: Build
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev dependencies)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production
|
||||||
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy the public folder
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set port environment variable
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "server.js"]
|
||||||
36
meteor-frontend/README.md
Normal file
36
meteor-frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
258
meteor-frontend/e2e/gallery.spec.ts
Normal file
258
meteor-frontend/e2e/gallery.spec.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Gallery Page E2E Tests', () => {
|
||||||
|
const testUser = {
|
||||||
|
email: 'e2e-test@meteor.dev',
|
||||||
|
password: 'TestPassword123',
|
||||||
|
displayName: 'E2E Test User'
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// 导航到首页
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should redirect to login when accessing gallery without authentication', async ({ page }) => {
|
||||||
|
// 直接访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 应该被重定向到登录页面
|
||||||
|
await expect(page).toHaveURL('/login');
|
||||||
|
|
||||||
|
// 检查登录页面元素
|
||||||
|
await expect(page.locator('h1')).toContainText('登录');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display gallery page after successful login', async ({ page }) => {
|
||||||
|
// 先注册用户
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.fill('input[name="email"]', testUser.email);
|
||||||
|
await page.fill('input[name="password"]', testUser.password);
|
||||||
|
await page.fill('input[name="displayName"]', testUser.displayName);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// 等待重定向到dashboard
|
||||||
|
await expect(page).toHaveURL('/dashboard');
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 检查gallery页面元素
|
||||||
|
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||||
|
|
||||||
|
// 检查空状态消息
|
||||||
|
await expect(page.locator('text=No events captured yet')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display loading state initially', async ({ page }) => {
|
||||||
|
// 登录用户
|
||||||
|
await loginUser(page, testUser);
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 检查loading状态(可能很快消失,所以用waitFor)
|
||||||
|
const loadingText = page.locator('text=Loading events...');
|
||||||
|
if (await loadingText.isVisible()) {
|
||||||
|
await expect(loadingText).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终应该显示空状态或事件列表
|
||||||
|
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle API errors gracefully', async ({ page }) => {
|
||||||
|
// 模拟API错误
|
||||||
|
await page.route('**/api/v1/events**', route => {
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
body: JSON.stringify({ error: 'Server Error' })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录用户
|
||||||
|
await loginUser(page, testUser);
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 检查错误消息
|
||||||
|
await expect(page.locator('text=Error loading events')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display events when API returns data', async ({ page }) => {
|
||||||
|
// 模拟API返回测试数据
|
||||||
|
const mockEvents = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'test-event-1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'meteor',
|
||||||
|
capturedAt: '2024-01-01T12:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/test1.jpg',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-01T12:00:00Z',
|
||||||
|
metadata: { location: 'Test Location 1' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test-event-2',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'satellite',
|
||||||
|
capturedAt: '2024-01-01T11:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/test2.jpg',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-01T11:00:00Z',
|
||||||
|
metadata: { location: 'Test Location 2' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
nextCursor: null
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route('**/api/v1/events**', route => {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(mockEvents)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录用户
|
||||||
|
await loginUser(page, testUser);
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 检查事件卡片
|
||||||
|
await expect(page.locator('[data-testid="event-card"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('text=Type: meteor')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Type: satellite')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Location: Test Location 1')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Location: Test Location 2')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should implement infinite scroll', async ({ page }) => {
|
||||||
|
// 模拟分页数据
|
||||||
|
let pageCount = 0;
|
||||||
|
await page.route('**/api/v1/events**', route => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const cursor = url.searchParams.get('cursor');
|
||||||
|
|
||||||
|
if (!cursor) {
|
||||||
|
// 第一页
|
||||||
|
pageCount = 1;
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
id: `event-page1-${i}`,
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'meteor',
|
||||||
|
capturedAt: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
|
mediaUrl: `https://example.com/page1-${i}.jpg`,
|
||||||
|
isValid: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
})),
|
||||||
|
nextCursor: 'page2-cursor'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else if (cursor === 'page2-cursor') {
|
||||||
|
// 第二页
|
||||||
|
pageCount = 2;
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
id: `event-page2-${i}`,
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'satellite',
|
||||||
|
capturedAt: new Date(Date.now() - (i + 5) * 60000).toISOString(),
|
||||||
|
mediaUrl: `https://example.com/page2-${i}.jpg`,
|
||||||
|
isValid: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
})),
|
||||||
|
nextCursor: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录用户
|
||||||
|
await loginUser(page, testUser);
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 等待第一页加载
|
||||||
|
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(5);
|
||||||
|
|
||||||
|
// 滚动到底部触发infinite scroll
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待第二页加载
|
||||||
|
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(8);
|
||||||
|
|
||||||
|
// 检查loading more状态
|
||||||
|
const loadingMore = page.locator('text=Loading more events...');
|
||||||
|
if (await loadingMore.isVisible()) {
|
||||||
|
await expect(loadingMore).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be responsive on mobile devices', async ({ page }) => {
|
||||||
|
// 设置移动设备视口
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
// 模拟事件数据
|
||||||
|
await page.route('**/api/v1/events**', route => {
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [{
|
||||||
|
id: 'mobile-test-event',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'meteor',
|
||||||
|
capturedAt: '2024-01-01T12:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/mobile-test.jpg',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-01T12:00:00Z'
|
||||||
|
}],
|
||||||
|
nextCursor: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录用户
|
||||||
|
await loginUser(page, testUser);
|
||||||
|
|
||||||
|
// 访问gallery页面
|
||||||
|
await page.goto('/gallery');
|
||||||
|
|
||||||
|
// 检查移动端布局
|
||||||
|
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||||
|
await expect(page.locator('[data-testid="event-card"]')).toBeVisible();
|
||||||
|
|
||||||
|
// 检查网格布局在移动端的响应性
|
||||||
|
const grid = page.locator('[data-testid="gallery-grid"]');
|
||||||
|
if (await grid.isVisible()) {
|
||||||
|
const gridColumns = await grid.evaluate(el =>
|
||||||
|
window.getComputedStyle(el).gridTemplateColumns
|
||||||
|
);
|
||||||
|
// 移动端应该是单列
|
||||||
|
expect(gridColumns).toContain('1fr');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 辅助函数:登录用户
|
||||||
|
async function loginUser(page: any, user: typeof testUser) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('input[name="email"]', user.email);
|
||||||
|
await page.fill('input[name="password"]', user.password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
16
meteor-frontend/eslint.config.mjs
Normal file
16
meteor-frontend/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
15
meteor-frontend/jest.config.js
Normal file
15
meteor-frontend/jest.config.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const nextJest = require('next/jest')
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||||
|
dir: './',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add any custom config to be passed to Jest
|
||||||
|
const customJestConfig = {
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||||
|
module.exports = createJestConfig(customJestConfig)
|
||||||
1
meteor-frontend/jest.setup.js
Normal file
1
meteor-frontend/jest.setup.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
7
meteor-frontend/next.config.ts
Normal file
7
meteor-frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
11077
meteor-frontend/package-lock.json
generated
Normal file
11077
meteor-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
meteor-frontend/package.json
Normal file
47
meteor-frontend/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "meteor-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "jest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:integration": "npm run test && npm run test:e2e"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@playwright/test": "^1.54.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.534.0",
|
||||||
|
"next": "15.4.5",
|
||||||
|
"playwright": "^1.54.1",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-intersection-observer": "^9.16.0",
|
||||||
|
"zod": "^4.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.6.4",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.4.5",
|
||||||
|
"jest": "^30.0.5",
|
||||||
|
"jest-environment-jsdom": "^30.0.5",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
meteor-frontend/playwright.config.ts
Normal file
43
meteor-frontend/playwright.config.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3001',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:3001',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
5
meteor-frontend/postcss.config.mjs
Normal file
5
meteor-frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
meteor-frontend/public/file.svg
Normal file
1
meteor-frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
meteor-frontend/public/globe.svg
Normal file
1
meteor-frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
meteor-frontend/public/next.svg
Normal file
1
meteor-frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
meteor-frontend/public/vercel.svg
Normal file
1
meteor-frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
meteor-frontend/public/window.svg
Normal file
1
meteor-frontend/public/window.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
117
meteor-frontend/src/app/dashboard/page.tsx
Normal file
117
meteor-frontend/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user, isAuthenticated, logout } = useAuth()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Welcome, {user?.email}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-3xl font-bold mb-4">
|
||||||
|
Welcome to your Dashboard!
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
You have successfully logged in to the Meteor platform. This is your personal dashboard where you can manage your account and access platform features.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold mb-2">
|
||||||
|
Device Monitoring
|
||||||
|
{!user?.hasActiveSubscription && <span className="text-xs text-muted-foreground ml-2">(Pro Feature)</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Monitor the real-time status of your registered devices
|
||||||
|
</p>
|
||||||
|
{user?.hasActiveSubscription ? (
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link href="/devices">View My Devices</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href="/subscription">Upgrade to Pro</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold mb-2">
|
||||||
|
Event Gallery
|
||||||
|
{!user?.hasActiveSubscription && <span className="text-xs text-muted-foreground ml-2">(Pro Feature)</span>}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Browse and explore captured meteor events
|
||||||
|
</p>
|
||||||
|
{user?.hasActiveSubscription ? (
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href="/gallery">View Gallery</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<Link href="/subscription">Upgrade to Pro</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold mb-2">Your Account Information</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p><strong>User ID:</strong> {user?.userId}</p>
|
||||||
|
<p><strong>Email:</strong> {user?.email}</p>
|
||||||
|
<p><strong>Display Name:</strong> {user?.displayName || "Not set"}</p>
|
||||||
|
<p><strong>Subscription:</strong>
|
||||||
|
<span className={`ml-1 ${user?.hasActiveSubscription ? 'text-green-600' : 'text-orange-600'}`}>
|
||||||
|
{user?.hasActiveSubscription ? 'Pro (Active)' : 'Free Plan'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!user?.hasActiveSubscription && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<Link href="/subscription">Upgrade to Pro</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
meteor-frontend/src/app/devices/page.tsx
Normal file
176
meteor-frontend/src/app/devices/page.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { StatusIndicator } from "@/components/ui/status-indicator"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useDevicesList } from "@/hooks/use-devices"
|
||||||
|
import { DeviceDto } from "@/types/device"
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth()
|
||||||
|
const { devices, isLoading, isError, error, refetch } = useDevicesList()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login")
|
||||||
|
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||||
|
router.push("/subscription?message=Device monitoring requires an active subscription")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, user, router])
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLastSeen = (lastSeenAt?: string) => {
|
||||||
|
if (!lastSeenAt) return "Never"
|
||||||
|
const date = new Date(lastSeenAt)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now"
|
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h1 className="text-2xl font-bold">My Devices</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Welcome, {user?.email}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
<span>/</span>
|
||||||
|
{user?.hasActiveSubscription ? (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href="/gallery">Gallery</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" disabled className="opacity-50 cursor-not-allowed">
|
||||||
|
Gallery (Pro)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-medium">Devices</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Device Monitoring</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Monitor the real-time status of your registered devices. Data refreshes automatically every 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
Refresh Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<div className="bg-destructive/10 border border-destructive rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold text-destructive mb-2">Error Loading Devices</h3>
|
||||||
|
<p className="text-sm text-destructive/80 mb-4">
|
||||||
|
{error?.message || "Failed to load devices. Please try again."}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => refetch()}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="bg-card border rounded-lg p-8">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="text-lg text-muted-foreground">Loading devices...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="bg-card border rounded-lg p-8 text-center">
|
||||||
|
<h3 className="font-semibold mb-2">No Devices Found</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
You haven't registered any devices yet. Register your first device to start monitoring.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline">Register Device</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card border rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 divide-y">
|
||||||
|
{devices.map((device: DeviceDto) => (
|
||||||
|
<div key={device.id} className="p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{device.deviceName || device.hardwareId}
|
||||||
|
</h3>
|
||||||
|
<StatusIndicator status={device.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{device.deviceName && (
|
||||||
|
<p><strong>Hardware ID:</strong> {device.hardwareId}</p>
|
||||||
|
)}
|
||||||
|
<p><strong>Last Seen:</strong> {formatLastSeen(device.lastSeenAt)}</p>
|
||||||
|
<p><strong>Registered:</strong> {new Date(device.registeredAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end text-sm text-muted-foreground">
|
||||||
|
<div className="text-xs">Device ID</div>
|
||||||
|
<div className="font-mono text-xs">{device.id.slice(0, 8)}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
Showing {devices.length} device{devices.length !== 1 ? 's' : ''} •
|
||||||
|
Auto-refresh enabled (every 30 seconds)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
meteor-frontend/src/app/events/[eventId]/page.tsx
Normal file
177
meteor-frontend/src/app/events/[eventId]/page.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useEvent } from "@/hooks/use-events"
|
||||||
|
import { MediaViewer } from "@/components/event-details/media-viewer"
|
||||||
|
import { MetadataDisplay } from "@/components/event-details/metadata-display"
|
||||||
|
|
||||||
|
interface EventDetailsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
eventId: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventDetailsPage({ params }: EventDetailsPageProps) {
|
||||||
|
const [eventId, setEventId] = React.useState<string>("")
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
params.then((resolvedParams) => {
|
||||||
|
setEventId(resolvedParams.eventId)
|
||||||
|
})
|
||||||
|
}, [params])
|
||||||
|
const router = useRouter()
|
||||||
|
const { user, isAuthenticated, isLoading: authLoading } = useAuth()
|
||||||
|
const { data: event, isLoading, isError, error } = useEvent(eventId)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login")
|
||||||
|
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||||
|
router.push("/subscription?message=Event details require an active subscription")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, user, router])
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||||
|
<div className="text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400">Loading event details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 text-red-400">Error Loading Event</h1>
|
||||||
|
<p className="text-gray-400 mb-6">
|
||||||
|
{error?.message || "Failed to load event details. Please try again."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Event Not Found</h1>
|
||||||
|
<p className="text-gray-400 mb-6">
|
||||||
|
The event you're looking for doesn't exist or you don't have permission to view it.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Navigation Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center text-gray-400 hover:text-white transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Gallery
|
||||||
|
</button>
|
||||||
|
<h1 className="text-3xl font-bold">Event Details</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Content */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Media Section */}
|
||||||
|
<MediaViewer event={event} />
|
||||||
|
|
||||||
|
{/* Details Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Event Overview */}
|
||||||
|
<div className="bg-gray-900 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Event Overview</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Event ID</label>
|
||||||
|
<p className="font-mono text-sm">{event.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Event Type</label>
|
||||||
|
<p className="capitalize">{event.eventType}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Captured At</label>
|
||||||
|
<p>{new Date(event.capturedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Device ID</label>
|
||||||
|
<p className="font-mono text-sm">{event.deviceId}</p>
|
||||||
|
</div>
|
||||||
|
{event.validationScore && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Validation Score</label>
|
||||||
|
<p className="text-green-400">{(parseFloat(event.validationScore) * 100).toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500">Status</label>
|
||||||
|
<p className={`inline-flex px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
event.isValid ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
|
||||||
|
}`}>
|
||||||
|
{event.isValid ? 'Valid' : 'Invalid'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Section */}
|
||||||
|
<MetadataDisplay metadata={event.metadata || {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
meteor-frontend/src/app/favicon.ico
Normal file
BIN
meteor-frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
148
meteor-frontend/src/app/gallery/page.tsx
Normal file
148
meteor-frontend/src/app/gallery/page.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useInView } from "react-intersection-observer"
|
||||||
|
import { useAllEvents } from "@/hooks/use-events"
|
||||||
|
import { GalleryGrid } from "@/components/gallery/gallery-grid"
|
||||||
|
import { LoadingState, LoadingMoreState, ScrollForMoreState } from "@/components/gallery/loading-state"
|
||||||
|
import { EmptyState } from "@/components/gallery/empty-state"
|
||||||
|
import { DatePicker } from "@/components/ui/date-picker"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export default function GalleryPage() {
|
||||||
|
const { user, isAuthenticated, isLoading: authLoading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
events,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useAllEvents(selectedDate)
|
||||||
|
|
||||||
|
const { ref, inView } = useInView({
|
||||||
|
threshold: 0,
|
||||||
|
rootMargin: "100px",
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAuthenticated) {
|
||||||
|
router.push("/login")
|
||||||
|
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||||
|
router.push("/subscription?message=Gallery access requires an active subscription")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, authLoading, user, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||||
|
<div className="text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Event Gallery</h1>
|
||||||
|
<div className="text-center text-red-400">
|
||||||
|
Error loading events: {error?.message || "Unknown error"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-8">Event Gallery</h1>
|
||||||
|
|
||||||
|
{/* Date Filter */}
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="date-filter" className="text-sm text-gray-400">
|
||||||
|
Filter by date:
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<DatePicker
|
||||||
|
id="date-filter"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={setSelectedDate}
|
||||||
|
className="w-48"
|
||||||
|
placeholder="Select a date..."
|
||||||
|
/>
|
||||||
|
{selectedDate && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedDate(null)}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Clear Filter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedDate && (
|
||||||
|
<div className="text-sm text-gray-400 mt-2 sm:mt-0 sm:ml-4">
|
||||||
|
Showing events from {new Date(selectedDate + 'T00:00:00').toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && events.length === 0 ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
selectedDate ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<h2 className="text-xl text-gray-400 mb-4">No events found for selected date</h2>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
No meteor events were captured on {new Date(selectedDate + 'T00:00:00').toLocaleDateString()}.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSelectedDate(null)}
|
||||||
|
>
|
||||||
|
View All Events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GalleryGrid events={events} />
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div ref={ref}>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<LoadingMoreState />
|
||||||
|
) : (
|
||||||
|
<ScrollForMoreState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
meteor-frontend/src/app/globals.css
Normal file
79
meteor-frontend/src/app/globals.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #171717;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #171717;
|
||||||
|
--primary: #171717;
|
||||||
|
--primary-foreground: #fafafa;
|
||||||
|
--secondary: #f5f5f5;
|
||||||
|
--secondary-foreground: #171717;
|
||||||
|
--muted: #f5f5f5;
|
||||||
|
--muted-foreground: #737373;
|
||||||
|
--accent: #f5f5f5;
|
||||||
|
--accent-foreground: #171717;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fafafa;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--input: #e5e5e5;
|
||||||
|
--ring: #171717;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
--card: #0a0a0a;
|
||||||
|
--card-foreground: #ededed;
|
||||||
|
--popover: #0a0a0a;
|
||||||
|
--popover-foreground: #ededed;
|
||||||
|
--primary: #ededed;
|
||||||
|
--primary-foreground: #0a0a0a;
|
||||||
|
--secondary: #262626;
|
||||||
|
--secondary-foreground: #ededed;
|
||||||
|
--muted: #262626;
|
||||||
|
--muted-foreground: #a3a3a3;
|
||||||
|
--accent: #262626;
|
||||||
|
--accent-foreground: #ededed;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ededed;
|
||||||
|
--border: #262626;
|
||||||
|
--input: #262626;
|
||||||
|
--ring: #d4d4d8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
40
meteor-frontend/src/app/layout.tsx
Normal file
40
meteor-frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
import QueryProvider from "@/contexts/query-provider";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryProvider>
|
||||||
|
{children}
|
||||||
|
</QueryProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
meteor-frontend/src/app/login/page.tsx
Normal file
46
meteor-frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { AuthForm } from "@/components/auth/auth-form"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { login, isLoading, isAuthenticated } = useAuth()
|
||||||
|
const [error, setError] = React.useState<string>("")
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.push("/dashboard")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
await login(data.email, data.password)
|
||||||
|
// The auth context will handle setting the user state
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Login failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<AuthForm
|
||||||
|
mode="login"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
meteor-frontend/src/app/page.test.tsx
Normal file
29
meteor-frontend/src/app/page.test.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import Home from './page'
|
||||||
|
import { AuthProvider } from '@/contexts/auth-context'
|
||||||
|
import QueryProvider from '@/contexts/query-provider'
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryProvider>
|
||||||
|
{children}
|
||||||
|
</QueryProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('Home page', () => {
|
||||||
|
it('renders the welcome title', () => {
|
||||||
|
render(<Home />, { wrapper: TestWrapper })
|
||||||
|
|
||||||
|
const heading = screen.getByRole('heading', { level: 1 })
|
||||||
|
expect(heading).toBeInTheDocument()
|
||||||
|
expect(heading).toHaveTextContent('分布式流星监测网络')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the English subtitle', () => {
|
||||||
|
render(<Home />, { wrapper: TestWrapper })
|
||||||
|
|
||||||
|
const subtitle = screen.getByText('Distributed Meteor Monitoring Network')
|
||||||
|
expect(subtitle).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
61
meteor-frontend/src/app/page.tsx
Normal file
61
meteor-frontend/src/app/page.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { isAuthenticated, user } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Navigation */}
|
||||||
|
<header className="absolute top-0 right-0 p-6 z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Welcome, {user?.email}
|
||||||
|
</span>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/login">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/register">Get Started</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||||
|
<main className="text-center px-4">
|
||||||
|
<h1 className="text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
分布式流星监测网络
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8">
|
||||||
|
Distributed Meteor Monitoring Network
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link href="/register">Join the Network</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<Link href="/login">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
meteor-frontend/src/app/register/page.tsx
Normal file
46
meteor-frontend/src/app/register/page.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { AuthForm } from "@/components/auth/auth-form"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { register, isLoading, isAuthenticated } = useAuth()
|
||||||
|
const [error, setError] = React.useState<string>("")
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.push("/dashboard")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
await register(data.email, data.password, data.displayName)
|
||||||
|
// The auth context will handle the redirect to dashboard
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Registration failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
|
<AuthForm
|
||||||
|
mode="register"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
286
meteor-frontend/src/app/subscription/page.tsx
Normal file
286
meteor-frontend/src/app/subscription/page.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { subscriptionApi, SubscriptionData } from "@/services/subscription"
|
||||||
|
|
||||||
|
export default function SubscriptionPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [redirectMessage, setRedirectMessage] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Get the message from URL search params client-side
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
setRedirectMessage(urlParams.get('message'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
|
const { data: subscriptionResponse, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['subscription'],
|
||||||
|
queryFn: subscriptionApi.getSubscription,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscription = subscriptionResponse?.subscription
|
||||||
|
|
||||||
|
const handleSubscribe = async () => {
|
||||||
|
try {
|
||||||
|
const currentUrl = window.location.origin
|
||||||
|
const checkoutResponse = await subscriptionApi.createCheckoutSession(
|
||||||
|
'price_1234567890', // This would be your actual Stripe price ID
|
||||||
|
`${currentUrl}/subscription?success=true`,
|
||||||
|
`${currentUrl}/subscription?canceled=true`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (checkoutResponse.success) {
|
||||||
|
window.location.href = checkoutResponse.session.url
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create checkout session:', error)
|
||||||
|
alert('Failed to start subscription process. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManageBilling = async () => {
|
||||||
|
try {
|
||||||
|
const currentUrl = window.location.origin
|
||||||
|
const portalResponse = await subscriptionApi.createCustomerPortalSession(
|
||||||
|
`${currentUrl}/subscription`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (portalResponse.success) {
|
||||||
|
window.location.href = portalResponse.url
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create customer portal session:', error)
|
||||||
|
alert('Failed to open billing management. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null // Will redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading subscription details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-red-800 font-semibold mb-2">Error Loading Subscription</h3>
|
||||||
|
<p className="text-red-700 mb-4">{(error as Error).message}</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{redirectMessage && (
|
||||||
|
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-blue-800">{redirectMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Manage Your Subscription</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
View and manage your subscription plan and billing information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subscription?.hasActiveSubscription ? (
|
||||||
|
<ActiveSubscriptionView
|
||||||
|
subscription={subscription}
|
||||||
|
onManageBilling={handleManageBilling}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NoSubscriptionView onSubscribe={handleSubscribe} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveSubscriptionView({
|
||||||
|
subscription,
|
||||||
|
onManageBilling
|
||||||
|
}: {
|
||||||
|
subscription: SubscriptionData
|
||||||
|
onManageBilling: () => void
|
||||||
|
}) {
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return 'N/A'
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string | null) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'text-green-600 bg-green-50 border-green-200'
|
||||||
|
case 'canceled':
|
||||||
|
case 'cancelled':
|
||||||
|
return 'text-red-600 bg-red-50 border-red-200'
|
||||||
|
case 'past_due':
|
||||||
|
return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-50 border-gray-200'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xl font-semibold">Current Subscription</h3>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(subscription.subscriptionStatus)}`}>
|
||||||
|
{subscription.subscriptionStatus ? subscription.subscriptionStatus.charAt(0).toUpperCase() + subscription.subscriptionStatus.slice(1) : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-lg">{subscription.currentPlan?.name}</h4>
|
||||||
|
<p className="text-muted-foreground">Your current subscription plan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Next Billing Date</p>
|
||||||
|
<p className="text-lg">{formatDate(subscription.nextBillingDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Plan ID</p>
|
||||||
|
<p className="text-sm font-mono text-muted-foreground">{subscription.currentPlan?.priceId}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold mb-4">Billing Management</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Manage your payment methods, view billing history, and update your subscription.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onManageBilling}>
|
||||||
|
Manage Billing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoSubscriptionView({ onSubscribe }: { onSubscribe: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">Available Plans</h3>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold">Pro Plan</h4>
|
||||||
|
<p className="text-muted-foreground">Full access to all platform features</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold">$29</div>
|
||||||
|
<div className="text-sm text-muted-foreground">per month</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2 mb-6">
|
||||||
|
<li className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||||
|
Unlimited device monitoring
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||||
|
Real-time event notifications
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||||
|
Advanced analytics dashboard
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||||
|
Priority customer support
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button onClick={onSubscribe} className="w-full">
|
||||||
|
Subscribe to Pro Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<h3 className="font-semibold mb-2">Why Subscribe?</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Subscribing to our Pro Plan gives you access to all premium features and ensures
|
||||||
|
uninterrupted service for your meteor monitoring needs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
meteor-frontend/src/components/auth/auth-form.tsx
Normal file
151
meteor-frontend/src/components/auth/auth-form.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { FormField } from "@/components/ui/form-field"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email("Please provide a valid email address"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.email("Please provide a valid email address"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters long")
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||||
|
"Password must contain at least one lowercase letter, one uppercase letter, and one number"
|
||||||
|
),
|
||||||
|
displayName: z.string().min(1, "Display name is required"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type LoginFormData = z.infer<typeof loginSchema>
|
||||||
|
export type RegisterFormData = z.infer<typeof registerSchema>
|
||||||
|
|
||||||
|
type AuthFormData = LoginFormData | RegisterFormData
|
||||||
|
|
||||||
|
interface AuthFormProps {
|
||||||
|
mode: "login" | "register"
|
||||||
|
onSubmit: (data: AuthFormData) => Promise<void>
|
||||||
|
isLoading?: boolean
|
||||||
|
error?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthForm({ mode, onSubmit, isLoading, error, className }: AuthFormProps) {
|
||||||
|
const schema = mode === "login" ? loginSchema : registerSchema
|
||||||
|
const isRegister = mode === "register"
|
||||||
|
|
||||||
|
const form = useForm<AuthFormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
...(isRegister && { displayName: "" }),
|
||||||
|
} as AuthFormData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (data: AuthFormData) => {
|
||||||
|
try {
|
||||||
|
await onSubmit(data)
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is managed by parent component
|
||||||
|
console.error("Form submission error:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full max-w-md mx-auto", className)}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{isRegister ? "Create Account" : "Welcome Back"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{isRegister
|
||||||
|
? "Enter your information to create an account"
|
||||||
|
: "Enter your email and password to sign in"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
error={form.formState.errors.email?.message}
|
||||||
|
{...form.register("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<FormField
|
||||||
|
label="Display Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your display name"
|
||||||
|
error={(form.formState.errors as any).displayName?.message} // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
{...form.register("displayName")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
error={form.formState.errors.password?.message}
|
||||||
|
{...form.register("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? isRegister
|
||||||
|
? "Creating Account..."
|
||||||
|
: "Signing In..."
|
||||||
|
: isRegister
|
||||||
|
? "Create Account"
|
||||||
|
: "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
{isRegister ? (
|
||||||
|
<p>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login" className="font-medium text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<a href="/register" className="font-medium text-primary hover:underline">
|
||||||
|
Create one
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { EventDto } from "@/services/events"
|
||||||
|
|
||||||
|
interface MediaViewerProps {
|
||||||
|
event: EventDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaViewer({ event }: MediaViewerProps) {
|
||||||
|
const isVideo = event.fileType?.startsWith('video/')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Media Container */}
|
||||||
|
<div className="aspect-video bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
{isVideo ? (
|
||||||
|
<video
|
||||||
|
src={event.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
poster={event.mediaUrl.replace(/\.[^/.]+$/, '_thumbnail.jpg')}
|
||||||
|
>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={event.mediaUrl}
|
||||||
|
alt={`Event ${event.id}`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media Info */}
|
||||||
|
<div className="bg-gray-900 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold mb-2 text-white">Media Information</h3>
|
||||||
|
<div className="space-y-1 text-sm text-gray-300">
|
||||||
|
{event.originalFilename && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Filename:</span>
|
||||||
|
<span className="break-all ml-2">{event.originalFilename}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.fileType && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Type:</span>
|
||||||
|
<span className="ml-2">{event.fileType}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.fileSize && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Size:</span>
|
||||||
|
<span className="ml-2">{(parseInt(event.fileSize) / (1024 * 1024)).toFixed(2)} MB</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Media Type:</span>
|
||||||
|
<span className="ml-2 capitalize">{isVideo ? 'Video' : 'Image'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface MetadataDisplayProps {
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadataDisplay({ metadata }: MetadataDisplayProps) {
|
||||||
|
if (!metadata || Object.keys(metadata).length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-white">Metadata</h2>
|
||||||
|
<p className="text-gray-400 text-sm">No metadata available for this event.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatKey = (key: string): string => {
|
||||||
|
return key
|
||||||
|
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
|
||||||
|
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
|
||||||
|
.replace(/_/g, ' ') // Replace underscores with spaces
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'N/A'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'Yes' : 'No'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a date string
|
||||||
|
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLongValue = (value: unknown): boolean => {
|
||||||
|
const stringValue = formatValue(value)
|
||||||
|
return stringValue.length > 50 || stringValue.includes('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-white">Metadata</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(metadata).map(([key, value]) => (
|
||||||
|
<div key={key} className="border-b border-gray-800 pb-3 last:border-b-0">
|
||||||
|
<label className="text-sm text-gray-500 font-medium block mb-1">
|
||||||
|
{formatKey(key)}
|
||||||
|
</label>
|
||||||
|
<div className={`text-sm text-gray-300 ${isLongValue(value) ? 'whitespace-pre-wrap' : ''}`}>
|
||||||
|
{isLongValue(value) && typeof formatValue(value) === 'string' && formatValue(value).includes('{') ? (
|
||||||
|
<pre className="bg-gray-800 p-3 rounded text-xs overflow-x-auto">
|
||||||
|
{formatValue(value)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<span className="break-words">{formatValue(value)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
meteor-frontend/src/components/gallery/empty-state.tsx
Normal file
7
meteor-frontend/src/components/gallery/empty-state.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-400 text-lg">No events captured yet</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
meteor-frontend/src/components/gallery/event-card.test.tsx
Normal file
42
meteor-frontend/src/components/gallery/event-card.test.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { EventCard } from './event-card'
|
||||||
|
import { EventDto } from '@/services/events'
|
||||||
|
|
||||||
|
const mockEvent: EventDto = {
|
||||||
|
id: '123',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'meteor',
|
||||||
|
capturedAt: '2024-01-01T12:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/image.jpg',
|
||||||
|
fileSize: '1024',
|
||||||
|
fileType: 'image/jpeg',
|
||||||
|
originalFilename: 'meteor.jpg',
|
||||||
|
metadata: { location: 'Test Location' },
|
||||||
|
validationScore: '0.95',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-01T12:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockEventWithoutImage: EventDto = {
|
||||||
|
...mockEvent,
|
||||||
|
mediaUrl: '',
|
||||||
|
metadata: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventCard', () => {
|
||||||
|
it('renders event information correctly', () => {
|
||||||
|
render(<EventCard event={mockEvent} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Location: Test Location')).toBeInTheDocument()
|
||||||
|
expect(screen.getByAltText('Event meteor')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders without image when mediaUrl is empty', () => {
|
||||||
|
render(<EventCard event={mockEventWithoutImage} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('No Image')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Location:')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
41
meteor-frontend/src/components/gallery/event-card.tsx
Normal file
41
meteor-frontend/src/components/gallery/event-card.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { EventDto } from "@/services/events"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
interface EventCardProps {
|
||||||
|
event: EventDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventCard({ event }: EventCardProps) {
|
||||||
|
const location = event.metadata?.location as string | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-shadow" data-testid="event-card">
|
||||||
|
<div className="aspect-video bg-gray-800 flex items-center justify-center relative">
|
||||||
|
{event.mediaUrl ? (
|
||||||
|
<Image
|
||||||
|
src={event.mediaUrl}
|
||||||
|
alt={`Event ${event.eventType}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500">No Image</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-sm text-gray-400 mb-2">
|
||||||
|
{new Date(event.capturedAt).toLocaleDateString()} {new Date(event.capturedAt).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300 mb-1">
|
||||||
|
Type: {event.eventType}
|
||||||
|
</div>
|
||||||
|
{location && (
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
Location: {location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
meteor-frontend/src/components/gallery/gallery-grid.test.tsx
Normal file
41
meteor-frontend/src/components/gallery/gallery-grid.test.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { GalleryGrid } from './gallery-grid'
|
||||||
|
import { EventDto } from '@/services/events'
|
||||||
|
|
||||||
|
const mockEvents: EventDto[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
deviceId: 'device-1',
|
||||||
|
eventType: 'meteor',
|
||||||
|
capturedAt: '2024-01-01T12:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/image1.jpg',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-01T12:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
deviceId: 'device-2',
|
||||||
|
eventType: 'asteroid',
|
||||||
|
capturedAt: '2024-01-02T12:00:00Z',
|
||||||
|
mediaUrl: 'https://example.com/image2.jpg',
|
||||||
|
isValid: true,
|
||||||
|
createdAt: '2024-01-02T12:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('GalleryGrid', () => {
|
||||||
|
it('renders all events', () => {
|
||||||
|
render(<GalleryGrid events={mockEvents} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Type: asteroid')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty grid when no events', () => {
|
||||||
|
const { container } = render(<GalleryGrid events={[]} />)
|
||||||
|
|
||||||
|
const grid = container.querySelector('.grid')
|
||||||
|
expect(grid).toBeInTheDocument()
|
||||||
|
expect(grid?.children).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
meteor-frontend/src/components/gallery/gallery-grid.tsx
Normal file
16
meteor-frontend/src/components/gallery/gallery-grid.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { EventDto } from "@/services/events"
|
||||||
|
import { EventCard } from "./event-card"
|
||||||
|
|
||||||
|
interface GalleryGridProps {
|
||||||
|
events: EventDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GalleryGrid({ events }: GalleryGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" data-testid="gallery-grid">
|
||||||
|
{events.map((event) => (
|
||||||
|
<EventCard key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
meteor-frontend/src/components/gallery/loading-state.tsx
Normal file
23
meteor-frontend/src/components/gallery/loading-state.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-lg text-gray-400">Loading events...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingMoreState() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mt-8 py-4">
|
||||||
|
<div className="text-gray-400">Loading more events...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollForMoreState() {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mt-8 py-4">
|
||||||
|
<div className="text-gray-600">Scroll for more</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
meteor-frontend/src/components/gallery/states.test.tsx
Normal file
33
meteor-frontend/src/components/gallery/states.test.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { LoadingState, LoadingMoreState, ScrollForMoreState } from './loading-state'
|
||||||
|
import { EmptyState } from './empty-state'
|
||||||
|
|
||||||
|
describe('Gallery States', () => {
|
||||||
|
describe('LoadingState', () => {
|
||||||
|
it('renders loading message', () => {
|
||||||
|
render(<LoadingState />)
|
||||||
|
expect(screen.getByText('Loading events...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LoadingMoreState', () => {
|
||||||
|
it('renders loading more message', () => {
|
||||||
|
render(<LoadingMoreState />)
|
||||||
|
expect(screen.getByText('Loading more events...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ScrollForMoreState', () => {
|
||||||
|
it('renders scroll for more message', () => {
|
||||||
|
render(<ScrollForMoreState />)
|
||||||
|
expect(screen.getByText('Scroll for more')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders empty state message', () => {
|
||||||
|
render(<EmptyState />)
|
||||||
|
expect(screen.getByText('No events captured yet')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
57
meteor-frontend/src/components/ui/button.tsx
Normal file
57
meteor-frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
48
meteor-frontend/src/components/ui/date-picker.tsx
Normal file
48
meteor-frontend/src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const datePickerVariants = cva(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
error: "border-destructive focus-visible:ring-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface DatePickerProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'>,
|
||||||
|
VariantProps<typeof datePickerVariants> {
|
||||||
|
value?: string | null
|
||||||
|
onChange?: (date: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DatePicker = React.forwardRef<HTMLInputElement, DatePickerProps>(
|
||||||
|
({ className, variant, value, onChange, ...props }, ref) => {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value || null
|
||||||
|
onChange?.(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={cn(datePickerVariants({ variant, className }))}
|
||||||
|
ref={ref}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DatePicker.displayName = "DatePicker"
|
||||||
|
|
||||||
|
export { DatePicker, datePickerVariants }
|
||||||
51
meteor-frontend/src/components/ui/form-field.tsx
Normal file
51
meteor-frontend/src/components/ui/form-field.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label } from "./label"
|
||||||
|
import { Input, type InputProps } from "./input"
|
||||||
|
|
||||||
|
export interface FormFieldProps extends InputProps {
|
||||||
|
label: string
|
||||||
|
error?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
|
||||||
|
({ className, label, error, description, id, ...props }, ref) => {
|
||||||
|
const generatedId = React.useId()
|
||||||
|
const inputId = id || `field-${generatedId}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={inputId} className={error ? "text-destructive" : ""}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
ref={ref}
|
||||||
|
variant={error ? "error" : "default"}
|
||||||
|
className={className}
|
||||||
|
aria-describedby={
|
||||||
|
error ? `${inputId}-error` : description ? `${inputId}-description` : undefined
|
||||||
|
}
|
||||||
|
aria-invalid={error ? "true" : "false"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={`${inputId}-error`} className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{description && !error && (
|
||||||
|
<p id={`${inputId}-description`} className="text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FormField.displayName = "FormField"
|
||||||
|
|
||||||
|
export { FormField }
|
||||||
39
meteor-frontend/src/components/ui/input.tsx
Normal file
39
meteor-frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const inputVariants = cva(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
error: "border-destructive focus-visible:ring-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
VariantProps<typeof inputVariants> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, variant, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(inputVariants({ variant, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input, inputVariants }
|
||||||
26
meteor-frontend/src/components/ui/label.tsx
Normal file
26
meteor-frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
69
meteor-frontend/src/components/ui/status-indicator.tsx
Normal file
69
meteor-frontend/src/components/ui/status-indicator.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DeviceStatus } from "@/types/device"
|
||||||
|
|
||||||
|
const statusIndicatorVariants = cva(
|
||||||
|
"inline-flex items-center gap-2 text-sm font-medium",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
[DeviceStatus.ONLINE]: "text-green-700",
|
||||||
|
[DeviceStatus.OFFLINE]: "text-gray-500",
|
||||||
|
[DeviceStatus.ACTIVE]: "text-blue-600",
|
||||||
|
[DeviceStatus.INACTIVE]: "text-gray-400",
|
||||||
|
[DeviceStatus.MAINTENANCE]: "text-yellow-600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const dotVariants = cva(
|
||||||
|
"inline-block w-2 h-2 rounded-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
[DeviceStatus.ONLINE]: "bg-green-500",
|
||||||
|
[DeviceStatus.OFFLINE]: "bg-gray-400",
|
||||||
|
[DeviceStatus.ACTIVE]: "bg-blue-500",
|
||||||
|
[DeviceStatus.INACTIVE]: "bg-gray-300",
|
||||||
|
[DeviceStatus.MAINTENANCE]: "bg-yellow-500",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
[DeviceStatus.ONLINE]: "Online",
|
||||||
|
[DeviceStatus.OFFLINE]: "Offline",
|
||||||
|
[DeviceStatus.ACTIVE]: "Active",
|
||||||
|
[DeviceStatus.INACTIVE]: "Inactive",
|
||||||
|
[DeviceStatus.MAINTENANCE]: "Maintenance",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusIndicatorProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof statusIndicatorVariants> {
|
||||||
|
status: DeviceStatus
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusIndicator = React.forwardRef<HTMLDivElement, StatusIndicatorProps>(
|
||||||
|
({ className, status, showLabel = true, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(statusIndicatorVariants({ status, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className={cn(dotVariants({ status }))} />
|
||||||
|
{showLabel && (
|
||||||
|
<span>{statusLabels[status]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
StatusIndicator.displayName = "StatusIndicator"
|
||||||
|
|
||||||
|
export { StatusIndicator, statusIndicatorVariants }
|
||||||
182
meteor-frontend/src/contexts/auth-context.tsx
Normal file
182
meteor-frontend/src/contexts/auth-context.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
displayName: string | null
|
||||||
|
subscriptionStatus: string | null
|
||||||
|
hasActiveSubscription: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
register: (email: string, password: string, displayName: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
setUser: (user: User | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = React.createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = React.useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
|
||||||
|
const isAuthenticated = !!user
|
||||||
|
|
||||||
|
const fetchUserProfile = async (): Promise<User | null> => {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/auth/profile", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Token might be invalid, remove it
|
||||||
|
localStorage.removeItem("accessToken")
|
||||||
|
localStorage.removeItem("refreshToken")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileData = await response.json()
|
||||||
|
return {
|
||||||
|
userId: profileData.userId,
|
||||||
|
email: profileData.email,
|
||||||
|
displayName: profileData.displayName,
|
||||||
|
subscriptionStatus: profileData.subscriptionStatus,
|
||||||
|
hasActiveSubscription: profileData.hasActiveSubscription,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Network error or other issue
|
||||||
|
console.error("Failed to fetch user profile:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/auth/login-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.message || "Login failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Store tokens in localStorage (in production, consider httpOnly cookies)
|
||||||
|
localStorage.setItem("accessToken", data.accessToken)
|
||||||
|
localStorage.setItem("refreshToken", data.refreshToken)
|
||||||
|
|
||||||
|
// Fetch and set user profile data including subscription status
|
||||||
|
const userProfile = await fetchUserProfile()
|
||||||
|
if (userProfile) {
|
||||||
|
setUser(userProfile)
|
||||||
|
} else {
|
||||||
|
// Fallback to basic user data if profile fetch fails
|
||||||
|
setUser({
|
||||||
|
userId: data.userId,
|
||||||
|
email,
|
||||||
|
displayName: null,
|
||||||
|
subscriptionStatus: null,
|
||||||
|
hasActiveSubscription: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (email: string, password: string, displayName: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/auth/register-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password, displayName }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.message || "Registration failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
await response.json()
|
||||||
|
|
||||||
|
// After successful registration, automatically log in
|
||||||
|
await login(email, password)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("accessToken")
|
||||||
|
localStorage.removeItem("refreshToken")
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing token on mount and fetch user profile
|
||||||
|
React.useEffect(() => {
|
||||||
|
const initializeAuth = async () => {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (token) {
|
||||||
|
const userProfile = await fetchUserProfile()
|
||||||
|
if (userProfile) {
|
||||||
|
setUser(userProfile)
|
||||||
|
} else {
|
||||||
|
// Token is invalid or profile fetch failed, clean up
|
||||||
|
localStorage.removeItem("accessToken")
|
||||||
|
localStorage.removeItem("refreshToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
setUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = React.useContext(AuthContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
19
meteor-frontend/src/contexts/query-provider.tsx
Normal file
19
meteor-frontend/src/contexts/query-provider.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export default function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
39
meteor-frontend/src/hooks/use-devices.ts
Normal file
39
meteor-frontend/src/hooks/use-devices.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { devicesApi } from "@/services/devices"
|
||||||
|
import { DeviceDto } from "@/types/device"
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["devices"],
|
||||||
|
queryFn: () => devicesApi.getDevices(),
|
||||||
|
staleTime: 0, // Always consider data stale to enable frequent refetching
|
||||||
|
refetchInterval: 30 * 1000, // Refetch every 30 seconds
|
||||||
|
refetchIntervalInBackground: true, // Continue polling when tab is not in focus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevicesList(): {
|
||||||
|
devices: DeviceDto[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: Error | null
|
||||||
|
refetch: () => void
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useDevices()
|
||||||
|
|
||||||
|
const devices = data?.devices ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
devices,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
59
meteor-frontend/src/hooks/use-events.ts
Normal file
59
meteor-frontend/src/hooks/use-events.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||||
|
import { eventsApi, EventDto } from "@/services/events"
|
||||||
|
|
||||||
|
const EVENTS_LIMIT = 20
|
||||||
|
|
||||||
|
export function useEvents(date?: string | null) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["events", date],
|
||||||
|
queryFn: ({ pageParam }) => eventsApi.getEvents({
|
||||||
|
limit: EVENTS_LIMIT,
|
||||||
|
cursor: pageParam,
|
||||||
|
date: date || undefined
|
||||||
|
}),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllEvents(date?: string | null): {
|
||||||
|
events: EventDto[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: Error | null
|
||||||
|
hasNextPage: boolean
|
||||||
|
fetchNextPage: () => void
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useEvents(date)
|
||||||
|
|
||||||
|
const events = data?.pages.flatMap(page => page.data) ?? []
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
hasNextPage: hasNextPage ?? false,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEvent(eventId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["event", eventId],
|
||||||
|
queryFn: () => eventsApi.getEventById(eventId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
enabled: !!eventId, // Only run query if eventId is provided
|
||||||
|
})
|
||||||
|
}
|
||||||
5
meteor-frontend/src/lib/utils.ts
Normal file
5
meteor-frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return clsx(inputs)
|
||||||
|
}
|
||||||
27
meteor-frontend/src/services/devices.ts
Normal file
27
meteor-frontend/src/services/devices.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { DevicesResponse } from '../types/device'
|
||||||
|
|
||||||
|
export const devicesApi = {
|
||||||
|
async getDevices(): Promise<DevicesResponse> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/devices", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch devices: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
92
meteor-frontend/src/services/events.ts
Normal file
92
meteor-frontend/src/services/events.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
export interface EventDto {
|
||||||
|
id: string
|
||||||
|
deviceId: string
|
||||||
|
eventType: string
|
||||||
|
capturedAt: string
|
||||||
|
mediaUrl: string
|
||||||
|
fileSize?: string
|
||||||
|
fileType?: string
|
||||||
|
originalFilename?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
validationScore?: string
|
||||||
|
isValid: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventsResponse {
|
||||||
|
data: EventDto[]
|
||||||
|
nextCursor: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEventsParams {
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventsApi = {
|
||||||
|
async getEvents(params: GetEventsParams = {}): Promise<EventsResponse> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL("http://localhost:3000/api/v1/events")
|
||||||
|
|
||||||
|
if (params.limit) {
|
||||||
|
url.searchParams.append("limit", params.limit.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.cursor) {
|
||||||
|
url.searchParams.append("cursor", params.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.date) {
|
||||||
|
url.searchParams.append("date", params.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch events: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEventById(eventId: string): Promise<EventDto> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:3000/api/v1/events/${eventId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error("Event not found")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch event: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
111
meteor-frontend/src/services/subscription.ts
Normal file
111
meteor-frontend/src/services/subscription.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
export interface SubscriptionData {
|
||||||
|
hasActiveSubscription: boolean
|
||||||
|
subscriptionStatus: string | null
|
||||||
|
currentPlan: {
|
||||||
|
id: string
|
||||||
|
priceId: string
|
||||||
|
name: string
|
||||||
|
} | null
|
||||||
|
nextBillingDate: Date | null
|
||||||
|
customerId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionResponse {
|
||||||
|
success: boolean
|
||||||
|
subscription: SubscriptionData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerPortalResponse {
|
||||||
|
success: boolean
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckoutSessionResponse {
|
||||||
|
success: boolean
|
||||||
|
session: {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
customerId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscriptionApi = {
|
||||||
|
async getSubscription(): Promise<SubscriptionResponse> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/payments/subscription", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch subscription: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCustomerPortalSession(returnUrl: string): Promise<CustomerPortalResponse> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/payments/customer-portal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ returnUrl }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to create customer portal session: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCheckoutSession(priceId: string, successUrl: string, cancelUrl: string): Promise<CheckoutSessionResponse> {
|
||||||
|
const token = localStorage.getItem("accessToken")
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No access token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/payments/checkout-session/stripe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
priceId,
|
||||||
|
successUrl,
|
||||||
|
cancelUrl,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized - please login again")
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to create checkout session: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
23
meteor-frontend/src/types/device.ts
Normal file
23
meteor-frontend/src/types/device.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export enum DeviceStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
MAINTENANCE = 'maintenance',
|
||||||
|
ONLINE = 'online',
|
||||||
|
OFFLINE = 'offline',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDto {
|
||||||
|
id: string
|
||||||
|
userProfileId: string
|
||||||
|
hardwareId: string
|
||||||
|
deviceName?: string
|
||||||
|
status: DeviceStatus
|
||||||
|
lastSeenAt?: string
|
||||||
|
registeredAt: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicesResponse {
|
||||||
|
devices: DeviceDto[]
|
||||||
|
}
|
||||||
27
meteor-frontend/tsconfig.json
Normal file
27
meteor-frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
Subproject commit 18e60aa03a2bf01acf6331995e28319fedc903da
|
|
||||||
50
meteor-web-backend/.dockerignore
Normal file
50
meteor-web-backend/.dockerignore
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Distribution files
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Operating system files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test files and coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Other
|
||||||
|
.prettierrc
|
||||||
|
eslint.config.mjs
|
||||||
37
meteor-web-backend/.env.example
Normal file
37
meteor-web-backend/.env.example
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/meteor_dev
|
||||||
|
TEST_DATABASE_URL=postgresql://username:password@host:port/test_database_name
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_ACCESS_SECRET=your-super-secret-access-key-change-this-in-production
|
||||||
|
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
|
||||||
|
JWT_ACCESS_EXPIRATION=15m
|
||||||
|
JWT_REFRESH_EXPIRATION=7d
|
||||||
|
|
||||||
|
|
||||||
|
# Optional - Application Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Optional - Security Configuration
|
||||||
|
BCRYPT_SALT_ROUNDS=10
|
||||||
|
|
||||||
|
# AWS Configuration (required for event upload functionality)
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=your-aws-access-key-id
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key
|
||||||
|
AWS_S3_BUCKET_NAME=meteor-events-bucket
|
||||||
|
AWS_SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/meteor-events-queue
|
||||||
|
|
||||||
|
# Payment Provider Configuration
|
||||||
|
# Stripe
|
||||||
|
STRIPE_API_KEY=sk_test_your_stripe_secret_key_here
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret_here
|
||||||
|
|
||||||
|
# Additional payment providers can be added here:
|
||||||
|
# PAYPAL_CLIENT_ID=your_paypal_client_id
|
||||||
|
# PAYPAL_WEBHOOK_SECRET=your_paypal_webhook_secret
|
||||||
|
# ALIPAY_API_KEY=your_alipay_api_key
|
||||||
|
# ALIPAY_WEBHOOK_SECRET=your_alipay_webhook_secret
|
||||||
|
# WECHAT_API_KEY=your_wechat_api_key
|
||||||
|
# WECHAT_WEBHOOK_SECRET=your_wechat_webhook_secret
|
||||||
56
meteor-web-backend/.gitignore
vendored
Normal file
56
meteor-web-backend/.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
4
meteor-web-backend/.prettierrc
Normal file
4
meteor-web-backend/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
50
meteor-web-backend/Dockerfile
Normal file
50
meteor-web-backend/Dockerfile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Multi-stage Dockerfile for NestJS application
|
||||||
|
|
||||||
|
# Stage 1: Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev dependencies)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production stage
|
||||||
|
FROM node:18-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nestjs -u 1001
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Change ownership of the app directory to the nestjs user
|
||||||
|
RUN chown -R nestjs:nodejs /app
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
330
meteor-web-backend/EVENT_UPLOAD_README.md
Normal file
330
meteor-web-backend/EVENT_UPLOAD_README.md
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
# Event Data Upload API - Story 1.10 Implementation
|
||||||
|
|
||||||
|
This document describes the implementation of the Event Data Upload API that allows registered edge devices to securely upload event data (files and metadata) to the cloud platform.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Event Upload API provides a secure, asynchronous endpoint for edge devices to upload event data including:
|
||||||
|
- Media files (images, videos)
|
||||||
|
- Event metadata (timestamp, type, device information)
|
||||||
|
- Automatic cloud storage and processing queue integration
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /api/v1/events/upload
|
||||||
|
|
||||||
|
Uploads an event file with metadata from a registered device.
|
||||||
|
|
||||||
|
**Authentication**: JWT Bearer token required
|
||||||
|
**Content-Type**: multipart/form-data
|
||||||
|
**Response**: 202 Accepted
|
||||||
|
|
||||||
|
#### Request Format
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/events/upload
|
||||||
|
Authorization: Bearer <JWT_TOKEN>
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
Form Fields:
|
||||||
|
- file: Binary file data (image/video)
|
||||||
|
- eventData: JSON string containing event metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Event Data JSON Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventType": "motion",
|
||||||
|
"eventTimestamp": "2023-07-31T10:00:00Z",
|
||||||
|
"metadata": {
|
||||||
|
"deviceId": "device-uuid-here",
|
||||||
|
"location": "front_door",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"temperature": 22.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rawEventId": "event-uuid-here",
|
||||||
|
"message": "Event uploaded successfully and queued for processing"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Responses
|
||||||
|
|
||||||
|
- `401 Unauthorized`: Invalid or missing JWT token
|
||||||
|
- `400 Bad Request`: Missing file, invalid event data, unsupported file type
|
||||||
|
- `404 Not Found`: Device not found or doesn't belong to user
|
||||||
|
- `413 Payload Too Large`: File exceeds 100MB limit
|
||||||
|
- `500 Internal Server Error`: AWS or database failure
|
||||||
|
|
||||||
|
### GET /api/v1/events/user
|
||||||
|
|
||||||
|
Retrieves events for the authenticated user.
|
||||||
|
|
||||||
|
**Authentication**: JWT Bearer token required
|
||||||
|
|
||||||
|
#### Query Parameters
|
||||||
|
|
||||||
|
- `limit` (optional): Maximum number of events to return (default: 50)
|
||||||
|
- `offset` (optional): Number of events to skip (default: 0)
|
||||||
|
|
||||||
|
#### Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "event-uuid",
|
||||||
|
"deviceId": "device-uuid",
|
||||||
|
"eventType": "motion",
|
||||||
|
"eventTimestamp": "2023-07-31T10:00:00Z",
|
||||||
|
"filePath": "events/device-123/motion/2023-07-31_uuid.jpg",
|
||||||
|
"processingStatus": "pending",
|
||||||
|
"metadata": {
|
||||||
|
"location": "front_door",
|
||||||
|
"confidence": 0.95
|
||||||
|
},
|
||||||
|
"createdAt": "2023-07-31T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/events/device/:deviceId
|
||||||
|
|
||||||
|
Retrieves events for a specific device.
|
||||||
|
|
||||||
|
**Authentication**: JWT Bearer token required
|
||||||
|
|
||||||
|
#### Response Format
|
||||||
|
|
||||||
|
Same as `/api/v1/events/user` but filtered by device.
|
||||||
|
|
||||||
|
### GET /api/v1/events/health/check
|
||||||
|
|
||||||
|
Health check endpoint for the events service.
|
||||||
|
|
||||||
|
#### Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"checks": {
|
||||||
|
"database": true,
|
||||||
|
"aws": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### raw_events Table
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | uuid | Primary key |
|
||||||
|
| device_id | uuid | Foreign key to devices table |
|
||||||
|
| user_profile_id | uuid | Foreign key to user_profiles table |
|
||||||
|
| file_path | text | S3 file path |
|
||||||
|
| file_size | bigint | File size in bytes |
|
||||||
|
| file_type | varchar(100) | MIME type |
|
||||||
|
| original_filename | varchar(255) | Original filename |
|
||||||
|
| event_type | varchar(50) | Type of event |
|
||||||
|
| event_timestamp | timestamptz | When event occurred on device |
|
||||||
|
| metadata | jsonb | Additional event metadata |
|
||||||
|
| processing_status | varchar(20) | pending/processing/completed/failed |
|
||||||
|
| sqs_message_id | varchar(255) | SQS message ID for tracking |
|
||||||
|
| processed_at | timestamptz | When processing completed |
|
||||||
|
| created_at | timestamptz | Record creation time |
|
||||||
|
| updated_at | timestamptz | Record update time |
|
||||||
|
|
||||||
|
## AWS Integration
|
||||||
|
|
||||||
|
### S3 File Storage
|
||||||
|
|
||||||
|
Files are stored in S3 with the following structure:
|
||||||
|
```
|
||||||
|
events/{deviceId}/{eventType}/{timestamp}_{uuid}.{extension}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `events/device-123/motion/2023-07-31T10-00-00_a1b2c3d4.jpg`
|
||||||
|
|
||||||
|
### SQS Message Queue
|
||||||
|
|
||||||
|
After successful upload, a message is sent to SQS for async processing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rawEventId": "event-uuid",
|
||||||
|
"deviceId": "device-uuid",
|
||||||
|
"userProfileId": "user-uuid",
|
||||||
|
"eventType": "motion",
|
||||||
|
"timestamp": "2023-07-31T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AWS Configuration (required)
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=your-access-key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
AWS_S3_BUCKET_NAME=meteor-events-bucket
|
||||||
|
AWS_SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/meteor-events-queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Upload Limits
|
||||||
|
|
||||||
|
- Maximum file size: 100MB
|
||||||
|
- Supported file types:
|
||||||
|
- Images: JPEG, PNG, GIF, WebP
|
||||||
|
- Videos: MP4, AVI, QuickTime, X-MSVIDEO
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
1. **JWT Authentication**: All endpoints require valid JWT tokens
|
||||||
|
2. **Device Ownership Validation**: Users can only upload for their registered devices
|
||||||
|
3. **File Type Validation**: Only approved media types are accepted
|
||||||
|
4. **File Size Limits**: Prevents abuse with large file uploads
|
||||||
|
5. **Input Sanitization**: All inputs are validated and sanitized
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API implements comprehensive error handling:
|
||||||
|
|
||||||
|
- **Validation Errors**: Client-side input validation with detailed messages
|
||||||
|
- **Authentication Errors**: Clear JWT-related error responses
|
||||||
|
- **AWS Failures**: Graceful handling of S3/SQS service failures
|
||||||
|
- **Database Errors**: Transaction rollbacks and appropriate error responses
|
||||||
|
- **File Processing Errors**: Proper cleanup of partial uploads
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Run unit tests for the EventsService:
|
||||||
|
```bash
|
||||||
|
npm run test -- --testPathPattern=events.service.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Run end-to-end tests:
|
||||||
|
```bash
|
||||||
|
npm run test:e2e -- --testPathPattern=events.e2e-spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
Use the provided test script:
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install node-fetch@2 form-data
|
||||||
|
|
||||||
|
# Run test script
|
||||||
|
node test-event-upload.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
### Edge Device Implementation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function uploadEvent(filePath, eventData, jwtToken) {
|
||||||
|
const form = new FormData();
|
||||||
|
|
||||||
|
// Add file
|
||||||
|
form.append('file', fs.createReadStream(filePath));
|
||||||
|
|
||||||
|
// Add event metadata
|
||||||
|
form.append('eventData', JSON.stringify({
|
||||||
|
eventType: 'motion',
|
||||||
|
eventTimestamp: new Date().toISOString(),
|
||||||
|
metadata: {
|
||||||
|
deviceId: 'your-device-id',
|
||||||
|
location: 'front_door',
|
||||||
|
confidence: 0.95
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:3000/api/v1/events/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${jwtToken}`,
|
||||||
|
},
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Event uploaded:', result.rawEventId);
|
||||||
|
} else {
|
||||||
|
console.error('Upload failed:', await response.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### cURL Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/events/upload \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-F "file=@motion_capture.jpg" \
|
||||||
|
-F 'eventData={"eventType":"motion","eventTimestamp":"2023-07-31T10:00:00Z","metadata":{"deviceId":"device-uuid","location":"front_door"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Streaming Uploads**: Files are processed as streams to avoid memory issues
|
||||||
|
- **Async Processing**: Upload returns immediately; processing happens asynchronously
|
||||||
|
- **Database Indexing**: Optimized indexes for common query patterns
|
||||||
|
- **Connection Pooling**: Database connections are pooled for efficiency
|
||||||
|
|
||||||
|
## Monitoring and Observability
|
||||||
|
|
||||||
|
- **Health Checks**: `/api/v1/events/health/check` endpoint for monitoring
|
||||||
|
- **Structured Logging**: Comprehensive logging for debugging and monitoring
|
||||||
|
- **Error Tracking**: Detailed error logs with context information
|
||||||
|
- **Metrics**: Processing status tracking for analytics
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
1. **AWS Setup**: Ensure S3 bucket and SQS queue are created and accessible
|
||||||
|
2. **IAM Permissions**: Configure appropriate IAM roles for S3 and SQS access
|
||||||
|
3. **Environment Variables**: Set all required AWS configuration variables
|
||||||
|
4. **Database Migration**: Run migrations to create the raw_events table
|
||||||
|
5. **File Upload Limits**: Configure reverse proxy (nginx) for large file uploads
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future iterations:
|
||||||
|
|
||||||
|
1. **File Compression**: Automatic compression of uploaded files
|
||||||
|
2. **Batch Uploads**: Support for uploading multiple files at once
|
||||||
|
3. **Resumable Uploads**: Support for resuming interrupted uploads
|
||||||
|
4. **Image Processing**: Automatic thumbnail generation and image optimization
|
||||||
|
5. **Video Transcoding**: Automatic video format conversion and optimization
|
||||||
|
6. **Content Analysis**: AI-powered content analysis and tagging
|
||||||
|
7. **Retention Policies**: Automatic cleanup of old files based on policies
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions regarding the Event Upload API:
|
||||||
|
|
||||||
|
1. Check the application logs for detailed error information
|
||||||
|
2. Verify AWS credentials and service availability
|
||||||
|
3. Ensure database connectivity and table existence
|
||||||
|
4. Review file format and size requirements
|
||||||
179
meteor-web-backend/README.md
Normal file
179
meteor-web-backend/README.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Meteor Web Backend
|
||||||
|
|
||||||
|
A NestJS-based backend service for the Meteor application with user authentication.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- User registration with email/password
|
||||||
|
- Password hashing using bcrypt
|
||||||
|
- PostgreSQL database with TypeORM
|
||||||
|
- Database migrations
|
||||||
|
- Input validation
|
||||||
|
- Transaction support
|
||||||
|
- Comprehensive unit and integration tests
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v18 or higher)
|
||||||
|
- PostgreSQL database
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file based on `.env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/meteor_dev
|
||||||
|
BCRYPT_SALT_ROUNDS=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Setup
|
||||||
|
|
||||||
|
Run migrations to set up the database schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:up
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /api/v1/auth/register-email
|
||||||
|
|
||||||
|
Register a new user with email and password.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "Password123",
|
||||||
|
"displayName": "John Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "User registered successfully",
|
||||||
|
"userId": "uuid-string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- Email must be a valid email format
|
||||||
|
- Password must be at least 8 characters long
|
||||||
|
- Password must contain at least one lowercase letter, one uppercase letter, and one number
|
||||||
|
- Display name is required
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request` - Invalid input data
|
||||||
|
- `409 Conflict` - Email already registered
|
||||||
|
- `500 Internal Server Error` - Server error
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
### Create New Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:create migration-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── auth/ # Authentication module
|
||||||
|
│ ├── dto/ # Data transfer objects
|
||||||
|
│ ├── auth.controller.ts
|
||||||
|
│ ├── auth.service.ts
|
||||||
|
│ └── auth.module.ts
|
||||||
|
├── entities/ # TypeORM entities
|
||||||
|
│ ├── user-profile.entity.ts
|
||||||
|
│ └── user-identity.entity.ts
|
||||||
|
├── app.module.ts # Main application module
|
||||||
|
└── main.ts # Application entry point
|
||||||
|
|
||||||
|
migrations/ # Database migrations
|
||||||
|
test/ # Integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### user_profiles
|
||||||
|
- `id` (UUID, Primary Key)
|
||||||
|
- `display_name` (VARCHAR, nullable)
|
||||||
|
- `avatar_url` (TEXT, nullable)
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
- `updated_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
### user_identities
|
||||||
|
- `id` (UUID, Primary Key)
|
||||||
|
- `user_profile_id` (UUID, Foreign Key)
|
||||||
|
- `provider` (VARCHAR) - e.g., 'email'
|
||||||
|
- `provider_id` (VARCHAR) - e.g., email address
|
||||||
|
- `email` (VARCHAR, nullable, unique for email provider)
|
||||||
|
- `password_hash` (VARCHAR, nullable)
|
||||||
|
- `created_at` (TIMESTAMP)
|
||||||
|
- `updated_at` (TIMESTAMP)
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- Passwords are hashed using bcrypt with configurable salt rounds
|
||||||
|
- Email uniqueness validation
|
||||||
|
- Input sanitization and validation
|
||||||
|
- Database transactions for data consistency
|
||||||
|
- No sensitive data exposed in API responses
|
||||||
34
meteor-web-backend/eslint.config.mjs
Normal file
34
meteor-web-backend/eslint.config.mjs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
export const shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
// Create user_profiles table
|
||||||
|
pgm.createTable('user_profiles', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
display_name: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: false,
|
||||||
|
},
|
||||||
|
avatar_url: {
|
||||||
|
type: 'text',
|
||||||
|
notNull: false,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user_identities table
|
||||||
|
pgm.createTable('user_identities', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
user_profile_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'user_profiles(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
type: 'varchar(50)',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
provider_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: false,
|
||||||
|
},
|
||||||
|
password_hash: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: false,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create unique index on email for email provider
|
||||||
|
pgm.createIndex('user_identities', ['email'], {
|
||||||
|
unique: true,
|
||||||
|
where: "provider = 'email' AND email IS NOT NULL",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create unique index on provider and provider_id combination
|
||||||
|
pgm.createIndex('user_identities', ['provider', 'provider_id'], {
|
||||||
|
unique: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.dropTable('user_identities');
|
||||||
|
pgm.dropTable('user_profiles');
|
||||||
|
};
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
exports.shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
// Create inventory_devices table - for pre-registered legitimate devices
|
||||||
|
pgm.createTable('inventory_devices', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
hardware_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
is_claimed: {
|
||||||
|
type: 'boolean',
|
||||||
|
notNull: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
device_model: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
comment: 'Optional device model information',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create devices table - for user-claimed devices
|
||||||
|
pgm.createTable('devices', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
user_profile_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'user_profiles(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
hardware_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
device_name: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
comment: 'User-assigned friendly name for the device',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'varchar(50)',
|
||||||
|
notNull: true,
|
||||||
|
default: 'active',
|
||||||
|
comment: 'Device status: active, inactive, maintenance',
|
||||||
|
},
|
||||||
|
last_seen_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
comment: 'Last time device was seen online',
|
||||||
|
},
|
||||||
|
registered_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
pgm.createIndex('inventory_devices', 'hardware_id');
|
||||||
|
pgm.createIndex('inventory_devices', 'is_claimed');
|
||||||
|
pgm.createIndex('devices', 'user_profile_id');
|
||||||
|
pgm.createIndex('devices', 'hardware_id');
|
||||||
|
pgm.createIndex('devices', 'status');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('devices');
|
||||||
|
pgm.dropTable('inventory_devices');
|
||||||
|
};
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
exports.shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
// Create raw_events table for storing uploaded event data
|
||||||
|
pgm.createTable('raw_events', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
device_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'devices(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
comment: 'The device that uploaded this event',
|
||||||
|
},
|
||||||
|
user_profile_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'user_profiles(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
comment: 'Owner of the device',
|
||||||
|
},
|
||||||
|
file_path: {
|
||||||
|
type: 'text',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'S3 file path/key for the uploaded file',
|
||||||
|
},
|
||||||
|
file_size: {
|
||||||
|
type: 'bigint',
|
||||||
|
comment: 'File size in bytes',
|
||||||
|
},
|
||||||
|
file_type: {
|
||||||
|
type: 'varchar(100)',
|
||||||
|
comment: 'MIME type of the uploaded file',
|
||||||
|
},
|
||||||
|
original_filename: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
comment: 'Original filename from the client',
|
||||||
|
},
|
||||||
|
event_type: {
|
||||||
|
type: 'varchar(50)',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'Type of event (motion, alert, etc.)',
|
||||||
|
},
|
||||||
|
event_timestamp: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'When the event occurred on the device',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'jsonb',
|
||||||
|
comment: 'Additional event metadata from the device',
|
||||||
|
},
|
||||||
|
processing_status: {
|
||||||
|
type: 'varchar(20)',
|
||||||
|
notNull: true,
|
||||||
|
default: 'pending',
|
||||||
|
comment: 'Processing status: pending, processing, completed, failed',
|
||||||
|
},
|
||||||
|
sqs_message_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
comment: 'SQS message ID for tracking',
|
||||||
|
},
|
||||||
|
processed_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
comment: 'When processing was completed',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
pgm.createIndex('raw_events', 'device_id');
|
||||||
|
pgm.createIndex('raw_events', 'user_profile_id');
|
||||||
|
pgm.createIndex('raw_events', 'event_type');
|
||||||
|
pgm.createIndex('raw_events', 'event_timestamp');
|
||||||
|
pgm.createIndex('raw_events', 'processing_status');
|
||||||
|
pgm.createIndex('raw_events', ['device_id', 'event_timestamp']);
|
||||||
|
pgm.createIndex('raw_events', ['user_profile_id', 'created_at']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('raw_events');
|
||||||
|
};
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
exports.shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
// Create validated_events table for storing processed/validated event data
|
||||||
|
pgm.createTable('validated_events', {
|
||||||
|
id: {
|
||||||
|
type: 'uuid',
|
||||||
|
primaryKey: true,
|
||||||
|
default: pgm.func('gen_random_uuid()'),
|
||||||
|
},
|
||||||
|
raw_event_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
unique: true,
|
||||||
|
references: 'raw_events(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
comment: 'Reference to the original raw event',
|
||||||
|
},
|
||||||
|
device_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'devices(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
comment: 'The device that uploaded this event',
|
||||||
|
},
|
||||||
|
user_profile_id: {
|
||||||
|
type: 'uuid',
|
||||||
|
notNull: true,
|
||||||
|
references: 'user_profiles(id)',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
comment: 'Owner of the device',
|
||||||
|
},
|
||||||
|
media_url: {
|
||||||
|
type: 'text',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'URL to access the media file (could be S3 signed URL or CloudFront URL)',
|
||||||
|
},
|
||||||
|
file_size: {
|
||||||
|
type: 'bigint',
|
||||||
|
comment: 'File size in bytes',
|
||||||
|
},
|
||||||
|
file_type: {
|
||||||
|
type: 'varchar(100)',
|
||||||
|
comment: 'MIME type of the file',
|
||||||
|
},
|
||||||
|
original_filename: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
comment: 'Original filename from the client',
|
||||||
|
},
|
||||||
|
event_type: {
|
||||||
|
type: 'varchar(50)',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'Type of event (motion, alert, meteor, etc.)',
|
||||||
|
},
|
||||||
|
event_timestamp: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
comment: 'When the event occurred on the device',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'jsonb',
|
||||||
|
comment: 'Additional event metadata from the device',
|
||||||
|
},
|
||||||
|
validation_score: {
|
||||||
|
type: 'decimal(5,4)',
|
||||||
|
comment: 'Validation confidence score (0.0000 to 1.0000)',
|
||||||
|
},
|
||||||
|
validation_details: {
|
||||||
|
type: 'jsonb',
|
||||||
|
comment: 'Details from the validation process',
|
||||||
|
},
|
||||||
|
is_valid: {
|
||||||
|
type: 'boolean',
|
||||||
|
notNull: true,
|
||||||
|
default: true,
|
||||||
|
comment: 'Whether the event passed validation',
|
||||||
|
},
|
||||||
|
validation_algorithm: {
|
||||||
|
type: 'varchar(50)',
|
||||||
|
comment: 'Algorithm used for validation',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('now()'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes for better performance
|
||||||
|
pgm.createIndex('validated_events', 'raw_event_id');
|
||||||
|
pgm.createIndex('validated_events', 'device_id');
|
||||||
|
pgm.createIndex('validated_events', 'user_profile_id');
|
||||||
|
pgm.createIndex('validated_events', 'event_type');
|
||||||
|
pgm.createIndex('validated_events', 'event_timestamp');
|
||||||
|
pgm.createIndex('validated_events', 'is_valid');
|
||||||
|
pgm.createIndex('validated_events', ['device_id', 'event_timestamp']);
|
||||||
|
pgm.createIndex('validated_events', ['user_profile_id', 'created_at']);
|
||||||
|
pgm.createIndex('validated_events', ['event_type', 'is_valid']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropTable('validated_events');
|
||||||
|
};
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||||
|
*/
|
||||||
|
export const shorthands = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
// Add payment provider columns to user_profiles table
|
||||||
|
pgm.addColumns('user_profiles', {
|
||||||
|
payment_provider_customer_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: false,
|
||||||
|
comment: 'Customer ID from the payment provider (e.g., Stripe customer ID)',
|
||||||
|
},
|
||||||
|
payment_provider_subscription_id: {
|
||||||
|
type: 'varchar(255)',
|
||||||
|
notNull: false,
|
||||||
|
comment: 'Subscription ID from the payment provider (e.g., Stripe subscription ID)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index for faster lookups by payment provider customer ID
|
||||||
|
pgm.createIndex('user_profiles', ['payment_provider_customer_id'], {
|
||||||
|
unique: false,
|
||||||
|
where: 'payment_provider_customer_id IS NOT NULL',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index for faster lookups by payment provider subscription ID
|
||||||
|
pgm.createIndex('user_profiles', ['payment_provider_subscription_id'], {
|
||||||
|
unique: false,
|
||||||
|
where: 'payment_provider_subscription_id IS NOT NULL',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||||
|
* @param run {() => void | undefined}
|
||||||
|
* @returns {Promise<void> | void}
|
||||||
|
*/
|
||||||
|
export const down = (pgm) => {
|
||||||
|
// Drop indexes first
|
||||||
|
pgm.dropIndex('user_profiles', ['payment_provider_subscription_id']);
|
||||||
|
pgm.dropIndex('user_profiles', ['payment_provider_customer_id']);
|
||||||
|
|
||||||
|
// Drop columns
|
||||||
|
pgm.dropColumns('user_profiles', [
|
||||||
|
'payment_provider_customer_id',
|
||||||
|
'payment_provider_subscription_id',
|
||||||
|
]);
|
||||||
|
};
|
||||||
8
meteor-web-backend/nest-cli.json
Normal file
8
meteor-web-backend/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14186
meteor-web-backend/package-lock.json
generated
Normal file
14186
meteor-web-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
102
meteor-web-backend/package.json
Normal file
102
meteor-web-backend/package.json
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"name": "meteor-web-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"test:integration": "TEST_DATABASE_URL=postgresql://meteor_test:meteor_test_pass@localhost:5433/meteor_test jest --config ./test/jest-e2e.json --testPathPattern=integration",
|
||||||
|
"migrate:up": "node-pg-migrate up",
|
||||||
|
"migrate:down": "node-pg-migrate down",
|
||||||
|
"migrate:create": "node-pg-migrate create"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.856.0",
|
||||||
|
"@aws-sdk/client-sqs": "^3.856.0",
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.5",
|
||||||
|
"@nestjs/schedule": "^6.0.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"node-pg-migrate": "^8.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"stripe": "^18.4.0",
|
||||||
|
"typeorm": "^0.3.25",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@swc/cli": "^0.6.0",
|
||||||
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
meteor-web-backend/scripts/seed-inventory-devices.js
Normal file
68
meteor-web-backend/scripts/seed-inventory-devices.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function seedInventoryDevices() {
|
||||||
|
console.log('=== Seeding Inventory Devices ===');
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Sample inventory devices for testing
|
||||||
|
const deviceData = [
|
||||||
|
{ hardwareId: 'EDGE_DEVICE_001', deviceModel: 'EdgeBox Pro v1.0' },
|
||||||
|
{ hardwareId: 'EDGE_DEVICE_002', deviceModel: 'EdgeBox Pro v1.0' },
|
||||||
|
{ hardwareId: 'EDGE_DEVICE_003', deviceModel: 'EdgeBox Pro v1.1' },
|
||||||
|
{ hardwareId: 'SENSOR_NODE_001', deviceModel: 'SensorNode Lite v2.0' },
|
||||||
|
{ hardwareId: 'SENSOR_NODE_002', deviceModel: 'SensorNode Lite v2.0' },
|
||||||
|
{ hardwareId: 'GATEWAY_HUB_001', deviceModel: 'GatewayHub Enterprise v1.0' },
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('📦 Inserting sample inventory devices...');
|
||||||
|
|
||||||
|
for (const device of deviceData) {
|
||||||
|
// Check if device already exists
|
||||||
|
const existsResult = await client.query(
|
||||||
|
'SELECT id FROM inventory_devices WHERE hardware_id = $1',
|
||||||
|
[device.hardwareId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existsResult.rows.length === 0) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO inventory_devices (hardware_id, device_model, is_claimed)
|
||||||
|
VALUES ($1, $2, false)`,
|
||||||
|
[device.hardwareId, device.deviceModel]
|
||||||
|
);
|
||||||
|
console.log(` ✅ Added: ${device.hardwareId} (${device.deviceModel})`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⏭️ Skipped: ${device.hardwareId} (already exists)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show current inventory status
|
||||||
|
const inventoryResult = await client.query(`
|
||||||
|
SELECT hardware_id, device_model, is_claimed, created_at
|
||||||
|
FROM inventory_devices
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📋 Current Inventory Status:');
|
||||||
|
inventoryResult.rows.forEach(row => {
|
||||||
|
const status = row.is_claimed ? '🔴 CLAIMED' : '🟢 AVAILABLE';
|
||||||
|
console.log(` ${status} ${row.hardware_id} - ${row.device_model || 'No model'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Seeding completed! ${deviceData.length} devices processed.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error seeding inventory devices:', error.message);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedInventoryDevices();
|
||||||
70
meteor-web-backend/scripts/test-db-connection.js
Normal file
70
meteor-web-backend/scripts/test-db-connection.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const { Client } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
async function testDatabaseConnection() {
|
||||||
|
console.log('=== Database Connection Test ===');
|
||||||
|
console.log('DATABASE_URL from .env:', process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.error('❌ DATABASE_URL not found in environment variables');
|
||||||
|
console.log('Current environment variables:');
|
||||||
|
Object.keys(process.env).filter(key => key.includes('DATABASE')).forEach(key => {
|
||||||
|
console.log(`${key}: ${process.env[key]}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
ssl: false, // Set to true if your database requires SSL
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 Attempting to connect to database...');
|
||||||
|
await client.connect();
|
||||||
|
console.log('✅ Successfully connected to database!');
|
||||||
|
|
||||||
|
// Test a simple query
|
||||||
|
const result = await client.query('SELECT version()');
|
||||||
|
console.log('📊 Database version:', result.rows[0].version);
|
||||||
|
|
||||||
|
// Check if our tables exist
|
||||||
|
const tablesResult = await client.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN ('user_profiles', 'user_identities')
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📋 Found tables:', tablesResult.rows.map(row => row.table_name));
|
||||||
|
|
||||||
|
if (tablesResult.rows.length === 0) {
|
||||||
|
console.log('⚠️ No user tables found. You may need to run migrations.');
|
||||||
|
console.log('Run: npm run migrate:up');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database connection failed:', error.message);
|
||||||
|
console.error('🔍 Error details:', {
|
||||||
|
code: error.code,
|
||||||
|
errno: error.errno,
|
||||||
|
syscall: error.syscall,
|
||||||
|
address: error.address,
|
||||||
|
port: error.port
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.code === 'ENOTFOUND') {
|
||||||
|
console.error('🌐 DNS resolution failed - check if the host is correct');
|
||||||
|
} else if (error.code === 'ECONNREFUSED') {
|
||||||
|
console.error('🚫 Connection refused - check if database server is running');
|
||||||
|
} else if (error.code === '28P01') {
|
||||||
|
console.error('🔐 Authentication failed - check username/password');
|
||||||
|
} else if (error.code === '3D000') {
|
||||||
|
console.error('🗄️ Database does not exist - check database name');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testDatabaseConnection();
|
||||||
29
meteor-web-backend/src/app.controller.spec.ts
Normal file
29
meteor-web-backend/src/app.controller.spec.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('health', () => {
|
||||||
|
it('should return status ok', () => {
|
||||||
|
const result = appController.getHealth();
|
||||||
|
expect(result).toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
meteor-web-backend/src/app.controller.ts
Normal file
17
meteor-web-backend/src/app.controller.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('health')
|
||||||
|
getHealth(): { status: string } {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
48
meteor-web-backend/src/app.module.ts
Normal file
48
meteor-web-backend/src/app.module.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { DevicesModule } from './devices/devices.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
|
import { UserProfile } from './entities/user-profile.entity';
|
||||||
|
import { UserIdentity } from './entities/user-identity.entity';
|
||||||
|
import { Device } from './entities/device.entity';
|
||||||
|
import { InventoryDevice } from './entities/inventory-device.entity';
|
||||||
|
import { RawEvent } from './entities/raw-event.entity';
|
||||||
|
|
||||||
|
// Ensure dotenv is loaded before anything else
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
console.log('=== Database Configuration Debug ===');
|
||||||
|
console.log('DATABASE_URL from env:', process.env.DATABASE_URL);
|
||||||
|
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||||
|
console.log('Current working directory:', process.cwd());
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
TypeOrmModule.forRoot({
|
||||||
|
type: 'postgres',
|
||||||
|
url:
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
'postgresql://user:password@localhost:5432/meteor_dev',
|
||||||
|
entities: [UserProfile, UserIdentity, Device, InventoryDevice, RawEvent],
|
||||||
|
synchronize: false, // Use migrations instead
|
||||||
|
logging: ['error', 'warn', 'info', 'log'],
|
||||||
|
logger: 'advanced-console',
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 3000,
|
||||||
|
}),
|
||||||
|
AuthModule,
|
||||||
|
DevicesModule,
|
||||||
|
EventsModule,
|
||||||
|
PaymentsModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
8
meteor-web-backend/src/app.service.ts
Normal file
8
meteor-web-backend/src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
39
meteor-web-backend/src/auth/auth.controller.ts
Normal file
39
meteor-web-backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Request,
|
||||||
|
ValidationPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||||
|
import { LoginEmailDto } from './dto/login-email.dto';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@Controller('api/v1/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register-email')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async registerWithEmail(@Body(ValidationPipe) registerDto: RegisterEmailDto) {
|
||||||
|
return await this.authService.registerWithEmail(registerDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login-email')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async loginWithEmail(@Body(ValidationPipe) loginDto: LoginEmailDto) {
|
||||||
|
return await this.authService.loginWithEmail(loginDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getProfile(@Request() req: any) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
return await this.authService.getUserProfile(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
meteor-web-backend/src/auth/auth.module.ts
Normal file
27
meteor-web-backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { UserProfile } from '../entities/user-profile.entity';
|
||||||
|
import { UserIdentity } from '../entities/user-identity.entity';
|
||||||
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserProfile, UserIdentity]),
|
||||||
|
PassportModule,
|
||||||
|
JwtModule.register({
|
||||||
|
secret:
|
||||||
|
process.env.JWT_ACCESS_SECRET || 'default-secret-change-in-production',
|
||||||
|
signOptions: { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' },
|
||||||
|
}),
|
||||||
|
forwardRef(() => PaymentsModule),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
353
meteor-web-backend/src/auth/auth.service.spec.ts
Normal file
353
meteor-web-backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Repository, DataSource, QueryRunner } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { UserProfile } from '../entities/user-profile.entity';
|
||||||
|
import { UserIdentity } from '../entities/user-identity.entity';
|
||||||
|
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||||
|
import { LoginEmailDto } from './dto/login-email.dto';
|
||||||
|
|
||||||
|
jest.mock('bcrypt');
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let userProfileRepository: jest.Mocked<Repository<UserProfile>>;
|
||||||
|
let userIdentityRepository: jest.Mocked<Repository<UserIdentity>>;
|
||||||
|
let dataSource: jest.Mocked<DataSource>;
|
||||||
|
let queryRunner: jest.Mocked<QueryRunner>;
|
||||||
|
let jwtService: jest.Mocked<JwtService>;
|
||||||
|
|
||||||
|
const mockUserProfile = {
|
||||||
|
id: 'test-uuid-123',
|
||||||
|
displayName: 'Test User',
|
||||||
|
avatarUrl: null,
|
||||||
|
paymentProviderCustomerId: null,
|
||||||
|
paymentProviderSubscriptionId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
identities: [],
|
||||||
|
} as UserProfile;
|
||||||
|
|
||||||
|
const mockUserIdentity = {
|
||||||
|
id: 'identity-uuid-123',
|
||||||
|
userProfileId: 'test-uuid-123',
|
||||||
|
provider: 'email',
|
||||||
|
providerId: 'test@example.com',
|
||||||
|
email: 'test@example.com',
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
} as UserIdentity;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mockQueryRunner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
startTransaction: jest.fn(),
|
||||||
|
commitTransaction: jest.fn(),
|
||||||
|
rollbackTransaction: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
manager: {
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDataSource = {
|
||||||
|
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserProfileRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserIdentityRepo = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
sign: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserProfile),
|
||||||
|
useValue: mockUserProfileRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(UserIdentity),
|
||||||
|
useValue: mockUserIdentityRepo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DataSource,
|
||||||
|
useValue: mockDataSource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: JwtService,
|
||||||
|
useValue: mockJwtService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
userProfileRepository = module.get(getRepositoryToken(UserProfile));
|
||||||
|
userIdentityRepository = module.get(getRepositoryToken(UserIdentity));
|
||||||
|
dataSource = module.get(DataSource);
|
||||||
|
jwtService = module.get(JwtService);
|
||||||
|
queryRunner = mockQueryRunner as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerWithEmail', () => {
|
||||||
|
const validRegisterDto: RegisterEmailDto = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123',
|
||||||
|
displayName: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should register a new user successfully', async () => {
|
||||||
|
// Arrange
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||||
|
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||||
|
(queryRunner.manager.create as jest.Mock)
|
||||||
|
.mockReturnValueOnce(mockUserProfile)
|
||||||
|
.mockReturnValueOnce(mockUserIdentity);
|
||||||
|
(queryRunner.manager.save as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(mockUserProfile)
|
||||||
|
.mockResolvedValueOnce(mockUserIdentity);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.registerWithEmail(validRegisterDto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'test@example.com', provider: 'email' },
|
||||||
|
});
|
||||||
|
expect(bcrypt.hash).toHaveBeenCalledWith('Password123', 10);
|
||||||
|
expect(queryRunner.connect).toHaveBeenCalled();
|
||||||
|
expect(queryRunner.startTransaction).toHaveBeenCalled();
|
||||||
|
expect(queryRunner.commitTransaction).toHaveBeenCalled();
|
||||||
|
expect(queryRunner.release).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: 'User registered successfully',
|
||||||
|
userId: 'test-uuid-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException if email already exists', async () => {
|
||||||
|
// Arrange
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(mockUserIdentity);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.registerWithEmail(validRegisterDto)).rejects.toThrow(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'test@example.com', provider: 'email' },
|
||||||
|
});
|
||||||
|
expect(bcrypt.hash).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback transaction and throw InternalServerErrorException on database error', async () => {
|
||||||
|
// Arrange
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||||
|
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||||
|
(queryRunner.manager.save as jest.Mock).mockRejectedValue(
|
||||||
|
new Error('Database error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.registerWithEmail(validRegisterDto)).rejects.toThrow(
|
||||||
|
InternalServerErrorException,
|
||||||
|
);
|
||||||
|
expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
|
||||||
|
expect(queryRunner.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use environment variable for salt rounds', async () => {
|
||||||
|
// Arrange - Create a new service instance with the environment variable set
|
||||||
|
const originalEnv = process.env.BCRYPT_SALT_ROUNDS;
|
||||||
|
process.env.BCRYPT_SALT_ROUNDS = '12';
|
||||||
|
|
||||||
|
const testService = new AuthService(
|
||||||
|
userProfileRepository,
|
||||||
|
userIdentityRepository,
|
||||||
|
dataSource,
|
||||||
|
jwtService,
|
||||||
|
);
|
||||||
|
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||||
|
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||||
|
(queryRunner.manager.create as jest.Mock)
|
||||||
|
.mockReturnValueOnce(mockUserProfile)
|
||||||
|
.mockReturnValueOnce(mockUserIdentity);
|
||||||
|
(queryRunner.manager.save as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(mockUserProfile)
|
||||||
|
.mockResolvedValueOnce(mockUserIdentity);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await testService.registerWithEmail(validRegisterDto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(bcrypt.hash).toHaveBeenCalledWith('Password123', 12);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
if (originalEnv) {
|
||||||
|
process.env.BCRYPT_SALT_ROUNDS = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.BCRYPT_SALT_ROUNDS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginWithEmail', () => {
|
||||||
|
const validLoginDto: LoginEmailDto = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'Password123',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should login successfully with valid credentials', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockUserIdentityWithProfile = {
|
||||||
|
...mockUserIdentity,
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
userProfile: mockUserProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(
|
||||||
|
mockUserIdentityWithProfile,
|
||||||
|
);
|
||||||
|
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||||
|
jwtService.sign
|
||||||
|
.mockReturnValueOnce('access-token')
|
||||||
|
.mockReturnValueOnce('refresh-token');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await service.loginWithEmail(validLoginDto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'test@example.com', provider: 'email' },
|
||||||
|
relations: ['userProfile'],
|
||||||
|
});
|
||||||
|
expect(bcrypt.compare).toHaveBeenCalledWith(
|
||||||
|
'Password123',
|
||||||
|
'hashed-password',
|
||||||
|
);
|
||||||
|
expect(jwtService.sign).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toEqual({
|
||||||
|
message: 'Login successful',
|
||||||
|
userId: 'test-uuid-123',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when user does not exist', async () => {
|
||||||
|
// Arrange
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { email: 'test@example.com', provider: 'email' },
|
||||||
|
relations: ['userProfile'],
|
||||||
|
});
|
||||||
|
expect(bcrypt.compare).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when password is invalid', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockUserIdentityWithProfile = {
|
||||||
|
...mockUserIdentity,
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
userProfile: mockUserProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(
|
||||||
|
mockUserIdentityWithProfile,
|
||||||
|
);
|
||||||
|
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(bcrypt.compare).toHaveBeenCalledWith(
|
||||||
|
'Password123',
|
||||||
|
'hashed-password',
|
||||||
|
);
|
||||||
|
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when user has no password hash', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockUserIdentityWithoutPassword = {
|
||||||
|
...mockUserIdentity,
|
||||||
|
passwordHash: null,
|
||||||
|
userProfile: mockUserProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(
|
||||||
|
mockUserIdentityWithoutPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(bcrypt.compare).not.toHaveBeenCalled();
|
||||||
|
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate JWT tokens with correct payload', async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockUserIdentityWithProfile = {
|
||||||
|
...mockUserIdentity,
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
userProfile: mockUserProfile,
|
||||||
|
};
|
||||||
|
|
||||||
|
userIdentityRepository.findOne.mockResolvedValue(
|
||||||
|
mockUserIdentityWithProfile,
|
||||||
|
);
|
||||||
|
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||||
|
jwtService.sign
|
||||||
|
.mockReturnValueOnce('access-token')
|
||||||
|
.mockReturnValueOnce('refresh-token');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.loginWithEmail(validLoginDto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const expectedPayload = {
|
||||||
|
userId: 'test-uuid-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
sub: 'test-uuid-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(jwtService.sign).toHaveBeenNthCalledWith(1, expectedPayload);
|
||||||
|
expect(jwtService.sign).toHaveBeenNthCalledWith(2, expectedPayload, {
|
||||||
|
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||||
|
expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
187
meteor-web-backend/src/auth/auth.service.ts
Normal file
187
meteor-web-backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ConflictException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
UnauthorizedException,
|
||||||
|
NotFoundException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { UserProfile } from '../entities/user-profile.entity';
|
||||||
|
import { UserIdentity } from '../entities/user-identity.entity';
|
||||||
|
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||||
|
import { LoginEmailDto } from './dto/login-email.dto';
|
||||||
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly saltRounds: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserProfile)
|
||||||
|
private userProfileRepository: Repository<UserProfile>,
|
||||||
|
@InjectRepository(UserIdentity)
|
||||||
|
private userIdentityRepository: Repository<UserIdentity>,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
@Inject(forwardRef(() => PaymentsService))
|
||||||
|
private paymentsService: PaymentsService,
|
||||||
|
) {
|
||||||
|
this.saltRounds = parseInt(process.env.BCRYPT_SALT_ROUNDS || '10') || 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerWithEmail(
|
||||||
|
registerDto: RegisterEmailDto,
|
||||||
|
): Promise<{ message: string; userId: string }> {
|
||||||
|
const { email, password, displayName } = registerDto;
|
||||||
|
|
||||||
|
// Check if email is already registered
|
||||||
|
const existingIdentity = await this.userIdentityRepository.findOne({
|
||||||
|
where: { email, provider: 'email' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingIdentity) {
|
||||||
|
throw new ConflictException('Email is already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
const passwordHash = await bcrypt.hash(password, this.saltRounds);
|
||||||
|
|
||||||
|
// Create user in a transaction
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create user profile
|
||||||
|
const userProfile = queryRunner.manager.create(UserProfile, {
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
const savedProfile = await queryRunner.manager.save(userProfile);
|
||||||
|
|
||||||
|
// Create user identity
|
||||||
|
const userIdentity = queryRunner.manager.create(UserIdentity, {
|
||||||
|
userProfileId: savedProfile.id,
|
||||||
|
provider: 'email',
|
||||||
|
providerId: email,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(userIdentity);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'User registered successfully',
|
||||||
|
userId: savedProfile.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw new InternalServerErrorException('Failed to register user');
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithEmail(loginDto: LoginEmailDto): Promise<{
|
||||||
|
message: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}> {
|
||||||
|
const { email, password } = loginDto;
|
||||||
|
|
||||||
|
// Find user identity by email and provider
|
||||||
|
const userIdentity = await this.userIdentityRepository.findOne({
|
||||||
|
where: { email, provider: 'email' },
|
||||||
|
relations: ['userProfile'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if user exists and password matches
|
||||||
|
if (!userIdentity || !userIdentity.passwordHash) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(
|
||||||
|
password,
|
||||||
|
userIdentity.passwordHash,
|
||||||
|
);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT tokens
|
||||||
|
const payload = {
|
||||||
|
userId: userIdentity.userProfileId,
|
||||||
|
email: userIdentity.email,
|
||||||
|
sub: userIdentity.userProfileId, // Standard JWT claim
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
const refreshToken = this.jwtService.sign(payload, {
|
||||||
|
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||||
|
expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Login successful',
|
||||||
|
userId: userIdentity.userProfileId,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserProfile(userId: string): Promise<{
|
||||||
|
userId: string;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string;
|
||||||
|
subscriptionStatus: string | null;
|
||||||
|
hasActiveSubscription: boolean;
|
||||||
|
}> {
|
||||||
|
// Get user profile
|
||||||
|
const userProfile = await this.userProfileRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
relations: ['identities'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
throw new NotFoundException('User profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the primary email from identities
|
||||||
|
const emailIdentity = userProfile.identities.find(
|
||||||
|
(identity) => identity.provider === 'email',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!emailIdentity) {
|
||||||
|
throw new NotFoundException('User email not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscription status
|
||||||
|
try {
|
||||||
|
const subscriptionData =
|
||||||
|
await this.paymentsService.getUserSubscription(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: userProfile.id,
|
||||||
|
displayName: userProfile.displayName,
|
||||||
|
email: emailIdentity.email || '',
|
||||||
|
subscriptionStatus: subscriptionData.subscriptionStatus,
|
||||||
|
hasActiveSubscription: subscriptionData.hasActiveSubscription,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// If there's an error getting subscription, return without subscription info
|
||||||
|
return {
|
||||||
|
userId: userProfile.id,
|
||||||
|
displayName: userProfile.displayName,
|
||||||
|
email: emailIdentity.email || '',
|
||||||
|
subscriptionStatus: null,
|
||||||
|
hasActiveSubscription: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
meteor-web-backend/src/auth/dto/login-email.dto.ts
Normal file
11
meteor-web-backend/src/auth/dto/login-email.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginEmailDto {
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
@IsNotEmpty({ message: 'Email is required' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Password is required' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
26
meteor-web-backend/src/auth/dto/register-email.dto.ts
Normal file
26
meteor-web-backend/src/auth/dto/register-email.dto.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterEmailDto {
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
@IsNotEmpty({ message: 'Email is required' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Password is required' })
|
||||||
|
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||||
|
message:
|
||||||
|
'Password must contain at least one lowercase letter, one uppercase letter, and one number',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Display name is required' })
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
5
meteor-web-backend/src/auth/guards/jwt-auth.guard.ts
Normal file
5
meteor-web-backend/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
55
meteor-web-backend/src/auth/guards/subscription.guard.ts
Normal file
55
meteor-web-backend/src/auth/guards/subscription.guard.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserProfile } from '../../entities/user-profile.entity';
|
||||||
|
import { PaymentsService } from '../../payments/payments.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubscriptionGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserProfile)
|
||||||
|
private readonly userProfileRepository: Repository<UserProfile>,
|
||||||
|
@Inject(forwardRef(() => PaymentsService))
|
||||||
|
private readonly paymentsService: PaymentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !user.userId) {
|
||||||
|
throw new ForbiddenException('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user's subscription status using the same logic as the subscription endpoint
|
||||||
|
const subscriptionData = await this.paymentsService.getUserSubscription(
|
||||||
|
user.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subscriptionData.hasActiveSubscription) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'This feature requires an active subscription. Please subscribe to continue using this service.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's an error checking subscription, deny access for security
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Unable to verify subscription status. Please try again or contact support.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
meteor-web-backend/src/auth/strategies/jwt.strategy.ts
Normal file
47
meteor-web-backend/src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserProfile } from '../../entities/user-profile.entity';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
sub: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserProfile)
|
||||||
|
private userProfileRepository: Repository<UserProfile>,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey:
|
||||||
|
process.env.JWT_ACCESS_SECRET || 'default-secret-change-in-production',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const { userId } = payload;
|
||||||
|
|
||||||
|
// Verify user still exists
|
||||||
|
const user = await this.userProfileRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
email: payload.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
202
meteor-web-backend/src/aws/aws.service.ts
Normal file
202
meteor-web-backend/src/aws/aws.service.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
PutObjectCommandInput,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import {
|
||||||
|
SQSClient,
|
||||||
|
SendMessageCommand,
|
||||||
|
SendMessageCommandInput,
|
||||||
|
} from '@aws-sdk/client-sqs';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export interface UploadFileParams {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalFilename: string;
|
||||||
|
mimeType: string;
|
||||||
|
deviceId: string;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueMessageParams {
|
||||||
|
rawEventId: string;
|
||||||
|
deviceId: string;
|
||||||
|
userProfileId: string;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AwsService {
|
||||||
|
private readonly logger = new Logger(AwsService.name);
|
||||||
|
private readonly s3Client: S3Client;
|
||||||
|
private readonly sqsClient: SQSClient;
|
||||||
|
|
||||||
|
private readonly s3BucketName: string;
|
||||||
|
private readonly sqsQueueUrl: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const region = process.env.AWS_REGION || 'us-east-1';
|
||||||
|
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sqsClient = new SQSClient({
|
||||||
|
region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.s3BucketName =
|
||||||
|
process.env.AWS_S3_BUCKET_NAME || 'meteor-events-bucket';
|
||||||
|
this.sqsQueueUrl = process.env.AWS_SQS_QUEUE_URL || '';
|
||||||
|
|
||||||
|
this.logger.log(`AWS Service initialized with region: ${region}`);
|
||||||
|
this.logger.log(`S3 Bucket: ${this.s3BucketName}`);
|
||||||
|
this.logger.log(`SQS Queue URL: ${this.sqsQueueUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to S3 and return the file path
|
||||||
|
*/
|
||||||
|
async uploadFile(params: UploadFileParams): Promise<string> {
|
||||||
|
const { buffer, originalFilename, mimeType, deviceId, eventType } = params;
|
||||||
|
|
||||||
|
// Generate a unique file path
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const uniqueId = uuidv4();
|
||||||
|
const fileExtension = this.getFileExtension(originalFilename);
|
||||||
|
const filePath = `events/${deviceId}/${eventType}/${timestamp}_${uniqueId}${fileExtension}`;
|
||||||
|
|
||||||
|
const uploadParams: PutObjectCommandInput = {
|
||||||
|
Bucket: this.s3BucketName,
|
||||||
|
Key: filePath,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: mimeType,
|
||||||
|
Metadata: {
|
||||||
|
originalFilename,
|
||||||
|
deviceId,
|
||||||
|
eventType,
|
||||||
|
uploadTimestamp: timestamp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Uploading file to S3: ${filePath}`);
|
||||||
|
await this.s3Client.send(new PutObjectCommand(uploadParams));
|
||||||
|
this.logger.log(`Successfully uploaded file to S3: ${filePath}`);
|
||||||
|
return filePath;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to upload file to S3: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
throw new Error(`S3 upload failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to SQS queue for processing
|
||||||
|
*/
|
||||||
|
async sendProcessingMessage(params: QueueMessageParams): Promise<string> {
|
||||||
|
const { rawEventId, deviceId, userProfileId, eventType } = params;
|
||||||
|
|
||||||
|
const messageBody = {
|
||||||
|
rawEventId,
|
||||||
|
deviceId,
|
||||||
|
userProfileId,
|
||||||
|
eventType,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendParams: SendMessageCommandInput = {
|
||||||
|
QueueUrl: this.sqsQueueUrl,
|
||||||
|
MessageBody: JSON.stringify(messageBody),
|
||||||
|
MessageAttributes: {
|
||||||
|
eventType: {
|
||||||
|
DataType: 'String',
|
||||||
|
StringValue: eventType,
|
||||||
|
},
|
||||||
|
deviceId: {
|
||||||
|
DataType: 'String',
|
||||||
|
StringValue: deviceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Sending message to SQS for event: ${rawEventId}`);
|
||||||
|
const result = await this.sqsClient.send(
|
||||||
|
new SendMessageCommand(sendParams),
|
||||||
|
);
|
||||||
|
const messageId = result.MessageId;
|
||||||
|
this.logger.log(`Successfully sent message to SQS: ${messageId}`);
|
||||||
|
return messageId || '';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send message to SQS: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
|
throw new Error(`SQS send failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from filename
|
||||||
|
*/
|
||||||
|
private getFileExtension(filename: string): string {
|
||||||
|
const lastDotIndex = filename.lastIndexOf('.');
|
||||||
|
if (lastDotIndex === -1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return filename.substring(lastDotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for AWS services
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<{ s3: boolean; sqs: boolean }> {
|
||||||
|
const results = { s3: false, sqs: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test basic S3 connectivity by listing the bucket (without actually listing objects)
|
||||||
|
// We'll just check if we can construct a valid command
|
||||||
|
const testCommand = new PutObjectCommand({
|
||||||
|
Bucket: this.s3BucketName,
|
||||||
|
Key: 'health-check-test',
|
||||||
|
Body: Buffer.from('test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't actually send it, just validate that we can create the command
|
||||||
|
if (testCommand) {
|
||||||
|
results.s3 = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`S3 health check failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test SQS connectivity by checking if we can create a send command
|
||||||
|
const testMessage = new SendMessageCommand({
|
||||||
|
QueueUrl: this.sqsQueueUrl,
|
||||||
|
MessageBody: JSON.stringify({ test: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't actually send it, just validate configuration
|
||||||
|
if (testMessage && this.sqsQueueUrl) {
|
||||||
|
results.sqs = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`SQS health check failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
meteor-web-backend/src/devices/devices.controller.ts
Normal file
74
meteor-web-backend/src/devices/devices.controller.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { SubscriptionGuard } from '../auth/guards/subscription.guard';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||||
|
import { HeartbeatDto } from './dto/heartbeat.dto';
|
||||||
|
import { Device } from '../entities/device.entity';
|
||||||
|
|
||||||
|
@Controller('api/v1/devices')
|
||||||
|
@UseGuards(JwtAuthGuard, SubscriptionGuard)
|
||||||
|
export class DevicesController {
|
||||||
|
constructor(private readonly devicesService: DevicesService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async registerDevice(
|
||||||
|
@Body() registerDeviceDto: RegisterDeviceDto,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
device: Device;
|
||||||
|
}> {
|
||||||
|
const userProfileId = req.user.userId; // From JWT payload
|
||||||
|
|
||||||
|
const device = await this.devicesService.registerDevice(
|
||||||
|
registerDeviceDto,
|
||||||
|
userProfileId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Device registered successfully',
|
||||||
|
device,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getUserDevices(@Request() req: any): Promise<{
|
||||||
|
devices: Device[];
|
||||||
|
}> {
|
||||||
|
const userProfileId = req.user.userId;
|
||||||
|
const devices = await this.devicesService.findDevicesByUser(userProfileId);
|
||||||
|
|
||||||
|
return { devices };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('heartbeat')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async heartbeat(
|
||||||
|
@Body() heartbeatDto: HeartbeatDto,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const userProfileId = req.user.userId; // From JWT payload
|
||||||
|
|
||||||
|
await this.devicesService.processHeartbeat(
|
||||||
|
heartbeatDto.hardwareId,
|
||||||
|
userProfileId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Heartbeat processed successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
meteor-web-backend/src/devices/devices.module.ts
Normal file
14
meteor-web-backend/src/devices/devices.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { DevicesController } from './devices.controller';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
import { Device } from '../entities/device.entity';
|
||||||
|
import { InventoryDevice } from '../entities/inventory-device.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Device, InventoryDevice])],
|
||||||
|
controllers: [DevicesController],
|
||||||
|
providers: [DevicesService],
|
||||||
|
exports: [DevicesService],
|
||||||
|
})
|
||||||
|
export class DevicesModule {}
|
||||||
406
meteor-web-backend/src/devices/devices.service.spec.ts
Normal file
406
meteor-web-backend/src/devices/devices.service.spec.ts
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource, UpdateResult } from 'typeorm';
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DevicesService } from './devices.service';
|
||||||
|
import { Device, DeviceStatus } from '../entities/device.entity';
|
||||||
|
import { InventoryDevice } from '../entities/inventory-device.entity';
|
||||||
|
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||||
|
|
||||||
|
describe('DevicesService', () => {
|
||||||
|
let service: DevicesService;
|
||||||
|
let deviceRepository: Repository<Device>;
|
||||||
|
let inventoryDeviceRepository: Repository<InventoryDevice>;
|
||||||
|
let dataSource: DataSource;
|
||||||
|
|
||||||
|
const mockDeviceRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockInventoryDeviceRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockQueryRunner = {
|
||||||
|
connect: jest.fn(),
|
||||||
|
startTransaction: jest.fn(),
|
||||||
|
commitTransaction: jest.fn(),
|
||||||
|
rollbackTransaction: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
manager: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDataSource = {
|
||||||
|
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||||
|
transaction: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DevicesService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(Device),
|
||||||
|
useValue: mockDeviceRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(InventoryDevice),
|
||||||
|
useValue: mockInventoryDeviceRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DataSource,
|
||||||
|
useValue: mockDataSource,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<DevicesService>(DevicesService);
|
||||||
|
deviceRepository = module.get<Repository<Device>>(
|
||||||
|
getRepositoryToken(Device),
|
||||||
|
);
|
||||||
|
inventoryDeviceRepository = module.get<Repository<InventoryDevice>>(
|
||||||
|
getRepositoryToken(InventoryDevice),
|
||||||
|
);
|
||||||
|
dataSource = module.get<DataSource>(DataSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerDevice', () => {
|
||||||
|
const registerDeviceDto: RegisterDeviceDto = {
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
};
|
||||||
|
const userProfileId = 'user-123';
|
||||||
|
|
||||||
|
it('should successfully register a device', async () => {
|
||||||
|
const mockInventoryDevice = {
|
||||||
|
id: 'inventory-123',
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
isClaimed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockNewDevice = {
|
||||||
|
id: 'device-123',
|
||||||
|
userProfileId,
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
status: DeviceStatus.ACTIVE,
|
||||||
|
registeredAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock transaction behavior
|
||||||
|
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||||
|
const mockManager = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(mockInventoryDevice) // inventory device found
|
||||||
|
.mockResolvedValueOnce(null), // no existing device
|
||||||
|
create: jest.fn().mockReturnValue(mockNewDevice),
|
||||||
|
save: jest.fn().mockResolvedValue(mockNewDevice),
|
||||||
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
|
};
|
||||||
|
return fn(mockManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.registerDevice(
|
||||||
|
registerDeviceDto,
|
||||||
|
userProfileId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockNewDevice);
|
||||||
|
expect(mockDataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when hardware_id not in inventory', async () => {
|
||||||
|
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||||
|
const mockManager = {
|
||||||
|
findOne: jest.fn().mockResolvedValueOnce(null), // inventory device not found
|
||||||
|
};
|
||||||
|
return fn(mockManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Device with hardware ID '${registerDeviceDto.hardwareId}' is not found in inventory. Please contact support.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException when device is already claimed', async () => {
|
||||||
|
const mockInventoryDevice = {
|
||||||
|
id: 'inventory-123',
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
isClaimed: true, // Already claimed
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||||
|
const mockManager = {
|
||||||
|
findOne: jest.fn().mockResolvedValueOnce(mockInventoryDevice),
|
||||||
|
};
|
||||||
|
return fn(mockManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Device with hardware ID '${registerDeviceDto.hardwareId}' has already been claimed.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ConflictException when device is already registered', async () => {
|
||||||
|
const mockInventoryDevice = {
|
||||||
|
id: 'inventory-123',
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
isClaimed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockExistingDevice = {
|
||||||
|
id: 'device-456',
|
||||||
|
userProfileId: 'other-user',
|
||||||
|
hardwareId: 'TEST_DEVICE_001',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||||
|
const mockManager = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(mockInventoryDevice) // inventory device found
|
||||||
|
.mockResolvedValueOnce(mockExistingDevice), // existing device found
|
||||||
|
};
|
||||||
|
return fn(mockManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.registerDevice(registerDeviceDto, userProfileId),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Device with hardware ID '${registerDeviceDto.hardwareId}' is already registered.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDevicesByUser', () => {
|
||||||
|
it('should return user devices ordered by registration date', async () => {
|
||||||
|
const userProfileId = 'user-123';
|
||||||
|
const mockDevices = [
|
||||||
|
{
|
||||||
|
id: 'device-1',
|
||||||
|
userProfileId,
|
||||||
|
hardwareId: 'DEVICE_001',
|
||||||
|
registeredAt: new Date('2023-02-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-2',
|
||||||
|
userProfileId,
|
||||||
|
hardwareId: 'DEVICE_002',
|
||||||
|
registeredAt: new Date('2023-01-01'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockDeviceRepository.find.mockResolvedValue(mockDevices);
|
||||||
|
|
||||||
|
const result = await service.findDevicesByUser(userProfileId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDevices);
|
||||||
|
expect(mockDeviceRepository.find).toHaveBeenCalledWith({
|
||||||
|
where: { userProfileId },
|
||||||
|
order: { registeredAt: 'DESC' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findDeviceById', () => {
|
||||||
|
it('should return device when found', async () => {
|
||||||
|
const deviceId = 'device-123';
|
||||||
|
const userProfileId = 'user-123';
|
||||||
|
const mockDevice = {
|
||||||
|
id: deviceId,
|
||||||
|
userProfileId,
|
||||||
|
hardwareId: 'DEVICE_001',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||||
|
|
||||||
|
const result = await service.findDeviceById(deviceId, userProfileId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDevice);
|
||||||
|
expect(mockDeviceRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { id: deviceId, userProfileId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when device not found', async () => {
|
||||||
|
const deviceId = 'device-123';
|
||||||
|
const userProfileId = 'user-123';
|
||||||
|
|
||||||
|
mockDeviceRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.findDeviceById(deviceId, userProfileId),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.findDeviceById(deviceId, userProfileId),
|
||||||
|
).rejects.toThrow('Device not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processHeartbeat', () => {
|
||||||
|
const hardwareId = 'TEST_DEVICE_001';
|
||||||
|
const userProfileId = 'user-123';
|
||||||
|
|
||||||
|
it('should successfully process heartbeat for valid device', async () => {
|
||||||
|
const mockDevice = {
|
||||||
|
id: 'device-123',
|
||||||
|
userProfileId,
|
||||||
|
hardwareId,
|
||||||
|
status: DeviceStatus.OFFLINE,
|
||||||
|
lastSeenAt: new Date('2023-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUpdateResult = { affected: 1 } as UpdateResult;
|
||||||
|
|
||||||
|
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||||
|
mockDeviceRepository.update.mockResolvedValue(mockUpdateResult);
|
||||||
|
|
||||||
|
await service.processHeartbeat(hardwareId, userProfileId);
|
||||||
|
|
||||||
|
expect(mockDeviceRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { hardwareId },
|
||||||
|
});
|
||||||
|
expect(mockDeviceRepository.update).toHaveBeenCalledWith(
|
||||||
|
{ id: mockDevice.id },
|
||||||
|
expect.objectContaining({
|
||||||
|
status: DeviceStatus.ONLINE,
|
||||||
|
lastSeenAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when device not found', async () => {
|
||||||
|
mockDeviceRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.processHeartbeat(hardwareId, userProfileId),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(
|
||||||
|
service.processHeartbeat(hardwareId, userProfileId),
|
||||||
|
).rejects.toThrow(`Device with hardware ID '${hardwareId}' not found`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when device belongs to different user', async () => {
|
||||||
|
const mockDevice = {
|
||||||
|
id: 'device-123',
|
||||||
|
userProfileId: 'other-user-456',
|
||||||
|
hardwareId,
|
||||||
|
status: DeviceStatus.OFFLINE,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.processHeartbeat(hardwareId, userProfileId),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.processHeartbeat(hardwareId, userProfileId),
|
||||||
|
).rejects.toThrow('Device does not belong to the authenticated user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markStaleDevicesOffline', () => {
|
||||||
|
it('should mark stale devices as offline and return count', async () => {
|
||||||
|
const thresholdMinutes = 15;
|
||||||
|
const mockUpdateResult = { affected: 3 } as UpdateResult;
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||||
|
|
||||||
|
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||||
|
|
||||||
|
expect(result).toBe(3);
|
||||||
|
expect(mockDeviceRepository.createQueryBuilder).toHaveBeenCalled();
|
||||||
|
expect(mockQueryBuilder.update).toHaveBeenCalledWith(Device);
|
||||||
|
expect(mockQueryBuilder.set).toHaveBeenCalledWith({
|
||||||
|
status: DeviceStatus.OFFLINE,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
|
||||||
|
'lastSeenAt < :cutoffTime',
|
||||||
|
{ cutoffTime: expect.any(Date) },
|
||||||
|
);
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'status != :offlineStatus',
|
||||||
|
{ offlineStatus: DeviceStatus.OFFLINE },
|
||||||
|
);
|
||||||
|
expect(mockQueryBuilder.execute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when no devices need to be marked offline', async () => {
|
||||||
|
const thresholdMinutes = 15;
|
||||||
|
const mockUpdateResult = { affected: 0 } as UpdateResult;
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||||
|
|
||||||
|
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined affected count', async () => {
|
||||||
|
const thresholdMinutes = 15;
|
||||||
|
const mockUpdateResult = { affected: undefined } as UpdateResult;
|
||||||
|
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
set: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||||
|
|
||||||
|
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user