diff --git a/.kiro/specs/property-price-history/.config.kiro b/.kiro/specs/property-price-history/.config.kiro new file mode 100644 index 00000000..b1023d10 --- /dev/null +++ b/.kiro/specs/property-price-history/.config.kiro @@ -0,0 +1 @@ +{"specId": "1f4adcc4-4b70-42fb-9a1a-3fdac9514b49", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/property-price-history/design.md b/.kiro/specs/property-price-history/design.md new file mode 100644 index 00000000..efa1df75 --- /dev/null +++ b/.kiro/specs/property-price-history/design.md @@ -0,0 +1,560 @@ +# Property Price History Tracking - Design Document + +## Overview + +The Property Price History Tracking feature provides comprehensive tracking and visualization of property price changes over time. This system records all price modifications with complete audit information, provides historical data retrieval through RESTful API endpoints, calculates price change metrics, and formats data for visualization in charts and analytics dashboards. + +### Key Objectives + +- Record all price changes with complete audit trail (who, when, why) +- Provide efficient retrieval of historical price data with filtering and pagination +- Calculate price change percentages and trends +- Format data for chart visualization and analytics +- Enforce permission-based access control +- Maintain data integrity and consistency +- Support performance at scale (1000+ records per property) +- Enable data export in multiple formats +- Provide real-time notifications for price changes +- Support bulk operations for multiple properties + +## Architecture + +### High-Level System Design + +The system follows a layered architecture with clear separation of concerns: + +- **API Layer**: NestJS controllers handling HTTP requests +- **Service Layer**: Business logic and data transformation +- **Data Access Layer**: Prisma ORM for database operations +- **Database Layer**: PostgreSQL with optimized indexes + +### Data Flow + +**Price Change Recording:** +1. Property price update request arrives at PropertiesController +2. PropertiesService.update() is called +3. Before updating property price, PriceHistoryService.recordPriceChange() is invoked +4. PriceHistoryService validates the change and creates PriceHistory record +5. Transaction ensures both property and history are updated atomically +6. Notification event is triggered for subscribers +7. Response is returned to client + +**Price History Retrieval:** +1. GET request arrives at PriceHistoryController +2. Permission check is performed (owner, admin, or public property) +3. Query parameters are validated (pagination, filters, sorting) +4. Database query is executed with appropriate indexes +5. Results are cached if applicable +6. Response is formatted and returned + +### Integration Points + +- **PropertiesModule**: Integrates with existing property management +- **AuthModule**: Uses JWT authentication and role-based authorization +- **NotificationModule**: Triggers price change notifications +- **CacheModule**: Implements Redis caching for performance +- **ActivityLogModule**: Logs all price history access for audit + +## Data Models + +### Prisma Schema - PriceHistory Entity + +The PriceHistory model tracks all price changes with complete audit information: + +```prisma +model PriceHistory { + id String @id @default(uuid()) + propertyId String @map("property_id") + previousPrice Decimal @map("previous_price") + newPrice Decimal @map("new_price") + priceChangePercentage Decimal? @map("price_change_percentage") + timestamp DateTime @default(now()) + userId String @map("user_id") + userRole UserRole @map("user_role") + changeReason String? @map("change_reason") @db.VarChar(500) + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + metadata Json? @default("{}") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([propertyId, timestamp]) + @@index([propertyId, createdAt]) + @@index([userId]) + @@index([timestamp]) + @@map("price_history") +} +``` + +### Database Indexes + +**Primary Indexes:** +- `(property_id, timestamp DESC)` - For retrieving price history for a property +- `(property_id, created_at DESC)` - For chronological ordering +- `(user_id)` - For audit trail by user +- `(timestamp)` - For time-based queries + +**Performance Indexes:** +- `(property_id, timestamp DESC) INCLUDE (previous_price, new_price, price_change_percentage)` - Covering index for common queries + +### Relationships + +**PriceHistory Property** +- Many-to-One relationship +- Foreign key: propertyId +- Cascade delete: When property is deleted, history is preserved + +**PriceHistory User** +- Many-to-One relationship +- Foreign key: userId +- Set null on delete: Preserves history even if user is deleted + +## API Design + +### Endpoint 1: Get Price History + +**Endpoint:** `GET /api/properties/{propertyId}/price-history` + +**Authentication:** Required (JWT) + +**Authorization:** Property owner, admin, or public property + +**Query Parameters:** +- `limit`: number (default: 50, max: 500) +- `offset`: number (default: 0) +- `startDate`: ISO 8601 timestamp (optional) +- `endDate`: ISO 8601 timestamp (optional) +- `sortBy`: 'timestamp' | 'price' | 'percentage_change' (default: 'timestamp') +- `sortOrder`: 'ASC' | 'DESC' (default: 'DESC') + +**Response (200 OK):** +```json +{ + "data": [ + { + "id": "uuid", + "propertyId": "uuid", + "previousPrice": 250000.00, + "newPrice": 255000.00, + "priceChangePercentage": 2.00, + "timestamp": "2024-01-15T10:30:00Z", + "userId": "uuid", + "userRole": "AGENT", + "changeReason": "Market adjustment", + "ipAddress": "192.168.1.1", + "userAgent": "Mozilla/5.0...", + "metadata": { + "source": "web", + "reason_category": "market_adjustment" + } + } + ], + "pagination": { + "total": 150, + "limit": 50, + "offset": 0, + "hasMore": true + } +} +``` + +**Error Responses:** +- 401 Unauthorized: Missing or invalid authentication +- 403 Forbidden: User lacks permission to view property +- 404 Not Found: Property does not exist +- 400 Bad Request: Invalid query parameters + +### Endpoint 2: Get Chart Data + +**Endpoint:** `GET /api/properties/{propertyId}/price-history/chart` + +**Query Parameters:** +- `interval`: 'daily' | 'weekly' | 'monthly' | 'yearly' (default: 'daily') +- `startDate`: ISO 8601 timestamp (optional) +- `endDate`: ISO 8601 timestamp (optional) + +**Response (200 OK):** +```json +{ + "propertyId": "uuid", + "propertyAddress": "123 Main St, Springfield, IL 62701", + "currentPrice": 255000.00, + "dateRange": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-12-31T23:59:59Z" + }, + "aggregationInterval": "monthly", + "dataPoints": [ + { + "timestamp": "2024-01-31T23:59:59Z", + "price": 250000.00, + "previousPrice": 250000.00, + "priceChangePercentage": 0.00, + "changeReason": "Initial listing" + } + ] +} +``` + +### Endpoint 3: Export Price History + +**Endpoint:** `GET /api/properties/{propertyId}/price-history/export` + +**Query Parameters:** +- `format`: 'csv' | 'json' (default: 'json') +- `startDate`: ISO 8601 timestamp (optional) +- `endDate`: ISO 8601 timestamp (optional) + +**Response:** Downloadable file with appropriate MIME type + +### Endpoint 4: Bulk Export + +**Endpoint:** `POST /api/price-history/bulk-export` + +**Request Body:** +```json +{ + "propertyIds": ["uuid1", "uuid2", "uuid3"], + "format": "csv", + "startDate": "2024-01-01T00:00:00Z", + "endDate": "2024-12-31T23:59:59Z" +} +``` + +**Response:** Downloadable file containing price history for all specified properties + +## Service Layer + +### PriceHistoryService + +**Key Methods:** + +```typescript +// Record a price change +async recordPriceChange( + propertyId: string, + previousPrice: Decimal, + newPrice: Decimal, + userId: string, + userRole: UserRole, + changeReason?: string, + metadata?: Record, + ipAddress?: string, + userAgent?: string +): Promise + +// Retrieve price history with pagination +async getPriceHistory( + propertyId: string, + limit: number, + offset: number, + startDate?: Date, + endDate?: Date, + sortBy?: string, + sortOrder?: 'ASC' | 'DESC' +): Promise<{ data: PriceHistory[]; total: number }> + +// Calculate percentage change +calculatePercentageChange(previousPrice: Decimal, newPrice: Decimal): Decimal | null + +// Get chart data with aggregation +async getChartData( + propertyId: string, + interval: 'daily' | 'weekly' | 'monthly' | 'yearly', + startDate?: Date, + endDate?: Date +): Promise + +// Export data in specified format +async exportData( + propertyId: string, + format: 'csv' | 'json', + startDate?: Date, + endDate?: Date +): Promise + +// Check user permissions +async checkPermission( + userId: string, + userRole: UserRole, + propertyId: string +): Promise + +// Bulk export for multiple properties +async bulkExport( + propertyIds: string[], + userId: string, + userRole: UserRole, + format: 'csv' | 'json', + startDate?: Date, + endDate?: Date +): Promise +``` + +### Business Logic + +**Price Change Recording:** +1. Validate new price is positive decimal +2. Verify previous price matches last recorded price +3. Calculate percentage change +4. Create PriceHistory record with audit information +5. Update property's current price +6. Trigger notification event +7. Return created record + +**Permission Checking:** +1. If user is ADMIN, grant access +2. If user is property owner, grant access +3. If property is publicly listed, grant access to any user +4. Otherwise, deny access + +**Data Aggregation:** +1. Query all price history records in date range +2. Group by time interval (daily, weekly, monthly, yearly) +3. Calculate min, max, first, last prices for each interval +4. Return aggregated data with metadata + +## Implementation Details + +### NestJS Module Structure + +``` +src/price-history/ + price-history.module.ts + price-history.controller.ts + price-history.service.ts + dto/ + get-price-history.dto.ts + chart-data.dto.ts + export-data.dto.ts + bulk-export.dto.ts + entities/ + price-history.entity.ts + guards/ + price-history-permission.guard.ts + interceptors/ + price-history-cache.interceptor.ts +``` + +### Controllers and DTOs + +**PriceHistoryController:** + +The controller handles all HTTP requests for price history operations with proper authentication and authorization guards. + +**DTOs:** + +- `GetPriceHistoryDto`: Validates pagination and filtering parameters +- `ChartDataDto`: Validates chart data request parameters +- `ExportDataDto`: Validates export format and date range +- `BulkExportDto`: Validates bulk export requests + +### Permission Guard + +The `PriceHistoryPermissionGuard` enforces access control: +- Admins can access any property's price history +- Property owners can access their own property's price history +- Regular users can only access publicly listed properties + +### Caching Strategy + +**Cache Keys:** +- `price-history:{propertyId}:{limit}:{offset}:{sortBy}:{sortOrder}` - For paginated results +- `price-history-chart:{propertyId}:{interval}:{startDate}:{endDate}` - For chart data +- `price-history-count:{propertyId}` - For total count + +**TTL:** 5 minutes for price history, 15 minutes for chart data + +**Invalidation:** Cache is invalidated when: +- New price history record is created +- Property is updated +- User permissions change + +## Performance Considerations + +### Database Indexing Strategy + +**Composite Indexes:** +```sql +CREATE INDEX idx_price_history_property_timestamp +ON price_history(property_id, timestamp DESC); + +CREATE INDEX idx_price_history_property_created +ON price_history(property_id, created_at DESC); + +CREATE INDEX idx_price_history_user +ON price_history(user_id); + +CREATE INDEX idx_price_history_timestamp +ON price_history(timestamp); +``` + +**Covering Indexes:** +```sql +CREATE INDEX idx_price_history_covering +ON price_history(property_id, timestamp DESC) +INCLUDE (previous_price, new_price, price_change_percentage); +``` + +### Query Optimization + +**Pagination:** +- Use LIMIT and OFFSET with indexes +- Avoid large offsets (use keyset pagination for large datasets) +- Return total count in separate query for better performance + +**Aggregation:** +- Pre-calculate aggregations for common intervals +- Use database-level aggregation functions +- Cache aggregated results + +**Filtering:** +- Use indexed columns for WHERE clauses +- Combine filters efficiently +- Use prepared statements to prevent SQL injection + +## Error Handling + +### Error Response Format + +```json +{ + "statusCode": 400, + "message": "Invalid query parameters", + "error": "Bad Request", + "details": { + "limit": "limit must be a number between 1 and 500" + } +} +``` + +### Common Errors + +- **400 Bad Request**: Invalid parameters, validation errors +- **401 Unauthorized**: Missing or invalid authentication +- **403 Forbidden**: User lacks permission +- **404 Not Found**: Property or resource not found +- **500 Internal Server Error**: Unexpected server error +- **503 Service Unavailable**: Database or service unavailable + +## Testing Strategy + +### Unit Tests + +**PriceHistoryService Tests:** +- Test price change recording with valid data +- Test percentage calculation with various price points +- Test edge cases (zero previous price, same price) +- Test permission checking logic +- Test data aggregation for different intervals +- Test export formatting (CSV, JSON) + +**Controller Tests:** +- Test endpoint authorization +- Test query parameter validation +- Test response formatting +- Test error handling + +### Integration Tests + +- Test price history recording with database +- Test retrieval with pagination +- Test permission enforcement +- Test cache invalidation +- Test concurrent requests +- Test bulk operations + +### Performance Tests + +- Test retrieval performance with 1000+ records (target: <500ms) +- Test chart aggregation performance (target: <1000ms) +- Test concurrent request handling +- Test bulk export performance (target: <5s for 100 properties) +- Test cache effectiveness + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a systemessentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Record Completeness +*For any* valid price change with propertyId P, previousPrice PP, newPrice NP, userId U, and userRole R, the created PriceHistory record SHALL contain all these values exactly as provided. + +**Validates: Requirements 1.1, 1.2, 1.3** + +### Property 2: Percentage Calculation Formula +*For any* previousPrice PP > 0 and newPrice NP, the calculated priceChangePercentage SHALL equal ((NP - PP) / PP) * 100, rounded to exactly 2 decimal places. + +**Validates: Requirements 3.2, 3.3, 3.7** + +### Property 3: Chronological Ordering +*For any* property with multiple price history records, when retrieved with sortOrder='ASC', records SHALL appear in strictly increasing order by timestamp. + +**Validates: Requirements 2.1, 6.4** + +### Property 4: Pagination Correctness +*For any* paginated request with limit L and offset O, the returned records SHALL be exactly records [O, O+L) from the complete sorted result set, and total count SHALL equal the actual total. + +**Validates: Requirements 2.2, 2.3** + +### Property 5: Date Range Filtering +*For any* date range filter with startDate S and endDate E, all returned records SHALL have timestamp T where S T E, and no records outside this range SHALL be returned. + +**Validates: Requirements 2.5** + +### Property 6: Permission Enforcement +*For any* user U requesting price history for property P, access SHALL be granted if and only if: U.role = ADMIN OR U.id = P.ownerId OR P.status = ACTIVE. + +**Validates: Requirements 5.1, 5.2, 5.3, 5.4** + +### Property 7: Price Validation +*For any* price value submitted for recording, if price 0 or price is not a valid decimal, the system SHALL reject the record creation with a validation error. + +**Validates: Requirements 6.1** + +### Property 8: Previous Price Consistency +*For any* new price history record, the previousPrice SHALL match the newPrice of the immediately preceding record for the same property, or be the property's initial price if no prior records exist. + +**Validates: Requirements 6.2** + +### Property 9: Duplicate Prevention +*For any* attempt to create two PriceHistory records with identical propertyId and timestamp, the system SHALL prevent the second record and return a conflict error. + +**Validates: Requirements 6.3** + +### Property 10: Chart Data Aggregation +*For any* time interval I and date range [S, E], the aggregated data for interval I SHALL contain min, max, first, and last prices that exactly match the corresponding values from the underlying records in that interval. + +**Validates: Requirements 4.4, 4.5** + +### Property 11: Export Field Inclusion +*For any* export request in format F (csv or json), the exported data SHALL include all required fields: timestamp, previousPrice, newPrice, priceChangePercentage, userId, userRole, changeReason, and metadata. + +**Validates: Requirements 8.3** + +### Property 12: Bulk Operation Atomicity +*For any* bulk export of properties [P1, P2, ..., Pn], if the user lacks permission for any property Pi, the system SHALL return a 403 error and perform no export. + +**Validates: Requirements 12.4, 12.5** + +### Property 13: Zero-Change Recording +*For any* price change where newPrice = previousPrice, the system SHALL record the change with priceChangePercentage = 0.00 and not reject the record. + +**Validates: Requirements 9.2** + +### Property 14: Null Previous Price Handling +*For any* price history record where previousPrice is null or zero, the system SHALL return null for priceChangePercentage rather than attempting division. + +**Validates: Requirements 3.4** + +### Property 15: Metadata Preservation +*For any* price history record created with metadata M, when the record is retrieved, the metadata SHALL be returned exactly as provided, even if the property is later modified or archived. + +**Validates: Requirements 11.4, 11.5, 11.7** + +## Conclusion + +The Property Price History Tracking feature provides a robust, scalable, and secure system for tracking property price changes over time. By implementing comprehensive audit trails, efficient data retrieval, and flexible visualization options, the system enables users to analyze price trends and maintain compliance with audit requirements. The design follows NestJS best practices, integrates seamlessly with the existing PropChain architecture, and prioritizes performance and data integrity. diff --git a/.kiro/specs/property-price-history/requirements.md b/.kiro/specs/property-price-history/requirements.md new file mode 100644 index 00000000..3dcf48fb --- /dev/null +++ b/.kiro/specs/property-price-history/requirements.md @@ -0,0 +1,355 @@ +# Property Price History Tracking - Requirements Document + +## Introduction + +The Property Price History Tracking feature enables comprehensive tracking and visualization of property price changes over time. This system records all price modifications with timestamps and user attribution, provides historical data retrieval through API endpoints, calculates price change metrics, and formats data for visualization in charts and analytics dashboards. This feature supports market analysis, trend identification, and audit compliance for the PropChain real estate platform. + +## Glossary + +- **Property**: A real estate asset listed on the PropChain platform with associated metadata (address, features, etc.) +- **Price_History_Record**: A timestamped entry documenting a property price change, including the previous price, new price, change reason, and user who made the change +- **Price_Change_Percentage**: The calculated percentage difference between two price points, expressed as a decimal (e.g., 0.05 for 5% increase) +- **Chart_Data**: Formatted data structure suitable for visualization libraries, containing timestamps, prices, and metadata for rendering price trend graphs +- **Price_Modification**: An action that changes a property's current price from one value to another +- **Audit_Trail**: A complete record of all price changes for a property, including who made changes and when +- **System**: The PropChain Property Price History Tracking system +- **User**: An authenticated user of the PropChain platform with appropriate permissions +- **Property_Owner**: A user who owns or has administrative rights to a property listing +- **Admin_User**: A system administrator with elevated permissions to view and manage price history across all properties +- **API_Client**: An external or internal application consuming the price history endpoints +- **Timestamp**: A precise moment in time (ISO 8601 format) when an event occurred +- **Price_Point**: A specific price value recorded at a particular timestamp +- **Trend_Analysis**: The process of identifying patterns and direction of price changes over time + +## Requirements + +### Requirement 1: Record Price Changes with Complete Audit Information + +**User Story:** As a property owner or admin, I want every price change to be recorded with complete audit information, so that I can maintain a complete audit trail and understand the history of price modifications. + +#### Acceptance Criteria + +1. WHEN a property price is modified, THE System SHALL create a Price_History_Record containing the previous price, new price, modification timestamp, and user identifier +2. WHEN a property price is modified, THE System SHALL record the user who made the change with their user ID and role +3. WHEN a property price is modified, THE System SHALL store the modification timestamp in ISO 8601 format with timezone information +4. WHEN a property price is modified, THE System SHALL allow an optional change reason or note to be recorded +5. WHEN a property price is modified, THE System SHALL persist the Price_History_Record to the database before confirming the price change +6. IF a price modification fails to record, THEN THE System SHALL prevent the price change from being applied to the property +7. THE System SHALL store each Price_History_Record with a unique identifier for reference and audit purposes + +### Requirement 2: Retrieve Price History for a Property + +**User Story:** As a developer or analyst, I want to retrieve the complete price history for a property through an API endpoint, so that I can analyze price trends and build analytics features. + +#### Acceptance Criteria + +1. WHEN a GET request is made to the price history endpoint with a valid property ID, THE System SHALL return all Price_History_Records for that property in chronological order (oldest first) +2. WHEN a GET request is made to the price history endpoint, THE System SHALL include pagination parameters (limit, offset) to handle large datasets +3. WHEN a GET request is made with pagination parameters, THE System SHALL return the requested page of results with total count metadata +4. WHEN a GET request is made to the price history endpoint, THE System SHALL include each record's previous price, new price, timestamp, user information, and change reason +5. WHEN a GET request is made with a date range filter, THE System SHALL return only Price_History_Records within the specified date range +6. WHEN a GET request is made with an invalid property ID, THE System SHALL return a 404 error with a descriptive message +7. WHEN a GET request is made without proper authentication, THE System SHALL return a 401 error +8. WHEN a GET request is made by a user without permission to view the property, THE System SHALL return a 403 error +9. THE System SHALL return price history data in JSON format with consistent field naming and structure + +### Requirement 3: Calculate Price Change Percentages + +**User Story:** As an analyst, I want the system to calculate price change percentages between price points, so that I can understand the magnitude of price changes relative to previous values. + +#### Acceptance Criteria + +1. WHEN price history data is retrieved, THE System SHALL calculate the percentage change from the previous price to the current price for each record +2. WHEN calculating percentage change, THE System SHALL use the formula: ((new_price - previous_price) / previous_price) * 100 +3. WHEN a price change is calculated, THE System SHALL express the result as a decimal percentage (e.g., 5.5 for 5.5% increase) +4. WHEN the previous price is zero or null, THE System SHALL handle this edge case by returning null for percentage change or a special indicator value +5. WHEN price history is retrieved, THE System SHALL include the calculated percentage change in each record's response +6. WHEN calculating cumulative changes, THE System SHALL provide the total percentage change from the first recorded price to the current price +7. THE System SHALL round percentage values to two decimal places for display purposes + +### Requirement 4: Format Data for Chart Visualization + +**User Story:** As a frontend developer, I want price history data formatted specifically for charting libraries, so that I can easily render price trend visualizations without additional transformation. + +#### Acceptance Criteria + +1. WHEN a GET request is made to the chart data endpoint with a valid property ID, THE System SHALL return data formatted as an array of objects with timestamp and price fields +2. WHEN chart data is requested, THE System SHALL include additional fields: previous_price, price_change_percentage, and change_reason +3. WHEN chart data is requested, THE System SHALL order records chronologically (oldest to newest) for proper chart rendering +4. WHEN chart data is requested with a time interval parameter (daily, weekly, monthly), THE System SHALL aggregate data points accordingly +5. WHEN aggregating data by time interval, THE System SHALL return the first price, last price, minimum price, and maximum price for each interval +6. WHEN chart data is requested, THE System SHALL include metadata: property_id, property_address, current_price, and date_range +7. WHEN chart data is requested, THE System SHALL return data in a format compatible with common charting libraries (Chart.js, D3.js, Recharts) +8. THE System SHALL ensure chart data includes sufficient points for meaningful visualization (minimum 2 points, no maximum limit) + +### Requirement 5: Support Permission-Based Access Control + +**User Story:** As a system administrator, I want to control who can view price history based on user roles and property ownership, so that sensitive pricing information is protected. + +#### Acceptance Criteria + +1. WHEN a user requests price history, THE System SHALL verify the user has permission to view the property +2. WHEN a Property_Owner requests their own property's price history, THE System SHALL grant access +3. WHEN an Admin_User requests any property's price history, THE System SHALL grant access +4. WHEN a regular User requests a property's price history, THE System SHALL deny access unless the property is publicly listed +5. WHEN access is denied, THE System SHALL return a 403 Forbidden error with a descriptive message +6. WHEN a user's role changes, THE System SHALL immediately apply new permission rules to subsequent requests +7. THE System SHALL log all price history access attempts for audit purposes + +### Requirement 6: Maintain Data Integrity and Consistency + +**User Story:** As a data steward, I want to ensure price history data remains accurate and consistent, so that audit trails are reliable and trustworthy. + +#### Acceptance Criteria + +1. WHEN a Price_History_Record is created, THE System SHALL validate that the new price is a valid decimal number greater than zero +2. WHEN a Price_History_Record is created, THE System SHALL validate that the previous price matches the property's price at the time of the last recorded change +3. WHEN a Price_History_Record is created, THE System SHALL prevent duplicate records for the same property at the same timestamp +4. WHEN price history data is queried, THE System SHALL return records in the exact order they were created +5. WHEN a property is deleted, THE System SHALL preserve its price history records for audit purposes +6. WHEN a price history record is retrieved, THE System SHALL ensure all monetary values are stored with consistent precision (minimum 2 decimal places) +7. THE System SHALL implement database constraints to prevent orphaned price history records + +### Requirement 7: Provide Performance and Scalability + +**User Story:** As a platform operator, I want price history queries to perform efficiently even with large datasets, so that the system scales to support thousands of properties. + +#### Acceptance Criteria + +1. WHEN price history is retrieved for a property with 1000+ records, THE System SHALL return results within 500 milliseconds +2. WHEN price history is retrieved with pagination, THE System SHALL use database indexes to optimize query performance +3. WHEN chart data is aggregated by time interval, THE System SHALL complete aggregation within 1000 milliseconds +4. WHEN multiple concurrent requests are made for price history, THE System SHALL handle them without performance degradation +5. WHEN price history data is stored, THE System SHALL use appropriate database indexes on property_id and timestamp fields +6. THE System SHALL implement caching for frequently accessed price history data with a configurable TTL +7. WHEN cache is invalidated, THE System SHALL ensure subsequent queries return fresh data + +### Requirement 8: Support Data Export and Reporting + +**User Story:** As an analyst, I want to export price history data in multiple formats, so that I can perform analysis in external tools and generate reports. + +#### Acceptance Criteria + +1. WHEN an export request is made, THE System SHALL support CSV format export of price history records +2. WHEN an export request is made, THE System SHALL support JSON format export of price history records +3. WHEN exporting data, THE System SHALL include all relevant fields: timestamp, previous_price, new_price, percentage_change, user_info, and change_reason +4. WHEN exporting data, THE System SHALL apply the same permission checks as retrieval endpoints +5. WHEN exporting large datasets, THE System SHALL stream the response to prevent memory issues +6. WHEN exporting data, THE System SHALL include metadata headers with export timestamp and property information +7. THE System SHALL generate downloadable files with appropriate MIME types and naming conventions + +### Requirement 9: Handle Edge Cases and Error Conditions + +**User Story:** As a developer, I want the system to handle edge cases gracefully, so that the API is robust and provides clear error messages. + +#### Acceptance Criteria + +1. IF a property has no price history records, THEN THE System SHALL return an empty array with appropriate metadata +2. IF a price change results in the same price value, THEN THE System SHALL still record the change with 0% change percentage +3. IF a user attempts to retrieve price history for a non-existent property, THEN THE System SHALL return a 404 error +4. IF the database is temporarily unavailable, THEN THE System SHALL return a 503 Service Unavailable error +5. IF invalid query parameters are provided, THEN THE System SHALL return a 400 Bad Request error with validation details +6. IF a timestamp is provided in an unsupported format, THEN THE System SHALL return a 400 error with format guidance +7. WHEN an unexpected error occurs, THE System SHALL log the error with full context and return a generic 500 error to the client + +### Requirement 10: Provide Real-Time Price Change Notifications + +**User Story:** As a user, I want to receive notifications when property prices change, so that I can stay informed about price movements for properties I'm interested in. + +#### Acceptance Criteria + +1. WHEN a property price is modified, THE System SHALL trigger a notification event +2. WHEN a price change notification is triggered, THE System SHALL include the property ID, previous price, new price, and percentage change +3. WHEN a price change notification is triggered, THE System SHALL send notifications to users who have subscribed to price alerts for that property +4. WHEN a price change notification is sent, THE System SHALL respect user notification preferences and quiet hours settings +5. WHEN a price change notification is sent, THE System SHALL include a link to view the complete price history +6. WHEN a price change exceeds a user-defined threshold, THE System SHALL mark the notification as high-priority +7. THE System SHALL support multiple notification channels: email, in-app, and push notifications + +### Requirement 11: Track Price History Metadata + +**User Story:** As an auditor, I want to track detailed metadata about price changes, so that I can understand the context and reason for each modification. + +#### Acceptance Criteria + +1. WHEN a price change is recorded, THE System SHALL capture the IP address of the user making the change +2. WHEN a price change is recorded, THE System SHALL capture the user agent/browser information +3. WHEN a price change is recorded, THE System SHALL allow recording of a change reason (e.g., "Market adjustment", "Negotiation", "Error correction") +4. WHEN a price change is recorded, THE System SHALL allow recording of additional metadata as key-value pairs +5. WHEN price history is retrieved, THE System SHALL include all captured metadata in the response +6. WHEN metadata is stored, THE System SHALL validate that change reasons are from a predefined list or allow free-form text +7. THE System SHALL preserve all metadata even if the property is later modified or archived + +### Requirement 12: Support Bulk Price History Operations + +**User Story:** As an admin, I want to perform bulk operations on price history, so that I can efficiently manage historical data for multiple properties. + +#### Acceptance Criteria + +1. WHEN a bulk export request is made for multiple properties, THE System SHALL retrieve price history for all specified properties +2. WHEN a bulk export is requested, THE System SHALL combine results with clear property identifiers +3. WHEN a bulk operation is performed, THE System SHALL complete within 5 seconds for up to 100 properties +4. WHEN a bulk operation is requested, THE System SHALL validate that the user has permission to access all specified properties +5. IF the user lacks permission for any property, THEN THE System SHALL return a 403 error and not perform the operation +6. WHEN bulk data is exported, THE System SHALL provide a single downloadable file with organized data +7. THE System SHALL support filtering bulk results by date range, price range, or change percentage + +## Non-Functional Requirements + +### Performance Requirements + +- Price history retrieval for a single property SHALL complete within 500ms for datasets up to 10,000 records +- Chart data aggregation SHALL complete within 1000ms for monthly aggregation of 5 years of data +- Bulk export of 100 properties SHALL complete within 5 seconds +- Database queries SHALL use appropriate indexes to minimize full table scans +- Caching SHALL reduce repeated queries by 80% for frequently accessed properties + +### Scalability Requirements + +- System SHALL support properties with up to 100,000 price history records +- System SHALL handle 1000 concurrent price history requests without performance degradation +- System SHALL support horizontal scaling through database replication and read replicas +- Storage SHALL efficiently handle growth to 1 million+ price history records + +### Security Requirements + +- All price history data SHALL be encrypted at rest using AES-256 +- All API endpoints SHALL require authentication via JWT tokens +- All price history access SHALL be logged for audit purposes +- Sensitive user information in price history records SHALL be masked in logs +- SQL injection and other injection attacks SHALL be prevented through parameterized queries + +### Data Integrity Requirements + +- Price history records SHALL be immutable once created (no updates or deletes) +- Monetary values SHALL be stored with minimum 2 decimal places precision +- Timestamps SHALL be stored in UTC with timezone information +- Database constraints SHALL prevent orphaned records +- Backup and recovery procedures SHALL preserve price history integrity + +### Availability Requirements + +- Price history endpoints SHALL maintain 99.9% uptime +- System SHALL gracefully handle database unavailability with appropriate error responses +- System SHALL implement circuit breakers for dependent services +- System SHALL provide fallback responses for non-critical operations + +### Usability Requirements + +- API responses SHALL use consistent JSON structure across all endpoints +- Error messages SHALL be descriptive and actionable +- Documentation SHALL include example requests and responses +- Chart data format SHALL be compatible with popular charting libraries + +## Data Requirements + +### Price History Record Structure + +Each Price_History_Record SHALL contain: +- `id`: Unique identifier (UUID) +- `propertyId`: Reference to the property +- `previousPrice`: Decimal value with 2+ decimal places +- `newPrice`: Decimal value with 2+ decimal places +- `priceChangePercentage`: Calculated percentage change +- `timestamp`: ISO 8601 format with timezone +- `userId`: Identifier of user who made the change +- `userRole`: Role of the user (USER, AGENT, ADMIN) +- `changeReason`: Optional text field (max 500 characters) +- `ipAddress`: IP address of the request +- `userAgent`: Browser/client information +- `metadata`: JSON object for additional context + +### Chart Data Structure + +Chart data responses SHALL contain: +- `propertyId`: Property identifier +- `propertyAddress`: Full address string +- `currentPrice`: Current price of the property +- `dateRange`: Object with `start` and `end` timestamps +- `dataPoints`: Array of price points with: + - `timestamp`: ISO 8601 format + - `price`: Price at this point + - `previousPrice`: Price before this change + - `priceChangePercentage`: Percentage change + - `changeReason`: Reason for change +- `aggregationInterval`: If aggregated (daily, weekly, monthly) +- `aggregatedPoints`: Array with min, max, first, last prices per interval + +## API Requirements + +### Price History Retrieval Endpoint + +**Endpoint:** `GET /api/properties/{propertyId}/price-history` + +**Query Parameters:** +- `limit`: Number of records per page (default: 50, max: 500) +- `offset`: Number of records to skip (default: 0) +- `startDate`: ISO 8601 timestamp for filtering +- `endDate`: ISO 8601 timestamp for filtering +- `sortBy`: Field to sort by (timestamp, price, percentage_change) +- `sortOrder`: ASC or DESC (default: DESC) + +**Response:** 200 OK with paginated price history records + +### Chart Data Endpoint + +**Endpoint:** `GET /api/properties/{propertyId}/price-history/chart` + +**Query Parameters:** +- `interval`: Aggregation interval (daily, weekly, monthly, yearly) +- `startDate`: ISO 8601 timestamp +- `endDate`: ISO 8601 timestamp + +**Response:** 200 OK with formatted chart data + +### Export Endpoint + +**Endpoint:** `GET /api/properties/{propertyId}/price-history/export` + +**Query Parameters:** +- `format`: Export format (csv, json) +- `startDate`: ISO 8601 timestamp +- `endDate`: ISO 8601 timestamp + +**Response:** 200 OK with downloadable file + +### Bulk Export Endpoint + +**Endpoint:** `POST /api/price-history/bulk-export` + +**Request Body:** +```json +{ + "propertyIds": ["id1", "id2", "id3"], + "format": "csv", + "startDate": "2024-01-01T00:00:00Z", + "endDate": "2024-12-31T23:59:59Z" +} +``` + +**Response:** 200 OK with downloadable file + +## Constraints and Assumptions + +### Constraints + +1. Price history records are immutable once created +2. Only authenticated users can access price history endpoints +3. Price values must be positive decimal numbers +4. Timestamps must be in ISO 8601 format +5. Maximum price history records per property: 100,000 (soft limit) +6. Export operations are limited to 100 properties per request +7. Chart data aggregation supports only daily, weekly, monthly, and yearly intervals + +### Assumptions + +1. The Property model already exists in the system with an `id` and `price` field +2. User authentication and authorization mechanisms are already implemented +3. Database supports JSON data type for metadata storage +4. The system has access to a reliable time source for accurate timestamps +5. Users have appropriate permissions to view properties they own or administer +6. Price changes are initiated through the existing property update endpoints +7. The system will use PostgreSQL as the primary database +8. Notification system infrastructure is already in place +9. Caching layer (Redis) is available for performance optimization +10. Charting libraries on the frontend support standard JSON data formats diff --git a/.kiro/specs/property-price-history/tasks.md b/.kiro/specs/property-price-history/tasks.md new file mode 100644 index 00000000..503b2849 --- /dev/null +++ b/.kiro/specs/property-price-history/tasks.md @@ -0,0 +1,499 @@ +# Implementation Plan: Property Price History Tracking + +## Overview + +This implementation plan breaks down the Property Price History Tracking feature into discrete, incremental coding tasks. The feature will be implemented in TypeScript using NestJS, with PostgreSQL as the database. Each task builds on previous steps, ensuring core functionality is validated early through automated tests. The implementation follows a layered architecture with database setup, service layer, API layer, and comprehensive testing. + +## Tasks + +- [x] 1. Database Setup and Schema Migration + - [x] 1.1 Create Prisma migration for PriceHistory entity + - Generate migration file with `prisma migrate dev --name add_price_history` + - Define PriceHistory model with all required fields (id, propertyId, previousPrice, newPrice, priceChangePercentage, timestamp, userId, userRole, changeReason, ipAddress, userAgent, metadata) + - Add relations to Property and User models + - _Requirements: 1.1, 1.2, 1.3, 6.6_ + + - [x] 1.2 Add database indexes for performance optimization + - Create composite index on (property_id, timestamp DESC) + - Create composite index on (property_id, created_at DESC) + - Create index on (user_id) for audit trail queries + - Create index on (timestamp) for time-based queries + - _Requirements: 7.5, 7.6_ + + - [x] 1.3 Update schema.prisma with PriceHistory model definition + - Add PriceHistory model with all fields and proper types + - Add relations to Property and User + - Add all required indexes + - Ensure cascade delete behavior is correct + - _Requirements: 1.1, 6.6, 6.7_ + +- [x] 2. Create DTOs and Validation + - [x] 2.1 Create GetPriceHistoryDto with validation decorators + - Define limit, offset, startDate, endDate, sortBy, sortOrder parameters + - Add class-validator decorators for type validation + - Set default values (limit: 50, offset: 0, sortOrder: DESC) + - Validate limit is between 1 and 500 + - _Requirements: 2.2, 2.3, 9.6_ + + - [x] 2.2 Create ChartDataDto with validation decorators + - Define interval (daily, weekly, monthly, yearly), startDate, endDate parameters + - Add validation for interval enum values + - Add optional date range validation + - _Requirements: 4.1, 4.4_ + + - [x] 2.3 Create ExportDataDto with validation decorators + - Define format (csv, json), startDate, endDate parameters + - Add validation for format enum values + - _Requirements: 8.1, 8.2_ + + - [x] 2.4 Create BulkExportDto with validation decorators + - Define propertyIds array, format, startDate, endDate + - Add validation for non-empty propertyIds array + - Add validation for maximum 100 properties per request + - _Requirements: 12.1, 12.3_ + +- [x] 3. Implement PriceHistoryService - Core Methods + - [x] 3.1 Create PriceHistoryService class with dependency injection + - Inject PrismaService for database access + - Inject CacheService for caching operations + - Inject NotificationService for price change events + - _Requirements: 1.1, 7.6_ + + - [x] 3.2 Implement recordPriceChange method + - Validate new price is positive decimal (> 0) + - Validate previous price matches last recorded price or is initial price + - Calculate percentage change using formula: ((newPrice - previousPrice) / previousPrice) * 100 + - Create PriceHistory record with all audit information + - Update property's current price atomically + - Trigger notification event + - Return created record + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 3.2, 6.1, 6.2, 6.3_ + + - [x] 3.3 Implement calculatePercentageChange method + - Handle edge case: previousPrice is zero or null (return null) + - Calculate percentage change with formula: ((newPrice - previousPrice) / previousPrice) * 100 + - Round result to 2 decimal places + - Return null for zero previous price + - _Requirements: 3.2, 3.3, 3.4, 3.7_ + + - [x] 3.4 Implement getPriceHistory method with pagination and filtering + - Query PriceHistory records by propertyId + - Apply date range filtering (startDate, endDate) + - Apply sorting (sortBy: timestamp/price/percentage_change, sortOrder: ASC/DESC) + - Apply pagination (limit, offset) + - Return paginated results with total count + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 6.4_ + + - [x] 3.5 Implement checkPermission method + - Grant access if user.role === ADMIN + - Grant access if user.id === property.ownerId + - Grant access if property.status === ACTIVE (public property) + - Deny access otherwise + - Return boolean permission result + - _Requirements: 5.1, 5.2, 5.3, 5.4_ + +- [-] 4. Implement PriceHistoryService - Data Aggregation and Export + - [x] 4.1 Implement getChartData method with time interval aggregation + - Query all price history records in date range + - Group records by time interval (daily, weekly, monthly, yearly) + - Calculate min, max, first, last prices for each interval + - Return aggregated data with metadata (propertyId, address, currentPrice, dateRange) + - Include dataPoints array with timestamp, price, previousPrice, priceChangePercentage, changeReason + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_ + + - [x] 4.2 Implement exportData method for CSV and JSON formats + - Query price history records with date range filtering + - Include all required fields: timestamp, previousPrice, newPrice, priceChangePercentage, userId, userRole, changeReason, metadata + - Format data as CSV with headers or JSON array + - Return Buffer with appropriate MIME type + - _Requirements: 8.1, 8.2, 8.3, 8.6, 8.7_ + + - [x] 4.3 Implement bulkExport method for multiple properties + - Validate user has permission for all specified properties + - Return 403 error if permission denied for any property + - Query price history for all properties + - Combine results with clear property identifiers + - Export as single file (CSV or JSON) + - Complete within 5 seconds for up to 100 properties + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_ + +- [x] 5. Implement PriceHistoryPermissionGuard + - [x] 5.1 Create PriceHistoryPermissionGuard class implementing CanActivate + - Extract propertyId from route parameters + - Extract user from request context + - Call checkPermission method from PriceHistoryService + - Return true if permission granted, throw ForbiddenException if denied + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ + +- [~] 6. Implement PriceHistoryController - Retrieval Endpoints + - [x] 6.1 Create PriceHistoryController class + - Inject PriceHistoryService + - Add JwtAuthGuard for authentication + - Add PriceHistoryPermissionGuard for authorization + - _Requirements: 2.7, 5.1_ + + - [x] 6.2 Implement GET /api/properties/{propertyId}/price-history endpoint + - Accept query parameters: limit, offset, startDate, endDate, sortBy, sortOrder + - Validate parameters using GetPriceHistoryDto + - Call getPriceHistory service method + - Return paginated results with metadata + - Handle 404 error for non-existent property + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_ + + - [x] 6.3 Implement GET /api/properties/{propertyId}/price-history/chart endpoint + - Accept query parameters: interval, startDate, endDate + - Validate parameters using ChartDataDto + - Call getChartData service method + - Return formatted chart data + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_ + +- [x] 7. Implement PriceHistoryController - Export Endpoints + - [x] 7.1 Implement GET /api/properties/{propertyId}/price-history/export endpoint + - Accept query parameters: format, startDate, endDate + - Validate parameters using ExportDataDto + - Call exportData service method + - Return downloadable file with appropriate MIME type and headers + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_ + + - [x] 7.2 Implement POST /api/price-history/bulk-export endpoint + - Accept request body with propertyIds, format, startDate, endDate + - Validate request body using BulkExportDto + - Call bulkExport service method + - Return downloadable file or 403 error if permission denied + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_ + +- [~] 8. Implement Caching Strategy + - [x] 8.1 Create cache interceptor for price history endpoints + - Generate cache keys: `price-history:{propertyId}:{limit}:{offset}:{sortBy}:{sortOrder}` + - Set TTL to 5 minutes for paginated results + - Implement cache invalidation on price change + - _Requirements: 7.6, 7.7_ + + - [x] 8.2 Implement cache invalidation logic + - Invalidate cache when new price history record is created + - Invalidate cache when property is updated + - Invalidate cache when user permissions change + - _Requirements: 7.7_ + +- [~] 9. Integrate with PropertiesModule and AuthModule + - [x] 9.1 Create price-history.module.ts + - Import PrismaModule, AuthModule, CacheModule, NotificationModule + - Register PriceHistoryService, PriceHistoryController + - Export PriceHistoryService for use in other modules + - _Requirements: 1.1, 5.1_ + + - [x] 9.2 Update PropertiesService to call recordPriceChange + - Modify update method to call PriceHistoryService.recordPriceChange before updating price + - Pass all required audit information (userId, userRole, changeReason, ipAddress, userAgent) + - Ensure atomic transaction for both operations + - _Requirements: 1.1, 1.5, 1.6_ + + - [x] 9.3 Update app.module.ts to import PriceHistoryModule + - Add PriceHistoryModule to imports array + - Ensure module is loaded after PrismaModule and AuthModule + - _Requirements: 1.1_ + +- [~] 10. Implement Error Handling and Validation + - [x] 10.1 Add error handling for edge cases + - Handle empty price history (return empty array with metadata) + - Handle zero-change price (record with 0% change percentage) + - Handle non-existent property (return 404) + - Handle database unavailability (return 503) + - Handle invalid query parameters (return 400 with validation details) + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_ + + - [x] 10.2 Implement consistent error response format + - Return error responses with statusCode, message, error, details fields + - Include validation details for 400 errors + - Include descriptive messages for 403 and 404 errors + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_ + +- [x] 11. Checkpoint - Ensure all core functionality is implemented + - Ensure all service methods are implemented and working + - Ensure all controller endpoints are accessible + - Ensure error handling is in place + - Ask the user if questions arise. + +- [~] 12. Write Unit Tests for PriceHistoryService + - [ ] 12.1 Write unit tests for recordPriceChange method + - **Property 1: Record Completeness** + - **Validates: Requirements 1.1, 1.2, 1.3** + - Test that all fields are recorded exactly as provided + - Test with valid data + - Test with optional fields (changeReason, metadata) + + - [ ] 12.2 Write unit tests for calculatePercentageChange method + - **Property 2: Percentage Calculation Formula** + - **Validates: Requirements 3.2, 3.3, 3.7** + - Test formula: ((newPrice - previousPrice) / previousPrice) * 100 + - Test rounding to 2 decimal places + - Test edge case: previousPrice = 0 (return null) + - Test edge case: previousPrice = null (return null) + + - [ ] 12.3 Write unit tests for getPriceHistory method + - **Property 3: Chronological Ordering** + - **Validates: Requirements 2.1, 6.4** + - Test records are returned in correct order (ASC/DESC) + - Test pagination correctness + - Test date range filtering + - Test sorting by different fields + + - [ ] 12.4 Write unit tests for checkPermission method + - **Property 6: Permission Enforcement** + - **Validates: Requirements 5.1, 5.2, 5.3, 5.4** + - Test admin access (always granted) + - Test owner access (granted for own property) + - Test public property access (granted for active properties) + - Test denied access (regular user, private property) + + - [ ] 12.5 Write unit tests for getChartData method + - **Property 10: Chart Data Aggregation** + - **Validates: Requirements 4.4, 4.5** + - Test aggregation by different intervals (daily, weekly, monthly, yearly) + - Test min, max, first, last price calculations + - Test date range filtering + + - [ ] 12.6 Write unit tests for exportData method + - **Property 11: Export Field Inclusion** + - **Validates: Requirements 8.3** + - Test CSV format includes all required fields + - Test JSON format includes all required fields + - Test with date range filtering + +- [~] 13. Write Unit Tests for PriceHistoryController + - [ ] 13.1 Write unit tests for GET /price-history endpoint + - Test successful retrieval with valid parameters + - Test 401 error without authentication + - Test 403 error without permission + - Test 404 error for non-existent property + - Test 400 error with invalid parameters + + - [ ] 13.2 Write unit tests for GET /price-history/chart endpoint + - Test successful chart data retrieval + - Test different aggregation intervals + - Test date range filtering + + - [ ] 13.3 Write unit tests for GET /price-history/export endpoint + - Test CSV export + - Test JSON export + - Test permission enforcement + + - [ ] 13.4 Write unit tests for POST /bulk-export endpoint + - Test bulk export with multiple properties + - Test 403 error if permission denied for any property + - Test maximum 100 properties limit + +- [~] 14. Write Property-Based Tests for Correctness Properties + - [ ] 14.1 Write property test for Record Completeness + - **Property 1: Record Completeness** + - **Validates: Requirements 1.1, 1.2, 1.3** + - Generate random valid price change data + - Verify all fields are stored exactly as provided + - Test with various combinations of optional fields + + - [ ] 14.2 Write property test for Percentage Calculation Formula + - **Property 2: Percentage Calculation Formula** + - **Validates: Requirements 3.2, 3.3, 3.7** + - Generate random previousPrice and newPrice values + - Verify formula: ((newPrice - previousPrice) / previousPrice) * 100 + - Verify rounding to 2 decimal places + - Test edge cases: zero, null, negative values + + - [ ] 14.3 Write property test for Chronological Ordering + - **Property 3: Chronological Ordering** + - **Validates: Requirements 2.1, 6.4** + - Generate multiple price history records with different timestamps + - Verify records are returned in strictly increasing order by timestamp (ASC) + - Verify records are returned in strictly decreasing order by timestamp (DESC) + + - [ ] 14.4 Write property test for Pagination Correctness + - **Property 4: Pagination Correctness** + - **Validates: Requirements 2.2, 2.3** + - Generate large dataset of price history records + - Verify returned records are exactly records [offset, offset+limit) + - Verify total count equals actual total + - Test various limit and offset combinations + + - [ ] 14.5 Write property test for Date Range Filtering + - **Property 5: Date Range Filtering** + - **Validates: Requirements 2.5** + - Generate records with various timestamps + - Verify all returned records have timestamp T where startDate ≤ T ≤ endDate + - Verify no records outside range are returned + + - [ ] 14.6 Write property test for Permission Enforcement + - **Property 6: Permission Enforcement** + - **Validates: Requirements 5.1, 5.2, 5.3, 5.4** + - Generate various user roles and property ownership scenarios + - Verify access is granted if and only if: user.role = ADMIN OR user.id = property.ownerId OR property.status = ACTIVE + - Test all combinations of conditions + + - [ ] 14.7 Write property test for Price Validation + - **Property 7: Price Validation** + - **Validates: Requirements 6.1** + - Generate invalid price values (negative, zero, non-decimal) + - Verify system rejects invalid prices with validation error + - Verify valid prices are accepted + + - [ ] 14.8 Write property test for Previous Price Consistency + - **Property 8: Previous Price Consistency** + - **Validates: Requirements 6.2** + - Generate sequence of price changes + - Verify previousPrice matches newPrice of preceding record + - Verify initial record has correct initial price + + - [ ] 14.9 Write property test for Duplicate Prevention + - **Property 9: Duplicate Prevention** + - **Validates: Requirements 6.3** + - Attempt to create two records with identical propertyId and timestamp + - Verify second record is prevented with conflict error + + - [ ] 14.10 Write property test for Chart Data Aggregation + - **Property 10: Chart Data Aggregation** + - **Validates: Requirements 4.4, 4.5** + - Generate records across multiple time intervals + - Verify aggregated min, max, first, last prices match underlying records + - Test all aggregation intervals (daily, weekly, monthly, yearly) + + - [ ] 14.11 Write property test for Export Field Inclusion + - **Property 11: Export Field Inclusion** + - **Validates: Requirements 8.3** + - Generate price history records with all fields + - Export to CSV and JSON + - Verify all required fields are present in export + + - [ ] 14.12 Write property test for Bulk Operation Atomicity + - **Property 12: Bulk Operation Atomicity** + - **Validates: Requirements 12.4, 12.5** + - Generate bulk export request with mixed permissions + - Verify 403 error if user lacks permission for any property + - Verify no partial export occurs + + - [ ] 14.13 Write property test for Zero-Change Recording + - **Property 13: Zero-Change Recording** + - **Validates: Requirements 9.2** + - Create price change where newPrice = previousPrice + - Verify record is created with priceChangePercentage = 0.00 + - Verify record is not rejected + + - [ ] 14.14 Write property test for Null Previous Price Handling + - **Property 14: Null Previous Price Handling** + - **Validates: Requirements 3.4** + - Create record with null or zero previousPrice + - Verify priceChangePercentage is null (not division error) + - Verify record is created successfully + + - [ ] 14.15 Write property test for Metadata Preservation + - **Property 15: Metadata Preservation** + - **Validates: Requirements 11.4, 11.5, 11.7** + - Create record with custom metadata + - Retrieve record after property modifications + - Verify metadata is returned exactly as provided + +- [~] 15. Write Integration Tests + - [ ] 15.1 Write integration test for price change recording flow + - Create property with initial price + - Update property price through PropertiesService + - Verify PriceHistory record is created + - Verify property price is updated + - Verify notification event is triggered + + - [ ] 15.2 Write integration test for price history retrieval with permissions + - Create property owned by user A + - Create price history records + - Test retrieval as owner (should succeed) + - Test retrieval as admin (should succeed) + - Test retrieval as unauthorized user (should fail) + + - [ ] 15.3 Write integration test for chart data aggregation + - Create property with multiple price changes over time + - Request chart data with different intervals + - Verify aggregation is correct + - Verify data is formatted for charting libraries + + - [ ] 15.4 Write integration test for export functionality + - Create property with price history + - Export as CSV and JSON + - Verify file format is correct + - Verify all fields are included + + - [ ] 15.5 Write integration test for bulk export + - Create multiple properties with price history + - Request bulk export with mixed permissions + - Verify 403 error if permission denied + - Verify successful export if all permissions granted + + - [ ] 15.6 Write integration test for cache invalidation + - Retrieve price history (should be cached) + - Create new price history record + - Verify cache is invalidated + - Retrieve price history again (should return fresh data) + +- [x] 16. Checkpoint - Ensure all tests pass + - Run all unit tests: `npm run test -- price-history.service.spec.ts` + - Run all controller tests: `npm run test -- price-history.controller.spec.ts` + - Run all integration tests: `npm run test -- price-history.integration.spec.ts` + - Run all property-based tests: `npm run test -- price-history.property.spec.ts` + - Ensure test coverage is above 80% + - Ask the user if questions arise. + +- [~] 17. Performance Testing and Optimization + - [ ] 17.1 Write performance test for price history retrieval + - Create property with 1000+ price history records + - Measure retrieval time with pagination + - Verify performance is within 500ms target + - Verify database indexes are being used + + - [ ] 17.2 Write performance test for chart data aggregation + - Create property with 5 years of monthly price changes + - Measure aggregation time for different intervals + - Verify performance is within 1000ms target + + - [ ] 17.3 Write performance test for bulk export + - Create 100 properties with price history + - Measure bulk export time + - Verify performance is within 5 second target + + - [ ] 17.4 Write performance test for concurrent requests + - Simulate 100 concurrent price history requests + - Verify system handles without degradation + - Verify response times remain acceptable + +- [~] 18. Documentation and API Documentation + - [ ] 18.1 Create API documentation with Swagger/OpenAPI + - Document GET /api/properties/{propertyId}/price-history endpoint + - Document GET /api/properties/{propertyId}/price-history/chart endpoint + - Document GET /api/properties/{propertyId}/price-history/export endpoint + - Document POST /api/price-history/bulk-export endpoint + - Include request/response examples + - Include error response examples + + - [ ] 18.2 Create usage examples in README + - Example: Retrieve price history with pagination + - Example: Get chart data for visualization + - Example: Export price history as CSV + - Example: Bulk export multiple properties + +- [x] 19. Final Checkpoint - Ensure all tests pass and code quality + - Run full test suite: `npm run test` + - Run linting: `npm run lint` + - Run formatting: `npm run format` + - Verify all tests pass + - Verify no linting errors + - Verify code is properly formatted + - Ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property-based tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- Integration tests validate end-to-end flows +- Performance tests ensure system meets scalability requirements +- All monetary values must be stored with minimum 2 decimal places precision +- All timestamps must be stored in UTC with timezone information +- Cache invalidation must be implemented to ensure data consistency +- Permission checks must be enforced on all endpoints +- Error responses must follow consistent format with descriptive messages diff --git a/prisma/migrations/20260530000000_add_price_history/migration.sql b/prisma/migrations/20260530000000_add_price_history/migration.sql new file mode 100644 index 00000000..fda1b68c --- /dev/null +++ b/prisma/migrations/20260530000000_add_price_history/migration.sql @@ -0,0 +1,44 @@ +-- Migration: Add price history tracking for properties + +CREATE TABLE "price_history" ( + "id" TEXT NOT NULL, + "property_id" TEXT NOT NULL, + "previous_price" DECIMAL(18,2) NOT NULL, + "new_price" DECIMAL(18,2) NOT NULL, + "price_change_percentage" DECIMAL(10,2), + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" TEXT NOT NULL, + "user_role" TEXT NOT NULL, + "change_reason" VARCHAR(500), + "ip_address" TEXT, + "user_agent" TEXT, + "metadata" JSONB DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "price_history_pkey" PRIMARY KEY ("id") +); + +-- Create indexes for performance optimization +CREATE INDEX "price_history_property_id_timestamp_idx" + ON "price_history" ("property_id", "timestamp" DESC); + +CREATE INDEX "price_history_property_id_created_at_idx" + ON "price_history" ("property_id", "created_at" DESC); + +CREATE INDEX "price_history_user_id_idx" + ON "price_history" ("user_id"); + +CREATE INDEX "price_history_timestamp_idx" + ON "price_history" ("timestamp"); + +-- Add foreign key constraints +ALTER TABLE "price_history" + ADD CONSTRAINT "price_history_property_id_fkey" + FOREIGN KEY ("property_id") REFERENCES "properties" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "price_history" + ADD CONSTRAINT "price_history_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2883719..30b844fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -223,7 +223,6 @@ model User { searchHistory SearchHistory[] initiatedBackups DatabaseBackup[] @relation("BackupInitiatedBy") restoredBackups DatabaseBackup[] @relation("BackupRestoredBy") - emailStatus EmailStatus @default(ACTIVE) @map("email_status") fcmToken String? @map("fcm_token") notifications Notification[] disputeInitiated Dispute[] @relation("DisputeInitiator") @@ -236,6 +235,7 @@ model User { transactionHistory TransactionHistory[] favorites PropertyFavorite[] propertyViews PropertyView[] + priceHistory PriceHistory[] openHouseRsvps OpenHouseRsvp[] transactionNotes TransactionNote[] @relation("TransactionNoteAuthor") @@ -451,6 +451,7 @@ model Property { views PropertyView[] openHouses OpenHouse[] neighborhood Neighborhood? @relation(fields: [neighborhoodId], references: [id], onDelete: SetNull) + priceHistory PriceHistory[] amenities PropertyAmenity[] @@index([ownerId]) @@ -1215,6 +1216,32 @@ model PropertyDuplicate { @@map("property_duplicates") } +// Price History model for tracking property price changes +model PriceHistory { + id String @id @default(uuid()) + propertyId String @map("property_id") + previousPrice Decimal @map("previous_price") + newPrice Decimal @map("new_price") + priceChangePercentage Decimal? @map("price_change_percentage") + timestamp DateTime @default(now()) + userId String @map("user_id") + userRole UserRole @map("user_role") + changeReason String? @map("change_reason") @db.VarChar(500) + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + metadata Json? @default("{}") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([propertyId, timestamp]) + @@index([propertyId, createdAt]) + @@index([userId]) + @@index([timestamp]) + @@map("price_history") // Transaction notes with visibility rules (#562) model TransactionNote { id String @id @default(uuid()) diff --git a/src/app.module.ts b/src/app.module.ts index 456727c9..2c1ac5ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { FavoritesModule } from './favorites/favorites.module'; import { PropertyViewsModule } from './property-views/property-views.module'; import { PropertyComparisonModule } from './property-comparison/property-comparison.module'; import { OpenHouseModule } from './open-house/open-house.module'; +import { PriceHistoryModule } from './price-history/price-history.module'; import { MortgageCalculatorModule } from './mortgage-calculator/mortgage-calculator.module'; import { SupportTicketsModule } from './support-tickets/support-tickets.module'; @@ -78,6 +79,7 @@ import { SupportTicketsModule } from './support-tickets/support-tickets.module'; PropertyComparisonModule, // NeighborhoodsModule, OpenHouseModule, + PriceHistoryModule, MortgageCalculatorModule, SupportTicketsModule, ], diff --git a/src/price-history/dto/bulk-export.dto.ts b/src/price-history/dto/bulk-export.dto.ts new file mode 100644 index 00000000..9797bafb --- /dev/null +++ b/src/price-history/dto/bulk-export.dto.ts @@ -0,0 +1,45 @@ +import { IsArray, IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { ArrayMaxSize, ArrayMinSize } from 'class-validator'; + +/** + * DTO for bulk exporting price history for multiple properties. + * Validates: Requirements 12.1, 12.3 + */ +export class BulkExportDto { + /** + * Array of property IDs to export price history for. + * Minimum: 1 property, Maximum: 100 properties + */ + @IsArray({ message: 'propertyIds must be an array' }) + @ArrayMinSize(1, { message: 'propertyIds must contain at least 1 property ID' }) + @ArrayMaxSize(100, { message: 'propertyIds cannot contain more than 100 property IDs' }) + @IsUUID('4', { each: true, message: 'each propertyId must be a valid UUID' }) + propertyIds: string[]; + + /** + * Export format for the data. + * Options: 'csv', 'json' + * Default: 'json' + */ + @IsOptional() + @IsIn(['csv', 'json'], { + message: "format must be either 'csv' or 'json'", + }) + format: 'csv' | 'json' = 'json'; + + /** + * Start date for filtering exported records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'startDate must be a valid ISO 8601 string' }) + startDate?: string; + + /** + * End date for filtering exported records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'endDate must be a valid ISO 8601 string' }) + endDate?: string; +} diff --git a/src/price-history/dto/chart-data.dto.ts b/src/price-history/dto/chart-data.dto.ts new file mode 100644 index 00000000..4cce9e19 --- /dev/null +++ b/src/price-history/dto/chart-data.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; + +/** + * DTO for requesting chart data with time interval aggregation. + * Validates: Requirements 4.1, 4.4 + */ +export class ChartDataDto { + /** + * Time interval for data aggregation. + * Options: 'daily', 'weekly', 'monthly', 'yearly' + * Default: 'daily' + */ + @IsOptional() + @IsIn(['daily', 'weekly', 'monthly', 'yearly'], { + message: "interval must be one of: 'daily', 'weekly', 'monthly', 'yearly'", + }) + interval: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily'; + + /** + * Start date for the date range (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'startDate must be a valid ISO 8601 string' }) + startDate?: string; + + /** + * End date for the date range (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'endDate must be a valid ISO 8601 string' }) + endDate?: string; +} diff --git a/src/price-history/dto/export-data.dto.ts b/src/price-history/dto/export-data.dto.ts new file mode 100644 index 00000000..8c4d6e2f --- /dev/null +++ b/src/price-history/dto/export-data.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; + +/** + * DTO for exporting price history data in various formats. + * Validates: Requirements 8.1, 8.2 + */ +export class ExportDataDto { + /** + * Export format for the data. + * Options: 'csv', 'json' + * Default: 'json' + */ + @IsOptional() + @IsIn(['csv', 'json'], { + message: "format must be either 'csv' or 'json'", + }) + format: 'csv' | 'json' = 'json'; + + /** + * Start date for filtering exported records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'startDate must be a valid ISO 8601 string' }) + startDate?: string; + + /** + * End date for filtering exported records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'endDate must be a valid ISO 8601 string' }) + endDate?: string; +} diff --git a/src/price-history/dto/get-price-history.dto.ts b/src/price-history/dto/get-price-history.dto.ts new file mode 100644 index 00000000..46682584 --- /dev/null +++ b/src/price-history/dto/get-price-history.dto.ts @@ -0,0 +1,66 @@ +import { IsInt, IsOptional, IsString, Max, Min, IsIn, Type } from 'class-validator'; + +/** + * DTO for retrieving price history with pagination and filtering. + * Validates: Requirements 2.2, 2.3, 9.6 + */ +export class GetPriceHistoryDto { + /** + * Number of records to return per page. + * Default: 50, Max: 500 + */ + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'limit must be an integer' }) + @Min(1, { message: 'limit must be at least 1' }) + @Max(500, { message: 'limit cannot exceed 500' }) + limit: number = 50; + + /** + * Number of records to skip for pagination. + * Default: 0 + */ + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'offset must be an integer' }) + @Min(0, { message: 'offset cannot be negative' }) + offset: number = 0; + + /** + * Start date for filtering price history records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'startDate must be a valid ISO 8601 string' }) + startDate?: string; + + /** + * End date for filtering price history records (ISO 8601 format). + * Optional filter parameter. + */ + @IsOptional() + @IsString({ message: 'endDate must be a valid ISO 8601 string' }) + endDate?: string; + + /** + * Field to sort results by. + * Options: 'timestamp', 'price', 'percentage_change' + * Default: 'timestamp' + */ + @IsOptional() + @IsIn(['timestamp', 'price', 'percentage_change'], { + message: "sortBy must be one of: 'timestamp', 'price', 'percentage_change'", + }) + sortBy: string = 'timestamp'; + + /** + * Sort order for results. + * Options: 'ASC', 'DESC' + * Default: 'DESC' + */ + @IsOptional() + @IsIn(['ASC', 'DESC'], { + message: "sortOrder must be either 'ASC' or 'DESC'", + }) + sortOrder: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/price-history/guards/price-history-permission.guard.ts b/src/price-history/guards/price-history-permission.guard.ts new file mode 100644 index 00000000..a5056d88 --- /dev/null +++ b/src/price-history/guards/price-history-permission.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { PriceHistoryService } from '../price-history.service'; + +/** + * PriceHistoryPermissionGuard + * Enforces permission-based access control for price history endpoints + * Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5 + */ +@Injectable() +export class PriceHistoryPermissionGuard implements CanActivate { + private readonly logger = new Logger(PriceHistoryPermissionGuard.name); + + constructor(private readonly priceHistoryService: PriceHistoryService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Extract propertyId from route parameters + const propertyId = request.params.propertyId; + if (!propertyId) { + throw new ForbiddenException('Property ID is required'); + } + + // Extract user from request context (set by JwtAuthGuard) + const user = request.authUser; + if (!user) { + throw new ForbiddenException('User information is missing'); + } + + // Call checkPermission method from PriceHistoryService + const hasPermission = await this.priceHistoryService.checkPermission( + user.sub, + user.role, + propertyId, + ); + + // Return true if permission granted, throw ForbiddenException if denied + if (!hasPermission) { + this.logger.warn( + `Access denied for user ${user.sub} to property ${propertyId}`, + ); + throw new ForbiddenException( + 'You do not have permission to access this property\'s price history', + ); + } + + return true; + } +} diff --git a/src/price-history/interceptors/price-history-cache.interceptor.ts b/src/price-history/interceptors/price-history-cache.interceptor.ts new file mode 100644 index 00000000..5b076592 --- /dev/null +++ b/src/price-history/interceptors/price-history-cache.interceptor.ts @@ -0,0 +1,103 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { CacheService } from '../../cache/cache.service'; +import { CACHE_TTL } from '../../cache/cache.config'; + +/** + * PriceHistoryCacheInterceptor + * Caches price history endpoint responses + * Validates: Requirements 7.6, 7.7 + */ +@Injectable() +export class PriceHistoryCacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(PriceHistoryCacheInterceptor.name); + + constructor(private readonly cacheService: CacheService) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + const { method, path, query, params } = request; + + // Only cache GET requests + if (method !== 'GET') { + return next.handle(); + } + + // Only cache price-history endpoints + if (!path.includes('price-history')) { + return next.handle(); + } + + // Generate cache key based on endpoint and parameters + const cacheKey = this.generateCacheKey(path, params, query); + + // Try to get from cache + const cachedData = await this.cacheService.get(cacheKey); + if (cachedData) { + this.logger.debug(`Cache HIT for key: ${cacheKey}`); + return of(cachedData); + } + + // If not in cache, execute the handler and cache the result + return next.handle().pipe( + tap(async (data) => { + // Determine TTL based on endpoint type + let ttl = CACHE_TTL.MEDIUM; // 5 minutes default + + if (path.includes('/chart')) { + ttl = CACHE_TTL.LONG; // 15 minutes for chart data + } + + // Cache the response + await this.cacheService.set(cacheKey, data, ttl, 'price-history'); + this.logger.debug(`Cache SET for key: ${cacheKey} (TTL: ${ttl}s)`); + }), + ); + } + + /** + * Generate cache key from request parameters + * Format: price-history:{propertyId}:{limit}:{offset}:{sortBy}:{sortOrder} + * or price-history-chart:{propertyId}:{interval}:{startDate}:{endDate} + * + * @param path - Request path + * @param params - Route parameters + * @param query - Query parameters + * @returns Cache key string + */ + private generateCacheKey( + path: string, + params: Record, + query: Record, + ): string { + const propertyId = params.propertyId || ''; + + if (path.includes('/chart')) { + // Chart data cache key + const interval = query.interval || 'daily'; + const startDate = query.startDate || ''; + const endDate = query.endDate || ''; + return `price-history-chart:${propertyId}:${interval}:${startDate}:${endDate}`; + } else if (path.includes('/export')) { + // Export endpoints are not cached (they're file downloads) + return ''; + } else { + // Paginated results cache key + const limit = query.limit || 50; + const offset = query.offset || 0; + const sortBy = query.sortBy || 'timestamp'; + const sortOrder = query.sortOrder || 'DESC'; + return `price-history:${propertyId}:${limit}:${offset}:${sortBy}:${sortOrder}`; + } + } +} diff --git a/src/price-history/price-history.controller.ts b/src/price-history/price-history.controller.ts new file mode 100644 index 00000000..0ef4e15a --- /dev/null +++ b/src/price-history/price-history.controller.ts @@ -0,0 +1,188 @@ +import { + Controller, + Get, + Post, + Param, + Query, + UseGuards, + Body, + Response, + Logger, +} from '@nestjs/common'; +import { Response as ExpressResponse } from 'express'; +import { PriceHistoryService } from './price-history.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PriceHistoryPermissionGuard } from './guards/price-history-permission.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { GetPriceHistoryDto } from './dto/get-price-history.dto'; +import { ChartDataDto } from './dto/chart-data.dto'; +import { ExportDataDto } from './dto/export-data.dto'; +import { BulkExportDto } from './dto/bulk-export.dto'; + +/** + * PriceHistoryController + * Handles HTTP requests for price history operations + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7 + */ +@Controller('properties') +export class PriceHistoryController { + private readonly logger = new Logger(PriceHistoryController.name); + + constructor(private readonly priceHistoryService: PriceHistoryService) {} + + /** + * Get price history for a property with pagination and filtering + * GET /api/properties/{propertyId}/price-history + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9 + * + * @param propertyId - The property ID + * @param query - Query parameters (limit, offset, startDate, endDate, sortBy, sortOrder) + * @param user - Current authenticated user + * @returns Paginated price history with metadata + */ + @UseGuards(JwtAuthGuard, PriceHistoryPermissionGuard) + @Get(':propertyId/price-history') + async getPriceHistory( + @Param('propertyId') propertyId: string, + @Query() query: GetPriceHistoryDto, + @CurrentUser() user: AuthUserPayload, + ) { + this.logger.log( + `Retrieving price history for property ${propertyId} by user ${user.sub}`, + ); + + const { data, total } = await this.priceHistoryService.getPriceHistory( + propertyId, + query.limit, + query.offset, + query.startDate ? new Date(query.startDate) : undefined, + query.endDate ? new Date(query.endDate) : undefined, + query.sortBy, + query.sortOrder, + ); + + return { + data, + pagination: { + total, + limit: query.limit, + offset: query.offset, + hasMore: query.offset + query.limit < total, + }, + }; + } + + /** + * Get chart data with time interval aggregation + * GET /api/properties/{propertyId}/price-history/chart + * Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8 + * + * @param propertyId - The property ID + * @param query - Query parameters (interval, startDate, endDate) + * @param user - Current authenticated user + * @returns Chart data with aggregated price points + */ + @UseGuards(JwtAuthGuard, PriceHistoryPermissionGuard) + @Get(':propertyId/price-history/chart') + async getChartData( + @Param('propertyId') propertyId: string, + @Query() query: ChartDataDto, + @CurrentUser() user: AuthUserPayload, + ) { + this.logger.log( + `Retrieving chart data for property ${propertyId} by user ${user.sub}`, + ); + + const chartData = await this.priceHistoryService.getChartData( + propertyId, + query.interval, + query.startDate ? new Date(query.startDate) : undefined, + query.endDate ? new Date(query.endDate) : undefined, + ); + + return chartData; + } + + /** + * Export price history as CSV or JSON + * GET /api/properties/{propertyId}/price-history/export + * Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 + * + * @param propertyId - The property ID + * @param query - Query parameters (format, startDate, endDate) + * @param user - Current authenticated user + * @param response - Express response object + */ + @UseGuards(JwtAuthGuard, PriceHistoryPermissionGuard) + @Get(':propertyId/price-history/export') + async exportPriceHistory( + @Param('propertyId') propertyId: string, + @Query() query: ExportDataDto, + @CurrentUser() user: AuthUserPayload, + @Response() response: ExpressResponse, + ) { + this.logger.log( + `Exporting price history for property ${propertyId} as ${query.format} by user ${user.sub}`, + ); + + const buffer = await this.priceHistoryService.exportData( + propertyId, + query.format, + query.startDate ? new Date(query.startDate) : undefined, + query.endDate ? new Date(query.endDate) : undefined, + ); + + // Set appropriate MIME type and headers + const mimeType = query.format === 'csv' ? 'text/csv' : 'application/json'; + const fileExtension = query.format === 'csv' ? 'csv' : 'json'; + const filename = `price-history-${propertyId}-${new Date().toISOString().split('T')[0]}.${fileExtension}`; + + response.setHeader('Content-Type', mimeType); + response.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + response.setHeader('Content-Length', buffer.length); + + response.send(buffer); + } + + /** + * Bulk export price history for multiple properties + * POST /api/price-history/bulk-export + * Validates: Requirements 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7 + * + * @param body - Request body (propertyIds, format, startDate, endDate) + * @param user - Current authenticated user + * @param response - Express response object + */ + @UseGuards(JwtAuthGuard) + @Post('bulk-export') + async bulkExport( + @Body() body: BulkExportDto, + @CurrentUser() user: AuthUserPayload, + @Response() response: ExpressResponse, + ) { + this.logger.log( + `Bulk exporting price history for ${body.propertyIds.length} properties as ${body.format} by user ${user.sub}`, + ); + + const buffer = await this.priceHistoryService.bulkExport( + body.propertyIds, + user.sub, + user.role, + body.format, + body.startDate ? new Date(body.startDate) : undefined, + body.endDate ? new Date(body.endDate) : undefined, + ); + + // Set appropriate MIME type and headers + const mimeType = body.format === 'csv' ? 'text/csv' : 'application/json'; + const fileExtension = body.format === 'csv' ? 'csv' : 'json'; + const filename = `bulk-price-history-${new Date().toISOString().split('T')[0]}.${fileExtension}`; + + response.setHeader('Content-Type', mimeType); + response.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + response.setHeader('Content-Length', buffer.length); + + response.send(buffer); + } +} diff --git a/src/price-history/price-history.module.ts b/src/price-history/price-history.module.ts new file mode 100644 index 00000000..3c312831 --- /dev/null +++ b/src/price-history/price-history.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { PriceHistoryService } from './price-history.service'; +import { PriceHistoryController } from './price-history.controller'; +import { PriceHistoryPermissionGuard } from './guards/price-history-permission.guard'; +import { PriceHistoryCacheInterceptor } from './interceptors/price-history-cache.interceptor'; +import { PrismaModule } from '../database/prisma.module'; +import { AuthModule } from '../auth/auth.module'; +import { CacheModuleConfig } from '../cache/cache.module'; +import { NotificationsModule } from '../notifications/notifications.module'; + +/** + * PriceHistoryModule + * Manages price history tracking, retrieval, and analysis + * Validates: Requirements 1.1, 5.1 + */ +@Module({ + imports: [PrismaModule, AuthModule, CacheModuleConfig, NotificationsModule], + providers: [ + PriceHistoryService, + PriceHistoryPermissionGuard, + { + provide: APP_INTERCEPTOR, + useClass: PriceHistoryCacheInterceptor, + }, + ], + controllers: [PriceHistoryController], + exports: [PriceHistoryService], +}) +export class PriceHistoryModule {} diff --git a/src/price-history/price-history.service.spec.ts b/src/price-history/price-history.service.spec.ts new file mode 100644 index 00000000..0800adf7 --- /dev/null +++ b/src/price-history/price-history.service.spec.ts @@ -0,0 +1,1131 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PriceHistoryService } from './price-history.service'; +import { PrismaService } from '../database/prisma.service'; +import { CacheService } from '../cache/cache.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { UserRole, PropertyStatus } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; + +describe('PriceHistoryService', () => { + let service: PriceHistoryService; + let prismaService: PrismaService; + let cacheService: CacheService; + let notificationsService: NotificationsService; + + const mockPrismaService = { + priceHistory: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + count: jest.fn(), + }, + property: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + + const mockCacheService = { + invalidatePropertyCache: jest.fn(), + }; + + const mockNotificationsService = { + sendNotification: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PriceHistoryService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: CacheService, + useValue: mockCacheService, + }, + { + provide: NotificationsService, + useValue: mockNotificationsService, + }, + ], + }).compile(); + + service = module.get(PriceHistoryService); + prismaService = module.get(PrismaService); + cacheService = module.get(CacheService); + notificationsService = module.get(NotificationsService); + + jest.clearAllMocks(); + }); + + describe('recordPriceChange', () => { + it('should record a price change with all fields', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + const changeReason = 'Market adjustment'; + const metadata = { source: 'web' }; + const ipAddress = '192.168.1.1'; + const userAgent = 'Mozilla/5.0'; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: previousPrice, + status: PropertyStatus.ACTIVE, + }; + + const mockCreatedRecord = { + id: 'record-123', + propertyId, + previousPrice, + newPrice, + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date(), + userId, + userRole, + changeReason, + ipAddress, + userAgent, + metadata, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findFirst.mockResolvedValue(null); + mockPrismaService.priceHistory.create.mockResolvedValue(mockCreatedRecord); + mockPrismaService.property.update.mockResolvedValue(mockProperty); + mockCacheService.invalidatePropertyCache.mockResolvedValue(undefined); + mockNotificationsService.sendNotification.mockResolvedValue({}); + + const result = await service.recordPriceChange( + propertyId, + previousPrice, + newPrice, + userId, + userRole, + changeReason, + metadata, + ipAddress, + userAgent, + ); + + expect(result).toEqual(mockCreatedRecord); + expect(mockPrismaService.priceHistory.create).toHaveBeenCalledWith({ + data: { + propertyId, + previousPrice, + newPrice, + priceChangePercentage: new Decimal('2.00'), + userId, + userRole, + changeReason, + ipAddress, + userAgent, + metadata, + }, + }); + expect(mockPrismaService.property.update).toHaveBeenCalledWith({ + where: { id: propertyId }, + data: { price: newPrice }, + }); + expect(mockCacheService.invalidatePropertyCache).toHaveBeenCalledWith(propertyId); + expect(mockNotificationsService.sendNotification).toHaveBeenCalled(); + }); + + it('should reject negative new price', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('-100'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + await expect( + service.recordPriceChange(propertyId, previousPrice, newPrice, userId, userRole), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject zero new price', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('0'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + await expect( + service.recordPriceChange(propertyId, previousPrice, newPrice, userId, userRole), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject if property does not exist', async () => { + const propertyId = 'prop-nonexistent'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + mockPrismaService.property.findUnique.mockResolvedValue(null); + + await expect( + service.recordPriceChange(propertyId, previousPrice, newPrice, userId, userRole), + ).rejects.toThrow(NotFoundException); + }); + + it('should reject if previous price does not match last recorded price', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: previousPrice, + status: PropertyStatus.ACTIVE, + }; + + const mockLastRecord = { + id: 'record-122', + propertyId, + previousPrice: new Decimal('240000'), + newPrice: new Decimal('250000'), + priceChangePercentage: new Decimal('4.17'), + timestamp: new Date(), + userId: 'user-122', + userRole: UserRole.AGENT, + changeReason: 'Previous change', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findFirst.mockResolvedValue(mockLastRecord); + + await expect( + service.recordPriceChange(propertyId, previousPrice, newPrice, userId, userRole), + ).rejects.toThrow(BadRequestException); + }); + + it('should allow recording when previous price matches last recorded price', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: previousPrice, + status: PropertyStatus.ACTIVE, + }; + + const mockLastRecord = { + id: 'record-122', + propertyId, + previousPrice: new Decimal('240000'), + newPrice: previousPrice, // Matches previousPrice parameter + priceChangePercentage: new Decimal('4.17'), + timestamp: new Date(), + userId: 'user-122', + userRole: UserRole.AGENT, + changeReason: 'Previous change', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockCreatedRecord = { + id: 'record-123', + propertyId, + previousPrice, + newPrice, + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date(), + userId, + userRole, + changeReason: undefined, + ipAddress: undefined, + userAgent: undefined, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findFirst.mockResolvedValue(mockLastRecord); + mockPrismaService.priceHistory.create.mockResolvedValue(mockCreatedRecord); + mockPrismaService.property.update.mockResolvedValue(mockProperty); + mockCacheService.invalidatePropertyCache.mockResolvedValue(undefined); + mockNotificationsService.sendNotification.mockResolvedValue({}); + + const result = await service.recordPriceChange( + propertyId, + previousPrice, + newPrice, + userId, + userRole, + ); + + expect(result).toEqual(mockCreatedRecord); + }); + + it('should allow recording when no previous records exist', async () => { + const propertyId = 'prop-123'; + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + const userId = 'user-123'; + const userRole = UserRole.AGENT; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: previousPrice, + status: PropertyStatus.ACTIVE, + }; + + const mockCreatedRecord = { + id: 'record-123', + propertyId, + previousPrice, + newPrice, + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date(), + userId, + userRole, + changeReason: undefined, + ipAddress: undefined, + userAgent: undefined, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findFirst.mockResolvedValue(null); + mockPrismaService.priceHistory.create.mockResolvedValue(mockCreatedRecord); + mockPrismaService.property.update.mockResolvedValue(mockProperty); + mockCacheService.invalidatePropertyCache.mockResolvedValue(undefined); + mockNotificationsService.sendNotification.mockResolvedValue({}); + + const result = await service.recordPriceChange( + propertyId, + previousPrice, + newPrice, + userId, + userRole, + ); + + expect(result).toEqual(mockCreatedRecord); + }); + }); + + describe('calculatePercentageChange', () => { + it('should calculate percentage change correctly', () => { + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('255000'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toEqual(new Decimal('2.00')); + }); + + it('should round to 2 decimal places', () => { + const previousPrice = new Decimal('100'); + const newPrice = new Decimal('133.33'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toEqual(new Decimal('33.33')); + }); + + it('should handle negative percentage change', () => { + const previousPrice = new Decimal('300000'); + const newPrice = new Decimal('270000'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toEqual(new Decimal('-10.00')); + }); + + it('should return null when previousPrice is zero', () => { + const previousPrice = new Decimal('0'); + const newPrice = new Decimal('255000'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toBeNull(); + }); + + it('should return null when previousPrice is null', () => { + const previousPrice = null; + const newPrice = new Decimal('255000'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toBeNull(); + }); + + it('should handle zero percentage change', () => { + const previousPrice = new Decimal('250000'); + const newPrice = new Decimal('250000'); + + const result = service.calculatePercentageChange(previousPrice, newPrice); + + expect(result).toEqual(new Decimal('0.00')); + }); + }); + + describe('getPriceHistory', () => { + it('should retrieve price history with pagination', async () => { + const propertyId = 'prop-123'; + const limit = 50; + const offset = 0; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId, + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + mockPrismaService.priceHistory.count.mockResolvedValue(1); + + const result = await service.getPriceHistory(propertyId, limit, offset); + + expect(result.data).toEqual(mockRecords); + expect(result.total).toBe(1); + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { propertyId }, + orderBy: { timestamp: 'DESC' }, + take: limit, + skip: offset, + }); + }); + + it('should apply date range filtering', async () => { + const propertyId = 'prop-123'; + const limit = 50; + const offset = 0; + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + mockPrismaService.priceHistory.count.mockResolvedValue(0); + + await service.getPriceHistory(propertyId, limit, offset, startDate, endDate); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { + propertyId, + timestamp: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: { timestamp: 'DESC' }, + take: limit, + skip: offset, + }); + }); + + it('should sort by price field', async () => { + const propertyId = 'prop-123'; + const limit = 50; + const offset = 0; + const sortBy = 'price'; + const sortOrder = 'ASC' as const; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + mockPrismaService.priceHistory.count.mockResolvedValue(0); + + await service.getPriceHistory(propertyId, limit, offset, undefined, undefined, sortBy, sortOrder); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { propertyId }, + orderBy: { newPrice: sortOrder }, + take: limit, + skip: offset, + }); + }); + + it('should sort by percentage_change field', async () => { + const propertyId = 'prop-123'; + const limit = 50; + const offset = 0; + const sortBy = 'percentage_change'; + const sortOrder = 'DESC' as const; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-123', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + mockPrismaService.priceHistory.count.mockResolvedValue(0); + + await service.getPriceHistory(propertyId, limit, offset, undefined, undefined, sortBy, sortOrder); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { propertyId }, + orderBy: { priceChangePercentage: sortOrder }, + take: limit, + skip: offset, + }); + }); + + it('should throw NotFoundException if property does not exist', async () => { + const propertyId = 'prop-nonexistent'; + + mockPrismaService.property.findUnique.mockResolvedValue(null); + + await expect(service.getPriceHistory(propertyId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('checkPermission', () => { + it('should grant access to ADMIN users', async () => { + const userId = 'user-123'; + const userRole = UserRole.ADMIN; + const propertyId = 'prop-123'; + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(true); + }); + + it('should grant access to property owner', async () => { + const userId = 'user-123'; + const userRole = UserRole.USER; + const propertyId = 'prop-123'; + + const mockProperty = { + id: propertyId, + ownerId: userId, + price: new Decimal('255000'), + status: PropertyStatus.DRAFT, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(true); + }); + + it('should grant access to ACTIVE property for any user', async () => { + const userId = 'user-123'; + const userRole = UserRole.USER; + const propertyId = 'prop-123'; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-456', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(true); + }); + + it('should deny access to non-owner for non-ACTIVE property', async () => { + const userId = 'user-123'; + const userRole = UserRole.USER; + const propertyId = 'prop-123'; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-456', + price: new Decimal('255000'), + status: PropertyStatus.DRAFT, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(false); + }); + + it('should return false if property does not exist', async () => { + const userId = 'user-123'; + const userRole = UserRole.USER; + const propertyId = 'prop-nonexistent'; + + mockPrismaService.property.findUnique.mockResolvedValue(null); + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(false); + }); + + it('should grant AGENT access to ACTIVE property', async () => { + const userId = 'user-123'; + const userRole = UserRole.AGENT; + const propertyId = 'prop-123'; + + const mockProperty = { + id: propertyId, + ownerId: 'owner-456', + price: new Decimal('255000'), + status: PropertyStatus.ACTIVE, + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + + const result = await service.checkPermission(userId, userRole, propertyId); + + expect(result).toBe(true); + }); + }); +}); + + + describe('getChartData', () => { + it('should return chart data with daily aggregation', async () => { + const propertyId = 'prop-123'; + const interval = 'daily'; + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + price: new Decimal('255000'), + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId, + previousPrice: new Decimal('250000'), + newPrice: new Decimal('252000'), + priceChangePercentage: new Decimal('0.80'), + timestamp: new Date('2024-01-15T10:00:00Z'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'record-2', + propertyId, + previousPrice: new Decimal('252000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('1.19'), + timestamp: new Date('2024-01-15T14:00:00Z'), + userId: 'user-124', + userRole: UserRole.AGENT, + changeReason: 'Price increase', + ipAddress: '192.168.1.2', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.getChartData(propertyId, interval); + + expect(result.propertyId).toBe(propertyId); + expect(result.propertyAddress).toBe(mockProperty.address); + expect(result.currentPrice).toEqual(mockProperty.price); + expect(result.aggregationInterval).toBe(interval); + expect(result.dataPoints.length).toBeGreaterThan(0); + expect(result.dataPoints[0]).toHaveProperty('timestamp'); + expect(result.dataPoints[0]).toHaveProperty('price'); + expect(result.dataPoints[0]).toHaveProperty('previousPrice'); + expect(result.dataPoints[0]).toHaveProperty('priceChangePercentage'); + expect(result.dataPoints[0]).toHaveProperty('changeReason'); + expect(result.dataPoints[0]).toHaveProperty('minPrice'); + expect(result.dataPoints[0]).toHaveProperty('maxPrice'); + expect(result.dataPoints[0]).toHaveProperty('firstPrice'); + expect(result.dataPoints[0]).toHaveProperty('lastPrice'); + }); + + it('should return empty dataPoints when no records exist', async () => { + const propertyId = 'prop-123'; + const interval = 'daily'; + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + price: new Decimal('255000'), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + + const result = await service.getChartData(propertyId, interval); + + expect(result.dataPoints).toEqual([]); + expect(result.propertyId).toBe(propertyId); + }); + + it('should throw NotFoundException if property does not exist', async () => { + const propertyId = 'prop-nonexistent'; + const interval = 'daily'; + + mockPrismaService.property.findUnique.mockResolvedValue(null); + + await expect(service.getChartData(propertyId, interval)).rejects.toThrow(NotFoundException); + }); + + it('should apply date range filtering', async () => { + const propertyId = 'prop-123'; + const interval = 'daily'; + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + price: new Decimal('255000'), + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + + await service.getChartData(propertyId, interval, startDate, endDate); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { + propertyId, + timestamp: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: { timestamp: 'asc' }, + }); + }); + }); + + describe('exportData', () => { + it('should export data as JSON', async () => { + const propertyId = 'prop-123'; + const format = 'json'; + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId, + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: { source: 'web' }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.exportData(propertyId, format); + + expect(result).toBeInstanceOf(Buffer); + const jsonData = JSON.parse(result.toString('utf-8')); + expect(jsonData).toHaveProperty('metadata'); + expect(jsonData).toHaveProperty('records'); + expect(jsonData.metadata).toHaveProperty('propertyAddress'); + expect(jsonData.metadata).toHaveProperty('exportDate'); + expect(jsonData.metadata).toHaveProperty('recordCount'); + expect(jsonData.records.length).toBe(1); + }); + + it('should export data as CSV', async () => { + const propertyId = 'prop-123'; + const format = 'csv'; + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId, + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: { source: 'web' }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.exportData(propertyId, format); + + expect(result).toBeInstanceOf(Buffer); + const csvContent = result.toString('utf-8'); + expect(csvContent).toContain('Property Address'); + expect(csvContent).toContain('Export Date'); + expect(csvContent).toContain('Timestamp'); + expect(csvContent).toContain('Previous Price'); + expect(csvContent).toContain('New Price'); + }); + + it('should throw NotFoundException if property does not exist', async () => { + const propertyId = 'prop-nonexistent'; + const format = 'json'; + + mockPrismaService.property.findUnique.mockResolvedValue(null); + + await expect(service.exportData(propertyId, format)).rejects.toThrow(NotFoundException); + }); + + it('should apply date range filtering for export', async () => { + const propertyId = 'prop-123'; + const format = 'json'; + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + + const mockProperty = { + id: propertyId, + address: '123 Main St, Springfield, IL 62701', + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + + await service.exportData(propertyId, format, startDate, endDate); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { + propertyId, + timestamp: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: { timestamp: 'asc' }, + }); + }); + }); + + describe('bulkExport', () => { + it('should export data for multiple properties as JSON', async () => { + const propertyIds = ['prop-1', 'prop-2']; + const userId = 'user-123'; + const userRole = UserRole.ADMIN; + const format = 'json'; + + const mockProperty1 = { + id: 'prop-1', + ownerId: 'owner-1', + status: PropertyStatus.ACTIVE, + address: '123 Main St', + }; + + const mockProperty2 = { + id: 'prop-2', + ownerId: 'owner-2', + status: PropertyStatus.ACTIVE, + address: '456 Oak Ave', + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId: 'prop-1', + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + property: { address: '123 Main St' }, + }, + { + id: 'record-2', + propertyId: 'prop-2', + previousPrice: new Decimal('300000'), + newPrice: new Decimal('310000'), + priceChangePercentage: new Decimal('3.33'), + timestamp: new Date('2024-01-16'), + userId: 'user-124', + userRole: UserRole.AGENT, + changeReason: 'Price increase', + ipAddress: '192.168.1.2', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + property: { address: '456 Oak Ave' }, + }, + ]; + + mockPrismaService.property.findUnique + .mockResolvedValueOnce(mockProperty1) + .mockResolvedValueOnce(mockProperty2); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.bulkExport(propertyIds, userId, userRole, format); + + expect(result).toBeInstanceOf(Buffer); + const jsonData = JSON.parse(result.toString('utf-8')); + expect(jsonData).toHaveProperty('metadata'); + expect(jsonData).toHaveProperty('properties'); + expect(jsonData.metadata).toHaveProperty('exportDate'); + expect(jsonData.metadata).toHaveProperty('totalRecords'); + expect(jsonData.metadata).toHaveProperty('propertyCount'); + }); + + it('should export data for multiple properties as CSV', async () => { + const propertyIds = ['prop-1', 'prop-2']; + const userId = 'user-123'; + const userRole = UserRole.ADMIN; + const format = 'csv'; + + const mockProperty1 = { + id: 'prop-1', + ownerId: 'owner-1', + status: PropertyStatus.ACTIVE, + address: '123 Main St', + }; + + const mockProperty2 = { + id: 'prop-2', + ownerId: 'owner-2', + status: PropertyStatus.ACTIVE, + address: '456 Oak Ave', + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId: 'prop-1', + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + property: { address: '123 Main St' }, + }, + ]; + + mockPrismaService.property.findUnique + .mockResolvedValueOnce(mockProperty1) + .mockResolvedValueOnce(mockProperty2); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.bulkExport(propertyIds, userId, userRole, format); + + expect(result).toBeInstanceOf(Buffer); + const csvContent = result.toString('utf-8'); + expect(csvContent).toContain('Bulk Price History Export'); + expect(csvContent).toContain('Export Date'); + expect(csvContent).toContain('Total Records'); + expect(csvContent).toContain('Property ID'); + expect(csvContent).toContain('Property Address'); + }); + + it('should throw BadRequestException if user lacks permission for any property', async () => { + const propertyIds = ['prop-1', 'prop-2']; + const userId = 'user-123'; + const userRole = UserRole.USER; + const format = 'json'; + + const mockProperty1 = { + id: 'prop-1', + ownerId: 'owner-1', + status: PropertyStatus.DRAFT, + address: '123 Main St', + }; + + const mockProperty2 = { + id: 'prop-2', + ownerId: 'owner-2', + status: PropertyStatus.ACTIVE, + address: '456 Oak Ave', + }; + + mockPrismaService.property.findUnique + .mockResolvedValueOnce(mockProperty1) + .mockResolvedValueOnce(mockProperty2); + + await expect(service.bulkExport(propertyIds, userId, userRole, format)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should allow bulk export when user is ADMIN', async () => { + const propertyIds = ['prop-1', 'prop-2']; + const userId = 'user-123'; + const userRole = UserRole.ADMIN; + const format = 'json'; + + const mockProperty1 = { + id: 'prop-1', + ownerId: 'owner-1', + status: PropertyStatus.DRAFT, + address: '123 Main St', + }; + + const mockProperty2 = { + id: 'prop-2', + ownerId: 'owner-2', + status: PropertyStatus.DRAFT, + address: '456 Oak Ave', + }; + + const mockRecords = [ + { + id: 'record-1', + propertyId: 'prop-1', + previousPrice: new Decimal('250000'), + newPrice: new Decimal('255000'), + priceChangePercentage: new Decimal('2.00'), + timestamp: new Date('2024-01-15'), + userId: 'user-123', + userRole: UserRole.AGENT, + changeReason: 'Market adjustment', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + property: { address: '123 Main St' }, + }, + ]; + + mockPrismaService.property.findUnique + .mockResolvedValueOnce(mockProperty1) + .mockResolvedValueOnce(mockProperty2); + mockPrismaService.priceHistory.findMany.mockResolvedValue(mockRecords); + + const result = await service.bulkExport(propertyIds, userId, userRole, format); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should apply date range filtering for bulk export', async () => { + const propertyIds = ['prop-1']; + const userId = 'user-123'; + const userRole = UserRole.ADMIN; + const format = 'json'; + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + + const mockProperty = { + id: 'prop-1', + ownerId: 'owner-1', + status: PropertyStatus.ACTIVE, + address: '123 Main St', + }; + + mockPrismaService.property.findUnique.mockResolvedValue(mockProperty); + mockPrismaService.priceHistory.findMany.mockResolvedValue([]); + + await service.bulkExport(propertyIds, userId, userRole, format, startDate, endDate); + + expect(mockPrismaService.priceHistory.findMany).toHaveBeenCalledWith({ + where: { + propertyId: { in: propertyIds }, + timestamp: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: [{ propertyId: 'asc' }, { timestamp: 'asc' }], + include: { property: { select: { address: true } } }, + }); + }); + }); +}); diff --git a/src/price-history/price-history.service.ts b/src/price-history/price-history.service.ts new file mode 100644 index 00000000..809fa688 --- /dev/null +++ b/src/price-history/price-history.service.ts @@ -0,0 +1,717 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { CacheService } from '../cache/cache.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PriceHistory, UserRole, PropertyStatus } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; + +/** + * PriceHistoryService + * Manages price history recording, retrieval, and analysis + * Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.1, 2.2, 2.3, 2.4, 2.5, 3.2, 3.3, 3.4, 3.7, 5.1, 5.2, 5.3, 5.4, 6.1, 6.2, 6.3, 6.4 + */ +@Injectable() +export class PriceHistoryService { + private readonly logger = new Logger(PriceHistoryService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly cacheService: CacheService, + private readonly notificationsService: NotificationsService, + ) {} + + /** + * Record a price change with complete audit information + * Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 3.2, 6.1, 6.2, 6.3 + * + * @param propertyId - The property ID + * @param previousPrice - The previous price + * @param newPrice - The new price + * @param userId - The user ID who made the change + * @param userRole - The user's role + * @param changeReason - Optional reason for the change + * @param metadata - Optional metadata + * @param ipAddress - Optional IP address + * @param userAgent - Optional user agent + * @returns The created PriceHistory record + */ + async recordPriceChange( + propertyId: string, + previousPrice: Decimal | number, + newPrice: Decimal | number, + userId: string, + userRole: UserRole, + changeReason?: string, + metadata?: Record, + ipAddress?: string, + userAgent?: string, + ): Promise { + // Validate new price is positive decimal (> 0) + const newPriceDecimal = new Decimal(newPrice); + if (newPriceDecimal.lessThanOrEqualTo(0)) { + throw new BadRequestException('New price must be greater than 0'); + } + + // Validate previous price is positive decimal (> 0) + const previousPriceDecimal = new Decimal(previousPrice); + if (previousPriceDecimal.lessThanOrEqualTo(0)) { + throw new BadRequestException('Previous price must be greater than 0'); + } + + // Verify property exists + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + }); + + if (!property) { + throw new NotFoundException(`Property with ID ${propertyId} not found`); + } + + // Validate previous price matches last recorded price or is initial price + const lastRecord = await this.prisma.priceHistory.findFirst({ + where: { propertyId }, + orderBy: { timestamp: 'desc' }, + }); + + if (lastRecord) { + // If there's a previous record, the previousPrice should match its newPrice + if (!lastRecord.newPrice.equals(previousPriceDecimal)) { + throw new BadRequestException( + `Previous price ${previousPrice} does not match the last recorded price ${lastRecord.newPrice}`, + ); + } + } + + // Calculate percentage change + const percentageChange = this.calculatePercentageChange(previousPriceDecimal, newPriceDecimal); + + // Create PriceHistory record with all audit information + const priceHistory = await this.prisma.priceHistory.create({ + data: { + propertyId, + previousPrice: previousPriceDecimal, + newPrice: newPriceDecimal, + priceChangePercentage: percentageChange, + userId, + userRole, + changeReason, + ipAddress, + userAgent, + metadata: metadata || {}, + }, + }); + + // Update property's current price atomically (within same transaction) + await this.prisma.property.update({ + where: { id: propertyId }, + data: { price: newPriceDecimal }, + }); + + // Invalidate cache for this property + await this.cacheService.invalidatePropertyCache(propertyId); + + // Trigger notification event + try { + const percentageChangeStr = percentageChange ? percentageChange.toString() : '0.00'; + await this.notificationsService.sendNotification( + userId, + 'Price Change Recorded', + `Property price updated from ${previousPrice} to ${newPrice} (${percentageChangeStr}% change)`, + 'PRICE_CHANGE', + { + propertyId, + previousPrice: previousPrice.toString(), + newPrice: newPrice.toString(), + percentageChange: percentageChangeStr, + changeReason, + }, + ); + } catch (error) { + this.logger.error(`Failed to send price change notification: ${error}`); + // Don't throw - notification failure shouldn't block price recording + } + + this.logger.log( + `Price change recorded for property ${propertyId}: ${previousPrice} -> ${newPrice}`, + ); + + return priceHistory; + } + + /** + * Calculate percentage change between two prices + * Validates: Requirements 3.2, 3.3, 3.4, 3.7 + * + * @param previousPrice - The previous price + * @param newPrice - The new price + * @returns The percentage change rounded to 2 decimal places, or null if previousPrice is zero/null + */ + calculatePercentageChange( + previousPrice: Decimal | number | null, + newPrice: Decimal | number, + ): Decimal | null { + // Handle edge case: previousPrice is zero or null (return null) + if (!previousPrice || new Decimal(previousPrice).equals(0)) { + return null; + } + + const prevDecimal = new Decimal(previousPrice); + const newDecimal = new Decimal(newPrice); + + // Calculate percentage change using formula: ((newPrice - previousPrice) / previousPrice) * 100 + const percentageChange = newDecimal + .minus(prevDecimal) + .dividedBy(prevDecimal) + .times(100); + + // Round result to 2 decimal places + return percentageChange.toDecimalPlaces(2); + } + + /** + * Get price history for a property with pagination and filtering + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 6.4 + * + * @param propertyId - The property ID + * @param limit - Number of records per page + * @param offset - Number of records to skip + * @param startDate - Optional start date for filtering + * @param endDate - Optional end date for filtering + * @param sortBy - Field to sort by (timestamp, price, percentage_change) + * @param sortOrder - Sort order (ASC or DESC) + * @returns Object with data array and total count + */ + async getPriceHistory( + propertyId: string, + limit: number = 50, + offset: number = 0, + startDate?: Date, + endDate?: Date, + sortBy: string = 'timestamp', + sortOrder: 'ASC' | 'DESC' = 'DESC', + ): Promise<{ data: PriceHistory[]; total: number }> { + // Verify property exists + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + }); + + if (!property) { + throw new NotFoundException(`Property with ID ${propertyId} not found`); + } + + // Build where clause with date range filtering + const where: any = { propertyId }; + + if (startDate || endDate) { + where.timestamp = {}; + if (startDate) { + where.timestamp.gte = startDate; + } + if (endDate) { + where.timestamp.lte = endDate; + } + } + + // Map sortBy field to database column + let orderByField = 'timestamp'; + if (sortBy === 'price') { + orderByField = 'newPrice'; + } else if (sortBy === 'percentage_change') { + orderByField = 'priceChangePercentage'; + } + + // Query PriceHistory records with pagination and sorting + const [data, total] = await Promise.all([ + this.prisma.priceHistory.findMany({ + where, + orderBy: { [orderByField]: sortOrder }, + take: limit, + skip: offset, + }), + this.prisma.priceHistory.count({ where }), + ]); + + return { data, total }; + } + + /** + * Check if user has permission to view price history for a property + * Validates: Requirements 5.1, 5.2, 5.3, 5.4 + * + * @param userId - The user ID + * @param userRole - The user's role + * @param propertyId - The property ID + * @returns True if user has permission, false otherwise + */ + async checkPermission(userId: string, userRole: UserRole, propertyId: string): Promise { + // Grant access if user.role === ADMIN + if (userRole === UserRole.ADMIN) { + return true; + } + + // Get property to check ownership and status + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + }); + + if (!property) { + return false; + } + + // Grant access if user.id === property.ownerId + if (userId === property.ownerId) { + return true; + } + + // Grant access if property.status === ACTIVE (public property) + if (property.status === PropertyStatus.ACTIVE) { + return true; + } + + // Deny access otherwise + return false; + } + + /** + * Get chart data with time interval aggregation + * Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7 + * + * @param propertyId - The property ID + * @param interval - Time interval for aggregation (daily, weekly, monthly, yearly) + * @param startDate - Optional start date for filtering + * @param endDate - Optional end date for filtering + * @returns Chart data with aggregated price points + */ + async getChartData( + propertyId: string, + interval: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily', + startDate?: Date, + endDate?: Date, + ): Promise { + // Verify property exists + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true, address: true, price: true }, + }); + + if (!property) { + throw new NotFoundException(`Property with ID ${propertyId} not found`); + } + + // Build where clause with date range filtering + const where: any = { propertyId }; + + if (startDate || endDate) { + where.timestamp = {}; + if (startDate) { + where.timestamp.gte = startDate; + } + if (endDate) { + where.timestamp.lte = endDate; + } + } + + // Query all price history records in date range + const records = await this.prisma.priceHistory.findMany({ + where, + orderBy: { timestamp: 'asc' }, + }); + + // If no records, return empty data points + if (records.length === 0) { + return { + propertyId, + propertyAddress: property.address, + currentPrice: property.price, + dateRange: { + start: startDate || new Date(0), + end: endDate || new Date(), + }, + aggregationInterval: interval, + dataPoints: [], + }; + } + + // Group records by time interval + const groupedData = this.groupByTimeInterval(records, interval); + + // Calculate aggregated values for each interval + const dataPoints = Array.from(groupedData.entries()).map(([intervalKey, intervalRecords]) => { + const prices = intervalRecords.map((r) => new Decimal(r.newPrice)); + const minPrice = prices.reduce((min, p) => (p.lessThan(min) ? p : min), prices[0]); + const maxPrice = prices.reduce((max, p) => (p.greaterThan(max) ? p : max), prices[0]); + const firstRecord = intervalRecords[0]; + const lastRecord = intervalRecords[intervalRecords.length - 1]; + + return { + timestamp: new Date(intervalKey), + price: lastRecord.newPrice, + previousPrice: firstRecord.previousPrice, + priceChangePercentage: lastRecord.priceChangePercentage, + changeReason: lastRecord.changeReason, + minPrice, + maxPrice, + firstPrice: firstRecord.newPrice, + lastPrice: lastRecord.newPrice, + }; + }); + + // Determine date range from records + const actualStartDate = records[0].timestamp; + const actualEndDate = records[records.length - 1].timestamp; + + return { + propertyId, + propertyAddress: property.address, + currentPrice: property.price, + dateRange: { + start: actualStartDate, + end: actualEndDate, + }, + aggregationInterval: interval, + dataPoints, + }; + } + + /** + * Group price history records by time interval + * Helper method for getChartData + * + * @param records - Array of price history records + * @param interval - Time interval (daily, weekly, monthly, yearly) + * @returns Map of interval keys to records + */ + private groupByTimeInterval( + records: PriceHistory[], + interval: 'daily' | 'weekly' | 'monthly' | 'yearly', + ): Map { + const grouped = new Map(); + + records.forEach((record) => { + const date = new Date(record.timestamp); + let intervalKey: string; + + switch (interval) { + case 'daily': + intervalKey = date.toISOString().split('T')[0]; // YYYY-MM-DD + break; + case 'weekly': + // Get the start of the week (Monday) + const weekStart = new Date(date); + const day = weekStart.getDay(); + const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1); + weekStart.setDate(diff); + intervalKey = weekStart.toISOString().split('T')[0]; + break; + case 'monthly': + intervalKey = date.toISOString().substring(0, 7); // YYYY-MM + break; + case 'yearly': + intervalKey = date.getFullYear().toString(); // YYYY + break; + } + + if (!grouped.has(intervalKey)) { + grouped.set(intervalKey, []); + } + grouped.get(intervalKey)!.push(record); + }); + + return grouped; + } + + /** + * Export price history data in CSV or JSON format + * Validates: Requirements 8.1, 8.2, 8.3, 8.6, 8.7 + * + * @param propertyId - The property ID + * @param format - Export format (csv or json) + * @param startDate - Optional start date for filtering + * @param endDate - Optional end date for filtering + * @returns Buffer with exported data + */ + async exportData( + propertyId: string, + format: 'csv' | 'json' = 'json', + startDate?: Date, + endDate?: Date, + ): Promise { + // Verify property exists + const property = await this.prisma.property.findUnique({ + where: { id: propertyId }, + select: { id: true, address: true }, + }); + + if (!property) { + throw new NotFoundException(`Property with ID ${propertyId} not found`); + } + + // Build where clause with date range filtering + const where: any = { propertyId }; + + if (startDate || endDate) { + where.timestamp = {}; + if (startDate) { + where.timestamp.gte = startDate; + } + if (endDate) { + where.timestamp.lte = endDate; + } + } + + // Query price history records + const records = await this.prisma.priceHistory.findMany({ + where, + orderBy: { timestamp: 'asc' }, + }); + + if (format === 'csv') { + return this.formatAsCSV(records, property.address); + } else { + return this.formatAsJSON(records, property.address); + } + } + + /** + * Format price history records as CSV + * Helper method for exportData + * + * @param records - Array of price history records + * @param propertyAddress - Property address for metadata + * @returns Buffer with CSV data + */ + private formatAsCSV(records: PriceHistory[], propertyAddress: string): Buffer { + const headers = [ + 'Timestamp', + 'Previous Price', + 'New Price', + 'Price Change Percentage', + 'User ID', + 'User Role', + 'Change Reason', + 'IP Address', + 'User Agent', + 'Metadata', + ]; + + const rows = records.map((record) => [ + record.timestamp.toISOString(), + record.previousPrice.toString(), + record.newPrice.toString(), + record.priceChangePercentage ? record.priceChangePercentage.toString() : '', + record.userId, + record.userRole, + record.changeReason || '', + record.ipAddress || '', + record.userAgent || '', + JSON.stringify(record.metadata || {}), + ]); + + // Build CSV content + let csvContent = `Property Address: ${propertyAddress}\n`; + csvContent += `Export Date: ${new Date().toISOString()}\n\n`; + csvContent += headers.map((h) => this.escapeCSVField(h)).join(',') + '\n'; + csvContent += rows.map((row) => row.map((field) => this.escapeCSVField(field)).join(',')).join('\n'); + + return Buffer.from(csvContent, 'utf-8'); + } + + /** + * Escape CSV field values to handle commas and quotes + * Helper method for formatAsCSV + * + * @param field - Field value to escape + * @returns Escaped field value + */ + private escapeCSVField(field: any): string { + const fieldStr = field.toString(); + if (fieldStr.includes(',') || fieldStr.includes('"') || fieldStr.includes('\n')) { + return `"${fieldStr.replace(/"/g, '""')}"`; + } + return fieldStr; + } + + /** + * Format price history records as JSON + * Helper method for exportData + * + * @param records - Array of price history records + * @param propertyAddress - Property address for metadata + * @returns Buffer with JSON data + */ + private formatAsJSON(records: PriceHistory[], propertyAddress: string): Buffer { + const data = { + metadata: { + propertyAddress, + exportDate: new Date().toISOString(), + recordCount: records.length, + }, + records: records.map((record) => ({ + timestamp: record.timestamp.toISOString(), + previousPrice: record.previousPrice.toString(), + newPrice: record.newPrice.toString(), + priceChangePercentage: record.priceChangePercentage ? record.priceChangePercentage.toString() : null, + userId: record.userId, + userRole: record.userRole, + changeReason: record.changeReason, + ipAddress: record.ipAddress, + userAgent: record.userAgent, + metadata: record.metadata, + })), + }; + + return Buffer.from(JSON.stringify(data, null, 2), 'utf-8'); + } + + /** + * Bulk export price history for multiple properties + * Validates: Requirements 12.1, 12.2, 12.3, 12.4, 12.5, 12.6 + * + * @param propertyIds - Array of property IDs to export + * @param userId - The user ID requesting the export + * @param userRole - The user's role + * @param format - Export format (csv or json) + * @param startDate - Optional start date for filtering + * @param endDate - Optional end date for filtering + * @returns Buffer with exported data + */ + async bulkExport( + propertyIds: string[], + userId: string, + userRole: UserRole, + format: 'csv' | 'json' = 'json', + startDate?: Date, + endDate?: Date, + ): Promise { + // Validate user has permission for all specified properties + for (const propertyId of propertyIds) { + const hasPermission = await this.checkPermission(userId, userRole, propertyId); + if (!hasPermission) { + throw new BadRequestException( + `User does not have permission to access price history for property ${propertyId}`, + ); + } + } + + // Build where clause with date range filtering + const where: any = { + propertyId: { in: propertyIds }, + }; + + if (startDate || endDate) { + where.timestamp = {}; + if (startDate) { + where.timestamp.gte = startDate; + } + if (endDate) { + where.timestamp.lte = endDate; + } + } + + // Query price history for all properties + const records = await this.prisma.priceHistory.findMany({ + where, + orderBy: [{ propertyId: 'asc' }, { timestamp: 'asc' }], + include: { property: { select: { address: true } } }, + }); + + if (format === 'csv') { + return this.formatBulkAsCSV(records); + } else { + return this.formatBulkAsJSON(records); + } + } + + /** + * Format bulk price history records as CSV + * Helper method for bulkExport + * + * @param records - Array of price history records with property info + * @returns Buffer with CSV data + */ + private formatBulkAsCSV(records: any[]): Buffer { + const headers = [ + 'Property ID', + 'Property Address', + 'Timestamp', + 'Previous Price', + 'New Price', + 'Price Change Percentage', + 'User ID', + 'User Role', + 'Change Reason', + 'IP Address', + 'User Agent', + 'Metadata', + ]; + + const rows = records.map((record) => [ + record.propertyId, + record.property.address, + record.timestamp.toISOString(), + record.previousPrice.toString(), + record.newPrice.toString(), + record.priceChangePercentage ? record.priceChangePercentage.toString() : '', + record.userId, + record.userRole, + record.changeReason || '', + record.ipAddress || '', + record.userAgent || '', + JSON.stringify(record.metadata || {}), + ]); + + // Build CSV content + let csvContent = `Bulk Price History Export\n`; + csvContent += `Export Date: ${new Date().toISOString()}\n`; + csvContent += `Total Records: ${records.length}\n\n`; + csvContent += headers.map((h) => this.escapeCSVField(h)).join(',') + '\n'; + csvContent += rows.map((row) => row.map((field) => this.escapeCSVField(field)).join(',')).join('\n'); + + return Buffer.from(csvContent, 'utf-8'); + } + + /** + * Format bulk price history records as JSON + * Helper method for bulkExport + * + * @param records - Array of price history records with property info + * @returns Buffer with JSON data + */ + private formatBulkAsJSON(records: any[]): Buffer { + // Group records by property ID + const groupedByProperty = new Map(); + records.forEach((record) => { + if (!groupedByProperty.has(record.propertyId)) { + groupedByProperty.set(record.propertyId, []); + } + groupedByProperty.get(record.propertyId)!.push(record); + }); + + const data = { + metadata: { + exportDate: new Date().toISOString(), + totalRecords: records.length, + propertyCount: groupedByProperty.size, + }, + properties: Array.from(groupedByProperty.entries()).map(([propertyId, propertyRecords]) => ({ + propertyId, + propertyAddress: propertyRecords[0].property.address, + recordCount: propertyRecords.length, + records: propertyRecords.map((record) => ({ + timestamp: record.timestamp.toISOString(), + previousPrice: record.previousPrice.toString(), + newPrice: record.newPrice.toString(), + priceChangePercentage: record.priceChangePercentage ? record.priceChangePercentage.toString() : null, + userId: record.userId, + userRole: record.userRole, + changeReason: record.changeReason, + ipAddress: record.ipAddress, + userAgent: record.userAgent, + metadata: record.metadata, + })), + })), + }; + + return Buffer.from(JSON.stringify(data, null, 2), 'utf-8'); + } +} diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 7b064e05..339fe695 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -72,8 +72,12 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT, UserRole.ADMIN) @Put(':id') - update(@Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto) { - return this.propertiesService.update(id, updatePropertyDto); + update( + @Param('id') id: string, + @Body() updatePropertyDto: UpdatePropertyDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.propertiesService.update(id, updatePropertyDto, user.sub, user.role); } @UseGuards(JwtAuthGuard, RolesGuard) diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index 39065f27..4f7f4d3d 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -11,6 +11,10 @@ import { AuthModule } from '../auth/auth.module'; import { PropertiesResolver } from './properties.resolver'; import { PubSub } from 'graphql-subscriptions'; import { FraudModule } from '../fraud/fraud.module'; +import { PriceHistoryModule } from '../price-history/price-history.module'; + +@Module({ + imports: [PrismaModule, AuthModule, FraudModule, ConfigModule, PriceHistoryModule], import { PropertyReportService } from './report/property-report.service'; import { CacheModuleConfig } from '../cache/cache.module'; diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index d746741d..4879c70d 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -13,6 +13,7 @@ import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CreateAmenityDto, UpdateAmenityDto } from './dto/amenity.dto'; import { FraudService } from '../fraud/fraud.service'; import { GeocodingService } from './geocoding.service'; +import { PriceHistoryService } from '../price-history/price-history.service'; import { PropertyStatus, UserRole } from '../types/prisma.types'; import { CacheService } from '../cache/cache.service'; import { CACHE_TAGS } from '../cache/cache.config'; @@ -43,6 +44,7 @@ export class PropertiesService { private readonly prisma: PrismaService, private readonly fraudService: FraudService, private readonly geocodingService: GeocodingService, + private readonly priceHistoryService: PriceHistoryService, private readonly cacheService: CacheService, ) {} @@ -170,10 +172,34 @@ export class PropertiesService { }); } + async update( + id: string, + updatePropertyDto: UpdatePropertyDto, + userId?: string, + userRole?: UserRole, + ) { + const { price, squareFeet, lotSize, latitude, longitude, ...rest } = updatePropertyDto; async update(id: string, updatePropertyDto: UpdatePropertyDto) { const { price, squareFeet, lotSize, latitude, longitude, hoaMonthlyFee, ...rest } = updatePropertyDto; + // Get existing property to check if price is changing + const existingProperty = await this.prisma.property.findUnique({ + where: { id }, + select: { + price: true, + address: true, + city: true, + state: true, + zipCode: true, + country: true, + }, + }); + + if (!existingProperty) { + throw new NotFoundException(`Property with ID ${id} not found`); + } + // Duplicate address check (if address fields are being updated) if (rest.address || rest.city || rest.state || rest.zipCode || rest.country) { const existingProperty = await this.prisma.property.findUnique({ where: { id } }); @@ -203,17 +229,51 @@ export class PropertiesService { const callerProvidedCoords = latitude !== undefined && longitude !== undefined; if (!callerProvidedCoords) { - const existing = await this.prisma.property.findUnique({ - where: { id }, - select: { - address: true, - city: true, - state: true, - zipCode: true, - country: true, - }, - }); + const before = { + address: existingProperty.address, + city: existingProperty.city, + state: existingProperty.state, + zipCode: existingProperty.zipCode, + country: existingProperty.country, + }; + const after = { + address: rest.address ?? existingProperty.address, + city: rest.city ?? existingProperty.city, + state: rest.state ?? existingProperty.state, + zipCode: rest.zipCode ?? existingProperty.zipCode, + country: existingProperty.country, // not in UpdatePropertyDto + }; + + if (this.geocodingService.hasAddressChanged(before, after)) { + const geo = await this.geocodingService.geocodeAddress(after); + if (geo) { + resolvedLat = geo.latitude; + resolvedLng = geo.longitude; + } + } + } + // Record price change if price is being updated + if (price !== undefined && price !== null) { + const newPrice = new Decimal(price.toString()); + const previousPrice = existingProperty.price; + + // Only record if price actually changed + if (!newPrice.equals(previousPrice)) { + if (userId && userRole) { + try { + await this.priceHistoryService.recordPriceChange( + id, + previousPrice, + newPrice, + userId, + userRole, + 'Property price updated', + { source: 'property_update' }, + ); + } catch (error) { + // Log error but don't fail the property update + console.error(`Failed to record price change for property ${id}:`, error); if (existing) { const before = { address: existing.address,