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

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

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

  🔍 Technical Implementation

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

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

   All Acceptance Criteria Met

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

  🧪 Quality Assurance

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

  🎉 Epic 2 Complete!

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

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

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()));
}
}