🎉 Epic 2 Milestone: Successfully completed the final story of Epic 2: Commercialization & Core User Experience with full-stack date filtering functionality.
📋 What Was Accomplished Backend Changes: - ✅ Enhanced API Endpoint: Updated GET /api/v1/events to accept optional date query parameter - ✅ Input Validation: Added YYYY-MM-DD format validation to PaginationQueryDto - ✅ Database Filtering: Implemented timezone-aware date filtering in EventsService - ✅ Controller Integration: Updated EventsController to pass date parameter to service Frontend Changes: - ✅ Date Picker Component: Created reusable DatePicker component following project design system - ✅ Gallery UI Enhancement: Integrated date picker into gallery page with clear labeling - ✅ State Management: Implemented reactive date state with automatic re-fetching - ✅ Clear Filter Functionality: Added "Clear Filter" button for easy reset - ✅ Enhanced UX: Improved empty states for filtered vs unfiltered views 🔍 Technical Implementation API Design: GET /api/v1/events?date=2025-08-02&limit=20&cursor=xxx Key Files Modified: - meteor-web-backend/src/events/dto/pagination-query.dto.ts - meteor-web-backend/src/events/events.service.ts - meteor-web-backend/src/events/events.controller.ts - meteor-frontend/src/components/ui/date-picker.tsx (new) - meteor-frontend/src/app/gallery/page.tsx - meteor-frontend/src/hooks/use-events.ts - meteor-frontend/src/services/events.ts ✅ All Acceptance Criteria Met 1. ✅ Backend API Enhancement: Accepts optional date parameter 2. ✅ Date Filtering Logic: Returns events for specific calendar date 3. ✅ Date Picker UI: Clean, accessible interface component 4. ✅ Automatic Re-fetching: Immediate data updates on date selection 5. ✅ Filtered Display: Correctly shows only events for selected date 6. ✅ Clear Filter: One-click reset to view all events 🧪 Quality Assurance - ✅ Backend Build: Successful compilation with no errors - ✅ Frontend Build: Successful Next.js build with no warnings - ✅ Linting: All ESLint checks pass - ✅ Functionality: Feature working as specified 🎉 Epic 2 Complete! With Story 2.9 completion, Epic 2: Commercialization & Core User Experience is now DONE! Epic 2 Achievements: - 🔐 Full-stack device status monitoring - 💳 Robust payment and subscription system - 🛡️ Subscription-based access control - 📊 Enhanced data browsing with detail pages - 📅 Date-based event filtering
This commit is contained in:
parent
a04d6eba88
commit
46d8af6084
@ -21,3 +21,4 @@ retry_attempts = 3 # Number of upload retry attempts
|
||||
retry_delay_seconds = 2 # Initial delay between retries in seconds
|
||||
max_retry_delay_seconds = 60 # Maximum delay between retries in seconds
|
||||
request_timeout_seconds = 300 # Request timeout for uploads in seconds (5 minutes)
|
||||
heartbeat_interval_seconds = 300 # Heartbeat interval in seconds (5 minutes)
|
||||
@ -10,6 +10,13 @@ pub struct RegisterDeviceRequest {
|
||||
pub hardware_id: String,
|
||||
}
|
||||
|
||||
/// Request payload for device heartbeat
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeartbeatRequest {
|
||||
pub hardware_id: String,
|
||||
}
|
||||
|
||||
/// Response from successful device registration
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -141,6 +148,57 @@ impl ApiClient {
|
||||
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)]
|
||||
@ -158,6 +216,17 @@ mod tests {
|
||||
assert!(json.contains("TEST_DEVICE_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_request_serialization() {
|
||||
let request = HeartbeatRequest {
|
||||
hardware_id: "TEST_DEVICE_456".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("hardwareId"));
|
||||
assert!(json.contains("TEST_DEVICE_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_device_response_deserialization() {
|
||||
let json_response = r#"
|
||||
|
||||
@ -5,10 +5,11 @@ use tokio::time::sleep;
|
||||
|
||||
use crate::events::{EventBus, SystemEvent, SystemStartedEvent};
|
||||
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::storage::{StorageController, StorageConfig};
|
||||
use crate::communication::{CommunicationController, CommunicationConfig};
|
||||
use crate::api::ApiClient;
|
||||
|
||||
/// Core application coordinator that manages the event bus and background tasks
|
||||
pub struct Application {
|
||||
@ -147,6 +148,8 @@ impl Application {
|
||||
// Initialize and start communication controller
|
||||
println!("📡 Initializing communication controller...");
|
||||
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()) {
|
||||
Ok(controller) => controller,
|
||||
Err(e) => {
|
||||
@ -164,6 +167,16 @@ impl Application {
|
||||
|
||||
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
|
||||
println!("🔄 Starting main application loop...");
|
||||
self.main_loop().await?;
|
||||
@ -200,6 +213,60 @@ impl Application {
|
||||
pub fn subscriber_count(&self) -> usize {
|
||||
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)]
|
||||
|
||||
@ -17,6 +17,7 @@ pub struct CommunicationConfig {
|
||||
pub retry_delay_seconds: u64,
|
||||
pub max_retry_delay_seconds: u64,
|
||||
pub request_timeout_seconds: u64,
|
||||
pub heartbeat_interval_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for CommunicationConfig {
|
||||
@ -27,6 +28,7 @@ impl Default for CommunicationConfig {
|
||||
retry_delay_seconds: 2,
|
||||
max_retry_delay_seconds: 60,
|
||||
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.max_retry_delay_seconds, 60);
|
||||
assert_eq!(config.request_timeout_seconds, 300);
|
||||
assert_eq!(config.heartbeat_interval_seconds, 300);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,8 @@ pub struct Config {
|
||||
pub user_profile_id: Option<String>,
|
||||
/// Device ID returned from the registration API
|
||||
pub device_id: Option<String>,
|
||||
/// JWT token for authentication with backend services
|
||||
pub jwt_token: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@ -31,14 +33,16 @@ impl Config {
|
||||
registered_at: None,
|
||||
user_profile_id: None,
|
||||
device_id: None,
|
||||
jwt_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the configuration as registered with the given details
|
||||
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String) {
|
||||
pub fn mark_registered(&mut self, user_profile_id: String, device_id: String, jwt_token: String) {
|
||||
self.registered = true;
|
||||
self.user_profile_id = Some(user_profile_id);
|
||||
self.device_id = Some(device_id);
|
||||
self.jwt_token = Some(jwt_token);
|
||||
self.registered_at = Some(
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
);
|
||||
@ -157,6 +161,7 @@ pub struct CommunicationConfigToml {
|
||||
pub retry_delay_seconds: Option<u64>,
|
||||
pub max_retry_delay_seconds: Option<u64>,
|
||||
pub request_timeout_seconds: Option<u64>,
|
||||
pub heartbeat_interval_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for CommunicationConfigToml {
|
||||
@ -167,6 +172,7 @@ impl Default for CommunicationConfigToml {
|
||||
retry_delay_seconds: Some(2),
|
||||
max_retry_delay_seconds: Some(60),
|
||||
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),
|
||||
max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60),
|
||||
request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300),
|
||||
heartbeat_interval_seconds: communication_config.heartbeat_interval_seconds.unwrap_or(300),
|
||||
})
|
||||
}
|
||||
|
||||
@ -359,11 +366,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_config_mark_registered() {
|
||||
let mut config = Config::new("TEST_DEVICE_123".to_string());
|
||||
config.mark_registered("user-456".to_string(), "device-789".to_string());
|
||||
config.mark_registered("user-456".to_string(), "device-789".to_string(), "test-jwt-token".to_string());
|
||||
|
||||
assert!(config.registered);
|
||||
assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456");
|
||||
assert_eq!(config.device_id.as_ref().unwrap(), "device-789");
|
||||
assert_eq!(config.jwt_token.as_ref().unwrap(), "test-jwt-token");
|
||||
assert!(config.registered_at.is_some());
|
||||
}
|
||||
|
||||
@ -373,7 +381,7 @@ mod tests {
|
||||
let config_manager = ConfigManager::with_path(temp_file.path());
|
||||
|
||||
let mut config = Config::new("TEST_DEVICE_456".to_string());
|
||||
config.mark_registered("user-123".to_string(), "device-456".to_string());
|
||||
config.mark_registered("user-123".to_string(), "device-456".to_string(), "test-jwt-456".to_string());
|
||||
|
||||
// Save 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.user_profile_id.as_ref().unwrap(), "user-123");
|
||||
assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456");
|
||||
assert_eq!(loaded_config.jwt_token.as_ref().unwrap(), "test-jwt-456");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@ mod integration_tests {
|
||||
retry_delay_seconds: 1,
|
||||
max_retry_delay_seconds: 2,
|
||||
request_timeout_seconds: 5,
|
||||
heartbeat_interval_seconds: 300,
|
||||
};
|
||||
|
||||
let mut communication_controller = CommunicationController::new(communication_config, event_bus.clone()).unwrap();
|
||||
|
||||
@ -128,7 +128,7 @@ async fn register_device(jwt_token: String, api_url: String) -> Result<()> {
|
||||
// Attempt registration
|
||||
println!("📡 Registering device with backend...");
|
||||
let registration_response = api_client
|
||||
.register_device(hardware_id.clone(), jwt_token)
|
||||
.register_device(hardware_id.clone(), jwt_token.clone())
|
||||
.await?;
|
||||
|
||||
// Save configuration
|
||||
@ -137,6 +137,7 @@ async fn register_device(jwt_token: String, api_url: String) -> Result<()> {
|
||||
config.mark_registered(
|
||||
registration_response.device.user_profile_id,
|
||||
registration_response.device.id,
|
||||
jwt_token,
|
||||
);
|
||||
|
||||
config_manager.save_config(&config)?;
|
||||
|
||||
@ -1 +0,0 @@
|
||||
Subproject commit e3a9b283b272e59a12121a99672075d5cb360b6d
|
||||
62
meteor-frontend/.dockerignore
Normal file
62
meteor-frontend/.dockerignore
Normal file
@ -0,0 +1,62 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
out
|
||||
dist
|
||||
build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Operating system files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
jest-results.xml
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Misc
|
||||
.eslintcache
|
||||
41
meteor-frontend/.gitignore
vendored
Normal file
41
meteor-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
61
meteor-frontend/Dockerfile
Normal file
61
meteor-frontend/Dockerfile
Normal file
@ -0,0 +1,61 @@
|
||||
# Multi-stage Dockerfile for Next.js application
|
||||
|
||||
# Stage 1: Dependencies
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install all dependencies (including dev dependencies)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the public folder
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Set port environment variable
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
36
meteor-frontend/README.md
Normal file
36
meteor-frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
258
meteor-frontend/e2e/gallery.spec.ts
Normal file
258
meteor-frontend/e2e/gallery.spec.ts
Normal file
@ -0,0 +1,258 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Gallery Page E2E Tests', () => {
|
||||
const testUser = {
|
||||
email: 'e2e-test@meteor.dev',
|
||||
password: 'TestPassword123',
|
||||
displayName: 'E2E Test User'
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 导航到首页
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing gallery without authentication', async ({ page }) => {
|
||||
// 直接访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 应该被重定向到登录页面
|
||||
await expect(page).toHaveURL('/login');
|
||||
|
||||
// 检查登录页面元素
|
||||
await expect(page.locator('h1')).toContainText('登录');
|
||||
});
|
||||
|
||||
test('should display gallery page after successful login', async ({ page }) => {
|
||||
// 先注册用户
|
||||
await page.goto('/register');
|
||||
await page.fill('input[name="email"]', testUser.email);
|
||||
await page.fill('input[name="password"]', testUser.password);
|
||||
await page.fill('input[name="displayName"]', testUser.displayName);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// 等待重定向到dashboard
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 检查gallery页面元素
|
||||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||
|
||||
// 检查空状态消息
|
||||
await expect(page.locator('text=No events captured yet')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display loading state initially', async ({ page }) => {
|
||||
// 登录用户
|
||||
await loginUser(page, testUser);
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 检查loading状态(可能很快消失,所以用waitFor)
|
||||
const loadingText = page.locator('text=Loading events...');
|
||||
if (await loadingText.isVisible()) {
|
||||
await expect(loadingText).toBeVisible();
|
||||
}
|
||||
|
||||
// 最终应该显示空状态或事件列表
|
||||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||
});
|
||||
|
||||
test('should handle API errors gracefully', async ({ page }) => {
|
||||
// 模拟API错误
|
||||
await page.route('**/api/v1/events**', route => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: 'Server Error' })
|
||||
});
|
||||
});
|
||||
|
||||
// 登录用户
|
||||
await loginUser(page, testUser);
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 检查错误消息
|
||||
await expect(page.locator('text=Error loading events')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display events when API returns data', async ({ page }) => {
|
||||
// 模拟API返回测试数据
|
||||
const mockEvents = {
|
||||
data: [
|
||||
{
|
||||
id: 'test-event-1',
|
||||
deviceId: 'device-1',
|
||||
eventType: 'meteor',
|
||||
capturedAt: '2024-01-01T12:00:00Z',
|
||||
mediaUrl: 'https://example.com/test1.jpg',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
metadata: { location: 'Test Location 1' }
|
||||
},
|
||||
{
|
||||
id: 'test-event-2',
|
||||
deviceId: 'device-1',
|
||||
eventType: 'satellite',
|
||||
capturedAt: '2024-01-01T11:00:00Z',
|
||||
mediaUrl: 'https://example.com/test2.jpg',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-01T11:00:00Z',
|
||||
metadata: { location: 'Test Location 2' }
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
};
|
||||
|
||||
await page.route('**/api/v1/events**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockEvents)
|
||||
});
|
||||
});
|
||||
|
||||
// 登录用户
|
||||
await loginUser(page, testUser);
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 检查事件卡片
|
||||
await expect(page.locator('[data-testid="event-card"]').first()).toBeVisible();
|
||||
await expect(page.locator('text=Type: meteor')).toBeVisible();
|
||||
await expect(page.locator('text=Type: satellite')).toBeVisible();
|
||||
await expect(page.locator('text=Location: Test Location 1')).toBeVisible();
|
||||
await expect(page.locator('text=Location: Test Location 2')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should implement infinite scroll', async ({ page }) => {
|
||||
// 模拟分页数据
|
||||
let pageCount = 0;
|
||||
await page.route('**/api/v1/events**', route => {
|
||||
const url = new URL(route.request().url());
|
||||
const cursor = url.searchParams.get('cursor');
|
||||
|
||||
if (!cursor) {
|
||||
// 第一页
|
||||
pageCount = 1;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `event-page1-${i}`,
|
||||
deviceId: 'device-1',
|
||||
eventType: 'meteor',
|
||||
capturedAt: new Date(Date.now() - i * 60000).toISOString(),
|
||||
mediaUrl: `https://example.com/page1-${i}.jpg`,
|
||||
isValid: true,
|
||||
createdAt: new Date().toISOString()
|
||||
})),
|
||||
nextCursor: 'page2-cursor'
|
||||
})
|
||||
});
|
||||
} else if (cursor === 'page2-cursor') {
|
||||
// 第二页
|
||||
pageCount = 2;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `event-page2-${i}`,
|
||||
deviceId: 'device-1',
|
||||
eventType: 'satellite',
|
||||
capturedAt: new Date(Date.now() - (i + 5) * 60000).toISOString(),
|
||||
mediaUrl: `https://example.com/page2-${i}.jpg`,
|
||||
isValid: true,
|
||||
createdAt: new Date().toISOString()
|
||||
})),
|
||||
nextCursor: null
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 登录用户
|
||||
await loginUser(page, testUser);
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 等待第一页加载
|
||||
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(5);
|
||||
|
||||
// 滚动到底部触发infinite scroll
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
|
||||
// 等待第二页加载
|
||||
await expect(page.locator('[data-testid="event-card"]')).toHaveCount(8);
|
||||
|
||||
// 检查loading more状态
|
||||
const loadingMore = page.locator('text=Loading more events...');
|
||||
if (await loadingMore.isVisible()) {
|
||||
await expect(loadingMore).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should be responsive on mobile devices', async ({ page }) => {
|
||||
// 设置移动设备视口
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// 模拟事件数据
|
||||
await page.route('**/api/v1/events**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: [{
|
||||
id: 'mobile-test-event',
|
||||
deviceId: 'device-1',
|
||||
eventType: 'meteor',
|
||||
capturedAt: '2024-01-01T12:00:00Z',
|
||||
mediaUrl: 'https://example.com/mobile-test.jpg',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-01T12:00:00Z'
|
||||
}],
|
||||
nextCursor: null
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// 登录用户
|
||||
await loginUser(page, testUser);
|
||||
|
||||
// 访问gallery页面
|
||||
await page.goto('/gallery');
|
||||
|
||||
// 检查移动端布局
|
||||
await expect(page.locator('h1')).toContainText('Event Gallery');
|
||||
await expect(page.locator('[data-testid="event-card"]')).toBeVisible();
|
||||
|
||||
// 检查网格布局在移动端的响应性
|
||||
const grid = page.locator('[data-testid="gallery-grid"]');
|
||||
if (await grid.isVisible()) {
|
||||
const gridColumns = await grid.evaluate(el =>
|
||||
window.getComputedStyle(el).gridTemplateColumns
|
||||
);
|
||||
// 移动端应该是单列
|
||||
expect(gridColumns).toContain('1fr');
|
||||
}
|
||||
});
|
||||
|
||||
// 辅助函数:登录用户
|
||||
async function loginUser(page: any, user: typeof testUser) {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="email"]', user.email);
|
||||
await page.fill('input[name="password"]', user.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
}
|
||||
});
|
||||
16
meteor-frontend/eslint.config.mjs
Normal file
16
meteor-frontend/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
15
meteor-frontend/jest.config.js
Normal file
15
meteor-frontend/jest.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jsdom',
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
1
meteor-frontend/jest.setup.js
Normal file
1
meteor-frontend/jest.setup.js
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
7
meteor-frontend/next.config.ts
Normal file
7
meteor-frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
11077
meteor-frontend/package-lock.json
generated
Normal file
11077
meteor-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
meteor-frontend/package.json
Normal file
47
meteor-frontend/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "meteor-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:integration": "npm run test && npm run test:e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.534.0",
|
||||
"next": "15.4.5",
|
||||
"playwright": "^1.54.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"zod": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.5",
|
||||
"jest": "^30.0.5",
|
||||
"jest-environment-jsdom": "^30.0.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
43
meteor-frontend/playwright.config.ts
Normal file
43
meteor-frontend/playwright.config.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
5
meteor-frontend/postcss.config.mjs
Normal file
5
meteor-frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
meteor-frontend/public/file.svg
Normal file
1
meteor-frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
meteor-frontend/public/globe.svg
Normal file
1
meteor-frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
meteor-frontend/public/next.svg
Normal file
1
meteor-frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
meteor-frontend/public/vercel.svg
Normal file
1
meteor-frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
meteor-frontend/public/window.svg
Normal file
1
meteor-frontend/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
117
meteor-frontend/src/app/dashboard/page.tsx
Normal file
117
meteor-frontend/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, logout } = useAuth()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Welcome, {user?.email}
|
||||
</span>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
Welcome to your Dashboard!
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You have successfully logged in to the Meteor platform. This is your personal dashboard where you can manage your account and access platform features.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-2">
|
||||
Device Monitoring
|
||||
{!user?.hasActiveSubscription && <span className="text-xs text-muted-foreground ml-2">(Pro Feature)</span>}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Monitor the real-time status of your registered devices
|
||||
</p>
|
||||
{user?.hasActiveSubscription ? (
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/devices">View My Devices</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/subscription">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-2">
|
||||
Event Gallery
|
||||
{!user?.hasActiveSubscription && <span className="text-xs text-muted-foreground ml-2">(Pro Feature)</span>}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Browse and explore captured meteor events
|
||||
</p>
|
||||
{user?.hasActiveSubscription ? (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/gallery">View Gallery</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/subscription">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-2">Your Account Information</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>User ID:</strong> {user?.userId}</p>
|
||||
<p><strong>Email:</strong> {user?.email}</p>
|
||||
<p><strong>Display Name:</strong> {user?.displayName || "Not set"}</p>
|
||||
<p><strong>Subscription:</strong>
|
||||
<span className={`ml-1 ${user?.hasActiveSubscription ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
{user?.hasActiveSubscription ? 'Pro (Active)' : 'Free Plan'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{!user?.hasActiveSubscription && (
|
||||
<div className="mt-4">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/subscription">Upgrade to Pro</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
meteor-frontend/src/app/devices/page.tsx
Normal file
176
meteor-frontend/src/app/devices/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { StatusIndicator } from "@/components/ui/status-indicator"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useDevicesList } from "@/hooks/use-devices"
|
||||
import { DeviceDto } from "@/types/device"
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading: authLoading, logout } = useAuth()
|
||||
const { devices, isLoading, isError, error, refetch } = useDevicesList()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login")
|
||||
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||
router.push("/subscription?message=Device monitoring requires an active subscription")
|
||||
}
|
||||
}, [isAuthenticated, authLoading, user, router])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const formatLastSeen = (lastSeenAt?: string) => {
|
||||
if (!lastSeenAt) return "Never"
|
||||
const date = new Date(lastSeenAt)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
|
||||
if (diffMins < 1) return "Just now"
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h1 className="text-2xl font-bold">My Devices</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Welcome, {user?.email}
|
||||
</span>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
</Button>
|
||||
<span>/</span>
|
||||
{user?.hasActiveSubscription ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/gallery">Gallery</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" disabled className="opacity-50 cursor-not-allowed">
|
||||
Gallery (Pro)
|
||||
</Button>
|
||||
)}
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium">Devices</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-2">Device Monitoring</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor the real-time status of your registered devices. Data refreshes automatically every 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-6">
|
||||
<h3 className="font-semibold text-destructive mb-2">Error Loading Devices</h3>
|
||||
<p className="text-sm text-destructive/80 mb-4">
|
||||
{error?.message || "Failed to load devices. Please try again."}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="bg-card border rounded-lg p-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-lg text-muted-foreground">Loading devices...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="bg-card border rounded-lg p-8 text-center">
|
||||
<h3 className="font-semibold mb-2">No Devices Found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't registered any devices yet. Register your first device to start monitoring.
|
||||
</p>
|
||||
<Button variant="outline">Register Device</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border rounded-lg">
|
||||
<div className="grid grid-cols-1 divide-y">
|
||||
{devices.map((device: DeviceDto) => (
|
||||
<div key={device.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{device.deviceName || device.hardwareId}
|
||||
</h3>
|
||||
<StatusIndicator status={device.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{device.deviceName && (
|
||||
<p><strong>Hardware ID:</strong> {device.hardwareId}</p>
|
||||
)}
|
||||
<p><strong>Last Seen:</strong> {formatLastSeen(device.lastSeenAt)}</p>
|
||||
<p><strong>Registered:</strong> {new Date(device.registeredAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end text-sm text-muted-foreground">
|
||||
<div className="text-xs">Device ID</div>
|
||||
<div className="font-mono text-xs">{device.id.slice(0, 8)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
Showing {devices.length} device{devices.length !== 1 ? 's' : ''} •
|
||||
Auto-refresh enabled (every 30 seconds)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
meteor-frontend/src/app/events/[eventId]/page.tsx
Normal file
177
meteor-frontend/src/app/events/[eventId]/page.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useEvent } from "@/hooks/use-events"
|
||||
import { MediaViewer } from "@/components/event-details/media-viewer"
|
||||
import { MetadataDisplay } from "@/components/event-details/metadata-display"
|
||||
|
||||
interface EventDetailsPageProps {
|
||||
params: Promise<{
|
||||
eventId: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default function EventDetailsPage({ params }: EventDetailsPageProps) {
|
||||
const [eventId, setEventId] = React.useState<string>("")
|
||||
|
||||
React.useEffect(() => {
|
||||
params.then((resolvedParams) => {
|
||||
setEventId(resolvedParams.eventId)
|
||||
})
|
||||
}, [params])
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth()
|
||||
const { data: event, isLoading, isError, error } = useEvent(eventId)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login")
|
||||
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||
router.push("/subscription?message=Event details require an active subscription")
|
||||
}
|
||||
}, [isAuthenticated, authLoading, user, router])
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p className="text-gray-400">Loading event details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4 text-red-400">Error Loading Event</h1>
|
||||
<p className="text-gray-400 mb-6">
|
||||
{error?.message || "Failed to load event details. Please try again."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Event Not Found</h1>
|
||||
<p className="text-gray-400 mb-6">
|
||||
The event you're looking for doesn't exist or you don't have permission to view it.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Navigation Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center text-gray-400 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Gallery
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold">Event Details</h1>
|
||||
</div>
|
||||
|
||||
{/* Event Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Media Section */}
|
||||
<MediaViewer event={event} />
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="space-y-6">
|
||||
{/* Event Overview */}
|
||||
<div className="bg-gray-900 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Event Overview</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Event ID</label>
|
||||
<p className="font-mono text-sm">{event.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Event Type</label>
|
||||
<p className="capitalize">{event.eventType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Captured At</label>
|
||||
<p>{new Date(event.capturedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Device ID</label>
|
||||
<p className="font-mono text-sm">{event.deviceId}</p>
|
||||
</div>
|
||||
{event.validationScore && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Validation Score</label>
|
||||
<p className="text-green-400">{(parseFloat(event.validationScore) * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Status</label>
|
||||
<p className={`inline-flex px-2 py-1 rounded text-xs font-medium ${
|
||||
event.isValid ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
|
||||
}`}>
|
||||
{event.isValid ? 'Valid' : 'Invalid'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<MetadataDisplay metadata={event.metadata || {}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
meteor-frontend/src/app/favicon.ico
Normal file
BIN
meteor-frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
148
meteor-frontend/src/app/gallery/page.tsx
Normal file
148
meteor-frontend/src/app/gallery/page.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { useAllEvents } from "@/hooks/use-events"
|
||||
import { GalleryGrid } from "@/components/gallery/gallery-grid"
|
||||
import { LoadingState, LoadingMoreState, ScrollForMoreState } from "@/components/gallery/loading-state"
|
||||
import { EmptyState } from "@/components/gallery/empty-state"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth()
|
||||
const router = useRouter()
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
events,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useAllEvents(selectedDate)
|
||||
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0,
|
||||
rootMargin: "100px",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/login")
|
||||
} else if (!authLoading && isAuthenticated && user && !user.hasActiveSubscription) {
|
||||
router.push("/subscription?message=Gallery access requires an active subscription")
|
||||
}
|
||||
}, [isAuthenticated, authLoading, user, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || (user && !user.hasActiveSubscription)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Event Gallery</h1>
|
||||
<div className="text-center text-red-400">
|
||||
Error loading events: {error?.message || "Unknown error"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Event Gallery</h1>
|
||||
|
||||
{/* Date Filter */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="date-filter" className="text-sm text-gray-400">
|
||||
Filter by date:
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<DatePicker
|
||||
id="date-filter"
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-48"
|
||||
placeholder="Select a date..."
|
||||
/>
|
||||
{selectedDate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedDate(null)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Clear Filter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<div className="text-sm text-gray-400 mt-2 sm:mt-0 sm:ml-4">
|
||||
Showing events from {new Date(selectedDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && events.length === 0 ? (
|
||||
<LoadingState />
|
||||
) : events.length === 0 ? (
|
||||
selectedDate ? (
|
||||
<div className="text-center py-16">
|
||||
<h2 className="text-xl text-gray-400 mb-4">No events found for selected date</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
No meteor events were captured on {new Date(selectedDate + 'T00:00:00').toLocaleDateString()}.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedDate(null)}
|
||||
>
|
||||
View All Events
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<GalleryGrid events={events} />
|
||||
|
||||
{hasNextPage && (
|
||||
<div ref={ref}>
|
||||
{isFetchingNextPage ? (
|
||||
<LoadingMoreState />
|
||||
) : (
|
||||
<ScrollForMoreState />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
meteor-frontend/src/app/globals.css
Normal file
79
meteor-frontend/src/app/globals.css
Normal file
@ -0,0 +1,79 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #171717;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #171717;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #171717;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #171717;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #171717;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--radius: var(--radius);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card: #0a0a0a;
|
||||
--card-foreground: #ededed;
|
||||
--popover: #0a0a0a;
|
||||
--popover-foreground: #ededed;
|
||||
--primary: #ededed;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--secondary: #262626;
|
||||
--secondary-foreground: #ededed;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a3a3a3;
|
||||
--accent: #262626;
|
||||
--accent-foreground: #ededed;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ededed;
|
||||
--border: #262626;
|
||||
--input: #262626;
|
||||
--ring: #d4d4d8;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
40
meteor-frontend/src/app/layout.tsx
Normal file
40
meteor-frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/auth-context";
|
||||
import QueryProvider from "@/contexts/query-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
46
meteor-frontend/src/app/login/page.tsx
Normal file
46
meteor-frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AuthForm } from "@/components/auth/auth-form"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const { login, isLoading, isAuthenticated } = useAuth()
|
||||
const [error, setError] = React.useState<string>("")
|
||||
|
||||
// Redirect if already authenticated
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
const handleSubmit = async (data: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
setError("")
|
||||
try {
|
||||
await login(data.email, data.password)
|
||||
// The auth context will handle setting the user state
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<AuthForm
|
||||
mode="login"
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
className="w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
meteor-frontend/src/app/page.test.tsx
Normal file
29
meteor-frontend/src/app/page.test.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Home from './page'
|
||||
import { AuthProvider } from '@/contexts/auth-context'
|
||||
import QueryProvider from '@/contexts/query-provider'
|
||||
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>
|
||||
<QueryProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
describe('Home page', () => {
|
||||
it('renders the welcome title', () => {
|
||||
render(<Home />, { wrapper: TestWrapper })
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('分布式流星监测网络')
|
||||
})
|
||||
|
||||
it('renders the English subtitle', () => {
|
||||
render(<Home />, { wrapper: TestWrapper })
|
||||
|
||||
const subtitle = screen.getByText('Distributed Meteor Monitoring Network')
|
||||
expect(subtitle).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
61
meteor-frontend/src/app/page.tsx
Normal file
61
meteor-frontend/src/app/page.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Navigation */}
|
||||
<header className="absolute top-0 right-0 p-6 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Welcome, {user?.email}
|
||||
</span>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/register">Get Started</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<main className="text-center px-4">
|
||||
<h1 className="text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
分布式流星监测网络
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8">
|
||||
Distributed Meteor Monitoring Network
|
||||
</p>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/register">Join the Network</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
meteor-frontend/src/app/register/page.tsx
Normal file
46
meteor-frontend/src/app/register/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { AuthForm } from "@/components/auth/auth-form"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const { register, isLoading, isAuthenticated } = useAuth()
|
||||
const [error, setError] = React.useState<string>("")
|
||||
|
||||
// Redirect if already authenticated
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
const handleSubmit = async (data: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
setError("")
|
||||
try {
|
||||
await register(data.email, data.password, data.displayName)
|
||||
// The auth context will handle the redirect to dashboard
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Registration failed")
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
className="w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
286
meteor-frontend/src/app/subscription/page.tsx
Normal file
286
meteor-frontend/src/app/subscription/page.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { subscriptionApi, SubscriptionData } from "@/services/subscription"
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [redirectMessage, setRedirectMessage] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Get the message from URL search params client-side
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
setRedirectMessage(urlParams.get('message'))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
const { data: subscriptionResponse, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: subscriptionApi.getSubscription,
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const subscription = subscriptionResponse?.subscription
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
try {
|
||||
const currentUrl = window.location.origin
|
||||
const checkoutResponse = await subscriptionApi.createCheckoutSession(
|
||||
'price_1234567890', // This would be your actual Stripe price ID
|
||||
`${currentUrl}/subscription?success=true`,
|
||||
`${currentUrl}/subscription?canceled=true`
|
||||
)
|
||||
|
||||
if (checkoutResponse.success) {
|
||||
window.location.href = checkoutResponse.session.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create checkout session:', error)
|
||||
alert('Failed to start subscription process. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
try {
|
||||
const currentUrl = window.location.origin
|
||||
const portalResponse = await subscriptionApi.createCustomerPortalSession(
|
||||
`${currentUrl}/subscription`
|
||||
)
|
||||
|
||||
if (portalResponse.success) {
|
||||
window.location.href = portalResponse.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create customer portal session:', error)
|
||||
alert('Failed to open billing management. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading subscription details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-red-800 font-semibold mb-2">Error Loading Subscription</h3>
|
||||
<p className="text-red-700 mb-4">{(error as Error).message}</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<h1 className="text-2xl font-bold">Subscription & Billing</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{redirectMessage && (
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-blue-800">{redirectMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-3xl font-bold mb-2">Manage Your Subscription</h2>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your subscription plan and billing information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{subscription?.hasActiveSubscription ? (
|
||||
<ActiveSubscriptionView
|
||||
subscription={subscription}
|
||||
onManageBilling={handleManageBilling}
|
||||
/>
|
||||
) : (
|
||||
<NoSubscriptionView onSubscribe={handleSubscribe} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActiveSubscriptionView({
|
||||
subscription,
|
||||
onManageBilling
|
||||
}: {
|
||||
subscription: SubscriptionData
|
||||
onManageBilling: () => void
|
||||
}) {
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return 'N/A'
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string | null) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-50 border-green-200'
|
||||
case 'canceled':
|
||||
case 'cancelled':
|
||||
return 'text-red-600 bg-red-50 border-red-200'
|
||||
case 'past_due':
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-semibold">Current Subscription</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(subscription.subscriptionStatus)}`}>
|
||||
{subscription.subscriptionStatus ? subscription.subscriptionStatus.charAt(0).toUpperCase() + subscription.subscriptionStatus.slice(1) : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-lg">{subscription.currentPlan?.name}</h4>
|
||||
<p className="text-muted-foreground">Your current subscription plan</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Next Billing Date</p>
|
||||
<p className="text-lg">{formatDate(subscription.nextBillingDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Plan ID</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{subscription.currentPlan?.priceId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">Billing Management</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Manage your payment methods, view billing history, and update your subscription.
|
||||
</p>
|
||||
<Button onClick={onManageBilling}>
|
||||
Manage Billing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoSubscriptionView({ onSubscribe }: { onSubscribe: () => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Available Plans</h3>
|
||||
|
||||
<div className="border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold">Pro Plan</h4>
|
||||
<p className="text-muted-foreground">Full access to all platform features</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">$29</div>
|
||||
<div className="text-sm text-muted-foreground">per month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-6">
|
||||
<li className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||
Unlimited device monitoring
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||
Real-time event notifications
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||
Advanced analytics dashboard
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-3"></span>
|
||||
Priority customer support
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button onClick={onSubscribe} className="w-full">
|
||||
Subscribe to Pro Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-2">Why Subscribe?</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Subscribing to our Pro Plan gives you access to all premium features and ensures
|
||||
uninterrupted service for your meteor monitoring needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
meteor-frontend/src/components/auth/auth-form.tsx
Normal file
151
meteor-frontend/src/components/auth/auth-form.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FormField } from "@/components/ui/form-field"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Please provide a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
})
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Please provide a valid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters long")
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
"Password must contain at least one lowercase letter, one uppercase letter, and one number"
|
||||
),
|
||||
displayName: z.string().min(1, "Display name is required"),
|
||||
})
|
||||
|
||||
export type LoginFormData = z.infer<typeof loginSchema>
|
||||
export type RegisterFormData = z.infer<typeof registerSchema>
|
||||
|
||||
type AuthFormData = LoginFormData | RegisterFormData
|
||||
|
||||
interface AuthFormProps {
|
||||
mode: "login" | "register"
|
||||
onSubmit: (data: AuthFormData) => Promise<void>
|
||||
isLoading?: boolean
|
||||
error?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AuthForm({ mode, onSubmit, isLoading, error, className }: AuthFormProps) {
|
||||
const schema = mode === "login" ? loginSchema : registerSchema
|
||||
const isRegister = mode === "register"
|
||||
|
||||
const form = useForm<AuthFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
...(isRegister && { displayName: "" }),
|
||||
} as AuthFormData,
|
||||
})
|
||||
|
||||
const handleSubmit = async (data: AuthFormData) => {
|
||||
try {
|
||||
await onSubmit(data)
|
||||
} catch (error) {
|
||||
// Error handling is managed by parent component
|
||||
console.error("Form submission error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-md mx-auto", className)}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isRegister ? "Create Account" : "Welcome Back"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isRegister
|
||||
? "Enter your information to create an account"
|
||||
: "Enter your email and password to sign in"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive-foreground bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
error={form.formState.errors.email?.message}
|
||||
{...form.register("email")}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<FormField
|
||||
label="Display Name"
|
||||
type="text"
|
||||
placeholder="Enter your display name"
|
||||
error={(form.formState.errors as any).displayName?.message} // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
{...form.register("displayName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
error={form.formState.errors.password?.message}
|
||||
{...form.register("password")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? isRegister
|
||||
? "Creating Account..."
|
||||
: "Signing In..."
|
||||
: isRegister
|
||||
? "Create Account"
|
||||
: "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
{isRegister ? (
|
||||
<p>
|
||||
Already have an account?{" "}
|
||||
<a href="/login" className="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Don't have an account?{" "}
|
||||
<a href="/register" className="font-medium text-primary hover:underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { EventDto } from "@/services/events"
|
||||
|
||||
interface MediaViewerProps {
|
||||
event: EventDto
|
||||
}
|
||||
|
||||
export function MediaViewer({ event }: MediaViewerProps) {
|
||||
const isVideo = event.fileType?.startsWith('video/')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Media Container */}
|
||||
<div className="aspect-video bg-gray-900 rounded-lg overflow-hidden">
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={event.mediaUrl}
|
||||
controls
|
||||
className="w-full h-full object-contain"
|
||||
poster={event.mediaUrl.replace(/\.[^/.]+$/, '_thumbnail.jpg')}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={event.mediaUrl}
|
||||
alt={`Event ${event.id}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media Info */}
|
||||
<div className="bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-2 text-white">Media Information</h3>
|
||||
<div className="space-y-1 text-sm text-gray-300">
|
||||
{event.originalFilename && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filename:</span>
|
||||
<span className="break-all ml-2">{event.originalFilename}</span>
|
||||
</div>
|
||||
)}
|
||||
{event.fileType && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Type:</span>
|
||||
<span className="ml-2">{event.fileType}</span>
|
||||
</div>
|
||||
)}
|
||||
{event.fileSize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="ml-2">{(parseInt(event.fileSize) / (1024 * 1024)).toFixed(2)} MB</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Media Type:</span>
|
||||
<span className="ml-2 capitalize">{isVideo ? 'Video' : 'Image'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
|
||||
interface MetadataDisplayProps {
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function MetadataDisplay({ metadata }: MetadataDisplayProps) {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-white">Metadata</h2>
|
||||
<p className="text-gray-400 text-sm">No metadata available for this event.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatKey = (key: string): string => {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
|
||||
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
|
||||
.replace(/_/g, ' ') // Replace underscores with spaces
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No'
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a date string
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
|
||||
try {
|
||||
return new Date(value).toLocaleString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const isLongValue = (value: unknown): boolean => {
|
||||
const stringValue = formatValue(value)
|
||||
return stringValue.length > 50 || stringValue.includes('\n')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-white">Metadata</h2>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(metadata).map(([key, value]) => (
|
||||
<div key={key} className="border-b border-gray-800 pb-3 last:border-b-0">
|
||||
<label className="text-sm text-gray-500 font-medium block mb-1">
|
||||
{formatKey(key)}
|
||||
</label>
|
||||
<div className={`text-sm text-gray-300 ${isLongValue(value) ? 'whitespace-pre-wrap' : ''}`}>
|
||||
{isLongValue(value) && typeof formatValue(value) === 'string' && formatValue(value).includes('{') ? (
|
||||
<pre className="bg-gray-800 p-3 rounded text-xs overflow-x-auto">
|
||||
{formatValue(value)}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="break-words">{formatValue(value)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
meteor-frontend/src/components/gallery/empty-state.tsx
Normal file
7
meteor-frontend/src/components/gallery/empty-state.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg">No events captured yet</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
meteor-frontend/src/components/gallery/event-card.test.tsx
Normal file
42
meteor-frontend/src/components/gallery/event-card.test.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { EventCard } from './event-card'
|
||||
import { EventDto } from '@/services/events'
|
||||
|
||||
const mockEvent: EventDto = {
|
||||
id: '123',
|
||||
deviceId: 'device-1',
|
||||
eventType: 'meteor',
|
||||
capturedAt: '2024-01-01T12:00:00Z',
|
||||
mediaUrl: 'https://example.com/image.jpg',
|
||||
fileSize: '1024',
|
||||
fileType: 'image/jpeg',
|
||||
originalFilename: 'meteor.jpg',
|
||||
metadata: { location: 'Test Location' },
|
||||
validationScore: '0.95',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
}
|
||||
|
||||
const mockEventWithoutImage: EventDto = {
|
||||
...mockEvent,
|
||||
mediaUrl: '',
|
||||
metadata: undefined,
|
||||
}
|
||||
|
||||
describe('EventCard', () => {
|
||||
it('renders event information correctly', () => {
|
||||
render(<EventCard event={mockEvent} />)
|
||||
|
||||
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||
expect(screen.getByText('Location: Test Location')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('Event meteor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without image when mediaUrl is empty', () => {
|
||||
render(<EventCard event={mockEventWithoutImage} />)
|
||||
|
||||
expect(screen.getByText('No Image')).toBeInTheDocument()
|
||||
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Location:')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
41
meteor-frontend/src/components/gallery/event-card.tsx
Normal file
41
meteor-frontend/src/components/gallery/event-card.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { EventDto } from "@/services/events"
|
||||
import Image from "next/image"
|
||||
|
||||
interface EventCardProps {
|
||||
event: EventDto
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
const location = event.metadata?.location as string | undefined
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-shadow" data-testid="event-card">
|
||||
<div className="aspect-video bg-gray-800 flex items-center justify-center relative">
|
||||
{event.mediaUrl ? (
|
||||
<Image
|
||||
src={event.mediaUrl}
|
||||
alt={`Event ${event.eventType}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-500">No Image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-400 mb-2">
|
||||
{new Date(event.capturedAt).toLocaleDateString()} {new Date(event.capturedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-300 mb-1">
|
||||
Type: {event.eventType}
|
||||
</div>
|
||||
{location && (
|
||||
<div className="text-sm text-gray-300">
|
||||
Location: {location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
meteor-frontend/src/components/gallery/gallery-grid.test.tsx
Normal file
41
meteor-frontend/src/components/gallery/gallery-grid.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { GalleryGrid } from './gallery-grid'
|
||||
import { EventDto } from '@/services/events'
|
||||
|
||||
const mockEvents: EventDto[] = [
|
||||
{
|
||||
id: '1',
|
||||
deviceId: 'device-1',
|
||||
eventType: 'meteor',
|
||||
capturedAt: '2024-01-01T12:00:00Z',
|
||||
mediaUrl: 'https://example.com/image1.jpg',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
deviceId: 'device-2',
|
||||
eventType: 'asteroid',
|
||||
capturedAt: '2024-01-02T12:00:00Z',
|
||||
mediaUrl: 'https://example.com/image2.jpg',
|
||||
isValid: true,
|
||||
createdAt: '2024-01-02T12:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('GalleryGrid', () => {
|
||||
it('renders all events', () => {
|
||||
render(<GalleryGrid events={mockEvents} />)
|
||||
|
||||
expect(screen.getByText('Type: meteor')).toBeInTheDocument()
|
||||
expect(screen.getByText('Type: asteroid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty grid when no events', () => {
|
||||
const { container } = render(<GalleryGrid events={[]} />)
|
||||
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
expect(grid?.children).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
16
meteor-frontend/src/components/gallery/gallery-grid.tsx
Normal file
16
meteor-frontend/src/components/gallery/gallery-grid.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { EventDto } from "@/services/events"
|
||||
import { EventCard } from "./event-card"
|
||||
|
||||
interface GalleryGridProps {
|
||||
events: EventDto[]
|
||||
}
|
||||
|
||||
export function GalleryGrid({ events }: GalleryGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" data-testid="gallery-grid">
|
||||
{events.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
meteor-frontend/src/components/gallery/loading-state.tsx
Normal file
23
meteor-frontend/src/components/gallery/loading-state.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-lg text-gray-400">Loading events...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingMoreState() {
|
||||
return (
|
||||
<div className="flex justify-center mt-8 py-4">
|
||||
<div className="text-gray-400">Loading more events...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollForMoreState() {
|
||||
return (
|
||||
<div className="flex justify-center mt-8 py-4">
|
||||
<div className="text-gray-600">Scroll for more</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
meteor-frontend/src/components/gallery/states.test.tsx
Normal file
33
meteor-frontend/src/components/gallery/states.test.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LoadingState, LoadingMoreState, ScrollForMoreState } from './loading-state'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
describe('Gallery States', () => {
|
||||
describe('LoadingState', () => {
|
||||
it('renders loading message', () => {
|
||||
render(<LoadingState />)
|
||||
expect(screen.getByText('Loading events...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LoadingMoreState', () => {
|
||||
it('renders loading more message', () => {
|
||||
render(<LoadingMoreState />)
|
||||
expect(screen.getByText('Loading more events...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScrollForMoreState', () => {
|
||||
it('renders scroll for more message', () => {
|
||||
render(<ScrollForMoreState />)
|
||||
expect(screen.getByText('Scroll for more')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders empty state message', () => {
|
||||
render(<EmptyState />)
|
||||
expect(screen.getByText('No events captured yet')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
57
meteor-frontend/src/components/ui/button.tsx
Normal file
57
meteor-frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
48
meteor-frontend/src/components/ui/date-picker.tsx
Normal file
48
meteor-frontend/src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const datePickerVariants = cva(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
error: "border-destructive focus-visible:ring-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface DatePickerProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'>,
|
||||
VariantProps<typeof datePickerVariants> {
|
||||
value?: string | null
|
||||
onChange?: (date: string | null) => void
|
||||
}
|
||||
|
||||
const DatePicker = React.forwardRef<HTMLInputElement, DatePickerProps>(
|
||||
({ className, variant, value, onChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value || null
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
className={cn(datePickerVariants({ variant, className }))}
|
||||
ref={ref}
|
||||
value={value || ""}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
DatePicker.displayName = "DatePicker"
|
||||
|
||||
export { DatePicker, datePickerVariants }
|
||||
51
meteor-frontend/src/components/ui/form-field.tsx
Normal file
51
meteor-frontend/src/components/ui/form-field.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label } from "./label"
|
||||
import { Input, type InputProps } from "./input"
|
||||
|
||||
export interface FormFieldProps extends InputProps {
|
||||
label: string
|
||||
error?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const FormField = React.forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
({ className, label, error, description, id, ...props }, ref) => {
|
||||
const generatedId = React.useId()
|
||||
const inputId = id || `field-${generatedId}`
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={inputId} className={error ? "text-destructive" : ""}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
ref={ref}
|
||||
variant={error ? "error" : "default"}
|
||||
className={className}
|
||||
aria-describedby={
|
||||
error ? `${inputId}-error` : description ? `${inputId}-description` : undefined
|
||||
}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{description && !error && (
|
||||
<p id={`${inputId}-description`} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
FormField.displayName = "FormField"
|
||||
|
||||
export { FormField }
|
||||
39
meteor-frontend/src/components/ui/input.tsx
Normal file
39
meteor-frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const inputVariants = cva(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
error: "border-destructive focus-visible:ring-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||
VariantProps<typeof inputVariants> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, variant, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(inputVariants({ variant, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input, inputVariants }
|
||||
26
meteor-frontend/src/components/ui/label.tsx
Normal file
26
meteor-frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
69
meteor-frontend/src/components/ui/status-indicator.tsx
Normal file
69
meteor-frontend/src/components/ui/status-indicator.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DeviceStatus } from "@/types/device"
|
||||
|
||||
const statusIndicatorVariants = cva(
|
||||
"inline-flex items-center gap-2 text-sm font-medium",
|
||||
{
|
||||
variants: {
|
||||
status: {
|
||||
[DeviceStatus.ONLINE]: "text-green-700",
|
||||
[DeviceStatus.OFFLINE]: "text-gray-500",
|
||||
[DeviceStatus.ACTIVE]: "text-blue-600",
|
||||
[DeviceStatus.INACTIVE]: "text-gray-400",
|
||||
[DeviceStatus.MAINTENANCE]: "text-yellow-600",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const dotVariants = cva(
|
||||
"inline-block w-2 h-2 rounded-full",
|
||||
{
|
||||
variants: {
|
||||
status: {
|
||||
[DeviceStatus.ONLINE]: "bg-green-500",
|
||||
[DeviceStatus.OFFLINE]: "bg-gray-400",
|
||||
[DeviceStatus.ACTIVE]: "bg-blue-500",
|
||||
[DeviceStatus.INACTIVE]: "bg-gray-300",
|
||||
[DeviceStatus.MAINTENANCE]: "bg-yellow-500",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const statusLabels = {
|
||||
[DeviceStatus.ONLINE]: "Online",
|
||||
[DeviceStatus.OFFLINE]: "Offline",
|
||||
[DeviceStatus.ACTIVE]: "Active",
|
||||
[DeviceStatus.INACTIVE]: "Inactive",
|
||||
[DeviceStatus.MAINTENANCE]: "Maintenance",
|
||||
}
|
||||
|
||||
export interface StatusIndicatorProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof statusIndicatorVariants> {
|
||||
status: DeviceStatus
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const StatusIndicator = React.forwardRef<HTMLDivElement, StatusIndicatorProps>(
|
||||
({ className, status, showLabel = true, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(statusIndicatorVariants({ status, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn(dotVariants({ status }))} />
|
||||
{showLabel && (
|
||||
<span>{statusLabels[status]}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
StatusIndicator.displayName = "StatusIndicator"
|
||||
|
||||
export { StatusIndicator, statusIndicatorVariants }
|
||||
182
meteor-frontend/src/contexts/auth-context.tsx
Normal file
182
meteor-frontend/src/contexts/auth-context.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
interface User {
|
||||
userId: string
|
||||
email: string
|
||||
displayName: string | null
|
||||
subscriptionStatus: string | null
|
||||
hasActiveSubscription: boolean
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName: string) => Promise<void>
|
||||
logout: () => void
|
||||
setUser: (user: User | null) => void
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = React.useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
|
||||
const isAuthenticated = !!user
|
||||
|
||||
const fetchUserProfile = async (): Promise<User | null> => {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/v1/auth/profile", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Token might be invalid, remove it
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
return null
|
||||
}
|
||||
|
||||
const profileData = await response.json()
|
||||
return {
|
||||
userId: profileData.userId,
|
||||
email: profileData.email,
|
||||
displayName: profileData.displayName,
|
||||
subscriptionStatus: profileData.subscriptionStatus,
|
||||
hasActiveSubscription: profileData.hasActiveSubscription,
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error or other issue
|
||||
console.error("Failed to fetch user profile:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/v1/auth/login-email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || "Login failed")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Store tokens in localStorage (in production, consider httpOnly cookies)
|
||||
localStorage.setItem("accessToken", data.accessToken)
|
||||
localStorage.setItem("refreshToken", data.refreshToken)
|
||||
|
||||
// Fetch and set user profile data including subscription status
|
||||
const userProfile = await fetchUserProfile()
|
||||
if (userProfile) {
|
||||
setUser(userProfile)
|
||||
} else {
|
||||
// Fallback to basic user data if profile fetch fails
|
||||
setUser({
|
||||
userId: data.userId,
|
||||
email,
|
||||
displayName: null,
|
||||
subscriptionStatus: null,
|
||||
hasActiveSubscription: false,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, displayName: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/v1/auth/register-email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || "Registration failed")
|
||||
}
|
||||
|
||||
await response.json()
|
||||
|
||||
// After successful registration, automatically log in
|
||||
await login(email, password)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
// Check for existing token on mount and fetch user profile
|
||||
React.useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (token) {
|
||||
const userProfile = await fetchUserProfile()
|
||||
if (userProfile) {
|
||||
setUser(userProfile)
|
||||
} else {
|
||||
// Token is invalid or profile fetch failed, clean up
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
}, [])
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
setUser,
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = React.useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
19
meteor-frontend/src/contexts/query-provider.tsx
Normal file
19
meteor-frontend/src/contexts/query-provider.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
39
meteor-frontend/src/hooks/use-devices.ts
Normal file
39
meteor-frontend/src/hooks/use-devices.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { devicesApi } from "@/services/devices"
|
||||
import { DeviceDto } from "@/types/device"
|
||||
|
||||
export function useDevices() {
|
||||
return useQuery({
|
||||
queryKey: ["devices"],
|
||||
queryFn: () => devicesApi.getDevices(),
|
||||
staleTime: 0, // Always consider data stale to enable frequent refetching
|
||||
refetchInterval: 30 * 1000, // Refetch every 30 seconds
|
||||
refetchIntervalInBackground: true, // Continue polling when tab is not in focus
|
||||
})
|
||||
}
|
||||
|
||||
export function useDevicesList(): {
|
||||
devices: DeviceDto[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
refetch: () => void
|
||||
} {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useDevices()
|
||||
|
||||
const devices = data?.devices ?? []
|
||||
|
||||
return {
|
||||
devices,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
59
meteor-frontend/src/hooks/use-events.ts
Normal file
59
meteor-frontend/src/hooks/use-events.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { eventsApi, EventDto } from "@/services/events"
|
||||
|
||||
const EVENTS_LIMIT = 20
|
||||
|
||||
export function useEvents(date?: string | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["events", date],
|
||||
queryFn: ({ pageParam }) => eventsApi.getEvents({
|
||||
limit: EVENTS_LIMIT,
|
||||
cursor: pageParam,
|
||||
date: date || undefined
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
export function useAllEvents(date?: string | null): {
|
||||
events: EventDto[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
hasNextPage: boolean
|
||||
fetchNextPage: () => void
|
||||
isFetchingNextPage: boolean
|
||||
} {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useEvents(date)
|
||||
|
||||
const events = data?.pages.flatMap(page => page.data) ?? []
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage: hasNextPage ?? false,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
export function useEvent(eventId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["event", eventId],
|
||||
queryFn: () => eventsApi.getEventById(eventId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!eventId, // Only run query if eventId is provided
|
||||
})
|
||||
}
|
||||
5
meteor-frontend/src/lib/utils.ts
Normal file
5
meteor-frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs)
|
||||
}
|
||||
27
meteor-frontend/src/services/devices.ts
Normal file
27
meteor-frontend/src/services/devices.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { DevicesResponse } from '../types/device'
|
||||
|
||||
export const devicesApi = {
|
||||
async getDevices(): Promise<DevicesResponse> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:3000/api/v1/devices", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
throw new Error(`Failed to fetch devices: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
92
meteor-frontend/src/services/events.ts
Normal file
92
meteor-frontend/src/services/events.ts
Normal file
@ -0,0 +1,92 @@
|
||||
export interface EventDto {
|
||||
id: string
|
||||
deviceId: string
|
||||
eventType: string
|
||||
capturedAt: string
|
||||
mediaUrl: string
|
||||
fileSize?: string
|
||||
fileType?: string
|
||||
originalFilename?: string
|
||||
metadata?: Record<string, unknown>
|
||||
validationScore?: string
|
||||
isValid: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface EventsResponse {
|
||||
data: EventDto[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface GetEventsParams {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
date?: string
|
||||
}
|
||||
|
||||
export const eventsApi = {
|
||||
async getEvents(params: GetEventsParams = {}): Promise<EventsResponse> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const url = new URL("http://localhost:3000/api/v1/events")
|
||||
|
||||
if (params.limit) {
|
||||
url.searchParams.append("limit", params.limit.toString())
|
||||
}
|
||||
|
||||
if (params.cursor) {
|
||||
url.searchParams.append("cursor", params.cursor)
|
||||
}
|
||||
|
||||
if (params.date) {
|
||||
url.searchParams.append("date", params.date)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
throw new Error(`Failed to fetch events: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async getEventById(eventId: string): Promise<EventDto> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const response = await fetch(`http://localhost:3000/api/v1/events/${eventId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error("Event not found")
|
||||
}
|
||||
throw new Error(`Failed to fetch event: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
111
meteor-frontend/src/services/subscription.ts
Normal file
111
meteor-frontend/src/services/subscription.ts
Normal file
@ -0,0 +1,111 @@
|
||||
export interface SubscriptionData {
|
||||
hasActiveSubscription: boolean
|
||||
subscriptionStatus: string | null
|
||||
currentPlan: {
|
||||
id: string
|
||||
priceId: string
|
||||
name: string
|
||||
} | null
|
||||
nextBillingDate: Date | null
|
||||
customerId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SubscriptionResponse {
|
||||
success: boolean
|
||||
subscription: SubscriptionData
|
||||
}
|
||||
|
||||
export interface CustomerPortalResponse {
|
||||
success: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResponse {
|
||||
success: boolean
|
||||
session: {
|
||||
id: string
|
||||
url: string
|
||||
customerId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionApi = {
|
||||
async getSubscription(): Promise<SubscriptionResponse> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:3000/api/v1/payments/subscription", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
throw new Error(`Failed to fetch subscription: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async createCustomerPortalSession(returnUrl: string): Promise<CustomerPortalResponse> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:3000/api/v1/payments/customer-portal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ returnUrl }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
throw new Error(`Failed to create customer portal session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async createCheckoutSession(priceId: string, successUrl: string, cancelUrl: string): Promise<CheckoutSessionResponse> {
|
||||
const token = localStorage.getItem("accessToken")
|
||||
if (!token) {
|
||||
throw new Error("No access token found")
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:3000/api/v1/payments/checkout-session/stripe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized - please login again")
|
||||
}
|
||||
throw new Error(`Failed to create checkout session: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
23
meteor-frontend/src/types/device.ts
Normal file
23
meteor-frontend/src/types/device.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export enum DeviceStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
MAINTENANCE = 'maintenance',
|
||||
ONLINE = 'online',
|
||||
OFFLINE = 'offline',
|
||||
}
|
||||
|
||||
export interface DeviceDto {
|
||||
id: string
|
||||
userProfileId: string
|
||||
hardwareId: string
|
||||
deviceName?: string
|
||||
status: DeviceStatus
|
||||
lastSeenAt?: string
|
||||
registeredAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DevicesResponse {
|
||||
devices: DeviceDto[]
|
||||
}
|
||||
27
meteor-frontend/tsconfig.json
Normal file
27
meteor-frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
Subproject commit 18e60aa03a2bf01acf6331995e28319fedc903da
|
||||
50
meteor-web-backend/.dockerignore
Normal file
50
meteor-web-backend/.dockerignore
Normal file
@ -0,0 +1,50 @@
|
||||
# Node modules
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
# Distribution files
|
||||
dist
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Operating system files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test files and coverage
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
# Build artifacts
|
||||
build
|
||||
tmp
|
||||
temp
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Other
|
||||
.prettierrc
|
||||
eslint.config.mjs
|
||||
37
meteor-web-backend/.env.example
Normal file
37
meteor-web-backend/.env.example
Normal file
@ -0,0 +1,37 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/meteor_dev
|
||||
TEST_DATABASE_URL=postgresql://username:password@host:port/test_database_name
|
||||
|
||||
# JWT Configuration
|
||||
JWT_ACCESS_SECRET=your-super-secret-access-key-change-this-in-production
|
||||
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
|
||||
JWT_ACCESS_EXPIRATION=15m
|
||||
JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
|
||||
# Optional - Application Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Optional - Security Configuration
|
||||
BCRYPT_SALT_ROUNDS=10
|
||||
|
||||
# AWS Configuration (required for event upload functionality)
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-aws-access-key-id
|
||||
AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key
|
||||
AWS_S3_BUCKET_NAME=meteor-events-bucket
|
||||
AWS_SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/meteor-events-queue
|
||||
|
||||
# Payment Provider Configuration
|
||||
# Stripe
|
||||
STRIPE_API_KEY=sk_test_your_stripe_secret_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret_here
|
||||
|
||||
# Additional payment providers can be added here:
|
||||
# PAYPAL_CLIENT_ID=your_paypal_client_id
|
||||
# PAYPAL_WEBHOOK_SECRET=your_paypal_webhook_secret
|
||||
# ALIPAY_API_KEY=your_alipay_api_key
|
||||
# ALIPAY_WEBHOOK_SECRET=your_alipay_webhook_secret
|
||||
# WECHAT_API_KEY=your_wechat_api_key
|
||||
# WECHAT_WEBHOOK_SECRET=your_wechat_webhook_secret
|
||||
56
meteor-web-backend/.gitignore
vendored
Normal file
56
meteor-web-backend/.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
4
meteor-web-backend/.prettierrc
Normal file
4
meteor-web-backend/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
50
meteor-web-backend/Dockerfile
Normal file
50
meteor-web-backend/Dockerfile
Normal file
@ -0,0 +1,50 @@
|
||||
# Multi-stage Dockerfile for NestJS application
|
||||
|
||||
# Stage 1: Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including dev dependencies)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production stage
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nestjs -u 1001
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Change ownership of the app directory to the nestjs user
|
||||
RUN chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main"]
|
||||
330
meteor-web-backend/EVENT_UPLOAD_README.md
Normal file
330
meteor-web-backend/EVENT_UPLOAD_README.md
Normal file
@ -0,0 +1,330 @@
|
||||
# Event Data Upload API - Story 1.10 Implementation
|
||||
|
||||
This document describes the implementation of the Event Data Upload API that allows registered edge devices to securely upload event data (files and metadata) to the cloud platform.
|
||||
|
||||
## Overview
|
||||
|
||||
The Event Upload API provides a secure, asynchronous endpoint for edge devices to upload event data including:
|
||||
- Media files (images, videos)
|
||||
- Event metadata (timestamp, type, device information)
|
||||
- Automatic cloud storage and processing queue integration
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/v1/events/upload
|
||||
|
||||
Uploads an event file with metadata from a registered device.
|
||||
|
||||
**Authentication**: JWT Bearer token required
|
||||
**Content-Type**: multipart/form-data
|
||||
**Response**: 202 Accepted
|
||||
|
||||
#### Request Format
|
||||
|
||||
```http
|
||||
POST /api/v1/events/upload
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Form Fields:
|
||||
- file: Binary file data (image/video)
|
||||
- eventData: JSON string containing event metadata
|
||||
```
|
||||
|
||||
#### Event Data JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"eventType": "motion",
|
||||
"eventTimestamp": "2023-07-31T10:00:00Z",
|
||||
"metadata": {
|
||||
"deviceId": "device-uuid-here",
|
||||
"location": "front_door",
|
||||
"confidence": 0.95,
|
||||
"temperature": 22.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"rawEventId": "event-uuid-here",
|
||||
"message": "Event uploaded successfully and queued for processing"
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Responses
|
||||
|
||||
- `401 Unauthorized`: Invalid or missing JWT token
|
||||
- `400 Bad Request`: Missing file, invalid event data, unsupported file type
|
||||
- `404 Not Found`: Device not found or doesn't belong to user
|
||||
- `413 Payload Too Large`: File exceeds 100MB limit
|
||||
- `500 Internal Server Error`: AWS or database failure
|
||||
|
||||
### GET /api/v1/events/user
|
||||
|
||||
Retrieves events for the authenticated user.
|
||||
|
||||
**Authentication**: JWT Bearer token required
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
- `limit` (optional): Maximum number of events to return (default: 50)
|
||||
- `offset` (optional): Number of events to skip (default: 0)
|
||||
|
||||
#### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "event-uuid",
|
||||
"deviceId": "device-uuid",
|
||||
"eventType": "motion",
|
||||
"eventTimestamp": "2023-07-31T10:00:00Z",
|
||||
"filePath": "events/device-123/motion/2023-07-31_uuid.jpg",
|
||||
"processingStatus": "pending",
|
||||
"metadata": {
|
||||
"location": "front_door",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"createdAt": "2023-07-31T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/events/device/:deviceId
|
||||
|
||||
Retrieves events for a specific device.
|
||||
|
||||
**Authentication**: JWT Bearer token required
|
||||
|
||||
#### Response Format
|
||||
|
||||
Same as `/api/v1/events/user` but filtered by device.
|
||||
|
||||
### GET /api/v1/events/health/check
|
||||
|
||||
Health check endpoint for the events service.
|
||||
|
||||
#### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"checks": {
|
||||
"database": true,
|
||||
"aws": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### raw_events Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | uuid | Primary key |
|
||||
| device_id | uuid | Foreign key to devices table |
|
||||
| user_profile_id | uuid | Foreign key to user_profiles table |
|
||||
| file_path | text | S3 file path |
|
||||
| file_size | bigint | File size in bytes |
|
||||
| file_type | varchar(100) | MIME type |
|
||||
| original_filename | varchar(255) | Original filename |
|
||||
| event_type | varchar(50) | Type of event |
|
||||
| event_timestamp | timestamptz | When event occurred on device |
|
||||
| metadata | jsonb | Additional event metadata |
|
||||
| processing_status | varchar(20) | pending/processing/completed/failed |
|
||||
| sqs_message_id | varchar(255) | SQS message ID for tracking |
|
||||
| processed_at | timestamptz | When processing completed |
|
||||
| created_at | timestamptz | Record creation time |
|
||||
| updated_at | timestamptz | Record update time |
|
||||
|
||||
## AWS Integration
|
||||
|
||||
### S3 File Storage
|
||||
|
||||
Files are stored in S3 with the following structure:
|
||||
```
|
||||
events/{deviceId}/{eventType}/{timestamp}_{uuid}.{extension}
|
||||
```
|
||||
|
||||
Example: `events/device-123/motion/2023-07-31T10-00-00_a1b2c3d4.jpg`
|
||||
|
||||
### SQS Message Queue
|
||||
|
||||
After successful upload, a message is sent to SQS for async processing:
|
||||
|
||||
```json
|
||||
{
|
||||
"rawEventId": "event-uuid",
|
||||
"deviceId": "device-uuid",
|
||||
"userProfileId": "user-uuid",
|
||||
"eventType": "motion",
|
||||
"timestamp": "2023-07-31T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# AWS Configuration (required)
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
AWS_S3_BUCKET_NAME=meteor-events-bucket
|
||||
AWS_SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/meteor-events-queue
|
||||
```
|
||||
|
||||
### File Upload Limits
|
||||
|
||||
- Maximum file size: 100MB
|
||||
- Supported file types:
|
||||
- Images: JPEG, PNG, GIF, WebP
|
||||
- Videos: MP4, AVI, QuickTime, X-MSVIDEO
|
||||
|
||||
## Security Features
|
||||
|
||||
1. **JWT Authentication**: All endpoints require valid JWT tokens
|
||||
2. **Device Ownership Validation**: Users can only upload for their registered devices
|
||||
3. **File Type Validation**: Only approved media types are accepted
|
||||
4. **File Size Limits**: Prevents abuse with large file uploads
|
||||
5. **Input Sanitization**: All inputs are validated and sanitized
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API implements comprehensive error handling:
|
||||
|
||||
- **Validation Errors**: Client-side input validation with detailed messages
|
||||
- **Authentication Errors**: Clear JWT-related error responses
|
||||
- **AWS Failures**: Graceful handling of S3/SQS service failures
|
||||
- **Database Errors**: Transaction rollbacks and appropriate error responses
|
||||
- **File Processing Errors**: Proper cleanup of partial uploads
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Run unit tests for the EventsService:
|
||||
```bash
|
||||
npm run test -- --testPathPattern=events.service.spec.ts
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Run end-to-end tests:
|
||||
```bash
|
||||
npm run test:e2e -- --testPathPattern=events.e2e-spec.ts
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Use the provided test script:
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install node-fetch@2 form-data
|
||||
|
||||
# Run test script
|
||||
node test-event-upload.js
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Edge Device Implementation
|
||||
|
||||
```javascript
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
|
||||
async function uploadEvent(filePath, eventData, jwtToken) {
|
||||
const form = new FormData();
|
||||
|
||||
// Add file
|
||||
form.append('file', fs.createReadStream(filePath));
|
||||
|
||||
// Add event metadata
|
||||
form.append('eventData', JSON.stringify({
|
||||
eventType: 'motion',
|
||||
eventTimestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
deviceId: 'your-device-id',
|
||||
location: 'front_door',
|
||||
confidence: 0.95
|
||||
}
|
||||
}));
|
||||
|
||||
const response = await fetch('http://localhost:3000/api/v1/events/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwtToken}`,
|
||||
},
|
||||
body: form
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Event uploaded:', result.rawEventId);
|
||||
} else {
|
||||
console.error('Upload failed:', await response.text());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cURL Example
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/events/upload \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-F "file=@motion_capture.jpg" \
|
||||
-F 'eventData={"eventType":"motion","eventTimestamp":"2023-07-31T10:00:00Z","metadata":{"deviceId":"device-uuid","location":"front_door"}}'
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Streaming Uploads**: Files are processed as streams to avoid memory issues
|
||||
- **Async Processing**: Upload returns immediately; processing happens asynchronously
|
||||
- **Database Indexing**: Optimized indexes for common query patterns
|
||||
- **Connection Pooling**: Database connections are pooled for efficiency
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
- **Health Checks**: `/api/v1/events/health/check` endpoint for monitoring
|
||||
- **Structured Logging**: Comprehensive logging for debugging and monitoring
|
||||
- **Error Tracking**: Detailed error logs with context information
|
||||
- **Metrics**: Processing status tracking for analytics
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
1. **AWS Setup**: Ensure S3 bucket and SQS queue are created and accessible
|
||||
2. **IAM Permissions**: Configure appropriate IAM roles for S3 and SQS access
|
||||
3. **Environment Variables**: Set all required AWS configuration variables
|
||||
4. **Database Migration**: Run migrations to create the raw_events table
|
||||
5. **File Upload Limits**: Configure reverse proxy (nginx) for large file uploads
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future iterations:
|
||||
|
||||
1. **File Compression**: Automatic compression of uploaded files
|
||||
2. **Batch Uploads**: Support for uploading multiple files at once
|
||||
3. **Resumable Uploads**: Support for resuming interrupted uploads
|
||||
4. **Image Processing**: Automatic thumbnail generation and image optimization
|
||||
5. **Video Transcoding**: Automatic video format conversion and optimization
|
||||
6. **Content Analysis**: AI-powered content analysis and tagging
|
||||
7. **Retention Policies**: Automatic cleanup of old files based on policies
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions regarding the Event Upload API:
|
||||
|
||||
1. Check the application logs for detailed error information
|
||||
2. Verify AWS credentials and service availability
|
||||
3. Ensure database connectivity and table existence
|
||||
4. Review file format and size requirements
|
||||
179
meteor-web-backend/README.md
Normal file
179
meteor-web-backend/README.md
Normal file
@ -0,0 +1,179 @@
|
||||
# Meteor Web Backend
|
||||
|
||||
A NestJS-based backend service for the Meteor application with user authentication.
|
||||
|
||||
## Features
|
||||
|
||||
- User registration with email/password
|
||||
- Password hashing using bcrypt
|
||||
- PostgreSQL database with TypeORM
|
||||
- Database migrations
|
||||
- Input validation
|
||||
- Transaction support
|
||||
- Comprehensive unit and integration tests
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18 or higher)
|
||||
- PostgreSQL database
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file based on `.env.example`:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/meteor_dev
|
||||
BCRYPT_SALT_ROUNDS=10
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
|
||||
Run migrations to set up the database schema:
|
||||
|
||||
```bash
|
||||
npm run migrate:up
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/v1/auth/register-email
|
||||
|
||||
Register a new user with email and password.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Password123",
|
||||
"displayName": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "User registered successfully",
|
||||
"userId": "uuid-string"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Rules:**
|
||||
- Email must be a valid email format
|
||||
- Password must be at least 8 characters long
|
||||
- Password must contain at least one lowercase letter, one uppercase letter, and one number
|
||||
- Display name is required
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request` - Invalid input data
|
||||
- `409 Conflict` - Email already registered
|
||||
- `500 Internal Server Error` - Server error
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
```bash
|
||||
npm run test:cov
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Create New Migration
|
||||
|
||||
```bash
|
||||
npm run migrate:create migration-name
|
||||
```
|
||||
|
||||
### Run Migrations
|
||||
|
||||
```bash
|
||||
npm run migrate:up
|
||||
```
|
||||
|
||||
### Rollback Migrations
|
||||
|
||||
```bash
|
||||
npm run migrate:down
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── auth/ # Authentication module
|
||||
│ ├── dto/ # Data transfer objects
|
||||
│ ├── auth.controller.ts
|
||||
│ ├── auth.service.ts
|
||||
│ └── auth.module.ts
|
||||
├── entities/ # TypeORM entities
|
||||
│ ├── user-profile.entity.ts
|
||||
│ └── user-identity.entity.ts
|
||||
├── app.module.ts # Main application module
|
||||
└── main.ts # Application entry point
|
||||
|
||||
migrations/ # Database migrations
|
||||
test/ # Integration tests
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### user_profiles
|
||||
- `id` (UUID, Primary Key)
|
||||
- `display_name` (VARCHAR, nullable)
|
||||
- `avatar_url` (TEXT, nullable)
|
||||
- `created_at` (TIMESTAMP)
|
||||
- `updated_at` (TIMESTAMP)
|
||||
|
||||
### user_identities
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_profile_id` (UUID, Foreign Key)
|
||||
- `provider` (VARCHAR) - e.g., 'email'
|
||||
- `provider_id` (VARCHAR) - e.g., email address
|
||||
- `email` (VARCHAR, nullable, unique for email provider)
|
||||
- `password_hash` (VARCHAR, nullable)
|
||||
- `created_at` (TIMESTAMP)
|
||||
- `updated_at` (TIMESTAMP)
|
||||
|
||||
## Security Features
|
||||
|
||||
- Passwords are hashed using bcrypt with configurable salt rounds
|
||||
- Email uniqueness validation
|
||||
- Input sanitization and validation
|
||||
- Database transactions for data consistency
|
||||
- No sensitive data exposed in API responses
|
||||
34
meteor-web-backend/eslint.config.mjs
Normal file
34
meteor-web-backend/eslint.config.mjs
Normal file
@ -0,0 +1,34 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
export const shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
// Create user_profiles table
|
||||
pgm.createTable('user_profiles', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
display_name: {
|
||||
type: 'varchar(255)',
|
||||
notNull: false,
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'text',
|
||||
notNull: false,
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create user_identities table
|
||||
pgm.createTable('user_identities', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
user_profile_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'user_profiles(id)',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
provider: {
|
||||
type: 'varchar(50)',
|
||||
notNull: true,
|
||||
},
|
||||
provider_id: {
|
||||
type: 'varchar(255)',
|
||||
notNull: true,
|
||||
},
|
||||
email: {
|
||||
type: 'varchar(255)',
|
||||
notNull: false,
|
||||
},
|
||||
password_hash: {
|
||||
type: 'varchar(255)',
|
||||
notNull: false,
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create unique index on email for email provider
|
||||
pgm.createIndex('user_identities', ['email'], {
|
||||
unique: true,
|
||||
where: "provider = 'email' AND email IS NOT NULL",
|
||||
});
|
||||
|
||||
// Create unique index on provider and provider_id combination
|
||||
pgm.createIndex('user_identities', ['provider', 'provider_id'], {
|
||||
unique: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
export const down = (pgm) => {
|
||||
pgm.dropTable('user_identities');
|
||||
pgm.dropTable('user_profiles');
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
exports.shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.up = (pgm) => {
|
||||
// Create inventory_devices table - for pre-registered legitimate devices
|
||||
pgm.createTable('inventory_devices', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
hardware_id: {
|
||||
type: 'varchar(255)',
|
||||
notNull: true,
|
||||
unique: true,
|
||||
},
|
||||
is_claimed: {
|
||||
type: 'boolean',
|
||||
notNull: true,
|
||||
default: false,
|
||||
},
|
||||
device_model: {
|
||||
type: 'varchar(255)',
|
||||
comment: 'Optional device model information',
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create devices table - for user-claimed devices
|
||||
pgm.createTable('devices', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
user_profile_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'user_profiles(id)',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
hardware_id: {
|
||||
type: 'varchar(255)',
|
||||
notNull: true,
|
||||
unique: true,
|
||||
},
|
||||
device_name: {
|
||||
type: 'varchar(255)',
|
||||
comment: 'User-assigned friendly name for the device',
|
||||
},
|
||||
status: {
|
||||
type: 'varchar(50)',
|
||||
notNull: true,
|
||||
default: 'active',
|
||||
comment: 'Device status: active, inactive, maintenance',
|
||||
},
|
||||
last_seen_at: {
|
||||
type: 'timestamp with time zone',
|
||||
comment: 'Last time device was seen online',
|
||||
},
|
||||
registered_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create indexes for better performance
|
||||
pgm.createIndex('inventory_devices', 'hardware_id');
|
||||
pgm.createIndex('inventory_devices', 'is_claimed');
|
||||
pgm.createIndex('devices', 'user_profile_id');
|
||||
pgm.createIndex('devices', 'hardware_id');
|
||||
pgm.createIndex('devices', 'status');
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('devices');
|
||||
pgm.dropTable('inventory_devices');
|
||||
};
|
||||
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
exports.shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.up = (pgm) => {
|
||||
// Create raw_events table for storing uploaded event data
|
||||
pgm.createTable('raw_events', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
device_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'devices(id)',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'The device that uploaded this event',
|
||||
},
|
||||
user_profile_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'user_profiles(id)',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Owner of the device',
|
||||
},
|
||||
file_path: {
|
||||
type: 'text',
|
||||
notNull: true,
|
||||
comment: 'S3 file path/key for the uploaded file',
|
||||
},
|
||||
file_size: {
|
||||
type: 'bigint',
|
||||
comment: 'File size in bytes',
|
||||
},
|
||||
file_type: {
|
||||
type: 'varchar(100)',
|
||||
comment: 'MIME type of the uploaded file',
|
||||
},
|
||||
original_filename: {
|
||||
type: 'varchar(255)',
|
||||
comment: 'Original filename from the client',
|
||||
},
|
||||
event_type: {
|
||||
type: 'varchar(50)',
|
||||
notNull: true,
|
||||
comment: 'Type of event (motion, alert, etc.)',
|
||||
},
|
||||
event_timestamp: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
comment: 'When the event occurred on the device',
|
||||
},
|
||||
metadata: {
|
||||
type: 'jsonb',
|
||||
comment: 'Additional event metadata from the device',
|
||||
},
|
||||
processing_status: {
|
||||
type: 'varchar(20)',
|
||||
notNull: true,
|
||||
default: 'pending',
|
||||
comment: 'Processing status: pending, processing, completed, failed',
|
||||
},
|
||||
sqs_message_id: {
|
||||
type: 'varchar(255)',
|
||||
comment: 'SQS message ID for tracking',
|
||||
},
|
||||
processed_at: {
|
||||
type: 'timestamp with time zone',
|
||||
comment: 'When processing was completed',
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create indexes for better performance
|
||||
pgm.createIndex('raw_events', 'device_id');
|
||||
pgm.createIndex('raw_events', 'user_profile_id');
|
||||
pgm.createIndex('raw_events', 'event_type');
|
||||
pgm.createIndex('raw_events', 'event_timestamp');
|
||||
pgm.createIndex('raw_events', 'processing_status');
|
||||
pgm.createIndex('raw_events', ['device_id', 'event_timestamp']);
|
||||
pgm.createIndex('raw_events', ['user_profile_id', 'created_at']);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('raw_events');
|
||||
};
|
||||
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
exports.shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.up = (pgm) => {
|
||||
// Create validated_events table for storing processed/validated event data
|
||||
pgm.createTable('validated_events', {
|
||||
id: {
|
||||
type: 'uuid',
|
||||
primaryKey: true,
|
||||
default: pgm.func('gen_random_uuid()'),
|
||||
},
|
||||
raw_event_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
unique: true,
|
||||
references: 'raw_events(id)',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Reference to the original raw event',
|
||||
},
|
||||
device_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'devices(id)',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'The device that uploaded this event',
|
||||
},
|
||||
user_profile_id: {
|
||||
type: 'uuid',
|
||||
notNull: true,
|
||||
references: 'user_profiles(id)',
|
||||
onDelete: 'CASCADE',
|
||||
comment: 'Owner of the device',
|
||||
},
|
||||
media_url: {
|
||||
type: 'text',
|
||||
notNull: true,
|
||||
comment: 'URL to access the media file (could be S3 signed URL or CloudFront URL)',
|
||||
},
|
||||
file_size: {
|
||||
type: 'bigint',
|
||||
comment: 'File size in bytes',
|
||||
},
|
||||
file_type: {
|
||||
type: 'varchar(100)',
|
||||
comment: 'MIME type of the file',
|
||||
},
|
||||
original_filename: {
|
||||
type: 'varchar(255)',
|
||||
comment: 'Original filename from the client',
|
||||
},
|
||||
event_type: {
|
||||
type: 'varchar(50)',
|
||||
notNull: true,
|
||||
comment: 'Type of event (motion, alert, meteor, etc.)',
|
||||
},
|
||||
event_timestamp: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
comment: 'When the event occurred on the device',
|
||||
},
|
||||
metadata: {
|
||||
type: 'jsonb',
|
||||
comment: 'Additional event metadata from the device',
|
||||
},
|
||||
validation_score: {
|
||||
type: 'decimal(5,4)',
|
||||
comment: 'Validation confidence score (0.0000 to 1.0000)',
|
||||
},
|
||||
validation_details: {
|
||||
type: 'jsonb',
|
||||
comment: 'Details from the validation process',
|
||||
},
|
||||
is_valid: {
|
||||
type: 'boolean',
|
||||
notNull: true,
|
||||
default: true,
|
||||
comment: 'Whether the event passed validation',
|
||||
},
|
||||
validation_algorithm: {
|
||||
type: 'varchar(50)',
|
||||
comment: 'Algorithm used for validation',
|
||||
},
|
||||
created_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
updated_at: {
|
||||
type: 'timestamp with time zone',
|
||||
notNull: true,
|
||||
default: pgm.func('now()'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create indexes for better performance
|
||||
pgm.createIndex('validated_events', 'raw_event_id');
|
||||
pgm.createIndex('validated_events', 'device_id');
|
||||
pgm.createIndex('validated_events', 'user_profile_id');
|
||||
pgm.createIndex('validated_events', 'event_type');
|
||||
pgm.createIndex('validated_events', 'event_timestamp');
|
||||
pgm.createIndex('validated_events', 'is_valid');
|
||||
pgm.createIndex('validated_events', ['device_id', 'event_timestamp']);
|
||||
pgm.createIndex('validated_events', ['user_profile_id', 'created_at']);
|
||||
pgm.createIndex('validated_events', ['event_type', 'is_valid']);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('validated_events');
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
export const shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
// Add payment provider columns to user_profiles table
|
||||
pgm.addColumns('user_profiles', {
|
||||
payment_provider_customer_id: {
|
||||
type: 'varchar(255)',
|
||||
notNull: false,
|
||||
comment: 'Customer ID from the payment provider (e.g., Stripe customer ID)',
|
||||
},
|
||||
payment_provider_subscription_id: {
|
||||
type: 'varchar(255)',
|
||||
notNull: false,
|
||||
comment: 'Subscription ID from the payment provider (e.g., Stripe subscription ID)',
|
||||
},
|
||||
});
|
||||
|
||||
// Create index for faster lookups by payment provider customer ID
|
||||
pgm.createIndex('user_profiles', ['payment_provider_customer_id'], {
|
||||
unique: false,
|
||||
where: 'payment_provider_customer_id IS NOT NULL',
|
||||
});
|
||||
|
||||
// Create index for faster lookups by payment provider subscription ID
|
||||
pgm.createIndex('user_profiles', ['payment_provider_subscription_id'], {
|
||||
unique: false,
|
||||
where: 'payment_provider_subscription_id IS NOT NULL',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param pgm {import('node-pg-migrate').MigrationBuilder}
|
||||
* @param run {() => void | undefined}
|
||||
* @returns {Promise<void> | void}
|
||||
*/
|
||||
export const down = (pgm) => {
|
||||
// Drop indexes first
|
||||
pgm.dropIndex('user_profiles', ['payment_provider_subscription_id']);
|
||||
pgm.dropIndex('user_profiles', ['payment_provider_customer_id']);
|
||||
|
||||
// Drop columns
|
||||
pgm.dropColumns('user_profiles', [
|
||||
'payment_provider_customer_id',
|
||||
'payment_provider_subscription_id',
|
||||
]);
|
||||
};
|
||||
8
meteor-web-backend/nest-cli.json
Normal file
8
meteor-web-backend/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
14186
meteor-web-backend/package-lock.json
generated
Normal file
14186
meteor-web-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
102
meteor-web-backend/package.json
Normal file
102
meteor-web-backend/package.json
Normal file
@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "meteor-web-backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:integration": "TEST_DATABASE_URL=postgresql://meteor_test:meteor_test_pass@localhost:5433/meteor_test jest --config ./test/jest-e2e.json --testPathPattern=integration",
|
||||
"migrate:up": "node-pg-migrate up",
|
||||
"migrate:down": "node-pg-migrate down",
|
||||
"migrate:create": "node-pg-migrate create"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.856.0",
|
||||
"@aws-sdk/client-sqs": "^3.856.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.5",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"dotenv": "^17.2.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-pg-migrate": "^8.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^18.4.0",
|
||||
"typeorm": "^0.3.25",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.6.0",
|
||||
"@swc/core": "^1.10.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
68
meteor-web-backend/scripts/seed-inventory-devices.js
Normal file
68
meteor-web-backend/scripts/seed-inventory-devices.js
Normal file
@ -0,0 +1,68 @@
|
||||
const { Client } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
async function seedInventoryDevices() {
|
||||
console.log('=== Seeding Inventory Devices ===');
|
||||
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// Sample inventory devices for testing
|
||||
const deviceData = [
|
||||
{ hardwareId: 'EDGE_DEVICE_001', deviceModel: 'EdgeBox Pro v1.0' },
|
||||
{ hardwareId: 'EDGE_DEVICE_002', deviceModel: 'EdgeBox Pro v1.0' },
|
||||
{ hardwareId: 'EDGE_DEVICE_003', deviceModel: 'EdgeBox Pro v1.1' },
|
||||
{ hardwareId: 'SENSOR_NODE_001', deviceModel: 'SensorNode Lite v2.0' },
|
||||
{ hardwareId: 'SENSOR_NODE_002', deviceModel: 'SensorNode Lite v2.0' },
|
||||
{ hardwareId: 'GATEWAY_HUB_001', deviceModel: 'GatewayHub Enterprise v1.0' },
|
||||
];
|
||||
|
||||
console.log('📦 Inserting sample inventory devices...');
|
||||
|
||||
for (const device of deviceData) {
|
||||
// Check if device already exists
|
||||
const existsResult = await client.query(
|
||||
'SELECT id FROM inventory_devices WHERE hardware_id = $1',
|
||||
[device.hardwareId]
|
||||
);
|
||||
|
||||
if (existsResult.rows.length === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_devices (hardware_id, device_model, is_claimed)
|
||||
VALUES ($1, $2, false)`,
|
||||
[device.hardwareId, device.deviceModel]
|
||||
);
|
||||
console.log(` ✅ Added: ${device.hardwareId} (${device.deviceModel})`);
|
||||
} else {
|
||||
console.log(` ⏭️ Skipped: ${device.hardwareId} (already exists)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show current inventory status
|
||||
const inventoryResult = await client.query(`
|
||||
SELECT hardware_id, device_model, is_claimed, created_at
|
||||
FROM inventory_devices
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
console.log('\n📋 Current Inventory Status:');
|
||||
inventoryResult.rows.forEach(row => {
|
||||
const status = row.is_claimed ? '🔴 CLAIMED' : '🟢 AVAILABLE';
|
||||
console.log(` ${status} ${row.hardware_id} - ${row.device_model || 'No model'}`);
|
||||
});
|
||||
|
||||
console.log(`\n✅ Seeding completed! ${deviceData.length} devices processed.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding inventory devices:', error.message);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
seedInventoryDevices();
|
||||
70
meteor-web-backend/scripts/test-db-connection.js
Normal file
70
meteor-web-backend/scripts/test-db-connection.js
Normal file
@ -0,0 +1,70 @@
|
||||
const { Client } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
async function testDatabaseConnection() {
|
||||
console.log('=== Database Connection Test ===');
|
||||
console.log('DATABASE_URL from .env:', process.env.DATABASE_URL);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL not found in environment variables');
|
||||
console.log('Current environment variables:');
|
||||
Object.keys(process.env).filter(key => key.includes('DATABASE')).forEach(key => {
|
||||
console.log(`${key}: ${process.env[key]}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: false, // Set to true if your database requires SSL
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('🔄 Attempting to connect to database...');
|
||||
await client.connect();
|
||||
console.log('✅ Successfully connected to database!');
|
||||
|
||||
// Test a simple query
|
||||
const result = await client.query('SELECT version()');
|
||||
console.log('📊 Database version:', result.rows[0].version);
|
||||
|
||||
// Check if our tables exist
|
||||
const tablesResult = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('user_profiles', 'user_identities')
|
||||
`);
|
||||
|
||||
console.log('📋 Found tables:', tablesResult.rows.map(row => row.table_name));
|
||||
|
||||
if (tablesResult.rows.length === 0) {
|
||||
console.log('⚠️ No user tables found. You may need to run migrations.');
|
||||
console.log('Run: npm run migrate:up');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error.message);
|
||||
console.error('🔍 Error details:', {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall,
|
||||
address: error.address,
|
||||
port: error.port
|
||||
});
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
console.error('🌐 DNS resolution failed - check if the host is correct');
|
||||
} else if (error.code === 'ECONNREFUSED') {
|
||||
console.error('🚫 Connection refused - check if database server is running');
|
||||
} else if (error.code === '28P01') {
|
||||
console.error('🔐 Authentication failed - check username/password');
|
||||
} else if (error.code === '3D000') {
|
||||
console.error('🗄️ Database does not exist - check database name');
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
testDatabaseConnection();
|
||||
29
meteor-web-backend/src/app.controller.spec.ts
Normal file
29
meteor-web-backend/src/app.controller.spec.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('health', () => {
|
||||
it('should return status ok', () => {
|
||||
const result = appController.getHealth();
|
||||
expect(result).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
});
|
||||
17
meteor-web-backend/src/app.controller.ts
Normal file
17
meteor-web-backend/src/app.controller.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
getHealth(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
48
meteor-web-backend/src/app.module.ts
Normal file
48
meteor-web-backend/src/app.module.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DevicesModule } from './devices/devices.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { UserProfile } from './entities/user-profile.entity';
|
||||
import { UserIdentity } from './entities/user-identity.entity';
|
||||
import { Device } from './entities/device.entity';
|
||||
import { InventoryDevice } from './entities/inventory-device.entity';
|
||||
import { RawEvent } from './entities/raw-event.entity';
|
||||
|
||||
// Ensure dotenv is loaded before anything else
|
||||
dotenv.config();
|
||||
|
||||
console.log('=== Database Configuration Debug ===');
|
||||
console.log('DATABASE_URL from env:', process.env.DATABASE_URL);
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
console.log('Current working directory:', process.cwd());
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://user:password@localhost:5432/meteor_dev',
|
||||
entities: [UserProfile, UserIdentity, Device, InventoryDevice, RawEvent],
|
||||
synchronize: false, // Use migrations instead
|
||||
logging: ['error', 'warn', 'info', 'log'],
|
||||
logger: 'advanced-console',
|
||||
retryAttempts: 3,
|
||||
retryDelay: 3000,
|
||||
}),
|
||||
AuthModule,
|
||||
DevicesModule,
|
||||
EventsModule,
|
||||
PaymentsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
meteor-web-backend/src/app.service.ts
Normal file
8
meteor-web-backend/src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
39
meteor-web-backend/src/auth/auth.controller.ts
Normal file
39
meteor-web-backend/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Request,
|
||||
ValidationPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||
import { LoginEmailDto } from './dto/login-email.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@Controller('api/v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register-email')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async registerWithEmail(@Body(ValidationPipe) registerDto: RegisterEmailDto) {
|
||||
return await this.authService.registerWithEmail(registerDto);
|
||||
}
|
||||
|
||||
@Post('login-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async loginWithEmail(@Body(ValidationPipe) loginDto: LoginEmailDto) {
|
||||
return await this.authService.loginWithEmail(loginDto);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req: any) {
|
||||
const userId = req.user.userId;
|
||||
return await this.authService.getUserProfile(userId);
|
||||
}
|
||||
}
|
||||
27
meteor-web-backend/src/auth/auth.module.ts
Normal file
27
meteor-web-backend/src/auth/auth.module.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UserProfile } from '../entities/user-profile.entity';
|
||||
import { UserIdentity } from '../entities/user-identity.entity';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserProfile, UserIdentity]),
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret:
|
||||
process.env.JWT_ACCESS_SECRET || 'default-secret-change-in-production',
|
||||
signOptions: { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' },
|
||||
}),
|
||||
forwardRef(() => PaymentsModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
353
meteor-web-backend/src/auth/auth.service.spec.ts
Normal file
353
meteor-web-backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Repository, DataSource, QueryRunner } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserProfile } from '../entities/user-profile.entity';
|
||||
import { UserIdentity } from '../entities/user-identity.entity';
|
||||
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||
import { LoginEmailDto } from './dto/login-email.dto';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userProfileRepository: jest.Mocked<Repository<UserProfile>>;
|
||||
let userIdentityRepository: jest.Mocked<Repository<UserIdentity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
let queryRunner: jest.Mocked<QueryRunner>;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
|
||||
const mockUserProfile = {
|
||||
id: 'test-uuid-123',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: null,
|
||||
paymentProviderCustomerId: null,
|
||||
paymentProviderSubscriptionId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
identities: [],
|
||||
} as UserProfile;
|
||||
|
||||
const mockUserIdentity = {
|
||||
id: 'identity-uuid-123',
|
||||
userProfileId: 'test-uuid-123',
|
||||
provider: 'email',
|
||||
providerId: 'test@example.com',
|
||||
email: 'test@example.com',
|
||||
passwordHash: 'hashed-password',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as UserIdentity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||
};
|
||||
|
||||
const mockUserProfileRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserIdentityRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: getRepositoryToken(UserProfile),
|
||||
useValue: mockUserProfileRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserIdentity),
|
||||
useValue: mockUserIdentityRepo,
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: mockJwtService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userProfileRepository = module.get(getRepositoryToken(UserProfile));
|
||||
userIdentityRepository = module.get(getRepositoryToken(UserIdentity));
|
||||
dataSource = module.get(DataSource);
|
||||
jwtService = module.get(JwtService);
|
||||
queryRunner = mockQueryRunner as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerWithEmail', () => {
|
||||
const validRegisterDto: RegisterEmailDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'Test User',
|
||||
};
|
||||
|
||||
it('should register a new user successfully', async () => {
|
||||
// Arrange
|
||||
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||
(queryRunner.manager.create as jest.Mock)
|
||||
.mockReturnValueOnce(mockUserProfile)
|
||||
.mockReturnValueOnce(mockUserIdentity);
|
||||
(queryRunner.manager.save as jest.Mock)
|
||||
.mockResolvedValueOnce(mockUserProfile)
|
||||
.mockResolvedValueOnce(mockUserIdentity);
|
||||
|
||||
// Act
|
||||
const result = await service.registerWithEmail(validRegisterDto);
|
||||
|
||||
// Assert
|
||||
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com', provider: 'email' },
|
||||
});
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('Password123', 10);
|
||||
expect(queryRunner.connect).toHaveBeenCalled();
|
||||
expect(queryRunner.startTransaction).toHaveBeenCalled();
|
||||
expect(queryRunner.commitTransaction).toHaveBeenCalled();
|
||||
expect(queryRunner.release).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
message: 'User registered successfully',
|
||||
userId: 'test-uuid-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConflictException if email already exists', async () => {
|
||||
// Arrange
|
||||
userIdentityRepository.findOne.mockResolvedValue(mockUserIdentity);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.registerWithEmail(validRegisterDto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com', provider: 'email' },
|
||||
});
|
||||
expect(bcrypt.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should rollback transaction and throw InternalServerErrorException on database error', async () => {
|
||||
// Arrange
|
||||
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||
(queryRunner.manager.save as jest.Mock).mockRejectedValue(
|
||||
new Error('Database error'),
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.registerWithEmail(validRegisterDto)).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
|
||||
expect(queryRunner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use environment variable for salt rounds', async () => {
|
||||
// Arrange - Create a new service instance with the environment variable set
|
||||
const originalEnv = process.env.BCRYPT_SALT_ROUNDS;
|
||||
process.env.BCRYPT_SALT_ROUNDS = '12';
|
||||
|
||||
const testService = new AuthService(
|
||||
userProfileRepository,
|
||||
userIdentityRepository,
|
||||
dataSource,
|
||||
jwtService,
|
||||
);
|
||||
|
||||
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||
(bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password');
|
||||
(queryRunner.manager.create as jest.Mock)
|
||||
.mockReturnValueOnce(mockUserProfile)
|
||||
.mockReturnValueOnce(mockUserIdentity);
|
||||
(queryRunner.manager.save as jest.Mock)
|
||||
.mockResolvedValueOnce(mockUserProfile)
|
||||
.mockResolvedValueOnce(mockUserIdentity);
|
||||
|
||||
// Act
|
||||
await testService.registerWithEmail(validRegisterDto);
|
||||
|
||||
// Assert
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('Password123', 12);
|
||||
|
||||
// Cleanup
|
||||
if (originalEnv) {
|
||||
process.env.BCRYPT_SALT_ROUNDS = originalEnv;
|
||||
} else {
|
||||
delete process.env.BCRYPT_SALT_ROUNDS;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginWithEmail', () => {
|
||||
const validLoginDto: LoginEmailDto = {
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
};
|
||||
|
||||
it('should login successfully with valid credentials', async () => {
|
||||
// Arrange
|
||||
const mockUserIdentityWithProfile = {
|
||||
...mockUserIdentity,
|
||||
passwordHash: 'hashed-password',
|
||||
userProfile: mockUserProfile,
|
||||
};
|
||||
|
||||
userIdentityRepository.findOne.mockResolvedValue(
|
||||
mockUserIdentityWithProfile,
|
||||
);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
jwtService.sign
|
||||
.mockReturnValueOnce('access-token')
|
||||
.mockReturnValueOnce('refresh-token');
|
||||
|
||||
// Act
|
||||
const result = await service.loginWithEmail(validLoginDto);
|
||||
|
||||
// Assert
|
||||
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com', provider: 'email' },
|
||||
relations: ['userProfile'],
|
||||
});
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith(
|
||||
'Password123',
|
||||
'hashed-password',
|
||||
);
|
||||
expect(jwtService.sign).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({
|
||||
message: 'Login successful',
|
||||
userId: 'test-uuid-123',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user does not exist', async () => {
|
||||
// Arrange
|
||||
userIdentityRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(userIdentityRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { email: 'test@example.com', provider: 'email' },
|
||||
relations: ['userProfile'],
|
||||
});
|
||||
expect(bcrypt.compare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when password is invalid', async () => {
|
||||
// Arrange
|
||||
const mockUserIdentityWithProfile = {
|
||||
...mockUserIdentity,
|
||||
passwordHash: 'hashed-password',
|
||||
userProfile: mockUserProfile,
|
||||
};
|
||||
|
||||
userIdentityRepository.findOne.mockResolvedValue(
|
||||
mockUserIdentityWithProfile,
|
||||
);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith(
|
||||
'Password123',
|
||||
'hashed-password',
|
||||
);
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when user has no password hash', async () => {
|
||||
// Arrange
|
||||
const mockUserIdentityWithoutPassword = {
|
||||
...mockUserIdentity,
|
||||
passwordHash: null,
|
||||
userProfile: mockUserProfile,
|
||||
};
|
||||
|
||||
userIdentityRepository.findOne.mockResolvedValue(
|
||||
mockUserIdentityWithoutPassword,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.loginWithEmail(validLoginDto)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(bcrypt.compare).not.toHaveBeenCalled();
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate JWT tokens with correct payload', async () => {
|
||||
// Arrange
|
||||
const mockUserIdentityWithProfile = {
|
||||
...mockUserIdentity,
|
||||
passwordHash: 'hashed-password',
|
||||
userProfile: mockUserProfile,
|
||||
};
|
||||
|
||||
userIdentityRepository.findOne.mockResolvedValue(
|
||||
mockUserIdentityWithProfile,
|
||||
);
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
jwtService.sign
|
||||
.mockReturnValueOnce('access-token')
|
||||
.mockReturnValueOnce('refresh-token');
|
||||
|
||||
// Act
|
||||
await service.loginWithEmail(validLoginDto);
|
||||
|
||||
// Assert
|
||||
const expectedPayload = {
|
||||
userId: 'test-uuid-123',
|
||||
email: 'test@example.com',
|
||||
sub: 'test-uuid-123',
|
||||
};
|
||||
|
||||
expect(jwtService.sign).toHaveBeenNthCalledWith(1, expectedPayload);
|
||||
expect(jwtService.sign).toHaveBeenNthCalledWith(2, expectedPayload, {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
187
meteor-web-backend/src/auth/auth.service.ts
Normal file
187
meteor-web-backend/src/auth/auth.service.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserProfile } from '../entities/user-profile.entity';
|
||||
import { UserIdentity } from '../entities/user-identity.entity';
|
||||
import { RegisterEmailDto } from './dto/register-email.dto';
|
||||
import { LoginEmailDto } from './dto/login-email.dto';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly saltRounds: number;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserProfile)
|
||||
private userProfileRepository: Repository<UserProfile>,
|
||||
@InjectRepository(UserIdentity)
|
||||
private userIdentityRepository: Repository<UserIdentity>,
|
||||
private dataSource: DataSource,
|
||||
private jwtService: JwtService,
|
||||
@Inject(forwardRef(() => PaymentsService))
|
||||
private paymentsService: PaymentsService,
|
||||
) {
|
||||
this.saltRounds = parseInt(process.env.BCRYPT_SALT_ROUNDS || '10') || 10;
|
||||
}
|
||||
|
||||
async registerWithEmail(
|
||||
registerDto: RegisterEmailDto,
|
||||
): Promise<{ message: string; userId: string }> {
|
||||
const { email, password, displayName } = registerDto;
|
||||
|
||||
// Check if email is already registered
|
||||
const existingIdentity = await this.userIdentityRepository.findOne({
|
||||
where: { email, provider: 'email' },
|
||||
});
|
||||
|
||||
if (existingIdentity) {
|
||||
throw new ConflictException('Email is already registered');
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const passwordHash = await bcrypt.hash(password, this.saltRounds);
|
||||
|
||||
// Create user in a transaction
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// Create user profile
|
||||
const userProfile = queryRunner.manager.create(UserProfile, {
|
||||
displayName,
|
||||
});
|
||||
const savedProfile = await queryRunner.manager.save(userProfile);
|
||||
|
||||
// Create user identity
|
||||
const userIdentity = queryRunner.manager.create(UserIdentity, {
|
||||
userProfileId: savedProfile.id,
|
||||
provider: 'email',
|
||||
providerId: email,
|
||||
email,
|
||||
passwordHash,
|
||||
});
|
||||
await queryRunner.manager.save(userIdentity);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
message: 'User registered successfully',
|
||||
userId: savedProfile.id,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw new InternalServerErrorException('Failed to register user');
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
async loginWithEmail(loginDto: LoginEmailDto): Promise<{
|
||||
message: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const { email, password } = loginDto;
|
||||
|
||||
// Find user identity by email and provider
|
||||
const userIdentity = await this.userIdentityRepository.findOne({
|
||||
where: { email, provider: 'email' },
|
||||
relations: ['userProfile'],
|
||||
});
|
||||
|
||||
// Check if user exists and password matches
|
||||
if (!userIdentity || !userIdentity.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
password,
|
||||
userIdentity.passwordHash,
|
||||
);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Generate JWT tokens
|
||||
const payload = {
|
||||
userId: userIdentity.userProfileId,
|
||||
email: userIdentity.email,
|
||||
sub: userIdentity.userProfileId, // Standard JWT claim
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
userId: userIdentity.userProfileId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string): Promise<{
|
||||
userId: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
subscriptionStatus: string | null;
|
||||
hasActiveSubscription: boolean;
|
||||
}> {
|
||||
// Get user profile
|
||||
const userProfile = await this.userProfileRepository.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['identities'],
|
||||
});
|
||||
|
||||
if (!userProfile) {
|
||||
throw new NotFoundException('User profile not found');
|
||||
}
|
||||
|
||||
// Get the primary email from identities
|
||||
const emailIdentity = userProfile.identities.find(
|
||||
(identity) => identity.provider === 'email',
|
||||
);
|
||||
|
||||
if (!emailIdentity) {
|
||||
throw new NotFoundException('User email not found');
|
||||
}
|
||||
|
||||
// Get subscription status
|
||||
try {
|
||||
const subscriptionData =
|
||||
await this.paymentsService.getUserSubscription(userId);
|
||||
|
||||
return {
|
||||
userId: userProfile.id,
|
||||
displayName: userProfile.displayName,
|
||||
email: emailIdentity.email || '',
|
||||
subscriptionStatus: subscriptionData.subscriptionStatus,
|
||||
hasActiveSubscription: subscriptionData.hasActiveSubscription,
|
||||
};
|
||||
} catch (error) {
|
||||
// If there's an error getting subscription, return without subscription info
|
||||
return {
|
||||
userId: userProfile.id,
|
||||
displayName: userProfile.displayName,
|
||||
email: emailIdentity.email || '',
|
||||
subscriptionStatus: null,
|
||||
hasActiveSubscription: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
11
meteor-web-backend/src/auth/dto/login-email.dto.ts
Normal file
11
meteor-web-backend/src/auth/dto/login-email.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginEmailDto {
|
||||
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||
@IsNotEmpty({ message: 'Email is required' })
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Password is required' })
|
||||
password: string;
|
||||
}
|
||||
26
meteor-web-backend/src/auth/dto/register-email.dto.ts
Normal file
26
meteor-web-backend/src/auth/dto/register-email.dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MinLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterEmailDto {
|
||||
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||
@IsNotEmpty({ message: 'Email is required' })
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Password is required' })
|
||||
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message:
|
||||
'Password must contain at least one lowercase letter, one uppercase letter, and one number',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Display name is required' })
|
||||
displayName?: string;
|
||||
}
|
||||
5
meteor-web-backend/src/auth/guards/jwt-auth.guard.ts
Normal file
5
meteor-web-backend/src/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
55
meteor-web-backend/src/auth/guards/subscription.guard.ts
Normal file
55
meteor-web-backend/src/auth/guards/subscription.guard.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserProfile } from '../../entities/user-profile.entity';
|
||||
import { PaymentsService } from '../../payments/payments.service';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionGuard implements CanActivate {
|
||||
constructor(
|
||||
@InjectRepository(UserProfile)
|
||||
private readonly userProfileRepository: Repository<UserProfile>,
|
||||
@Inject(forwardRef(() => PaymentsService))
|
||||
private readonly paymentsService: PaymentsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user || !user.userId) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's subscription status using the same logic as the subscription endpoint
|
||||
const subscriptionData = await this.paymentsService.getUserSubscription(
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!subscriptionData.hasActiveSubscription) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires an active subscription. Please subscribe to continue using this service.',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// If there's an error checking subscription, deny access for security
|
||||
throw new ForbiddenException(
|
||||
'Unable to verify subscription status. Please try again or contact support.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
meteor-web-backend/src/auth/strategies/jwt.strategy.ts
Normal file
47
meteor-web-backend/src/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserProfile } from '../../entities/user-profile.entity';
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
sub: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
@InjectRepository(UserProfile)
|
||||
private userProfileRepository: Repository<UserProfile>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey:
|
||||
process.env.JWT_ACCESS_SECRET || 'default-secret-change-in-production',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const { userId } = payload;
|
||||
|
||||
// Verify user still exists
|
||||
const user = await this.userProfileRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
email: payload.email,
|
||||
};
|
||||
}
|
||||
}
|
||||
202
meteor-web-backend/src/aws/aws.service.ts
Normal file
202
meteor-web-backend/src/aws/aws.service.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
PutObjectCommandInput,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
SQSClient,
|
||||
SendMessageCommand,
|
||||
SendMessageCommandInput,
|
||||
} from '@aws-sdk/client-sqs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface UploadFileParams {
|
||||
buffer: Buffer;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
deviceId: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
export interface QueueMessageParams {
|
||||
rawEventId: string;
|
||||
deviceId: string;
|
||||
userProfileId: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AwsService {
|
||||
private readonly logger = new Logger(AwsService.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly sqsClient: SQSClient;
|
||||
|
||||
private readonly s3BucketName: string;
|
||||
private readonly sqsQueueUrl: string;
|
||||
|
||||
constructor() {
|
||||
const region = process.env.AWS_REGION || 'us-east-1';
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
this.sqsClient = new SQSClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
this.s3BucketName =
|
||||
process.env.AWS_S3_BUCKET_NAME || 'meteor-events-bucket';
|
||||
this.sqsQueueUrl = process.env.AWS_SQS_QUEUE_URL || '';
|
||||
|
||||
this.logger.log(`AWS Service initialized with region: ${region}`);
|
||||
this.logger.log(`S3 Bucket: ${this.s3BucketName}`);
|
||||
this.logger.log(`SQS Queue URL: ${this.sqsQueueUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3 and return the file path
|
||||
*/
|
||||
async uploadFile(params: UploadFileParams): Promise<string> {
|
||||
const { buffer, originalFilename, mimeType, deviceId, eventType } = params;
|
||||
|
||||
// Generate a unique file path
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const uniqueId = uuidv4();
|
||||
const fileExtension = this.getFileExtension(originalFilename);
|
||||
const filePath = `events/${deviceId}/${eventType}/${timestamp}_${uniqueId}${fileExtension}`;
|
||||
|
||||
const uploadParams: PutObjectCommandInput = {
|
||||
Bucket: this.s3BucketName,
|
||||
Key: filePath,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
Metadata: {
|
||||
originalFilename,
|
||||
deviceId,
|
||||
eventType,
|
||||
uploadTimestamp: timestamp,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.log(`Uploading file to S3: ${filePath}`);
|
||||
await this.s3Client.send(new PutObjectCommand(uploadParams));
|
||||
this.logger.log(`Successfully uploaded file to S3: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to upload file to S3: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw new Error(`S3 upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to SQS queue for processing
|
||||
*/
|
||||
async sendProcessingMessage(params: QueueMessageParams): Promise<string> {
|
||||
const { rawEventId, deviceId, userProfileId, eventType } = params;
|
||||
|
||||
const messageBody = {
|
||||
rawEventId,
|
||||
deviceId,
|
||||
userProfileId,
|
||||
eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const sendParams: SendMessageCommandInput = {
|
||||
QueueUrl: this.sqsQueueUrl,
|
||||
MessageBody: JSON.stringify(messageBody),
|
||||
MessageAttributes: {
|
||||
eventType: {
|
||||
DataType: 'String',
|
||||
StringValue: eventType,
|
||||
},
|
||||
deviceId: {
|
||||
DataType: 'String',
|
||||
StringValue: deviceId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.logger.log(`Sending message to SQS for event: ${rawEventId}`);
|
||||
const result = await this.sqsClient.send(
|
||||
new SendMessageCommand(sendParams),
|
||||
);
|
||||
const messageId = result.MessageId;
|
||||
this.logger.log(`Successfully sent message to SQS: ${messageId}`);
|
||||
return messageId || '';
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send message to SQS: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw new Error(`SQS send failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
private getFileExtension(filename: string): string {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return '';
|
||||
}
|
||||
return filename.substring(lastDotIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for AWS services
|
||||
*/
|
||||
async healthCheck(): Promise<{ s3: boolean; sqs: boolean }> {
|
||||
const results = { s3: false, sqs: false };
|
||||
|
||||
try {
|
||||
// Test basic S3 connectivity by listing the bucket (without actually listing objects)
|
||||
// We'll just check if we can construct a valid command
|
||||
const testCommand = new PutObjectCommand({
|
||||
Bucket: this.s3BucketName,
|
||||
Key: 'health-check-test',
|
||||
Body: Buffer.from('test'),
|
||||
});
|
||||
|
||||
// Don't actually send it, just validate that we can create the command
|
||||
if (testCommand) {
|
||||
results.s3 = true;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`S3 health check failed: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Test SQS connectivity by checking if we can create a send command
|
||||
const testMessage = new SendMessageCommand({
|
||||
QueueUrl: this.sqsQueueUrl,
|
||||
MessageBody: JSON.stringify({ test: true }),
|
||||
});
|
||||
|
||||
// Don't actually send it, just validate configuration
|
||||
if (testMessage && this.sqsQueueUrl) {
|
||||
results.sqs = true;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`SQS health check failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
74
meteor-web-backend/src/devices/devices.controller.ts
Normal file
74
meteor-web-backend/src/devices/devices.controller.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SubscriptionGuard } from '../auth/guards/subscription.guard';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||
import { HeartbeatDto } from './dto/heartbeat.dto';
|
||||
import { Device } from '../entities/device.entity';
|
||||
|
||||
@Controller('api/v1/devices')
|
||||
@UseGuards(JwtAuthGuard, SubscriptionGuard)
|
||||
export class DevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async registerDevice(
|
||||
@Body() registerDeviceDto: RegisterDeviceDto,
|
||||
@Request() req: any,
|
||||
): Promise<{
|
||||
message: string;
|
||||
device: Device;
|
||||
}> {
|
||||
const userProfileId = req.user.userId; // From JWT payload
|
||||
|
||||
const device = await this.devicesService.registerDevice(
|
||||
registerDeviceDto,
|
||||
userProfileId,
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Device registered successfully',
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getUserDevices(@Request() req: any): Promise<{
|
||||
devices: Device[];
|
||||
}> {
|
||||
const userProfileId = req.user.userId;
|
||||
const devices = await this.devicesService.findDevicesByUser(userProfileId);
|
||||
|
||||
return { devices };
|
||||
}
|
||||
|
||||
@Post('heartbeat')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async heartbeat(
|
||||
@Body() heartbeatDto: HeartbeatDto,
|
||||
@Request() req: any,
|
||||
): Promise<{
|
||||
message: string;
|
||||
}> {
|
||||
const userProfileId = req.user.userId; // From JWT payload
|
||||
|
||||
await this.devicesService.processHeartbeat(
|
||||
heartbeatDto.hardwareId,
|
||||
userProfileId,
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Heartbeat processed successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
meteor-web-backend/src/devices/devices.module.ts
Normal file
14
meteor-web-backend/src/devices/devices.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { Device } from '../entities/device.entity';
|
||||
import { InventoryDevice } from '../entities/inventory-device.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Device, InventoryDevice])],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
406
meteor-web-backend/src/devices/devices.service.spec.ts
Normal file
406
meteor-web-backend/src/devices/devices.service.spec.ts
Normal file
@ -0,0 +1,406 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, UpdateResult } from 'typeorm';
|
||||
import {
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { Device, DeviceStatus } from '../entities/device.entity';
|
||||
import { InventoryDevice } from '../entities/inventory-device.entity';
|
||||
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||
|
||||
describe('DevicesService', () => {
|
||||
let service: DevicesService;
|
||||
let deviceRepository: Repository<Device>;
|
||||
let inventoryDeviceRepository: Repository<InventoryDevice>;
|
||||
let dataSource: DataSource;
|
||||
|
||||
const mockDeviceRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockInventoryDeviceRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockQueryRunner = {
|
||||
connect: jest.fn(),
|
||||
startTransaction: jest.fn(),
|
||||
commitTransaction: jest.fn(),
|
||||
rollbackTransaction: jest.fn(),
|
||||
release: jest.fn(),
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
createQueryRunner: jest.fn(() => mockQueryRunner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DevicesService,
|
||||
{
|
||||
provide: getRepositoryToken(Device),
|
||||
useValue: mockDeviceRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(InventoryDevice),
|
||||
useValue: mockInventoryDeviceRepository,
|
||||
},
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DevicesService>(DevicesService);
|
||||
deviceRepository = module.get<Repository<Device>>(
|
||||
getRepositoryToken(Device),
|
||||
);
|
||||
inventoryDeviceRepository = module.get<Repository<InventoryDevice>>(
|
||||
getRepositoryToken(InventoryDevice),
|
||||
);
|
||||
dataSource = module.get<DataSource>(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerDevice', () => {
|
||||
const registerDeviceDto: RegisterDeviceDto = {
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
};
|
||||
const userProfileId = 'user-123';
|
||||
|
||||
it('should successfully register a device', async () => {
|
||||
const mockInventoryDevice = {
|
||||
id: 'inventory-123',
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
isClaimed: false,
|
||||
};
|
||||
|
||||
const mockNewDevice = {
|
||||
id: 'device-123',
|
||||
userProfileId,
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
status: DeviceStatus.ACTIVE,
|
||||
registeredAt: new Date(),
|
||||
};
|
||||
|
||||
// Mock transaction behavior
|
||||
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||
const mockManager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockInventoryDevice) // inventory device found
|
||||
.mockResolvedValueOnce(null), // no existing device
|
||||
create: jest.fn().mockReturnValue(mockNewDevice),
|
||||
save: jest.fn().mockResolvedValue(mockNewDevice),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
return fn(mockManager);
|
||||
});
|
||||
|
||||
const result = await service.registerDevice(
|
||||
registerDeviceDto,
|
||||
userProfileId,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockNewDevice);
|
||||
expect(mockDataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when hardware_id not in inventory', async () => {
|
||||
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||
const mockManager = {
|
||||
findOne: jest.fn().mockResolvedValueOnce(null), // inventory device not found
|
||||
};
|
||||
return fn(mockManager);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(
|
||||
`Device with hardware ID '${registerDeviceDto.hardwareId}' is not found in inventory. Please contact support.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when device is already claimed', async () => {
|
||||
const mockInventoryDevice = {
|
||||
id: 'inventory-123',
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
isClaimed: true, // Already claimed
|
||||
};
|
||||
|
||||
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||
const mockManager = {
|
||||
findOne: jest.fn().mockResolvedValueOnce(mockInventoryDevice),
|
||||
};
|
||||
return fn(mockManager);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(
|
||||
`Device with hardware ID '${registerDeviceDto.hardwareId}' has already been claimed.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when device is already registered', async () => {
|
||||
const mockInventoryDevice = {
|
||||
id: 'inventory-123',
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
isClaimed: false,
|
||||
};
|
||||
|
||||
const mockExistingDevice = {
|
||||
id: 'device-456',
|
||||
userProfileId: 'other-user',
|
||||
hardwareId: 'TEST_DEVICE_001',
|
||||
};
|
||||
|
||||
mockDataSource.transaction.mockImplementation(async (fn) => {
|
||||
const mockManager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockInventoryDevice) // inventory device found
|
||||
.mockResolvedValueOnce(mockExistingDevice), // existing device found
|
||||
};
|
||||
return fn(mockManager);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(
|
||||
service.registerDevice(registerDeviceDto, userProfileId),
|
||||
).rejects.toThrow(
|
||||
`Device with hardware ID '${registerDeviceDto.hardwareId}' is already registered.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDevicesByUser', () => {
|
||||
it('should return user devices ordered by registration date', async () => {
|
||||
const userProfileId = 'user-123';
|
||||
const mockDevices = [
|
||||
{
|
||||
id: 'device-1',
|
||||
userProfileId,
|
||||
hardwareId: 'DEVICE_001',
|
||||
registeredAt: new Date('2023-02-01'),
|
||||
},
|
||||
{
|
||||
id: 'device-2',
|
||||
userProfileId,
|
||||
hardwareId: 'DEVICE_002',
|
||||
registeredAt: new Date('2023-01-01'),
|
||||
},
|
||||
];
|
||||
|
||||
mockDeviceRepository.find.mockResolvedValue(mockDevices);
|
||||
|
||||
const result = await service.findDevicesByUser(userProfileId);
|
||||
|
||||
expect(result).toEqual(mockDevices);
|
||||
expect(mockDeviceRepository.find).toHaveBeenCalledWith({
|
||||
where: { userProfileId },
|
||||
order: { registeredAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDeviceById', () => {
|
||||
it('should return device when found', async () => {
|
||||
const deviceId = 'device-123';
|
||||
const userProfileId = 'user-123';
|
||||
const mockDevice = {
|
||||
id: deviceId,
|
||||
userProfileId,
|
||||
hardwareId: 'DEVICE_001',
|
||||
};
|
||||
|
||||
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||
|
||||
const result = await service.findDeviceById(deviceId, userProfileId);
|
||||
|
||||
expect(result).toEqual(mockDevice);
|
||||
expect(mockDeviceRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: deviceId, userProfileId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when device not found', async () => {
|
||||
const deviceId = 'device-123';
|
||||
const userProfileId = 'user-123';
|
||||
|
||||
mockDeviceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findDeviceById(deviceId, userProfileId),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.findDeviceById(deviceId, userProfileId),
|
||||
).rejects.toThrow('Device not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processHeartbeat', () => {
|
||||
const hardwareId = 'TEST_DEVICE_001';
|
||||
const userProfileId = 'user-123';
|
||||
|
||||
it('should successfully process heartbeat for valid device', async () => {
|
||||
const mockDevice = {
|
||||
id: 'device-123',
|
||||
userProfileId,
|
||||
hardwareId,
|
||||
status: DeviceStatus.OFFLINE,
|
||||
lastSeenAt: new Date('2023-01-01'),
|
||||
};
|
||||
|
||||
const mockUpdateResult = { affected: 1 } as UpdateResult;
|
||||
|
||||
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||
mockDeviceRepository.update.mockResolvedValue(mockUpdateResult);
|
||||
|
||||
await service.processHeartbeat(hardwareId, userProfileId);
|
||||
|
||||
expect(mockDeviceRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { hardwareId },
|
||||
});
|
||||
expect(mockDeviceRepository.update).toHaveBeenCalledWith(
|
||||
{ id: mockDevice.id },
|
||||
expect.objectContaining({
|
||||
status: DeviceStatus.ONLINE,
|
||||
lastSeenAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when device not found', async () => {
|
||||
mockDeviceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.processHeartbeat(hardwareId, userProfileId),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.processHeartbeat(hardwareId, userProfileId),
|
||||
).rejects.toThrow(`Device with hardware ID '${hardwareId}' not found`);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when device belongs to different user', async () => {
|
||||
const mockDevice = {
|
||||
id: 'device-123',
|
||||
userProfileId: 'other-user-456',
|
||||
hardwareId,
|
||||
status: DeviceStatus.OFFLINE,
|
||||
};
|
||||
|
||||
mockDeviceRepository.findOne.mockResolvedValue(mockDevice);
|
||||
|
||||
await expect(
|
||||
service.processHeartbeat(hardwareId, userProfileId),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
service.processHeartbeat(hardwareId, userProfileId),
|
||||
).rejects.toThrow('Device does not belong to the authenticated user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markStaleDevicesOffline', () => {
|
||||
it('should mark stale devices as offline and return count', async () => {
|
||||
const thresholdMinutes = 15;
|
||||
const mockUpdateResult = { affected: 3 } as UpdateResult;
|
||||
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||
};
|
||||
|
||||
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(mockDeviceRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
expect(mockQueryBuilder.update).toHaveBeenCalledWith(Device);
|
||||
expect(mockQueryBuilder.set).toHaveBeenCalledWith({
|
||||
status: DeviceStatus.OFFLINE,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
|
||||
'lastSeenAt < :cutoffTime',
|
||||
{ cutoffTime: expect.any(Date) },
|
||||
);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'status != :offlineStatus',
|
||||
{ offlineStatus: DeviceStatus.OFFLINE },
|
||||
);
|
||||
expect(mockQueryBuilder.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 0 when no devices need to be marked offline', async () => {
|
||||
const thresholdMinutes = 15;
|
||||
const mockUpdateResult = { affected: 0 } as UpdateResult;
|
||||
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||
};
|
||||
|
||||
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined affected count', async () => {
|
||||
const thresholdMinutes = 15;
|
||||
const mockUpdateResult = { affected: undefined } as UpdateResult;
|
||||
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(mockUpdateResult),
|
||||
};
|
||||
|
||||
mockDeviceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||
|
||||
const result = await service.markStaleDevicesOffline(thresholdMinutes);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user