📋 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
265 lines
8.3 KiB
Rust
265 lines
8.3 KiB
Rust
use anyhow::{Context, Result};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::Duration;
|
|
|
|
/// Request payload for device registration
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RegisterDeviceRequest {
|
|
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
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RegisterDeviceResponse {
|
|
pub message: String,
|
|
pub device: DeviceInfo,
|
|
}
|
|
|
|
/// Device information returned from registration
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DeviceInfo {
|
|
pub id: String,
|
|
pub user_profile_id: String,
|
|
pub hardware_id: String,
|
|
pub status: String,
|
|
pub registered_at: String,
|
|
}
|
|
|
|
/// API error response structure
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ApiErrorResponse {
|
|
pub message: String,
|
|
#[serde(default)]
|
|
pub error: Option<String>,
|
|
#[serde(default)]
|
|
pub status_code: Option<u16>,
|
|
}
|
|
|
|
/// API client for communicating with the Meteor backend
|
|
pub struct ApiClient {
|
|
client: Client,
|
|
base_url: String,
|
|
}
|
|
|
|
impl ApiClient {
|
|
/// Creates a new API client with the given base URL
|
|
pub fn new(base_url: String) -> Self {
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("Failed to create HTTP client");
|
|
|
|
Self { client, base_url }
|
|
}
|
|
|
|
/// Creates a new API client with default localhost URL
|
|
pub fn default() -> Self {
|
|
Self::new("http://localhost:3000".to_string())
|
|
}
|
|
|
|
/// Registers a device with the backend API
|
|
pub async fn register_device(
|
|
&self,
|
|
hardware_id: String,
|
|
jwt_token: String,
|
|
) -> Result<RegisterDeviceResponse> {
|
|
let request_payload = RegisterDeviceRequest { hardware_id };
|
|
|
|
let url = format!("{}/api/v1/devices/register", self.base_url);
|
|
|
|
println!("🌐 Registering device with 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 registration request")?;
|
|
|
|
let status = response.status();
|
|
let response_text = response
|
|
.text()
|
|
.await
|
|
.context("Failed to read response body")?;
|
|
|
|
println!("📡 Response status: {}", status);
|
|
println!("📡 Response body: {}", response_text);
|
|
|
|
if status.is_success() {
|
|
let registration_response: RegisterDeviceResponse =
|
|
serde_json::from_str(&response_text)
|
|
.context("Failed to parse successful registration response")?;
|
|
|
|
println!("✅ Device registered successfully!");
|
|
println!(" Device ID: {}", registration_response.device.id);
|
|
println!(" User Profile ID: {}", registration_response.device.user_profile_id);
|
|
|
|
Ok(registration_response)
|
|
} else {
|
|
// Try to parse error response
|
|
if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&response_text) {
|
|
anyhow::bail!(
|
|
"Registration failed (HTTP {}): {}",
|
|
status.as_u16(),
|
|
error_response.message
|
|
);
|
|
} else {
|
|
anyhow::bail!(
|
|
"Registration failed (HTTP {}): {}",
|
|
status.as_u16(),
|
|
response_text
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Health check to verify backend connectivity
|
|
pub async fn health_check(&self) -> Result<()> {
|
|
let url = format!("{}/health", self.base_url);
|
|
|
|
println!("🏥 Checking backend health at: {}", url);
|
|
|
|
let response = self
|
|
.client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.context("Failed to reach backend health endpoint")?;
|
|
|
|
if response.status().is_success() {
|
|
println!("✅ Backend is healthy");
|
|
Ok(())
|
|
} else {
|
|
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)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_register_device_request_serialization() {
|
|
let request = RegisterDeviceRequest {
|
|
hardware_id: "TEST_DEVICE_123".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&request).unwrap();
|
|
assert!(json.contains("hardwareId"));
|
|
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]
|
|
fn test_register_device_response_deserialization() {
|
|
let json_response = r#"
|
|
{
|
|
"message": "Device registered successfully",
|
|
"device": {
|
|
"id": "device-123",
|
|
"userProfileId": "user-456",
|
|
"hardwareId": "TEST_DEVICE_123",
|
|
"status": "active",
|
|
"registeredAt": "2023-07-30T12:00:00Z"
|
|
}
|
|
}
|
|
"#;
|
|
|
|
let response: RegisterDeviceResponse = serde_json::from_str(json_response).unwrap();
|
|
assert_eq!(response.message, "Device registered successfully");
|
|
assert_eq!(response.device.id, "device-123");
|
|
assert_eq!(response.device.user_profile_id, "user-456");
|
|
assert_eq!(response.device.hardware_id, "TEST_DEVICE_123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_api_error_response_deserialization() {
|
|
let json_error = r#"
|
|
{
|
|
"message": "Device with hardware ID 'TEST_DEVICE' has already been claimed.",
|
|
"error": "Conflict"
|
|
}
|
|
"#;
|
|
|
|
let error: ApiErrorResponse = serde_json::from_str(json_error).unwrap();
|
|
assert!(error.message.contains("already been claimed"));
|
|
assert_eq!(error.error, Some("Conflict".to_string()));
|
|
}
|
|
} |