🎉 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:
grabbit 2025-08-02 12:36:05 +08:00
parent a04d6eba88
commit 46d8af6084
134 changed files with 37474 additions and 788 deletions

View File

@ -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)

View File

@ -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#"

View File

@ -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)]

View File

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

View File

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

View File

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

View File

@ -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

View 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
View 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

View 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
View 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.

View 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');
}
});

View 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;

View 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)

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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,
},
});

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View 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

View 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

View 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

View 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

View 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

View 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>
)
}

View 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&apos;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>
)
}

View 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&apos;re looking for doesn&apos;t exist or you don&apos;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>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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>
)
}

View 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;
}

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

View 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>
)
}

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;t have an account?{" "}
<a href="/register" className="font-medium text-primary hover:underline">
Create one
</a>
</p>
)}
</div>
</div>
</div>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

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

View 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>
)
}

View 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)
})
})

View 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>
)
}

View 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>
)
}

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

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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
}

View 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>
}

View 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,
}
}

View 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
})
}

View File

@ -0,0 +1,5 @@
import { type ClassValue, clsx } from "clsx"
export function cn(...inputs: ClassValue[]) {
return clsx(inputs)
}

View 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()
},
}

View 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()
},
}

View 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()
},
}

View 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[]
}

View 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

View 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

View 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
View 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

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View 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"]

View 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

View 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

View 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'
},
},
);

View File

@ -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');
};

View File

@ -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');
};

View File

@ -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');
};

View File

@ -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');
};

View File

@ -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',
]);
};

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

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

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

View 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' });
});
});
});

View 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' };
}
}

View 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 {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

View 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 {}

View 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',
});
});
});
});

View 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,
};
}
}
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View 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.',
);
}
}
}

View 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,
};
}
}

View 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;
}
}

View 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',
};
}
}

View 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 {}

View 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