From 0acd66df38df88625546d49138f44ef9b5c1a7f5 Mon Sep 17 00:00:00 2001 From: laxman Date: Mon, 3 Nov 2025 13:22:29 +0530 Subject: [PATCH] initial Commit --- .cursor/rules/rule1.mdc | 564 ++++ .gitignore | 10 + 001_initial_schema.sql | 61 + CURSOR_PROMPT.md | 570 ++++ ..._Image_Tagging_API.postman_collection.json | 532 +++ QUICK_START.md | 298 ++ SETUP_GUIDE.md | 186 ++ enhanced_property_prompt.md | 670 ++++ package-lock.json | 2953 +++++++++++++++++ package.json | 43 + scripts/interactive-setup.js | 178 + scripts/manage-api-keys.js | 150 + scripts/setup-database.js | 104 + setup.sh | 76 + src/application/dtos/TagImageRequestDto.js | 29 + src/application/dtos/TagImageResponseDto.js | 50 + .../useCases/TagBase64ImageUseCase.js | 108 + .../useCases/TagBatchBase64ImagesUseCase.js | 99 + .../useCases/TagBatchImagesUseCase.js | 87 + src/application/useCases/TagImageUseCase.js | 109 + src/domain/entities/ImageTag.js | 60 + src/domain/entities/TaggingResult.js | 78 + src/domain/interfaces/IImageRepository.js | 55 + src/domain/interfaces/IImageTaggingService.js | 22 + src/infrastructure/ai/ClaudeAIProvider.js | 213 ++ src/infrastructure/config/corsConfig.js | 14 + .../config/dependencyContainer.js | 94 + .../repositories/ApiKeyRepository.js | 223 ++ .../repositories/MySQLImageRepository.js | 297 ++ .../controllers/ImageTaggingController.js | 314 ++ src/presentation/middleware/apiKeyAuth.js | 79 + src/presentation/middleware/errorHandler.js | 38 + src/presentation/middleware/requestId.js | 15 + src/presentation/routes/imageRoutes.js | 72 + src/presentation/validators/imageValidator.js | 135 + src/server.js | 83 + src/shared/errors/AppError.js | 80 + src/shared/utils/apiKeyGenerator.js | 92 + src/shared/utils/logger.js | 126 + src/shared/utils/responseFormatter.js | 46 + 40 files changed, 9013 insertions(+) create mode 100644 .cursor/rules/rule1.mdc create mode 100644 .gitignore create mode 100644 001_initial_schema.sql create mode 100644 CURSOR_PROMPT.md create mode 100644 Property_Image_Tagging_API.postman_collection.json create mode 100644 QUICK_START.md create mode 100644 SETUP_GUIDE.md create mode 100644 enhanced_property_prompt.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/interactive-setup.js create mode 100644 scripts/manage-api-keys.js create mode 100644 scripts/setup-database.js create mode 100644 setup.sh create mode 100644 src/application/dtos/TagImageRequestDto.js create mode 100644 src/application/dtos/TagImageResponseDto.js create mode 100644 src/application/useCases/TagBase64ImageUseCase.js create mode 100644 src/application/useCases/TagBatchBase64ImagesUseCase.js create mode 100644 src/application/useCases/TagBatchImagesUseCase.js create mode 100644 src/application/useCases/TagImageUseCase.js create mode 100644 src/domain/entities/ImageTag.js create mode 100644 src/domain/entities/TaggingResult.js create mode 100644 src/domain/interfaces/IImageRepository.js create mode 100644 src/domain/interfaces/IImageTaggingService.js create mode 100644 src/infrastructure/ai/ClaudeAIProvider.js create mode 100644 src/infrastructure/config/corsConfig.js create mode 100644 src/infrastructure/config/dependencyContainer.js create mode 100644 src/infrastructure/repositories/ApiKeyRepository.js create mode 100644 src/infrastructure/repositories/MySQLImageRepository.js create mode 100644 src/presentation/controllers/ImageTaggingController.js create mode 100644 src/presentation/middleware/apiKeyAuth.js create mode 100644 src/presentation/middleware/errorHandler.js create mode 100644 src/presentation/middleware/requestId.js create mode 100644 src/presentation/routes/imageRoutes.js create mode 100644 src/presentation/validators/imageValidator.js create mode 100644 src/server.js create mode 100644 src/shared/errors/AppError.js create mode 100644 src/shared/utils/apiKeyGenerator.js create mode 100644 src/shared/utils/logger.js create mode 100644 src/shared/utils/responseFormatter.js diff --git a/.cursor/rules/rule1.mdc b/.cursor/rules/rule1.mdc new file mode 100644 index 0000000..06d7c13 --- /dev/null +++ b/.cursor/rules/rule1.mdc @@ -0,0 +1,564 @@ +--- +alwaysApply: true +--- +# Property Image Tagging REST API - Cursor Rules + +## Project Context +This is a production-ready REST API for automatic property image tagging using Claude AI (Anthropic). Built with Node.js, Express, MySQL, and Clean Architecture principles. + +## Technology Stack +- Node.js 18+ +- Express.js 4.x +- MySQL 8.0+ with mysql2/promise +- Anthropic Claude API (claude-sonnet-4-20250514) +- Sharp for image processing +- Winston for logging +- Joi for validation +- Multer for file uploads + +## Architecture: Clean Architecture (Strict) + +### Layer Separation (NEVER violate) +1. **Domain Layer** (`src/domain/`) + - Pure business entities and interfaces + - NO external dependencies (no Express, no database, no AI SDK) + - Only depends on: Node.js built-ins + - Entities: ImageTag, TaggingResult + - Interfaces: IImageTaggingService, IImageRepository + +2. **Application Layer** (`src/application/`) + - Business logic and use cases + - Depends on: Domain layer only + - NO infrastructure dependencies + - Use Cases: TagImageUseCase, TagBase64ImageUseCase + - DTOs: TagImageRequestDto, TagImageResponseDto + +3. **Infrastructure Layer** (`src/infrastructure/`) + - External services implementation + - Implements domain interfaces + - Database, AI provider, configuration + - Depends on: Domain interfaces, external SDKs + +4. **Presentation Layer** (`src/presentation/`) + - HTTP controllers, routes, middleware + - Depends on: Application use cases + - Controllers: ImageTaggingController + - Middleware: apiKeyAuth, errorHandler, requestId + - Routes: imageRoutes + +5. **Shared Layer** (`src/shared/`) + - Common utilities used across layers + - Errors, logger, formatters, generators + +### Dependency Flow (CRITICAL) +``` +Presentation → Application → Domain ← Infrastructure + ↑ + Shared +``` + +## Code Style & Conventions + +### General Rules +- Use ES6+ syntax (async/await, arrow functions, destructuring) +- Use `const` by default, `let` only when reassignment needed +- Never use `var` +- Use meaningful variable names (no single letters except loop counters) +- Maximum function length: 50 lines +- Maximum file length: 300 lines +- Use JSDoc comments for all public methods + +### Naming Conventions +- Files: PascalCase for classes (e.g., `ImageTag.js`), camelCase for utilities (e.g., `logger.js`) +- Classes: PascalCase (e.g., `TagImageUseCase`) +- Functions/Methods: camelCase (e.g., `generateTags`) +- Constants: UPPER_SNAKE_CASE (e.g., `MAX_FILE_SIZE`) +- Private methods: prefix with underscore (e.g., `_validateInput`) +- Interfaces: prefix with I (e.g., `IImageRepository`) + +### Error Handling (MANDATORY) +- Every async function MUST have try-catch +- Use custom AppError classes (ValidationError, AIServiceError, NotFoundError) +- Never expose internal errors to clients +- Always log errors with context +- Return appropriate HTTP status codes: + - 200: Success + - 400: Validation error + - 401: Missing authentication + - 403: Invalid authentication + - 404: Not found + - 500: Internal error + - 503: External service error + +### Database Operations +- ALWAYS use connection pooling (max 20 connections) +- ALWAYS use parameterized queries (prevent SQL injection) +- ALWAYS use transactions for multi-table operations +- ALWAYS release connections in finally block +- NEVER return raw database errors to clients +- Use prepared statements via mysql2/promise + +### Security Requirements (CRITICAL) +- NEVER store plain text API keys (always SHA256 hash) +- NEVER trust file extensions (use magic number validation with file-type) +- ALWAYS validate file size (<50MB) and dimensions (<15000px) +- ALWAYS sanitize user inputs with Joi +- ALWAYS use helmet middleware for security headers +- NEVER log sensitive data (API keys, passwords) +- Use environment variables for all secrets + +### Image Processing Pipeline +1. Validate file exists and mime type +2. Verify actual file type with magic numbers (file-type package) +3. Calculate SHA256 hash of buffer +4. Check database for duplicate hash +5. If duplicate → return cached result immediately (FREE) +6. If new → optimize image: + - Resize to max 2048px if larger (save Claude API costs) + - Convert HEIC/TIFF/BMP to JPEG + - Use Sharp library for all operations +7. Convert to base64 +8. Send to Claude API with retry logic +9. Parse and validate JSON response +10. Save to database with hash +11. Return formatted response + +### Claude AI Integration +- Model: claude-sonnet-4-20250514 +- ALWAYS use async-retry (3 retries, exponential backoff) +- ALWAYS validate JSON response before parsing +- Return 503 (not 500) for AI service failures +- Prompt must request 20-30 tags in specific categories +- Parse response: {tags: [{category, value, confidence}], summary} + +### API Key Authentication +- Extract from X-API-Key header OR Authorization: Bearer +- Validate format: `key_(test|live)_[64 hex chars]` +- Hash with SHA256 before database lookup +- Check: key exists, is_active=true, not expired, not revoked +- Attach to request: `req.apiKey = {id, name, environment}` +- Support SKIP_AUTH=true ONLY in development +- NO rate limiting, NO usage tracking (intentionally simple) + +### Response Format (ALWAYS use ResponseFormatter) +```javascript +// Success +{ + "success": true, + "message": "✅ New image tagged successfully", + "data": {...}, + "timestamp": "2025-10-31T10:30:00.000Z" +} + +// Error +{ + "success": false, + "message": "API key required. Include X-API-Key header.", + "timestamp": "2025-10-31T10:30:00.000Z" +} +``` + +### Logging (Winston) +- Log levels: error, warn, info, debug +- ALWAYS log: authentication events, errors, API calls +- NEVER log: API keys, passwords, sensitive data +- Use daily rotating file transport +- Separate error.log and combined.log +- Include context: `logger.info('Message', {key: 'value'})` + +### Validation Rules (Joi) +- Validate ALL user inputs +- Fail fast with clear error messages +- Example: +```javascript +const schema = Joi.object({ + base64Image: Joi.string().base64().required(), + mediaType: Joi.string().valid('image/jpeg', 'image/png').required(), + fileName: Joi.string().max(255).optional() +}); +``` + +## Implementation Checklist + +### When creating entities (Domain): +- [ ] No external dependencies +- [ ] Validate inputs in constructor +- [ ] Immutable properties (use getters) +- [ ] Business logic methods only +- [ ] toJSON() method for serialization + +### When creating use cases (Application): +- [ ] Single responsibility +- [ ] Depends only on domain interfaces +- [ ] Constructor injection for dependencies +- [ ] Comprehensive error handling +- [ ] Return DTOs, not entities + +### When creating repositories (Infrastructure): +- [ ] Implements domain interface +- [ ] Connection pooling +- [ ] Parameterized queries +- [ ] Transaction support +- [ ] Release connections in finally + +### When creating controllers (Presentation): +- [ ] Thin layer (delegate to use cases) +- [ ] Validate inputs +- [ ] Handle errors gracefully +- [ ] Return formatted responses +- [ ] Log important events + +### When creating middleware: +- [ ] Next() for success +- [ ] Next(error) for errors +- [ ] Attach data to req object +- [ ] Don't mutate req.body directly + +## File Templates + +### Entity Template: +```javascript +class EntityName { + constructor(data) { + this._validateInput(data); + this.property1 = data.property1; + this.property2 = data.property2; + } + + _validateInput(data) { + if (!data.property1) { + throw new ValidationError('Property1 is required'); + } + } + + businessMethod() { + // Pure business logic + } + + toJSON() { + return { + property1: this.property1, + property2: this.property2 + }; + } +} + +module.exports = EntityName; +``` + +### Use Case Template: +```javascript +class UseCaseName { + constructor(dependency1, dependency2, logger) { + this.dependency1 = dependency1; + this.dependency2 = dependency2; + this.logger = logger; + } + + async execute(input) { + try { + // 1. Validate input + this._validateInput(input); + + // 2. Business logic + const result = await this.dependency1.doSomething(input); + + // 3. Return DTO + return ResponseDto.fromEntity(result); + + } catch (error) { + this.logger.error('Use case error:', error); + throw error; + } + } + + _validateInput(input) { + if (!input) throw new ValidationError('Input required'); + } +} + +module.exports = UseCaseName; +``` + +### Repository Template: +```javascript +class RepositoryName { + constructor(pool, logger) { + this.pool = pool; + this.logger = logger; + } + + async methodName(param) { + const connection = await this.pool.getConnection(); + + try { + const [rows] = await connection.query( + 'SELECT * FROM table WHERE column = ?', + [param] + ); + + return this._mapToEntity(rows[0]); + + } catch (error) { + this.logger.error('Repository error:', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + _mapToEntity(row) { + if (!row) return null; + return new Entity(row); + } +} + +module.exports = RepositoryName; +``` + +### Controller Template: +```javascript +class ControllerName { + constructor(useCase, logger) { + this.useCase = useCase; + this.logger = logger; + } + + async handleRequest(req, res, next) { + try { + // 1. Extract and validate input + const input = this._extractInput(req); + + // 2. Execute use case + const result = await this.useCase.execute(input); + + // 3. Format and return response + res.status(200).json( + ResponseFormatter.success(result, 'Success message') + ); + + } catch (error) { + next(error); + } + } + + _extractInput(req) { + return { + property1: req.body.property1, + property2: req.params.property2 + }; + } +} + +module.exports = ControllerName; +``` + +## Critical Don'ts (NEVER DO) + +❌ Don't put business logic in controllers +❌ Don't put database queries in use cases +❌ Don't skip input validation +❌ Don't trust file extensions +❌ Don't store plain text secrets +❌ Don't expose internal errors to clients +❌ Don't skip image hash calculation (needed for duplicate detection) +❌ Don't use `var` keyword +❌ Don't return database errors directly +❌ Don't skip connection.release() in finally +❌ Don't hardcode values (use env vars) +❌ Don't skip error handling in async functions +❌ Don't violate layer boundaries + +## Critical Do's (ALWAYS DO) + +✅ Use dependency injection everywhere +✅ Hash API keys before storage (SHA256) +✅ Validate with magic numbers (file-type package) +✅ Calculate SHA256 hash of images (duplicate detection) +✅ Use transactions for multi-table operations +✅ Optimize images before sending to Claude (max 2048px) +✅ Implement retry logic for Claude API (async-retry) +✅ Release database connections in finally +✅ Return helpful error messages to users +✅ Log errors with context +✅ Use prepared statements for SQL +✅ Use environment variables for config +✅ Follow Clean Architecture strictly + +## Testing Requirements + +### Unit Tests (for each class): +- Test happy path +- Test error cases +- Mock all dependencies +- Test edge cases +- Aim for 80%+ coverage + +### Integration Tests: +- Test full API endpoints +- Test with real database (test DB) +- Test duplicate detection +- Test authentication +- Test error scenarios + +## Performance Requirements + +- API response time: <5 seconds (including Claude API) +- Duplicate detection: <50ms +- Database query optimization: use EXPLAIN +- Image optimization: resize large images +- Connection pooling: max 20 connections +- Memory: monitor with process.memoryUsage() + +## Documentation Requirements + +### JSDoc for public methods: +```javascript +/** + * Generate tags for an image + * @param {string} base64Image - Base64 encoded image + * @param {string} mediaType - MIME type (e.g., 'image/jpeg') + * @returns {Promise} Tagging result with tags and summary + * @throws {ValidationError} If inputs are invalid + * @throws {AIServiceError} If Claude API fails + */ +async generateTags(base64Image, mediaType) { + // Implementation +} +``` + +## Environment Variables Reference + +Required: +- ANTHROPIC_API_KEY - Claude API key +- DB_HOST, DB_USER, DB_PASSWORD - MySQL credentials + +Optional: +- NODE_ENV (default: development) +- PORT (default: 3000) +- SKIP_AUTH (default: false) +- LOG_LEVEL (default: info) + +## When Implementing New Features + +1. Start with Domain layer (entities, interfaces) +2. Add Application layer (use cases, DTOs) +3. Implement Infrastructure (repositories, providers) +4. Add Presentation (controllers, routes, middleware) +5. Update tests +6. Update documentation +7. Test end-to-end + +## Common Patterns + +### Dependency Container Pattern: +```javascript +class DependencyContainer { + constructor() { + this._services = new Map(); + this._initialize(); + } + + _initialize() { + // Register all services + this._services.set('serviceName', new Service()); + } + + get(serviceName) { + return this._services.get(serviceName); + } +} +``` + +### Repository Pattern: +```javascript +// Interface (Domain) +class IRepository { + async findById(id) { throw new Error('Not implemented'); } + async save(entity) { throw new Error('Not implemented'); } +} + +// Implementation (Infrastructure) +class MySQLRepository extends IRepository { + async findById(id) { /* MySQL implementation */ } + async save(entity) { /* MySQL implementation */ } +} +``` + +## API Endpoints Structure +``` +GET /api/images/health - Health check (public) +POST /api/images/tag - Tag uploaded image (auth required) +POST /api/images/tag-base64 - Tag base64 image (auth required) +GET /api/images/search - Search by tag (auth required) +GET /api/images/stats - Get statistics (auth required) +``` + +## Success Criteria + +Implementation is complete when: +- ✅ All layers properly separated +- ✅ Clean Architecture followed strictly +- ✅ Duplicate detection working (same image = cached result) +- ✅ All image formats supported +- ✅ Authentication working correctly +- ✅ Error handling comprehensive +- ✅ Database queries optimized with indexes +- ✅ Logging properly configured +- ✅ API responds within 5 seconds +- ✅ Tests passing +- ✅ Documentation complete + +## Quick Reference: Layer Responsibilities + +**Domain**: What the business does (entities, business rules) +**Application**: How the business operates (use cases, orchestration) +**Infrastructure**: Tools the business uses (database, AI, external services) +**Presentation**: How the business communicates (HTTP, controllers, routes) +**Shared**: Common tools for everyone (logger, errors, utilities) + +--- + +Remember: Clean Architecture is about making the code testable, maintainable, and independent of frameworks. The business logic should work even if you change Express to Fastify, or MySQL to PostgreSQL. +``` + +--- + +## 🎯 How to Use This File + +1. **Create `.cursorrules` file** in your project root (same level as `package.json`) +2. **Copy-paste** the entire content above into it +3. **Open your project in Cursor** +4. Cursor will automatically read and follow these rules +5. When you ask Cursor to implement features, it will follow these guidelines + +--- + +## 📋 Complete File Checklist for Cursor + +Now you have **6 essential files** to give Cursor: + +| File | Purpose | +|------|---------| +| `.cursorrules` | **Rules and guidelines for Cursor to follow** | +| `CURSOR_PROMPT.md` | Complete implementation instructions | +| `package.json` | Dependencies | +| `.env.example` | Configuration template | +| `001_initial_schema.sql` | Database schema | +| `.gitignore` | Files to ignore | + +--- + +## 🚀 Quick Start with Cursor + +1. **Create project folder**: `mkdir property-image-tagger && cd property-image-tagger` + +2. **Create the 6 files above** (copy-paste each content) + +3. **Open in Cursor**: `cursor .` + +4. **Tell Cursor**: +``` + Read CURSOR_PROMPT.md and .cursorrules, then implement the complete + Property Image Tagging REST API following Clean Architecture principles. + Start with the Domain layer and work your way up to Presentation. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fca09d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +logs/ +*.log +.DS_Store +.vscode/ +.idea/ +coverage/ +dist/ + diff --git a/001_initial_schema.sql b/001_initial_schema.sql new file mode 100644 index 0000000..10b0a61 --- /dev/null +++ b/001_initial_schema.sql @@ -0,0 +1,61 @@ +CREATE DATABASE IF NOT EXISTS property_tagging +CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE property_tagging; + +CREATE TABLE images ( + id CHAR(36) PRIMARY KEY, + file_name VARCHAR(255) NOT NULL, + original_name VARCHAR(255), + file_size INT UNSIGNED, + mime_type VARCHAR(50), + width INT UNSIGNED, + height INT UNSIGNED, + image_hash VARCHAR(64) UNIQUE NOT NULL, + s3_key VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_image_hash (image_hash), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE tagging_results ( + id CHAR(36) PRIMARY KEY, + image_id CHAR(36) NOT NULL, + tags JSON NOT NULL, + summary TEXT, + total_tags INT UNSIGNED, + model_version VARCHAR(50) DEFAULT 'claude-sonnet-4-20250514', + processing_time_ms INT UNSIGNED, + was_duplicate BOOLEAN DEFAULT FALSE, + tagged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, + INDEX idx_image_id (image_id), + INDEX idx_tagged_at (tagged_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE api_keys ( + id CHAR(36) PRIMARY KEY, + key_prefix VARCHAR(20) NOT NULL, + key_hash VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + environment ENUM('development', 'staging', 'production') DEFAULT 'development', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL, + revoked_at TIMESTAMP NULL, + revoked_reason TEXT, + INDEX idx_key_hash (key_hash), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO api_keys (id, key_prefix, key_hash, name, environment) +VALUES ( + UUID(), + 'key_test_', + SHA2('key_test_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 256), + 'Development Test Key', + 'development' +); diff --git a/CURSOR_PROMPT.md b/CURSOR_PROMPT.md new file mode 100644 index 0000000..b56ec66 --- /dev/null +++ b/CURSOR_PROMPT.md @@ -0,0 +1,570 @@ +# Property Image Tagging REST API - Cursor AI Implementation Guide + +## Project Overview +Build a production-ready REST API for automatic property image tagging using Claude AI (Anthropic). The system uses Node.js, Express, MySQL, and Clean Architecture patterns. + +## Core Requirements + +### 1. Technology Stack +- **Runtime**: Node.js 18+ +- **Framework**: Express.js +- **Database**: MySQL 8.0+ +- **AI Provider**: Anthropic Claude API (Claude Sonnet 4.5) +- **Architecture**: Clean Architecture (Domain, Application, Infrastructure, Presentation layers) +- **Image Processing**: Sharp library for format conversion and optimization +- **Authentication**: Simple API key system (no rate limiting, no usage tracking) + +### 2. Key Features +- Upload property images and get 20-30 AI-generated tags +- Duplicate detection using SHA256 image hashing (cache results to avoid re-tagging) +- Support multiple image formats (JPEG, PNG, WebP, HEIC, TIFF, BMP, GIF) +- Search images by tags using MySQL JSON queries +- Simple API key authentication (SHA256 hashed) +- RESTful API with Clean Architecture + +### 3. Architecture Principles +- **Clean Architecture**: Strict separation of Domain, Application, Infrastructure, Presentation +- **Dependency Injection**: Use container pattern for service management +- **Repository Pattern**: Abstract database operations +- **Use Case Pattern**: Each business operation is a separate use case class +- **DTO Pattern**: Data Transfer Objects for API boundaries + +## Project Structure + +Create this exact structure: +``` +src/ +├── domain/ +│ ├── entities/ +│ │ ├── ImageTag.js +│ │ └── TaggingResult.js +│ └── interfaces/ +│ ├── IImageTaggingService.js +│ └── IImageRepository.js +├── application/ +│ ├── dtos/ +│ │ ├── TagImageRequestDto.js +│ │ └── TagImageResponseDto.js +│ └── useCases/ +│ ├── TagImageUseCase.js +│ └── TagBase64ImageUseCase.js +├── infrastructure/ +│ ├── ai/ +│ │ └── ClaudeAIProvider.js +│ ├── repositories/ +│ │ ├── MySQLImageRepository.js +│ │ └── ApiKeyRepository.js +│ └── config/ +│ ├── dependencyContainer.js +│ └── corsConfig.js +├── presentation/ +│ ├── controllers/ +│ │ └── ImageTaggingController.js +│ ├── middleware/ +│ │ ├── errorHandler.js +│ │ ├── apiKeyAuth.js +│ │ └── requestId.js +│ ├── routes/ +│ │ └── imageRoutes.js +│ └── validators/ +│ └── imageValidator.js +├── shared/ +│ ├── errors/ +│ │ └── AppError.js +│ └── utils/ +│ ├── logger.js +│ ├── responseFormatter.js +│ └── apiKeyGenerator.js +└── server.js + +scripts/ +└── manage-api-keys.js + +migrations/ +└── 001_initial_schema.sql +``` + +## Database Schema + +### Table: images +```sql +CREATE TABLE images ( + id CHAR(36) PRIMARY KEY, + file_name VARCHAR(255) NOT NULL, + original_name VARCHAR(255), + file_size INT UNSIGNED, + mime_type VARCHAR(50), + width INT UNSIGNED, + height INT UNSIGNED, + image_hash VARCHAR(64) UNIQUE NOT NULL, + s3_key VARCHAR(500), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_image_hash (image_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### Table: tagging_results +```sql +CREATE TABLE tagging_results ( + id CHAR(36) PRIMARY KEY, + image_id CHAR(36) NOT NULL, + tags JSON NOT NULL, + summary TEXT, + total_tags INT UNSIGNED, + model_version VARCHAR(50) DEFAULT 'claude-sonnet-4-20250514', + processing_time_ms INT UNSIGNED, + was_duplicate BOOLEAN DEFAULT FALSE, + tagged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, + INDEX idx_image_id (image_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### Table: api_keys +```sql +CREATE TABLE api_keys ( + id CHAR(36) PRIMARY KEY, + key_prefix VARCHAR(20) NOT NULL, + key_hash VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + environment ENUM('development', 'staging', 'production') DEFAULT 'development', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NULL, + revoked_at TIMESTAMP NULL, + revoked_reason TEXT, + INDEX idx_key_hash (key_hash), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## Implementation Requirements + +### Domain Layer + +**ImageTag Entity:** +- Properties: category, value, confidence +- Method: isHighConfidence() - returns true if confidence >= 0.8 +- Method: toJSON() - serialize to plain object +- Validate category and value are not empty + +**TaggingResult Entity:** +- Properties: imageId, tags array, summary, createdAt +- Methods: + - getTagsByCategory(category) + - getHighConfidenceTags() + - getTotalTags() +- Validate tags array is not empty + +**Interfaces:** +- IImageTaggingService: generateTags(base64Image, mediaType) +- IImageRepository: save(taggingResult, imageBuffer), findById(imageId), findByImageHash(hash) + +### Application Layer + +**TagImageUseCase:** +1. Validate input (fileBuffer, mimeType required) +2. Calculate SHA256 hash of image buffer +3. Check database for existing hash +4. If duplicate → return cached result with isDuplicate: true, costSavings message +5. If new → convert to base64, call AI service +6. Create TaggingResult entity +7. Save to database with image buffer (for hash) +8. Return TagImageResponseDto + +**TagImageRequestDto:** +- fileBuffer (Buffer) +- mimeType (string) +- fileName (string) +- Method: toBase64() - convert buffer to base64 string + +**TagImageResponseDto:** +- imageId, tags, summary, totalTags, isDuplicate, cachedResult, processedAt +- Static method: fromTaggingResult(taggingResult) + +### Infrastructure Layer + +**ClaudeAIProvider:** +- Use @anthropic-ai/sdk npm package +- Model: claude-sonnet-4-20250514 +- Wrap API call with async-retry (3 retries, exponential backoff) +- Send this exact prompt: +``` +Analyze this property image and generate 20-30 descriptive tags categorized as follows: + +Tag Categories: +1. View: (e.g., Burj Khalifa view, ocean view, downtown skyline, marina view) +2. Furnishing: (e.g., fully furnished, unfurnished, modern, contemporary, luxury) +3. Kitchen: (e.g., with appliances, open kitchen, modular, closed kitchen) +4. Flooring: (e.g., wooden, marble, tile, carpet, laminate, porcelain) +5. Room Type: (e.g., bedroom, living room, bathroom, kitchen, balcony) +6. Style: (e.g., modern, traditional, scandinavian, industrial) +7. Features: (e.g., high ceiling, floor-to-ceiling windows, built-in wardrobes) +8. Condition: (e.g., newly renovated, well-maintained, ready to move) +9. Lighting: (e.g., natural light, ambient lighting, LED lighting) +10. Color Scheme: (e.g., neutral tones, warm colors, monochrome) + +Return ONLY a JSON object in this exact format: +{ + "tags": [ + {"category": "View", "value": "marina view", "confidence": 0.95}, + {"category": "Furnishing", "value": "fully furnished", "confidence": 0.90} + ], + "summary": "Brief one-sentence description" +} + +Include 20-30 tags total. +``` + +**MySQLImageRepository:** +- Use mysql2/promise with connection pool (max 20 connections) +- _calculateImageHash(buffer) - SHA256 hash using crypto +- findByImageHash(hash) - returns existing TaggingResult if found +- save(taggingResult, imageBuffer) - stores both image and tags in transaction + - Calculate hash + - Check for duplicate first + - If duplicate, return cached result + - Insert into images table + - Insert into tagging_results table + - Commit transaction +- findById(imageId) - returns TaggingResult +- findByTagValue(value) - use JSON_SEARCH for MySQL +- Support JSON queries using JSON_CONTAINS and JSON_SEARCH + +**ApiKeyRepository:** +- validateKey(apiKey) - hash key, check database, return key data if valid +- createKey(data) - generate secure key with ApiKeyGenerator, hash it, store +- revokeKey(keyId, reason) - set is_active=false +- getAllKeys() - list all keys + +### Presentation Layer + +**ImageValidator:** +- validateUpload(file) - async function + - Check file exists + - Check mime type in allowed list + - Use file-type npm package to verify actual type (magic number) + - Use Sharp to validate image and check dimensions (max 15000px) + - Max file size 50MB +- convertToClaudeSupportedFormat(buffer, mimeType) + - If already JPEG/PNG/WebP/GIF → optimize only + - If HEIC/TIFF/BMP → convert to JPEG + - Use Sharp for conversion +- optimizeForAI(buffer) - resize to max 2048px if larger + +**ApiKeyAuthMiddleware:** +- authenticate() middleware function +- Skip if SKIP_AUTH=true in development +- Extract key from X-API-Key header or Authorization: Bearer +- Validate key format: key_(test|live)_[64 hex chars] +- Call ApiKeyRepository.validateKey() +- If invalid → 401/403 with clear error +- If valid → attach req.apiKey = {id, name, environment} +- Log authentication + +**ImageTaggingController:** +- tagUploadedImage(req, res, next) + - Validate upload + - Convert format if needed + - Create TagImageRequestDto + - Execute TagImageUseCase + - Return formatted response +- tagBase64Image(req, res, next) + - Validate base64 input with Joi + - Execute TagBase64ImageUseCase + - Return formatted response +- searchByTag(req, res, next) + - Get tag value from query + - Call repository.findByTagValue() + - Return results +- getStats(req, res, next) + - Get statistics from repository + - Return stats +- getHealth(req, res) + - Check database connection + - Check memory usage + - Return health status + +**Routes:** +- GET /api/images/health - public +- POST /api/images/tag - requires auth, multipart upload +- POST /api/images/tag-base64 - requires auth, JSON body +- GET /api/images/search - requires auth +- GET /api/images/stats - requires auth + +### Shared Utilities + +**AppError Classes:** +```javascript +class AppError extends Error { + constructor(message, statusCode = 500, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + Error.captureStackTrace(this, this.constructor); + } +} + +class ValidationError extends AppError { + constructor(message) { super(message, 400); } +} + +class AIServiceError extends AppError { + constructor(message) { super(message, 503); } +} +``` + +**Logger (Winston):** +- Daily rotating file transport +- JSON formatting +- Separate error.log and combined.log +- Console output in development +- Methods: info(message, meta), error(message, error), warn(message, meta), debug(message, meta) + +**ApiKeyGenerator:** +- generate(prefix) - create secure 64-char hex key with prefix +- hash(apiKey) - SHA256 hash +- mask(apiKey) - show only first 13 and last 4 chars +- isValidFormat(apiKey) - validate pattern +- extractPrefix(apiKey) - get prefix part + +**ResponseFormatter:** +- success(data, message) - {success: true, message, data, timestamp} +- error(error, message) - {success: false, message, error, timestamp} + +### Server Setup (src/server.js) +```javascript +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const morgan = require('morgan'); + +// Import middleware and routes +const corsConfig = require('./infrastructure/config/corsConfig'); +const container = require('./infrastructure/config/dependencyContainer'); +const createImageRoutes = require('./presentation/routes/imageRoutes'); +const ImageTaggingController = require('./presentation/controllers/ImageTaggingController'); +const ApiKeyAuthMiddleware = require('./presentation/middleware/apiKeyAuth'); +const errorHandler = require('./presentation/middleware/errorHandler'); +const requestIdMiddleware = require('./presentation/middleware/requestId'); +const logger = require('./shared/utils/logger'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(helmet()); +app.use(compression()); +app.use(cors(corsConfig)); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(requestIdMiddleware); +app.use(morgan('combined')); + +// Root +app.get('/', (req, res) => { + res.json({ + service: 'Property Image Tagging API', + version: '1.0.0', + authentication: 'Simple API Key' + }); +}); + +// Dependency injection +const tagImageUseCase = container.get('tagImageUseCase'); +const tagBase64ImageUseCase = container.get('tagBase64ImageUseCase'); +const imageRepository = container.get('imageRepository'); +const apiKeyRepository = container.get('apiKeyRepository'); + +const imageController = new ImageTaggingController( + tagImageUseCase, + tagBase64ImageUseCase, + imageRepository, + logger +); + +const authMiddleware = new ApiKeyAuthMiddleware(apiKeyRepository, logger); + +// Routes +const imageRoutes = createImageRoutes(imageController, authMiddleware); +app.use('/api/images', imageRoutes); + +// Error handler (last) +app.use(errorHandler); + +// Graceful shutdown +const gracefulShutdown = async (signal) => { + logger.info(`${signal} received, shutting down`); + const pool = container.get('pool'); + await pool.end(); + process.exit(0); +}; + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +// Start +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + }); +} + +module.exports = app; +``` + +### CLI Tool (scripts/manage-api-keys.js) + +Create command-line tool with these commands: +- create [name] [environment] [description] - Generate new API key +- list - Display all keys +- revoke [reason] - Deactivate key +- activate - Reactivate key + +Use ApiKeyRepository methods. Show plain text key ONLY on creation. + +## Critical Requirements + +### MUST DO: +✅ Use connection pooling for MySQL (max 20) +✅ Use transactions for saving image + tags +✅ Implement retry logic for Claude API (3 retries) +✅ Validate JSON from Claude before parsing +✅ Hash API keys with SHA256 (never store plain text) +✅ Use prepared statements for SQL +✅ Optimize images before sending to Claude (max 2048px) +✅ Calculate image hash for duplicate detection +✅ Return helpful error messages +✅ Use async-retry package for Claude API calls +✅ Use file-type package for magic number validation +✅ Use Sharp for image processing +✅ Use Joi for input validation +✅ Use Winston for logging + +### MUST NOT DO: +❌ Store plain text API keys +❌ Trust file extensions alone +❌ Skip image hash calculation +❌ Return database errors to client +❌ Skip input validation +❌ Hardcode values (use env vars) + +## Expected API Response Examples + +### Successful New Image Tagging: +```json +{ + "success": true, + "message": "✅ New image tagged successfully", + "data": { + "imageId": "abc-123-def", + "tags": [ + {"category": "View", "value": "Burj Khalifa view", "confidence": 0.98}, + {"category": "Furnishing", "value": "fully furnished", "confidence": 0.95} + ], + "summary": "Modern luxury apartment with Burj Khalifa view", + "totalTags": 27, + "isDuplicate": false, + "processedAt": "2025-10-31T10:30:00.000Z" + } +} +``` + +### Duplicate Image Detection: +```json +{ + "success": true, + "message": "✅ Duplicate detected - returned cached tags (no cost)", + "data": { + "imageId": "abc-123-def", + "tags": [...], + "isDuplicate": true, + "cachedResult": true, + "costSavings": "This request was FREE - used cached result" + } +} +``` + +### Error Response: +```json +{ + "success": false, + "message": "API key required. Include X-API-Key header.", + "timestamp": "2025-10-31T10:30:00.000Z" +} +``` + +## Package Dependencies +```json +{ + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "async-retry": "^1.3.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "file-type": "^19.5.0", + "helmet": "^8.0.0", + "joi": "^17.13.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.11.5", + "sharp": "^0.33.5", + "uuid": "^11.0.3", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} +``` + +## Environment Variables + +Create .env file: +``` +NODE_ENV=development +PORT=3000 +ANTHROPIC_API_KEY=your_key_here +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=property_tagging +DB_USER=root +DB_PASSWORD=your_password +SKIP_AUTH=true +ALLOWED_ORIGIN=* +LOG_LEVEL=info +``` + +## Testing Checklist + +After implementation: +- [ ] Upload JPEG → verify tags returned +- [ ] Upload same image twice → verify duplicate detected +- [ ] Upload HEIC → verify converted and tagged +- [ ] Upload without API key → verify 401 +- [ ] Upload with invalid key → verify 403 +- [ ] Upload corrupted file → verify 400 +- [ ] Search by tag → verify results +- [ ] Check database has proper indexes +- [ ] Verify Claude API retry works +- [ ] Test graceful shutdown + +## Success Metrics + +- API responds within 5 seconds +- Duplicate detection works (same image = cached) +- All image formats supported +- Authentication blocks invalid keys +- Errors handled gracefully +- Database queries optimized +- Logs contain useful info + +Start implementation from Domain layer → Application → Infrastructure → Presentation. Follow Clean Architecture strictly. Implement comprehensive error handling at every layer. diff --git a/Property_Image_Tagging_API.postman_collection.json b/Property_Image_Tagging_API.postman_collection.json new file mode 100644 index 0000000..66e1623 --- /dev/null +++ b/Property_Image_Tagging_API.postman_collection.json @@ -0,0 +1,532 @@ +{ + "info": { + "_postman_id": "property-image-tagger-api", + "name": "Property Image Tagging API", + "description": "Complete API collection for Property Image Tagging REST API using Claude AI", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Public Endpoints", + "item": [ + { + "name": "Root - Service Info", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "" + ] + }, + "description": "Get service information" + }, + "response": [] + }, + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/images/health", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "health" + ] + }, + "description": "Check API and database health status" + }, + "response": [] + } + ], + "description": "Endpoints that don't require authentication" + }, + { + "name": "Image Tagging", + "item": [ + { + "name": "Tag Uploaded Image", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has success flag\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + "});", + "", + "pm.test(\"Response contains tags\", function () {", + " var jsonData = pm.response.json();", + " if (jsonData.success && jsonData.data) {", + " pm.expect(jsonData.data).to.have.property('tags');", + " pm.expect(jsonData.data.tags).to.be.an('array');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": [], + "description": "Upload an image file (JPEG, PNG, WebP, HEIC, TIFF, BMP, GIF)" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/images/tag", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag" + ] + }, + "description": "Tag a property image by uploading a file. Supports multiple formats and automatically detects duplicates." + }, + "response": [] + }, + { + "name": "Tag Base64 Image", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has success flag\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"base64Image\": \"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=\",\n \"mediaType\": \"image/jpeg\",\n \"fileName\": \"sample.jpg\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/images/tag-base64", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag-base64" + ] + }, + "description": "Tag a property image using base64 encoded data. Note: Replace the base64Image value with a real base64 encoded image." + }, + "response": [] + }, + { + "name": "Batch Tag Uploaded Images", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains batch results\", function () {", + " var jsonData = pm.response.json();", + " if (jsonData.success && jsonData.data) {", + " pm.expect(jsonData.data).to.have.property('total');", + " pm.expect(jsonData.data).to.have.property('results');", + " pm.expect(jsonData.data.results).to.be.an('array');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "images", + "type": "file", + "src": [], + "description": "Upload multiple images (up to 50)" + }, + { + "key": "images", + "type": "file", + "src": [], + "description": "Add more images as needed" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/images/tag/batch", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag", + "batch" + ] + }, + "description": "Tag multiple images in a single batch request. Maximum 50 images per request. All images are processed in parallel." + }, + "response": [] + }, + { + "name": "Batch Tag Base64 Images", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains batch results\", function () {", + " var jsonData = pm.response.json();", + " if (jsonData.success && jsonData.data) {", + " pm.expect(jsonData.data).to.have.property('total');", + " pm.expect(jsonData.data).to.have.property('succeeded');", + " pm.expect(jsonData.data).to.have.property('failed');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"images\": [\n {\n \"base64Image\": \"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=\",\n \"mediaType\": \"image/jpeg\",\n \"fileName\": \"image1.jpg\"\n },\n {\n \"base64Image\": \"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=\",\n \"mediaType\": \"image/jpeg\",\n \"fileName\": \"image2.jpg\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/images/tag-base64/batch", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag-base64", + "batch" + ] + }, + "description": "Tag multiple base64 encoded images in a single batch request. Maximum 50 images per request." + }, + "response": [] + } + ], + "description": "Endpoints for tagging property images" + }, + { + "name": "Search & Statistics", + "item": [ + { + "name": "Search by Tag", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains search results\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('success');", + " if (jsonData.data) {", + " pm.expect(jsonData.data).to.be.an('array');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/images/search?tag=marina view", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "search" + ], + "query": [ + { + "key": "tag", + "value": "marina view", + "description": "Tag value to search for" + } + ] + }, + "description": "Search for images by tag value. Returns all images that have been tagged with the specified tag." + }, + "response": [] + }, + { + "name": "Get Statistics", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains statistics\", function () {", + " var jsonData = pm.response.json();", + " if (jsonData.success && jsonData.data) {", + " pm.expect(jsonData.data).to.have.property('totalImages');", + " pm.expect(jsonData.data).to.have.property('totalTagged');", + " pm.expect(jsonData.data).to.have.property('totalDuplicates');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/images/stats", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "stats" + ] + }, + "description": "Get statistics about tagged images including total images, tagged count, duplicates detected, and average tags per image." + }, + "response": [] + } + ], + "description": "Endpoints for searching and getting statistics" + }, + { + "name": "Authentication Examples", + "item": [ + { + "name": "Missing API Key", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/images/tag", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag" + ] + }, + "description": "Example request without API key - should return 401" + }, + "response": [] + }, + { + "name": "Invalid API Key", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "invalid_key_here", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/images/tag", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "tag" + ] + }, + "description": "Example request with invalid API key - should return 403" + }, + "response": [] + }, + { + "name": "Using Authorization Header", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{apiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/images/stats", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "images", + "stats" + ] + }, + "description": "Alternative authentication using Authorization: Bearer header instead of X-API-Key" + }, + "response": [] + } + ], + "description": "Examples of authentication methods and error cases" + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000", + "type": "string" + }, + { + "key": "apiKey", + "value": "your_api_key_here", + "type": "string", + "description": "Replace with your actual API key. Get one using: npm run apikey:create" + } + ] +} + + + diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..16d8647 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,298 @@ +# 🚀 Quick Start - Property Image Tagging API + +## Current Status: ✅ RUNNING + +**URL:** http://localhost:3000 +**Mode:** Development (auto-reload enabled) +**Auth:** Disabled (SKIP_AUTH=true) +**Database:** Connected with 2 tagged images + +--- + +## 🧪 Test Right Now + +```bash +# Service information +curl http://localhost:3000/ + +# Health check +curl http://localhost:3000/api/images/health + +# Statistics +curl http://localhost:3000/api/images/stats + +# Search by tag +curl "http://localhost:3000/api/images/search?tag=modern" +``` + +--- + +## 📝 To Tag NEW Images + +### Step 1: Get Anthropic API Key + +1. Visit: https://console.anthropic.com/ +2. Sign up and create an API key +3. Copy your key (starts with `sk-ant-api-...`) + +### Step 2: Add to .env + +Edit the `.env` file in the project root: + +```env +ANTHROPIC_API_KEY=sk-ant-api-03-xxxxxxxxxxxxxxxxxxxx +``` + +### Step 3: Done! + +The server will auto-reload. Now you can tag images: + +```bash +# Upload and tag an image +curl -X POST http://localhost:3000/api/images/tag \ + -F "image=@/path/to/your/image.jpg" + +# Tag a base64 image +curl -X POST http://localhost:3000/api/images/tag-base64 \ + -H "Content-Type: application/json" \ + -d '{ + "base64Image": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "fileName": "property.jpg" + }' +``` + +--- + +## 🎯 Key Features + +### Automatic Duplicate Detection +- Same image = cached result (FREE, no API call) +- SHA256 hash-based deduplication + +### Smart Image Processing +- Supports: JPEG, PNG, WebP, HEIC, TIFF, BMP +- Auto-resize to 2048px (saves API costs) +- Magic number validation (security) + +### Rich Tagging +- 20-30 tags per image across categories: + - Room Type (kitchen, bedroom, etc.) + - Style (modern, traditional, etc.) + - Condition (well-maintained, renovated, etc.) + - Features (hardwood floors, granite counters, etc.) +- Includes AI-generated summary + +### Batch Processing +- Tag up to 10 images in one request +- Parallel processing for speed + +--- + +## 🛠️ Common Tasks + +### Start/Stop Server + +```bash +# Start in development mode (auto-reload) +npm run dev + +# Start in production mode +npm start + +# Stop server +Ctrl+C +``` + +### Database Management + +```bash +# Set up database (creates tables) +npm run db:setup + +# View logs +tail -f logs/combined-*.log +tail -f logs/error-*.log +``` + +### API Key Management + +```bash +# Create new API key +npm run apikey:create + +# List all API keys +npm run apikey:list + +# Revoke an API key +npm run apikey:revoke +``` + +### Enable Authentication + +Edit `.env`: +```env +SKIP_AUTH=false +``` + +Then use API keys in requests: +```bash +curl -X POST http://localhost:3000/api/images/tag \ + -H "X-API-Key: key_live_xxxxx" \ + -F "image=@photo.jpg" +``` + +--- + +## 📚 All API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | Service information | +| GET | `/api/images/health` | Health check | +| GET | `/api/images/stats` | Tagging statistics | +| POST | `/api/images/tag` | Tag uploaded image file | +| POST | `/api/images/tag-base64` | Tag base64-encoded image | +| POST | `/api/images/tag-batch` | Tag multiple files (up to 10) | +| POST | `/api/images/tag-batch-base64` | Tag multiple base64 images | +| GET | `/api/images/search?tag=value` | Search images by tag | + +--- + +## 📦 Use Postman for Easy Testing + +1. Open Postman +2. Import collection: `Property_Image_Tagging_API.postman_collection.json` +3. Test all endpoints with pre-configured requests + +--- + +## 🏗️ Project Architecture + +**Clean Architecture** - Separation of concerns: + +``` +src/ +├── domain/ # Core business logic (entities, interfaces) +├── application/ # Use cases (business workflows) +├── infrastructure/ # External services (database, AI) +├── presentation/ # HTTP layer (controllers, routes) +└── shared/ # Common utilities (logger, errors) +``` + +**Benefits:** +- Testable (mock any layer) +- Maintainable (clear separation) +- Swappable (change DB or AI provider easily) + +--- + +## ⚙️ Environment Variables Reference + +```env +# Server +NODE_ENV=development # or production +PORT=3000 # Server port + +# Database +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD= # Your MySQL password +DB_NAME=property_tagging + +# AI Provider (REQUIRED for tagging) +ANTHROPIC_API_KEY= # From console.anthropic.com + +# Development +SKIP_AUTH=true # Skip API key auth (dev only) +LOG_LEVEL=info # debug, info, warn, error +``` + +--- + +## 🔒 Security Features + +- ✅ SHA256 hashed API keys (never stored plain text) +- ✅ Magic number file type validation +- ✅ File size limits (50MB) +- ✅ Image dimension limits (15000px) +- ✅ Helmet.js security headers +- ✅ CORS protection +- ✅ SQL injection prevention (parameterized queries) +- ✅ Input validation (Joi schemas) + +--- + +## 📊 Response Format + +### Success Response +```json +{ + "success": true, + "message": "✅ New image tagged successfully", + "data": { + "imageId": 3, + "imageHash": "abc123...", + "tags": [ + { + "category": "room_type", + "value": "kitchen", + "confidence": "high" + } + ], + "summary": "Modern kitchen with...", + "totalTags": 28, + "isDuplicate": false + }, + "timestamp": "2025-11-03T10:30:00.000Z" +} +``` + +### Error Response +```json +{ + "success": false, + "message": "File size exceeds maximum allowed size", + "timestamp": "2025-11-03T10:30:00.000Z" +} +``` + +--- + +## 🐛 Troubleshooting + +### Server won't start +- Check MySQL is running: `systemctl status mysql` +- Verify `.env` credentials +- Check port 3000 is available + +### Can't tag images +- Verify `ANTHROPIC_API_KEY` is set in `.env` +- Check API key is valid at console.anthropic.com +- View logs: `tail -f logs/error-*.log` + +### Database connection error +- MySQL credentials in `.env` +- Run: `npm run db:setup` + +### API key authentication failing +- Set `SKIP_AUTH=true` in `.env` for development +- Or create API key: `npm run apikey:create` + +--- + +## 📖 Further Reading + +- **SETUP_GUIDE.md** - Complete setup instructions +- **CURSOR_PROMPT.md** - Full project specification +- **.cursorrules** - Development best practices +- **logs/** - Application logs for debugging + +--- + +## 🎉 You're All Set! + +The server is running at **http://localhost:3000** + +Add your Anthropic API key to start tagging images with AI! 🤖 + diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..1316e92 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,186 @@ +# 🚀 Quick Setup Guide + +## Prerequisites + +- Node.js 18+ installed +- MySQL 8.0+ running +- Anthropic Claude API key (get from https://console.anthropic.com/) + +## Step-by-Step Setup + +### 1. Configure Environment Variables + +Edit the `.env` file in the project root and fill in the required values: + +```bash +# Set your MySQL password (if root has a password) +DB_PASSWORD=your_mysql_root_password + +# Add your Anthropic API key (REQUIRED for image tagging) +ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxx +``` + +**Note:** If your MySQL root user doesn't have a password, leave `DB_PASSWORD` empty. + +### 2. Set Up the Database + +Run the database setup script: + +```bash +npm run db:setup +``` + +This will: +- Create the `property_tagging` database +- Import all required tables (api_keys, tagged_images, image_tags) +- Verify the setup + +### 3. Create an API Key (Optional) + +If you want to test with authentication (SKIP_AUTH=false), create an API key: + +```bash +npm run apikey:create +``` + +Save the generated API key for testing. + +### 4. Start the Server + +**Development mode (with auto-reload):** +```bash +npm run dev +``` + +**Production mode:** +```bash +npm start +``` + +The server will start on `http://localhost:3000` + +## Testing the API + +### Health Check (No Auth Required) + +```bash +curl http://localhost:3000/ +``` + +### Tag an Image (With SKIP_AUTH=true) + +```bash +curl -X POST http://localhost:3000/api/images/tag-base64 \ + -H "Content-Type: application/json" \ + -d '{ + "base64Image": "data:image/jpeg;base64,/9j/4AAQ...", + "fileName": "test.jpg" + }' +``` + +### Tag an Image (With API Key Authentication) + +```bash +curl -X POST http://localhost:3000/api/images/tag-base64 \ + -H "Content-Type: application/json" \ + -H "X-API-Key: key_live_xxxxxxxxxx" \ + -d '{ + "base64Image": "data:image/jpeg;base64,/9j/4AAQ...", + "fileName": "test.jpg" + }' +``` + +## Available Scripts + +| Command | Description | +|---------|-------------| +| `npm start` | Start the server in production mode | +| `npm run dev` | Start the server in development mode (with nodemon) | +| `npm run db:setup` | Set up the database and import schema | +| `npm run apikey:create` | Create a new API key | +| `npm run apikey:list` | List all API keys | +| `npm run apikey:revoke` | Revoke an API key | + +## API Endpoints + +- `GET /` - Health check and API information +- `GET /api/images/health` - Detailed health check +- `POST /api/images/tag` - Tag an uploaded image file +- `POST /api/images/tag-base64` - Tag a base64-encoded image +- `POST /api/images/tag-batch` - Tag multiple uploaded images +- `POST /api/images/tag-batch-base64` - Tag multiple base64 images +- `GET /api/images/search?tag=kitchen` - Search images by tag +- `GET /api/images/stats` - Get tagging statistics + +## Troubleshooting + +### MySQL Connection Error + +If you see "Access denied for user 'root'@'localhost'": + +1. Check your MySQL password in `.env` +2. Or try connecting with sudo: `sudo mysql` +3. Create a new MySQL user if needed: + +```sql +CREATE USER 'property_tagger'@'localhost' IDENTIFIED BY 'your_password'; +GRANT ALL PRIVILEGES ON property_tagging.* TO 'property_tagger'@'localhost'; +FLUSH PRIVILEGES; +``` + +Then update your `.env`: +``` +DB_USER=property_tagger +DB_PASSWORD=your_password +``` + +### Anthropic API Key Error + +If you see "ANTHROPIC_API_KEY not set": + +1. Sign up at https://console.anthropic.com/ +2. Create an API key +3. Add it to your `.env` file + +### Port Already in Use + +If port 3000 is already in use, change it in `.env`: +``` +PORT=3001 +``` + +## Development Mode + +For development with authentication disabled, set in `.env`: +``` +SKIP_AUTH=true +``` + +This allows testing without API keys. + +## Next Steps + +- Import the `Property_Image_Tagging_API.postman_collection.json` into Postman for easy API testing +- Check the logs in the `logs/` directory for debugging +- Review the code structure in the `src/` directory + +## Architecture + +This project follows Clean Architecture: + +``` +src/ +├── domain/ # Business entities and interfaces +├── application/ # Use cases and business logic +├── infrastructure/ # External services (DB, AI) +├── presentation/ # HTTP controllers and routes +└── shared/ # Common utilities +``` + +## Support + +For issues or questions, check the logs: +- `logs/combined-YYYY-MM-DD.log` - All logs +- `logs/error-YYYY-MM-DD.log` - Error logs only + + diff --git a/enhanced_property_prompt.md b/enhanced_property_prompt.md new file mode 100644 index 0000000..9b4c966 --- /dev/null +++ b/enhanced_property_prompt.md @@ -0,0 +1,670 @@ +# Enterprise Property Image Analysis System v3.0 + +## System Identity & Expertise +You are an elite real estate property analyst AI with comprehensive expertise in: +- **Architectural Photography Analysis**: Professional-grade image interpretation and spatial understanding +- **Interior Design Recognition**: Contemporary and classical design styles, furniture identification, spatial planning +- **Property Valuation Intelligence**: Feature recognition that impacts property value and marketability +- **Regional Market Knowledge**: Understanding of luxury property standards across global markets (Dubai, Mumbai, Singapore, London, NYC) + +Your core mission: Generate precise, actionable metadata from property images that drives listing performance, search relevance, and buyer engagement. + +--- + +## Analysis Objective & Output Requirements + +**Primary Goal**: Analyze property images and generate **exactly 30 high-confidence tags** across **10 standardized categories**, each with calibrated confidence scores that reflect detection certainty. + +**Quality Standards**: +- Precision over quantity: Every tag must be visually verifiable +- Enterprise-grade accuracy: >92% alignment with human expert assessment +- Search optimization: Tags must match common buyer search patterns +- No hallucination: Never infer elements that aren't visible or strongly evidenced + +--- + +## Systematic Analysis Framework (Chain-of-Thought Process) + +Execute this structured reasoning sequence before tag generation: + +### Phase 1: Scene Understanding (15 seconds) +**Primary Recognition** +- **Q1**: What is the dominant room type? (bedroom/living/kitchen/bathroom/exterior) +- **Q2**: What is the camera perspective? (wide-angle/corner/centered/detail shot) +- **Q3**: What is the overall quality tier? (luxury/premium/mid-range/budget/economy) +- **Q4**: What is the property age/condition? (brand new/modern/dated/renovated/original) + +**Visual Inventory** +- List 5-7 most prominent features in order of visual dominance +- Identify any unique or premium elements (views, materials, fixtures) +- Note architectural style indicators (ceiling height, window types, built-ins) + +### Phase 2: Category-Specific Deep Analysis + +#### 🌆 View Analysis (Target: 2-4 tags) +**Detection Protocol**: +1. Scan for windows, glass doors, or balcony access +2. Analyze exterior visibility: clear landmark/general vista/obstructed/none +3. Identify specific elements: water bodies, skyline, greenery, adjacent buildings + +**Tag Decision Tree**: +``` +Windows visible? + ├─ YES → Exterior clearly visible? + │ ├─ YES → Specific landmark visible? → Tag: "[landmark] view" (0.95-1.0) + │ │ └─ NO → General type visible? → Tag: "city view"/"ocean view" (0.85-0.95) + │ └─ NO → Tag: "limited view" or "building view" (0.70-0.80) + └─ NO → Skip View tags or use "interior view only" (0.75-0.85) +``` + +**Premium View Keywords**: Burj Khalifa view, Palm Jumeirah view, Dubai Marina view, ocean view, sea view, beachfront view, golf course view, park view, downtown skyline, city lights view, mountain view + +**Standard View Keywords**: partial view, garden view, pool view, courtyard view, street view, building view, unobstructed view + +**Confidence Calibration**: +- 0.95-1.0: Named landmark clearly visible and identifiable +- 0.85-0.94: View type unmistakable (ocean/city/garden) with clear visual evidence +- 0.75-0.84: View present but partially obstructed or at distance +- <0.75: Too ambiguous - omit tag + +--- + +#### 🛋️ Furnishing Analysis (Target: 3-4 tags) + +**Furnishing Status Assessment**: +``` +Furniture visible? + ├─ NO → Tag: "unfurnished" (0.95-1.0) + ├─ PARTIAL (1-3 items) → Tag: "semi-furnished" (0.85-0.92) + └─ FULL (complete room setup) → Tag: "fully furnished" (0.90-0.98) +``` + +**Style Classification Matrix**: +| Visual Indicators | Primary Tag | Secondary Tags | Confidence | +|------------------|-------------|----------------|------------| +| Clean lines, minimal decor, neutral palette | modern | contemporary, minimalist | 0.90-0.97 | +| Ornate details, rich fabrics, traditional wood | traditional | classic, elegant | 0.85-0.93 | +| Premium brands, designer pieces, high-end materials | luxury | premium, designer | 0.88-0.96 | +| Mix of old and new, eclectic | transitional | contemporary, mixed-style | 0.75-0.87 | +| Exposed elements, metal accents, raw materials | industrial | modern, loft-style | 0.82-0.91 | + +**Quality Tier Indicators**: +- **Luxury** (0.90+): Recognizable designer furniture, custom built-ins, premium upholstery, coordinated decor +- **Premium** (0.85+): High-quality furniture, cohesive styling, name-brand pieces +- **Standard** (0.75+): Functional furniture, basic coordination, mid-range quality +- **Budget** (0.70+): Basic furniture, minimal styling, entry-level pieces + +**Tag Examples**: fully furnished, unfurnished, semi-furnished, modern, contemporary, luxury, traditional, minimalist, designer, premium, custom furniture, eclectic, scandinavian, mid-century modern + +--- + +#### 🍳 Kitchen Analysis (Target: 2-4 tags) + +**Appliance Detection Protocol**: +1. Scan for visible appliances: refrigerator, oven/range, dishwasher, microwave, hood +2. Count visible appliances: 0 / 1-2 (basic) / 3+ (fully equipped) +3. Assess appliance quality: entry-level / mid-range / premium (stainless steel/integrated) + +**Layout Classification**: +``` +Kitchen Layout Decision Tree: +├─ Open to living/dining area? → "open kitchen" (0.92-0.98) +├─ Separated by wall/door? → "closed kitchen" (0.90-0.97) +├─ Central island visible? → "island kitchen" (0.93-0.99) +├─ Linear along one wall? → "galley kitchen" (0.88-0.95) +└─ L-shaped or U-shaped? → "modular kitchen" (0.85-0.92) +``` + +**Feature Recognition**: +- **Countertop Material**: granite, marble, quartz, laminate, butcher block +- **Cabinet Style**: shaker, flat-panel, glass-front, handleless, two-tone +- **Special Features**: breakfast bar, pantry, double sink, waterfall countertop, wine fridge + +**Tag Examples**: with appliances, fully equipped, without appliances, basic appliances, open kitchen, closed kitchen, island kitchen, galley kitchen, modular kitchen, modern appliances, premium appliances, European-style, breakfast bar, granite countertops, custom cabinetry + +**Confidence Rules**: +- "with appliances" requires 2+ visible appliances (0.90+) +- "fully equipped" requires 4+ appliances including major units (0.92+) +- Layout tags require clear visual confirmation of spatial arrangement (0.88+) + +--- + +#### 🏠 Flooring Analysis (Target: 2-3 tags) + +**Material Identification Strategy**: + +**Step 1: Visual Characteristics Analysis** +| Material | Key Visual Markers | Confidence Threshold | +|----------|-------------------|---------------------| +| **Marble** | Natural veining, high polish, cool color palette | 0.90+ | +| **Wooden/Hardwood** | Visible grain patterns, warm tones, plank seams | 0.88+ | +| **Tile/Ceramic** | Grout lines, uniform pattern, matte or glazed finish | 0.85+ | +| **Carpet** | Soft texture, no reflections, fabric appearance | 0.92+ | +| **Laminate** | Wood-look but synthetic, uniform pattern, moderate shine | 0.75+ | +| **Porcelain** | Large format, minimal grout, consistent appearance | 0.82+ | +| **Concrete** | Industrial look, seamless or minimal joints, gray tones | 0.80+ | + +**Step 2: Finish Assessment** +- **Polished**: Mirror-like reflections, high gloss (marble, porcelain) +- **Matte**: No reflections, flat appearance (some tile, carpet) +- **Textured**: Visible surface variation (wood grain, stone) +- **Glossy**: Moderate shine without mirror effect (glazed tile) + +**Step 3: Quality Indicators** +- **Premium**: Exotic wood species, book-matched marble, large-format porcelain +- **Standard**: Oak/maple hardwood, standard marble, regular ceramic tile +- **Budget**: Laminate, vinyl, basic carpet + +**Advanced Detection Techniques**: +``` +IF reflections visible AND veining patterns present: + → marble (0.90-0.97) +ELSE IF wood grain visible AND plank seams visible: + → IF grain is natural and varied: hardwood (0.88-0.95) + → IF grain is repetitive pattern: laminate (0.75-0.85) +ELSE IF grout lines in grid pattern: + → tile/ceramic (0.85-0.93) +``` + +**Tag Examples**: wooden, hardwood, engineered wood, parquet, marble, polished marble, tile, ceramic tile, porcelain, carpet, laminate, vinyl, concrete, polished, matte, textured, light wood, dark wood + +--- + +#### 🚪 Room Type Analysis (Target: 2-4 tags) + +**Primary Space Identification** (Confidence: 0.95-1.0) +- bedroom, living room, kitchen, bathroom, dining room, entrance/foyer, hallway + +**Specificity Enhancement** (Confidence: 0.85-0.95) +- master bedroom, guest bedroom, children's bedroom +- ensuite bathroom, powder room, guest bathroom +- formal dining, dining area +- study, home office, library + +**Secondary Spaces** (Confidence: 0.80-0.92) +- balcony, terrace, patio, rooftop +- walk-in closet, dressing room +- laundry room, utility room +- storage room, maid's room + +**Multi-Space Recognition**: +``` +IF multiple rooms visible: + 1. Tag primary room (largest/central) → 0.95-1.0 + 2. Tag secondary visible spaces → 0.85-0.92 + 3. Add "open-plan" or "connected spaces" to Features +``` + +**Decision Rules**: +- **Bedroom indicators**: Bed, nightstands, wardrobes, soft lighting +- **Living room indicators**: Sofa/seating area, TV/entertainment, coffee table, larger open space +- **Kitchen indicators**: Appliances, cabinets, sink, cooking surfaces +- **Bathroom indicators**: Sink/vanity, shower/tub, toilet, tiles + +**Tag Examples**: bedroom, master bedroom, living room, kitchen, bathroom, ensuite bathroom, dining room, balcony, study, walk-in closet, entrance, multi-purpose space + +--- + +#### 🎨 Style Analysis (Target: 3-4 tags) + +**Primary Style Classification Framework**: + +**Contemporary Styles**: +- **Modern** (0.88-0.96): Clean lines, minimal ornamentation, open spaces, neutral colors, innovative materials + - *Key markers*: Floor-to-ceiling windows, handleless cabinets, integrated appliances, geometric patterns + +- **Contemporary** (0.85-0.93): Current design trends, mix of materials, comfortable yet stylish, evolved modern + - *Key markers*: Mixed textures, statement lighting, curved furniture, bold accents + +- **Minimalist** (0.86-0.94): Extreme simplicity, "less is more", hidden storage, monochromatic palette + - *Key markers*: Sparse furnishing, concealed storage, neutral whites/grays, clean surfaces + +**Classic Styles**: +- **Traditional** (0.82-0.91): Timeless elegance, rich woods, ornate details, symmetry + - *Key markers*: Crown molding, chair rails, antique-style furniture, chandeliers + +- **Classical** (0.80-0.90): Formal, luxurious, European influences, refined details + - *Key markers*: Columns, arches, elaborate ceiling details, formal arrangements + +**Specialized Styles**: +- **Industrial** (0.83-0.92): Exposed elements, raw materials, urban loft aesthetic + - *Key markers*: Exposed brick/concrete/ductwork, metal fixtures, Edison bulbs + +- **Scandinavian** (0.81-0.90): Light woods, white walls, functional design, cozy (hygge) + - *Key markers*: Light wood floors, white/light gray walls, natural textiles, plants + +- **Mid-Century Modern** (0.79-0.88): 1950s-60s revival, organic curves, mixed materials + - *Key markers*: Tapered legs, sunburst patterns, teak wood, geometric shapes + +**Regional Styles**: +- **Arabic/Middle Eastern** (0.77-0.87): Intricate patterns, rich colors, ornate details +- **Mediterranean** (0.78-0.86): Warm colors, terracotta, arches, rustic elements +- **Asian-Inspired** (0.76-0.85): Zen aesthetics, natural materials, low furniture, minimalism + +**Style Confidence Matrix**: +``` +Single clear style indicators (3+) → Primary style: 0.88-0.96 +Mixed style elements → Multiple tags with: 0.80-0.88 +Ambiguous or neutral → Use "contemporary" or "modern": 0.75-0.83 +``` + +**Tag Examples**: modern, contemporary, minimalist, traditional, classical, luxury, industrial, scandinavian, mid-century modern, transitional, eclectic, mediterranean, art deco, bohemian, zen + +--- + +#### ⭐ Features Analysis (Target: 4-5 tags) + +**Feature Hierarchy & Valuation Impact**: + +**Tier 1 - High-Value Architectural Features** (Confidence: 0.90-0.99) +- floor-to-ceiling windows, floor-to-ceiling glass doors +- high ceiling (if visibly >3m/10ft) +- vaulted ceiling, coffered ceiling, tray ceiling +- exposed beams (wood or metal) +- architectural columns or arches + +**Tier 2 - Built-In & Storage Features** (Confidence: 0.85-0.95) +- built-in wardrobes, walk-in closet, dressing room +- built-in shelving, custom cabinetry +- entertainment center, media wall +- window seats, bay windows + +**Tier 3 - Premium Finishes & Details** (Confidence: 0.82-0.93) +- crown molding, ceiling molding +- wainscoting, wall paneling +- accent wall, feature wall +- fireplace (gas or electric visible) +- decorative columns + +**Tier 4 - Functional Features** (Confidence: 0.78-0.90) +- balcony access, terrace access +- ensuite bathroom (if bedroom visible) +- separate shower and tub +- double vanity, double sink +- smart home features (if visible: smart lighting, automated blinds) +- abundant storage space + +**Tier 5 - Material & Surface Features** (Confidence: 0.80-0.92) +- granite countertops, marble countertops, quartz countertops +- stainless steel appliances +- glass partitions, sliding glass doors +- marble walls, feature tile wall +- wooden accents, metal accents + +**Detection Best Practices**: +1. Prioritize clearly visible features over inferred ones +2. Use specific terms over general (e.g., "crown molding" not just "molding") +3. Combine related features appropriately (e.g., "high ceiling" + "floor-to-ceiling windows") +4. Avoid redundancy with other categories (don't repeat flooring materials here) + +**Tag Examples**: floor-to-ceiling windows, high ceiling, built-in wardrobes, walk-in closet, crown molding, accent wall, balcony access, ensuite bathroom, granite countertops, smart home features, fireplace, vaulted ceiling, exposed beams, bay windows, double vanity, glass partitions + +--- + +#### ✅ Condition Analysis (Target: 2-3 tags) + +**Condition Assessment Matrix**: + +**Renovation Status**: +``` +Visual Indicators Analysis: +├─ Brand new fixtures + modern finishes + no wear → "newly renovated" (0.90-0.97) +├─ Contemporary updates + good condition → "recently updated" (0.85-0.93) +├─ Mixed old/new elements → "partially renovated" (0.78-0.87) +├─ Dated finishes but clean → "original condition" (0.80-0.90) +└─ Visible wear/outdated → "needs renovation" (0.82-0.91) +``` + +**Maintenance Level**: +- **Pristine/Immaculate** (0.92-0.98): Spotless, showroom condition, no visible wear + - *Indicators*: Perfect surfaces, no scuffs, pristine walls, flawless finishes + +- **Well-Maintained** (0.88-0.95): Clean, good care, minor natural wear acceptable + - *Indicators*: Clean surfaces, good paint condition, functioning fixtures + +- **Average Maintenance** (0.75-0.85): Acceptable condition, some visible use + - *Indicators*: Some wear visible, functional but not pristine + +- **Requires Work** (0.80-0.90): Visible issues, outdated elements, needs attention + - *Indicators*: Dated fixtures, visible wear, repair needs apparent + +**Occupancy Readiness**: +- **Ready to Move / Move-In Ready** (0.90-0.97): Fully functional, no work required + - Must meet: Clean + Functional + Up-to-date + +- **Requires Cosmetic Work** (0.82-0.90): Functional but needs updates + +- **Needs Renovation** (0.85-0.93): Major work required before occupancy + +**Confidence Calibration Rules**: +``` +IF all visible elements are new/pristine: 0.93-0.97 +IF clear signs of recent work: 0.88-0.94 +IF condition is mixed: 0.80-0.87 +IF wear is obvious: 0.85-0.92 (for "needs work" tags) +``` + +**Tag Examples**: newly renovated, recently updated, well-maintained, pristine condition, ready to move, move-in ready, original condition, requires cosmetic work, needs renovation, immaculate, like-new, turnkey condition + +--- + +#### 💡 Lighting Analysis (Target: 3-4 tags) + +**Multi-Layer Lighting Assessment**: + +**Natural Lighting Evaluation**: +``` +Step 1: Window Assessment +├─ Large windows (floor-to-ceiling) → "abundant natural light" (0.92-0.98) +├─ Multiple standard windows → "natural light" (0.88-0.95) +├─ Small/few windows → "limited natural light" (0.82-0.90) +└─ No visible windows → Skip natural light tags + +Step 2: Light Quality +├─ Bright, even illumination → "bright" / "well-lit" (0.85-0.93) +├─ Soft, diffused light → "soft lighting" (0.80-0.88) +└─ Strong directional shadows → "directional light" (0.75-0.85) +``` + +**Artificial Lighting Detection**: + +**Fixture Types** (Confidence: 0.85-0.95 if clearly visible): +- **Recessed lighting**: Ceiling-mounted downlights, can lights, pot lights +- **Pendant lights**: Hanging fixtures over islands/tables/counters +- **Chandeliers**: Decorative hanging multi-light fixtures +- **Track lighting**: Adjustable directional lights on tracks +- **LED strip lighting**: Under-cabinet, cove, or accent strips +- **Wall sconces**: Wall-mounted decorative fixtures + +**Lighting Purposes** (Confidence: 0.78-0.90): +- **Ambient lighting**: General overall illumination (recessed, central fixtures) +- **Task lighting**: Focused work areas (under-cabinet, desk lamps, vanity lights) +- **Accent lighting**: Highlighting features (spotlights, picture lights, LED strips) +- **Mood lighting**: Decorative, dimmable, creating atmosphere + +**Lighting Quality Descriptors**: +- **Well-lit** (0.88-0.95): Adequate illumination for function and viewing +- **Bright** (0.85-0.93): High illumination level, energetic atmosphere +- **Warm lighting** (0.80-0.90): Yellow-toned, cozy illumination +- **Cool lighting** (0.80-0.90): White/blue-toned, modern/clinical +- **Layered lighting** (0.82-0.91): Multiple light sources/types visible + +**Advanced Detection Logic**: +``` +IF (large windows visible) AND (bright interior): + → "abundant natural light" (0.92-0.98) + → "well-lit" (0.88-0.94) + +IF (recessed lights visible) OR (modern ceiling fixtures): + → "ambient lighting" (0.85-0.92) + → "LED lighting" (0.82-0.90) + +IF (pendant lights over island/table): + → "pendant lights" (0.90-0.96) + → "task lighting" (0.83-0.90) +``` + +**Tag Examples**: natural light, abundant natural light, limited natural light, ambient lighting, LED lighting, recessed lighting, pendant lights, chandelier, track lighting, well-lit, bright, warm lighting, mood lighting, task lighting, accent lighting, layered lighting + +--- + +#### 🎨 Color Scheme Analysis (Target: 2-3 tags) + +**Color Analysis Protocol**: + +**Step 1: Dominant Color Identification** +Analyze the 3 most prominent colors across: +- Walls (largest surface area - highest weight) +- Flooring (second-largest surface - medium weight) +- Furniture & Accents (visual impact - lower weight) + +**Step 2: Palette Classification** + +**Neutral Palettes** (Confidence: 0.90-0.97): +- **Neutral tones**: White, beige, gray, taupe as dominant colors (60%+ of visible surfaces) + - Variations: "white and gray", "beige palette", "greige tones" +- **Monochrome**: Single color in various shades (all grays, all whites, all beiges) +- **Earth tones**: Browns, tans, warm beiges, terracotta, natural wood colors + +**Temperature-Based** (Confidence: 0.82-0.91): +- **Warm colors**: Reds, oranges, yellows, warm browns, gold accents (20%+ presence) +- **Cool colors**: Blues, greens, purples, cool grays, silver accents (20%+ presence) + +**Specific Color Palettes** (Confidence: 0.85-0.93): +- **Blue and white**: Coastal, Mediterranean, nautical themes +- **Black and white**: High contrast, modern, dramatic +- **White and wood**: Scandinavian, natural, warm minimalism +- **Gray and yellow**: Contemporary, cheerful, balanced + +**Intensity Classification** (Confidence: 0.80-0.90): +- **Bold colors**: High saturation, vibrant hues, statement colors +- **Pastel shades**: Soft, light, desaturated colors (mint, blush, sky blue) +- **Muted tones**: Low saturation, subdued, sophisticated colors +- **Vibrant**: Bright, energetic, high-impact colors + +**Color Scheme Decision Matrix**: +``` +IF 70%+ surfaces are white/beige/gray: + → "neutral tones" (0.92-0.97) + +IF warm wood tones prominent: + → "warm colors" (0.85-0.92) OR "earth tones" (0.88-0.94) + +IF bold accent colors visible (furniture/decor): + → Primary: "neutral tones" + Secondary: "[color] accents" (0.83-0.90) + +IF all shades of one color: + → "monochrome" (0.88-0.95) +``` + +**Tag Examples**: neutral tones, warm colors, cool colors, earth tones, monochrome, white and gray, beige palette, blue accents, wood tones, bold colors, pastel shades, muted tones, vibrant, black and white, blue and white, greige + +**Confidence Guidelines**: +- Dominant palette (60%+ of space): 0.90-0.97 +- Secondary color theme (30-60%): 0.85-0.92 +- Accent colors (10-30%): 0.80-0.88 +- Minor color presence (<10%): 0.75-0.82 or omit + +--- + +## Tag Distribution Strategy + +### Optimal Distribution Guidelines + +**Mandatory Minimums** (Must be satisfied): +- Each category: Minimum 1 tag +- Total tags: Exactly 30 + +**Recommended Distribution** (Adjust based on image content): +``` +High Visual Prominence Categories (3-4 tags each): +├─ Furnishing: 3-4 tags +├─ Style: 3-4 tags +├─ Features: 4-5 tags (most valuable for property differentiation) +└─ Lighting: 3-4 tags + +Medium Prominence Categories (2-3 tags each): +├─ Room Type: 2-4 tags +├─ Flooring: 2-3 tags +├─ Color Scheme: 2-3 tags +└─ View: 2-4 tags (if applicable) + +Standard Categories (2-3 tags each): +├─ Kitchen: 2-4 tags (if visible) +└─ Condition: 2-3 tags + +Total: 30 tags +``` + +**Dynamic Adjustment Rules**: +1. **If kitchen not visible**: Redistribute 2-3 tags to Features, Furnishing, or Style +2. **If no views available**: Compensate with additional Features or Room Type tags +3. **If image shows multiple rooms**: Increase Room Type tags (3-4), reduce others slightly +4. **If unfurnished space**: Prioritize Features, Flooring, Style architectural elements + +**Quality Over Quantity Balance**: +``` +PRIORITY 1: High-confidence tags (0.90+) = 15-20 tags +PRIORITY 2: Medium-confidence tags (0.80-0.89) = 8-12 tags +PRIORITY 3: Acceptable-confidence tags (0.70-0.79) = 2-3 tags +NEVER: Low-confidence tags (<0.70) = 0 tags +``` + +--- + +## Confidence Scoring System + +### Master Confidence Calibration Framework + +**Tier 1: Exceptional Certainty (0.95-1.0)** +- Element is the primary focus of the image +- Zero ambiguity in identification +- Multiple confirming visual markers present +- Professional expert would have 100% agreement + +*Examples*: +- Room type when furniture/fixtures clearly indicate function (0.98-1.0) +- Named landmark fully visible in view (0.97-1.0) +- Flooring material in close-up with texture visible (0.95-0.98) + +**Tier 2: High Confidence (0.85-0.94)** +- Element clearly visible with strong supporting evidence +- Minor ambiguity possible but unlikely +- 2-3 confirming indicators present +- 90-95% expert agreement expected + +*Examples*: +- Style classification with consistent design elements (0.88-0.93) +- Furnishing status when furniture clearly visible (0.90-0.95) +- Kitchen layout obvious from spatial arrangement (0.87-0.93) + +**Tier 3: Moderate Confidence (0.75-0.84)** +- Element reasonably inferable from visible evidence +- Some ambiguity or partial obstruction present +- 1-2 confirming indicators +- 75-85% expert agreement expected + +*Examples*: +- View type when partially obstructed (0.78-0.84) +- Flooring material at distance or under furniture (0.76-0.82) +- Condition assessment based on limited visible surfaces (0.77-0.83) + +**Tier 4: Acceptable Minimum (0.65-0.74)** +- Element suggested by context but not definitively confirmed +- Significant ambiguity or very limited visual information +- Educated inference based on partial evidence +- 65-75% expert agreement expected +- **Use sparingly** - only when necessary to reach 30-tag requirement + +*Examples*: +- Specific style when only generic modern elements visible (0.68-0.73) +- Feature presence inferred from partial view (0.70-0.75) + +**REJECTION ZONE (<0.65)** +- Too uncertain to include +- Would likely mislead buyers or search algorithms +- Insufficient visual evidence +- **Never include these tags** + +### Confidence Adjustment Factors + +**Increase Confidence (+0.03 to +0.08)**: +- Element appears in multiple areas of image +- Professional photography with good lighting +- Close-up or detailed view available +- Recent/modern property (easier style classification) +- Distinctive, unique features + +**Decrease Confidence (-0.05 to -0.15)**: +- Poor image quality, blur, or low resolution +- Extreme lighting (overexposed/underexposed) +- Long-distance view or small element in frame +- Partial obstruction or limited visibility +- Ambiguous or transitional styles + +**Context-Specific Rules**: +``` +IF image quality is poor: + REDUCE all confidence scores by 0.08-0.12 + +IF element is central focus: + INCREASE confidence by 0.05-0.08 + +IF multiple indicators confirm tag: + INCREASE confidence by 0.03-0.06 + +IF inference required (not directly visible): + REDUCE confidence by 0.10-0.15 + +IF expert might disagree: + REDUCE confidence to 0.75-0.82 range +``` + +--- + +## Cross-Category Validation & Consistency Checks + +### Logical Coherence Matrix + +Perform these validation checks after initial tag generation: + +**Rule 1: Furnishing-Style Alignment** +``` +IF "luxury" IN Furnishing: + THEN Style MUST include: "modern" OR "contemporary" OR "traditional" OR "classical" + AND Features SHOULD include premium elements + CONFIDENCE CHECK: All luxury indicators should be 0.88+ + +IF "minimalist" IN Style: + THEN Furnishing SHOULD NOT include: "traditional", "ornate", "eclectic" + IF conflict exists: REDUCE confidence of conflicting tag by 0.10 +``` + +**Rule 2: Condition-Feature Consistency** +``` +IF "newly renovated" IN Condition (confidence >0.90): + THEN expect: Modern fixtures, contemporary finishes + CHECK: Flooring, Lighting, Features should reflect newness + +IF "needs renovation" IN Condition: + THEN Features SHOULD NOT include: "newly installed", "modern appliances" (high confidence) + REDUCE confidence if modern features appear pristine +``` + +**Rule 3: Kitchen-Appliance Logic** +``` +IF "with appliances" IN Kitchen: + VERIFY: At least 2 appliances visible in image + IF NOT visible: REDUCE confidence to <0.75 or REMOVE tag + +IF "open kitchen" IN Kitchen: + VERIFY: Kitchen connects visibly to living/dining space + CHECK: Room Type should include "living room" or "dining area" or note "open-plan" +``` + +**Rule 4: Lighting-View Correlation** +``` +IF "abundant natural light" IN Lighting: + VERIFY: Large windows OR glass doors visible + EXPECT: View tags present (unless obstructed building view) + +IF NO windows visible: + ENSURE: No "natural light" tags above 0.75 confidence + FOCUS: Artificial lighting tags only +``` + +**Rule 5: Room Type-Feature Matching** +``` +IF "bedroom" IN Room Type: + EXPECT: "wardrobe" OR "closet" features if visible + FURNISHING: Bed-related furniture mentions + +IF "bathroom" IN Room Type: + EXPECT: Tile flooring (not carpet/wood with high confidence) + FEATURES: Vanity, shower, bathtub if visible +``` + +**Rule 6: Style Consistency Across Tags** +``` +IF "traditional" IN Style (confidence >0.85): + CHECK: Furn \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..11b37c7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2953 @@ +{ + "name": "property-image-tagger", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "property-image-tagger", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "async-retry": "^1.3.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "file-type": "^18.7.0", + "helmet": "^8.0.0", + "joi": "^17.13.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.11.5", + "sharp": "^0.33.5", + "uuid": "^11.0.3", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz", + "integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", + "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.0.1", + "color-string": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-convert": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", + "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/@so-ric/colorspace/node_modules/color-string": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", + "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/file-type": { + "version": "18.7.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", + "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/peek-readable": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", + "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", + "integrity": "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.1.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", + "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/winston": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", + "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1335718 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "property-image-tagger", + "version": "1.0.0", + "description": "REST API for automatic property image tagging using Claude AI", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "setup": "node scripts/interactive-setup.js", + "db:setup": "node scripts/setup-database.js", + "apikey:create": "node scripts/manage-api-keys.js create", + "apikey:list": "node scripts/manage-api-keys.js list", + "apikey:revoke": "node scripts/manage-api-keys.js revoke" + }, + "keywords": [ + "image-tagging", + "ai", + "claude", + "rest-api" + ], + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "async-retry": "^1.3.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "file-type": "^18.7.0", + "helmet": "^8.0.0", + "joi": "^17.13.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "mysql2": "^3.11.5", + "sharp": "^0.33.5", + "uuid": "^11.0.3", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/scripts/interactive-setup.js b/scripts/interactive-setup.js new file mode 100644 index 0000000..9fc863e --- /dev/null +++ b/scripts/interactive-setup.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +/** + * Interactive Setup Wizard + * Guides users through the complete setup process + */ + +const readline = require('readline'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +function question(query) { + return new Promise(resolve => rl.question(query, resolve)); +} + +function displayBanner() { + console.log('\n================================================'); + console.log('🚀 Property Image Tagging API - Setup Wizard'); + console.log('================================================\n'); +} + +async function checkEnvFile() { + const envPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(envPath)) { + console.log('✅ .env file found\n'); + return true; + } else { + console.log('❌ .env file not found\n'); + return false; + } +} + +async function setupEnvironment() { + console.log('📝 Environment Configuration\n'); + + const dbPassword = await question('Enter MySQL root password (press Enter if no password): '); + const anthropicKey = await question('Enter your Anthropic API key (or press Enter to skip): '); + const port = await question('Server port (default 3000): ') || '3000'; + const skipAuth = await question('Skip authentication for development? (y/n, default: y): '); + + const envContent = `# Environment Configuration +NODE_ENV=development +PORT=${port} + +# Database Configuration +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=${dbPassword} +DB_NAME=property_tagging + +# Claude AI Configuration (REQUIRED) +# Get your API key from: https://console.anthropic.com/ +ANTHROPIC_API_KEY=${anthropicKey} + +# Authentication (Optional - for development only) +# Set to true to skip API key authentication during development +SKIP_AUTH=${skipAuth.toLowerCase() !== 'n'} + +# Logging +LOG_LEVEL=info +`; + + const envPath = path.join(process.cwd(), '.env'); + fs.writeFileSync(envPath, envContent); + console.log('\n✅ .env file created\n'); +} + +async function setupDatabase() { + console.log('🗄️ Setting up database...\n'); + + try { + execSync('node scripts/setup-database.js', { stdio: 'inherit' }); + return true; + } catch (error) { + console.log('\n❌ Database setup failed\n'); + console.log('You can try setting it up manually later with: npm run db:setup\n'); + return false; + } +} + +async function createApiKey() { + const create = await question('\n🔑 Do you want to create an API key now? (y/n): '); + + if (create.toLowerCase() === 'y') { + try { + console.log('\n'); + execSync('node scripts/manage-api-keys.js create', { stdio: 'inherit' }); + console.log('\n✅ API key created! Save it securely.\n'); + } catch (error) { + console.log('\n❌ Failed to create API key\n'); + console.log('You can create one later with: npm run apikey:create\n'); + } + } +} + +async function displayNextSteps(dbSetup) { + console.log('\n================================================'); + console.log('✅ Setup Complete!'); + console.log('================================================\n'); + + console.log('Next steps:\n'); + + if (!dbSetup) { + console.log('⚠️ Database setup incomplete. Run:'); + console.log(' npm run db:setup\n'); + } + + console.log('🚀 Start the server:'); + console.log(' npm start (production mode)'); + console.log(' npm run dev (development mode with auto-reload)\n'); + + console.log('📚 Available commands:'); + console.log(' npm run db:setup - Set up database'); + console.log(' npm run apikey:create - Create API key'); + console.log(' npm run apikey:list - List API keys'); + console.log(' npm run apikey:revoke - Revoke API key\n'); + + console.log('📖 For more information, see SETUP_GUIDE.md\n'); + + console.log('🌐 The API will be available at: http://localhost:' + (process.env.PORT || '3000')); + console.log(''); +} + +async function main() { + try { + displayBanner(); + + const envExists = await checkEnvFile(); + + if (!envExists) { + const create = await question('Would you like to create it now? (y/n): '); + if (create.toLowerCase() === 'y') { + await setupEnvironment(); + } else { + console.log('\nSetup cancelled. Please create a .env file manually.\n'); + console.log('You can copy .env.example and fill in the values:\n'); + console.log(' cp .env.example .env\n'); + rl.close(); + return; + } + } else { + // Load existing .env + require('dotenv').config(); + + const update = await question('Would you like to update the .env configuration? (y/n): '); + if (update.toLowerCase() === 'y') { + await setupEnvironment(); + // Reload after update + require('dotenv').config(); + } + } + + const dbSetup = await setupDatabase(); + + if (dbSetup) { + await createApiKey(); + } + + await displayNextSteps(dbSetup); + + } catch (error) { + console.error('\n❌ Setup failed:', error.message); + console.error('\nPlease check the error and try again.\n'); + } finally { + rl.close(); + } +} + +// Run the wizard +main(); + diff --git a/scripts/manage-api-keys.js b/scripts/manage-api-keys.js new file mode 100644 index 0000000..ed1cc68 --- /dev/null +++ b/scripts/manage-api-keys.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +require('dotenv').config(); +const mysql = require('mysql2/promise'); +const ApiKeyRepository = require('../src/infrastructure/repositories/ApiKeyRepository'); +const logger = require('../src/shared/utils/logger'); + +/** + * CLI tool for managing API keys + */ +async function main() { + const command = process.argv[2]; + const args = process.argv.slice(3); + + // Create database connection pool + const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'property_tagging', + waitForConnections: true, + connectionLimit: 5 + }); + + const apiKeyRepository = new ApiKeyRepository(pool, logger); + + try { + switch (command) { + case 'create': + await handleCreate(apiKeyRepository, args); + break; + case 'list': + await handleList(apiKeyRepository); + break; + case 'revoke': + await handleRevoke(apiKeyRepository, args); + break; + case 'activate': + await handleActivate(apiKeyRepository, args); + break; + default: + console.log(` +Usage: node scripts/manage-api-keys.js [options] + +Commands: + create [environment] [description] Generate new API key + list List all API keys + revoke [reason] Revoke an API key + activate Activate a revoked API key + +Examples: + node scripts/manage-api-keys.js create "My App" production "Production API key" + node scripts/manage-api-keys.js list + node scripts/manage-api-keys.js revoke abc-123 "Security breach" + `); + process.exit(1); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } finally { + await pool.end(); + } +} + +async function handleCreate(repository, args) { + if (args.length < 1) { + throw new Error('Name is required: create [environment] [description]'); + } + + const name = args[0]; + const environment = args[1] || 'development'; + const description = args[2] || null; + + if (!['development', 'staging', 'production'].includes(environment)) { + throw new Error('Environment must be: development, staging, or production'); + } + + const result = await repository.createKey({ name, environment, description }); + + console.log('\n✅ API Key created successfully!\n'); + console.log('⚠️ IMPORTANT: Save this key now. It will NOT be shown again!\n'); + console.log(`Key ID: ${result.id}`); + console.log(`Name: ${result.name}`); + console.log(`Environment: ${result.environment}`); + console.log(`API Key: ${result.key}\n`); + console.log('---'); +} + +async function handleList(repository) { + const keys = await repository.getAllKeys(); + + if (keys.length === 0) { + console.log('No API keys found.'); + return; + } + + console.log('\nAPI Keys:\n'); + keys.forEach((key, index) => { + console.log(`${index + 1}. ${key.name}`); + console.log(` ID: ${key.id}`); + console.log(` Prefix: ${key.prefix}`); + console.log(` Environment: ${key.environment}`); + console.log(` Active: ${key.isActive ? '✅' : '❌'}`); + console.log(` Created: ${key.createdAt}`); + if (key.revokedAt) { + console.log(` Revoked: ${key.revokedAt}${key.revokedReason ? ` (${key.revokedReason})` : ''}`); + } + if (key.expiresAt) { + console.log(` Expires: ${key.expiresAt}`); + } + console.log(''); + }); +} + +async function handleRevoke(repository, args) { + if (args.length < 1) { + throw new Error('Key ID is required: revoke [reason]'); + } + + const keyId = args[0]; + const reason = args[1] || null; + + await repository.revokeKey(keyId, reason); + console.log(`✅ API key ${keyId} has been revoked.`); +} + +async function handleActivate(repository, args) { + if (args.length < 1) { + throw new Error('Key ID is required: activate '); + } + + const keyId = args[0]; + await repository.activateKey(keyId); + console.log(`✅ API key ${keyId} has been activated.`); +} + +// Run if called directly +if (require.main === module) { + main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + +module.exports = { main }; + + + diff --git a/scripts/setup-database.js b/scripts/setup-database.js new file mode 100644 index 0000000..8c51aee --- /dev/null +++ b/scripts/setup-database.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * Database Setup Script + * Creates the database and imports the schema + */ + +require('dotenv').config(); +const mysql = require('mysql2/promise'); +const fs = require('fs'); +const path = require('path'); + +async function setupDatabase() { + console.log('================================================'); + console.log('🗄️ Database Setup'); + console.log('================================================\n'); + + const config = { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + }; + + const dbName = process.env.DB_NAME || 'property_tagging'; + + let connection; + + try { + // Connect to MySQL (without selecting a database) + console.log(`📡 Connecting to MySQL at ${config.host}:${config.port}...`); + connection = await mysql.createConnection(config); + console.log('✅ Connected to MySQL\n'); + + // Create database if it doesn't exist + console.log(`📦 Creating database '${dbName}' if it doesn't exist...`); + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``); + console.log('✅ Database created/verified\n'); + + // Switch to the database + await connection.query(`USE \`${dbName}\``); + + // Check if tables already exist + const [tables] = await connection.query('SHOW TABLES'); + if (tables.length > 0) { + console.log('ℹ️ Tables already exist:'); + tables.forEach(table => { + console.log(` - ${Object.values(table)[0]}`); + }); + console.log('\n✅ Database is already set up!\n'); + } else { + // Import schema + console.log('📄 Importing schema from 001_initial_schema.sql...'); + const schemaPath = path.join(__dirname, '..', '001_initial_schema.sql'); + + if (!fs.existsSync(schemaPath)) { + throw new Error(`Schema file not found: ${schemaPath}`); + } + + const schema = fs.readFileSync(schemaPath, 'utf8'); + + // Split by semicolon and execute each statement + const statements = schema + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith('--')); + + for (const statement of statements) { + await connection.query(statement); + } + + console.log('✅ Schema imported successfully\n'); + + // Verify tables were created + const [newTables] = await connection.query('SHOW TABLES'); + console.log('📊 Created tables:'); + newTables.forEach(table => { + console.log(` - ${Object.values(table)[0]}`); + }); + console.log(''); + } + + console.log('================================================'); + console.log('✅ Database setup complete!'); + console.log('================================================\n'); + + } catch (error) { + console.error('❌ Database setup failed:'); + console.error(error.message); + console.error('\nPlease check:'); + console.error(' 1. MySQL is running: systemctl status mysql'); + console.error(' 2. Credentials in .env file are correct'); + console.error(' 3. MySQL user has CREATE DATABASE privileges\n'); + process.exit(1); + } finally { + if (connection) { + await connection.end(); + } + } +} + +// Run setup +setupDatabase(); + diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..7d4368b --- /dev/null +++ b/setup.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +echo "================================================" +echo "🚀 Property Image Tagging API - Quick Setup" +echo "================================================" +echo "" + +# Check if .env exists +if [ ! -f .env ]; then + echo "❌ .env file not found!" + echo "" + echo "Please create a .env file by copying .env.example:" + echo " cp .env.example .env" + echo "" + echo "Then edit .env and add:" + echo " 1. Your MySQL password (DB_PASSWORD)" + echo " 2. Your Anthropic API key (ANTHROPIC_API_KEY)" + echo "" + echo "Get your Anthropic API key from: https://console.anthropic.com/" + echo "" + exit 1 +fi + +echo "✅ .env file found" +echo "" + +# Load environment variables +export $(grep -v '^#' .env | xargs) + +# Check if database exists +echo "🔍 Checking database connection..." +mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" -e "SHOW DATABASES LIKE '${DB_NAME}';" 2>/dev/null | grep -q "${DB_NAME}" + +if [ $? -ne 0 ]; then + echo "❌ Database '${DB_NAME}' not found!" + echo "" + echo "Creating database and tables..." + + # Create database + mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" -e "CREATE DATABASE IF NOT EXISTS ${DB_NAME};" 2>/dev/null + + if [ $? -ne 0 ]; then + echo "❌ Failed to create database. Please check your MySQL credentials in .env" + exit 1 + fi + + # Import schema + mysql -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" < 001_initial_schema.sql 2>/dev/null + + if [ $? -eq 0 ]; then + echo "✅ Database created and schema imported successfully!" + else + echo "❌ Failed to import schema. Please check 001_initial_schema.sql" + exit 1 + fi +else + echo "✅ Database '${DB_NAME}' exists" +fi + +echo "" +echo "🔑 Creating a test API key..." +npm run apikey:create + +echo "" +echo "================================================" +echo "✅ Setup Complete!" +echo "================================================" +echo "" +echo "You can now:" +echo " • Start the server: npm start" +echo " • Development mode: npm run dev" +echo " • List API keys: npm run apikey:list" +echo "" +echo "The API will be available at: http://localhost:${PORT:-3000}" +echo "" + diff --git a/src/application/dtos/TagImageRequestDto.js b/src/application/dtos/TagImageRequestDto.js new file mode 100644 index 0000000..7368b3e --- /dev/null +++ b/src/application/dtos/TagImageRequestDto.js @@ -0,0 +1,29 @@ +/** + * Data Transfer Object for tag image request + */ +class TagImageRequestDto { + /** + * @param {Object} data - Request data + * @param {Buffer} data.fileBuffer - Image file buffer + * @param {string} data.mimeType - MIME type + * @param {string} [data.fileName] - Original file name + */ + constructor(data) { + this.fileBuffer = data.fileBuffer; + this.mimeType = data.mimeType; + this.fileName = data.fileName || 'unknown'; + } + + /** + * Convert buffer to base64 string + * @returns {string} Base64 encoded image + */ + toBase64() { + return this.fileBuffer.toString('base64'); + } +} + +module.exports = TagImageRequestDto; + + + diff --git a/src/application/dtos/TagImageResponseDto.js b/src/application/dtos/TagImageResponseDto.js new file mode 100644 index 0000000..435aac4 --- /dev/null +++ b/src/application/dtos/TagImageResponseDto.js @@ -0,0 +1,50 @@ +const TaggingResult = require('../../domain/entities/TaggingResult'); + +/** + * Data Transfer Object for tag image response + */ +class TagImageResponseDto { + /** + * @param {Object} data - Response data + * @param {string} data.imageId - Image ID + * @param {Array} data.tags - Tags array + * @param {string} data.summary - Summary + * @param {number} data.totalTags - Total tags count + * @param {boolean} data.isDuplicate - Whether result was cached + * @param {Date} data.processedAt - Processing timestamp + * @param {string} [data.costSavings] - Cost savings message for duplicates + */ + constructor(data) { + this.imageId = data.imageId; + this.tags = data.tags; + this.summary = data.summary; + this.totalTags = data.totalTags; + this.isDuplicate = data.isDuplicate || false; + this.processedAt = data.processedAt || new Date(); + this.costSavings = data.costSavings; + } + + /** + * Create DTO from TaggingResult entity + * @param {TaggingResult} taggingResult - Tagging result entity + * @param {boolean} isDuplicate - Whether result was cached + * @returns {TagImageResponseDto} + */ + static fromTaggingResult(taggingResult, isDuplicate = false) { + const json = taggingResult.toJSON(); + return new TagImageResponseDto({ + imageId: json.imageId, + tags: json.tags, + summary: json.summary, + totalTags: taggingResult.getTotalTags(), + isDuplicate, + processedAt: new Date(json.createdAt), + costSavings: isDuplicate ? 'This request was FREE - used cached result' : undefined + }); + } +} + +module.exports = TagImageResponseDto; + + + diff --git a/src/application/useCases/TagBase64ImageUseCase.js b/src/application/useCases/TagBase64ImageUseCase.js new file mode 100644 index 0000000..88a97cf --- /dev/null +++ b/src/application/useCases/TagBase64ImageUseCase.js @@ -0,0 +1,108 @@ +const { ValidationError } = require('../../shared/errors/AppError'); +const TaggingResult = require('../../domain/entities/TaggingResult'); +const TagImageResponseDto = require('../dtos/TagImageResponseDto'); +const crypto = require('crypto'); + +/** + * Use case for tagging base64 encoded images + */ +class TagBase64ImageUseCase { + /** + * @param {IImageRepository} imageRepository - Image repository + * @param {IImageTaggingService} aiService - AI tagging service + * @param {Object} logger - Logger instance + */ + constructor(imageRepository, aiService, logger) { + this.imageRepository = imageRepository; + this.aiService = aiService; + this.logger = logger; + } + + /** + * Execute the use case + * @param {string} base64Image - Base64 encoded image + * @param {string} mediaType - MIME type + * @param {string} [fileName] - Optional file name + * @returns {Promise} + */ + async execute(base64Image, mediaType, fileName = 'unknown') { + try { + this._validateInput(base64Image, mediaType); + + // Convert base64 to buffer + const imageBuffer = Buffer.from(base64Image, 'base64'); + + // Calculate SHA256 hash + const imageHash = this._calculateImageHash(imageBuffer); + + // Check for duplicate + const existingResult = await this.imageRepository.findByImageHash(imageHash); + if (existingResult) { + this.logger.info('Duplicate image detected (base64)', { hash: imageHash }); + return TagImageResponseDto.fromTaggingResult(existingResult, true); + } + + // Call AI service + const startTime = Date.now(); + const aiResult = await this.aiService.generateTags(base64Image, mediaType); + const processingTime = Date.now() - startTime; + + // Create TaggingResult entity + const taggingResult = new TaggingResult({ + imageId: this._generateId(), + tags: aiResult.tags, + summary: aiResult.summary || '', + createdAt: new Date() + }); + + // Save to repository + const savedResult = await this.imageRepository.save(taggingResult, imageBuffer); + + this.logger.info('Base64 image tagged successfully', { + imageId: savedResult.imageId, + totalTags: savedResult.getTotalTags(), + processingTime + }); + + return TagImageResponseDto.fromTaggingResult(savedResult, false); + } catch (error) { + this.logger.error('TagBase64ImageUseCase error', error); + throw error; + } + } + + /** + * Validate input + * @private + */ + _validateInput(base64Image, mediaType) { + if (!base64Image || typeof base64Image !== 'string' || base64Image.trim() === '') { + throw new ValidationError('Base64 image is required'); + } + if (!mediaType || typeof mediaType !== 'string') { + throw new ValidationError('Media type is required'); + } + } + + /** + * Calculate SHA256 hash of image buffer + * @private + */ + _calculateImageHash(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } + + /** + * Generate unique ID (UUID v4) + * @private + */ + _generateId() { + const { v4: uuidv4 } = require('uuid'); + return uuidv4(); + } +} + +module.exports = TagBase64ImageUseCase; + + + diff --git a/src/application/useCases/TagBatchBase64ImagesUseCase.js b/src/application/useCases/TagBatchBase64ImagesUseCase.js new file mode 100644 index 0000000..19f9006 --- /dev/null +++ b/src/application/useCases/TagBatchBase64ImagesUseCase.js @@ -0,0 +1,99 @@ +const { ValidationError } = require('../../shared/errors/AppError'); +const TagImageResponseDto = require('../dtos/TagImageResponseDto'); + +/** + * Use case for tagging multiple base64 images in batch + */ +class TagBatchBase64ImagesUseCase { + /** + * @param {TagBase64ImageUseCase} tagBase64ImageUseCase - Single base64 image tagging use case + * @param {Object} logger - Logger instance + */ + constructor(tagBase64ImageUseCase, logger) { + this.tagBase64ImageUseCase = tagBase64ImageUseCase; + this.logger = logger; + } + + /** + * Execute batch tagging for base64 images + * @param {Array} images - Array of {base64Image, mediaType, fileName} + * @returns {Promise} Array of response objects + */ + async execute(images) { + try { + this._validateInput(images); + + this.logger.info('Starting batch base64 image tagging', { count: images.length }); + + // Process all images in parallel + const results = await Promise.all( + images.map(async (image, index) => { + try { + const result = await this.tagBase64ImageUseCase.execute( + image.base64Image, + image.mediaType, + image.fileName + ); + return { + success: true, + index, + data: result + }; + } catch (error) { + this.logger.error(`Failed to tag base64 image at index ${index}`, error); + return { + success: false, + index, + error: error.message || 'Failed to tag image', + data: null + }; + } + }) + ); + + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + this.logger.info('Batch base64 tagging completed', { + total: images.length, + success: successCount, + failures: failureCount + }); + + return results; + } catch (error) { + this.logger.error('TagBatchBase64ImagesUseCase error', error); + throw error; + } + } + + /** + * Validate input + * @private + */ + _validateInput(images) { + if (!Array.isArray(images)) { + throw new ValidationError('Request must be an array of images'); + } + if (images.length === 0) { + throw new ValidationError('At least one image is required'); + } + if (images.length > 50) { + throw new ValidationError('Maximum 50 images allowed per batch request'); + } + + images.forEach((image, index) => { + if (!image.base64Image || typeof image.base64Image !== 'string') { + throw new ValidationError(`Image at index ${index}: base64Image is required`); + } + if (!image.mediaType || typeof image.mediaType !== 'string') { + throw new ValidationError(`Image at index ${index}: mediaType is required`); + } + }); + } +} + +module.exports = TagBatchBase64ImagesUseCase; + + + diff --git a/src/application/useCases/TagBatchImagesUseCase.js b/src/application/useCases/TagBatchImagesUseCase.js new file mode 100644 index 0000000..214aba4 --- /dev/null +++ b/src/application/useCases/TagBatchImagesUseCase.js @@ -0,0 +1,87 @@ +const { ValidationError } = require('../../shared/errors/AppError'); +const TagImageRequestDto = require('../dtos/TagImageRequestDto'); +const TagImageResponseDto = require('../dtos/TagImageResponseDto'); + +/** + * Use case for tagging multiple images in batch + */ +class TagBatchImagesUseCase { + /** + * @param {TagImageUseCase} tagImageUseCase - Single image tagging use case + * @param {Object} logger - Logger instance + */ + constructor(tagImageUseCase, logger) { + this.tagImageUseCase = tagImageUseCase; + this.logger = logger; + } + + /** + * Execute batch tagging + * @param {Array} requestDtos - Array of request DTOs + * @returns {Promise>} Array of response DTOs + */ + async execute(requestDtos) { + try { + this._validateInput(requestDtos); + + this.logger.info('Starting batch image tagging', { count: requestDtos.length }); + + // Process all images in parallel (or sequentially if preferred) + const results = await Promise.all( + requestDtos.map(async (requestDto, index) => { + try { + const result = await this.tagImageUseCase.execute(requestDto); + return { + success: true, + index, + data: result + }; + } catch (error) { + this.logger.error(`Failed to tag image at index ${index}`, error); + return { + success: false, + index, + error: error.message || 'Failed to tag image', + data: null + }; + } + }) + ); + + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + this.logger.info('Batch tagging completed', { + total: requestDtos.length, + success: successCount, + failures: failureCount + }); + + return results; + } catch (error) { + this.logger.error('TagBatchImagesUseCase error', error); + throw error; + } + } + + /** + * Validate input + * @private + */ + _validateInput(requestDtos) { + if (!Array.isArray(requestDtos)) { + throw new ValidationError('Request must be an array of images'); + } + if (requestDtos.length === 0) { + throw new ValidationError('At least one image is required'); + } + if (requestDtos.length > 50) { + throw new ValidationError('Maximum 50 images allowed per batch request'); + } + } +} + +module.exports = TagBatchImagesUseCase; + + + diff --git a/src/application/useCases/TagImageUseCase.js b/src/application/useCases/TagImageUseCase.js new file mode 100644 index 0000000..9e70f50 --- /dev/null +++ b/src/application/useCases/TagImageUseCase.js @@ -0,0 +1,109 @@ +const { ValidationError } = require('../../shared/errors/AppError'); +const TaggingResult = require('../../domain/entities/TaggingResult'); +const TagImageResponseDto = require('../dtos/TagImageResponseDto'); +const crypto = require('crypto'); + +/** + * Use case for tagging uploaded images + */ +class TagImageUseCase { + /** + * @param {IImageRepository} imageRepository - Image repository + * @param {IImageTaggingService} aiService - AI tagging service + * @param {Object} logger - Logger instance + */ + constructor(imageRepository, aiService, logger) { + this.imageRepository = imageRepository; + this.aiService = aiService; + this.logger = logger; + } + + /** + * Execute the use case + * @param {TagImageRequestDto} requestDto - Request DTO + * @returns {Promise} + */ + async execute(requestDto) { + try { + this._validateInput(requestDto); + + // Calculate SHA256 hash of image buffer + const imageHash = this._calculateImageHash(requestDto.fileBuffer); + + // Check for duplicate + const existingResult = await this.imageRepository.findByImageHash(imageHash); + if (existingResult) { + this.logger.info('Duplicate image detected', { hash: imageHash }); + return TagImageResponseDto.fromTaggingResult(existingResult, true); + } + + // Convert to base64 + const base64Image = requestDto.toBase64(); + + // Call AI service + const startTime = Date.now(); + const aiResult = await this.aiService.generateTags(base64Image, requestDto.mimeType); + const processingTime = Date.now() - startTime; + + // Create TaggingResult entity + const taggingResult = new TaggingResult({ + imageId: this._generateId(), + tags: aiResult.tags, + summary: aiResult.summary || '', + createdAt: new Date() + }); + + // Save to repository + const savedResult = await this.imageRepository.save(taggingResult, requestDto.fileBuffer); + + this.logger.info('Image tagged successfully', { + imageId: savedResult.imageId, + totalTags: savedResult.getTotalTags(), + processingTime + }); + + return TagImageResponseDto.fromTaggingResult(savedResult, false); + } catch (error) { + this.logger.error('TagImageUseCase error', error); + throw error; + } + } + + /** + * Validate input + * @private + */ + _validateInput(requestDto) { + if (!requestDto || !requestDto.fileBuffer) { + throw new ValidationError('File buffer is required'); + } + if (!Buffer.isBuffer(requestDto.fileBuffer)) { + throw new ValidationError('File buffer must be a Buffer instance'); + } + if (!requestDto.mimeType || typeof requestDto.mimeType !== 'string') { + throw new ValidationError('MIME type is required'); + } + } + + /** + * Calculate SHA256 hash of image buffer + * @private + */ + _calculateImageHash(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } + + /** + * Generate unique ID (UUID v4) + * @private + */ + _generateId() { + const { v4: uuidv4 } = require('uuid'); + return uuidv4(); + } +} + +module.exports = TagImageUseCase; + + + diff --git a/src/domain/entities/ImageTag.js b/src/domain/entities/ImageTag.js new file mode 100644 index 0000000..c887244 --- /dev/null +++ b/src/domain/entities/ImageTag.js @@ -0,0 +1,60 @@ +const { ValidationError } = require('../../shared/errors/AppError'); + +/** + * ImageTag entity - represents a single tag for an image + */ +class ImageTag { + /** + * @param {Object} data - Tag data + * @param {string} data.category - Tag category (e.g., "View", "Furnishing") + * @param {string} data.value - Tag value (e.g., "marina view", "fully furnished") + * @param {number} data.confidence - Confidence score (0-1) + */ + constructor(data) { + this._validateInput(data); + this.category = data.category; + this.value = data.value; + this.confidence = data.confidence; + } + + /** + * Validate input data + * @private + */ + _validateInput(data) { + if (!data.category || typeof data.category !== 'string' || data.category.trim() === '') { + throw new ValidationError('Category is required and must be a non-empty string'); + } + if (!data.value || typeof data.value !== 'string' || data.value.trim() === '') { + throw new ValidationError('Value is required and must be a non-empty string'); + } + if (typeof data.confidence !== 'number' || data.confidence < 0 || data.confidence > 1) { + throw new ValidationError('Confidence must be a number between 0 and 1'); + } + } + + /** + * Check if tag has high confidence (>= 0.8) + * @returns {boolean} + */ + isHighConfidence() { + return this.confidence >= 0.8; + } + + /** + * Serialize to JSON + * @returns {Object} + */ + toJSON() { + return { + category: this.category, + value: this.value, + confidence: this.confidence + }; + } +} + +module.exports = ImageTag; + + + diff --git a/src/domain/entities/TaggingResult.js b/src/domain/entities/TaggingResult.js new file mode 100644 index 0000000..085d12c --- /dev/null +++ b/src/domain/entities/TaggingResult.js @@ -0,0 +1,78 @@ +const { ValidationError } = require('../../shared/errors/AppError'); +const ImageTag = require('./ImageTag'); + +/** + * TaggingResult entity - represents the complete tagging result for an image + */ +class TaggingResult { + /** + * @param {Object} data - Tagging result data + * @param {string} data.imageId - Image ID + * @param {Array} data.tags - Array of tag objects + * @param {string} data.summary - Summary description + * @param {Date} [data.createdAt] - Creation timestamp + */ + constructor(data) { + this._validateInput(data); + this.imageId = data.imageId; + this.tags = data.tags.map(tag => new ImageTag(tag)); + this.summary = data.summary || ''; + this.createdAt = data.createdAt || new Date(); + } + + /** + * Validate input data + * @private + */ + _validateInput(data) { + if (!data.imageId || typeof data.imageId !== 'string') { + throw new ValidationError('ImageId is required and must be a string'); + } + if (!Array.isArray(data.tags) || data.tags.length === 0) { + throw new ValidationError('Tags array is required and must not be empty'); + } + } + + /** + * Get tags filtered by category + * @param {string} category - Category to filter by + * @returns {Array} + */ + getTagsByCategory(category) { + return this.tags.filter(tag => tag.category === category); + } + + /** + * Get only high confidence tags (>= 0.8) + * @returns {Array} + */ + getHighConfidenceTags() { + return this.tags.filter(tag => tag.isHighConfidence()); + } + + /** + * Get total number of tags + * @returns {number} + */ + getTotalTags() { + return this.tags.length; + } + + /** + * Serialize to JSON + * @returns {Object} + */ + toJSON() { + return { + imageId: this.imageId, + tags: this.tags.map(tag => tag.toJSON()), + summary: this.summary, + createdAt: this.createdAt.toISOString() + }; + } +} + +module.exports = TaggingResult; + + + diff --git a/src/domain/interfaces/IImageRepository.js b/src/domain/interfaces/IImageRepository.js new file mode 100644 index 0000000..e4563f6 --- /dev/null +++ b/src/domain/interfaces/IImageRepository.js @@ -0,0 +1,55 @@ +/** + * Interface for image repository + * This is an abstract interface that must be implemented by infrastructure layer + */ +class IImageRepository { + /** + * Save tagging result and image data + * @param {TaggingResult} taggingResult - Tagging result entity + * @param {Buffer} imageBuffer - Image buffer + * @returns {Promise} + */ + async save(taggingResult, imageBuffer) { + throw new Error('save() must be implemented'); + } + + /** + * Find tagging result by image ID + * @param {string} imageId - Image ID + * @returns {Promise} + */ + async findById(imageId) { + throw new Error('findById() must be implemented'); + } + + /** + * Find tagging result by image hash (for duplicate detection) + * @param {string} hash - SHA256 hash of image + * @returns {Promise} + */ + async findByImageHash(hash) { + throw new Error('findByImageHash() must be implemented'); + } + + /** + * Search images by tag value + * @param {string} tagValue - Tag value to search for + * @returns {Promise>} + */ + async findByTagValue(tagValue) { + throw new Error('findByTagValue() must be implemented'); + } + + /** + * Get statistics about tagged images + * @returns {Promise} Statistics object + */ + async getStats() { + throw new Error('getStats() must be implemented'); + } +} + +module.exports = IImageRepository; + + + diff --git a/src/domain/interfaces/IImageTaggingService.js b/src/domain/interfaces/IImageTaggingService.js new file mode 100644 index 0000000..a6b32c6 --- /dev/null +++ b/src/domain/interfaces/IImageTaggingService.js @@ -0,0 +1,22 @@ +/** + * Interface for image tagging service + * This is an abstract interface that must be implemented by infrastructure layer + */ +class IImageTaggingService { + /** + * Generate tags for an image + * @param {string} base64Image - Base64 encoded image + * @param {string} mediaType - MIME type (e.g., 'image/jpeg') + * @returns {Promise} Tagging result with tags and summary + * @throws {ValidationError} If inputs are invalid + * @throws {AIServiceError} If AI service fails + */ + async generateTags(base64Image, mediaType) { + throw new Error('generateTags() must be implemented'); + } +} + +module.exports = IImageTaggingService; + + + diff --git a/src/infrastructure/ai/ClaudeAIProvider.js b/src/infrastructure/ai/ClaudeAIProvider.js new file mode 100644 index 0000000..148ceb8 --- /dev/null +++ b/src/infrastructure/ai/ClaudeAIProvider.js @@ -0,0 +1,213 @@ +const Anthropic = require('@anthropic-ai/sdk'); +const retry = require('async-retry'); +const IImageTaggingService = require('../../domain/interfaces/IImageTaggingService'); +const { AIServiceError, ValidationError } = require('../../shared/errors/AppError'); + +/** + * Claude AI Provider - implements IImageTaggingService + */ +class ClaudeAIProvider extends IImageTaggingService { + /** + * @param {string} apiKey - Anthropic API key + * @param {Object} logger - Logger instance + */ + constructor(apiKey, logger) { + super(); + if (!apiKey) { + throw new ValidationError('Anthropic API key is required'); + } + this.client = new Anthropic({ apiKey }); + this.logger = logger; + this.model = 'claude-sonnet-4-20250514'; + } + + /** + * Generate tags for an image using Claude AI + * @param {string} base64Image - Base64 encoded image + * @param {string} mediaType - MIME type (e.g., 'image/jpeg') + * @returns {Promise} Tagging result with tags and summary + * @throws {ValidationError} If inputs are invalid + * @throws {AIServiceError} If Claude API fails + */ + async generateTags(base64Image, mediaType) { + try { + this._validateInput(base64Image, mediaType); + + const prompt = this._buildPrompt(); + + const response = await retry( + async () => { + const message = await this.client.messages.create({ + model: this.model, + max_tokens: 4096, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Image + } + }, + { + type: 'text', + text: prompt + } + ] + } + ] + }); + + if (!message || !message.content || message.content.length === 0) { + throw new AIServiceError('Empty response from Claude API'); + } + + // Extract text from response + const textContent = message.content.find(block => block.type === 'text'); + if (!textContent || !textContent.text) { + throw new AIServiceError('No text content in Claude API response'); + } + + return textContent.text; + }, + { + retries: 3, + factor: 2, + minTimeout: 1000, + maxTimeout: 10000, + onRetry: (error, attempt) => { + this.logger.warn(`Claude API retry attempt ${attempt}`, { error: error.message }); + } + } + ); + + // Parse and validate JSON response + const result = this._parseResponse(response); + + this.logger.info('Claude API tags generated', { + totalTags: result.tags.length, + hasSummary: !!result.summary + }); + + return result; + } catch (error) { + if (error instanceof AIServiceError || error instanceof ValidationError) { + throw error; + } + + this.logger.error('Claude API error', error); + throw new AIServiceError(`Failed to generate tags: ${error.message}`); + } + } + + /** + * Build the prompt for Claude AI + * @private + */ + _buildPrompt() { + return `You are an expert real estate property analyst AI with specialized training in architectural photography, interior design, and property valuation. Your task is to analyze property images with professional-grade accuracy and generate structured metadata for real estate listing systems. + +Core Objective +Analyze the provided property image and generate 30 precise, descriptive tags across 10 predefined categories with confidence scores, following enterprise data quality standards. + +Tag Categories: +1. View: (e.g., Burj Khalifa view, ocean view, downtown skyline, marina view etc.) +2. Furnishing: (e.g., fully furnished, unfurnished, modern, contemporary, luxury) +3. Kitchen: (e.g., with appliances, open kitchen, modular, closed kitchen) +4. Flooring: (e.g., wooden, marble, tile, carpet, laminate, porcelain) +5. Room Type: (e.g., bedroom, living room, bathroom, kitchen, balcony) +6. Style: (e.g., modern, traditional, scandinavian, industrial) +7. Features: (e.g., high ceiling, floor-to-ceiling windows, built-in wardrobes) +8. Condition: (e.g., newly renovated, well-maintained, ready to move) +9. Lighting: (e.g., natural light, ambient lighting, LED lighting) +10. Color Scheme: (e.g., neutral tones, warm colors, monochrome) + +Return ONLY a JSON object in this exact format: +{ + "tags": [ + {"category": "View", "value": "marina view", "confidence": 0.95}, + {"category": "Furnishing", "value": "fully furnished", "confidence": 0.90} + ], + "summary": "Brief one-sentence description" +} + +`; + } + + /** + * Parse and validate JSON response from Claude + * @private + */ + _parseResponse(responseText) { + try { + // Try to extract JSON from response (may have markdown code blocks) + let jsonText = responseText.trim(); + + // Remove markdown code blocks if present + const jsonMatch = jsonText.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/); + if (jsonMatch) { + jsonText = jsonMatch[1]; + } else { + // Try to find JSON object directly + const jsonObjectMatch = jsonText.match(/\{[\s\S]*\}/); + if (jsonObjectMatch) { + jsonText = jsonObjectMatch[0]; + } + } + + const parsed = JSON.parse(jsonText); + + // Validate structure + if (!parsed.tags || !Array.isArray(parsed.tags)) { + throw new AIServiceError('Invalid response: tags array is required'); + } + + if (parsed.tags.length < 20) { + this.logger.warn('Claude returned fewer tags than expected', { count: parsed.tags.length }); + } + + // Validate each tag + parsed.tags.forEach((tag, index) => { + if (!tag.category || !tag.value || typeof tag.confidence !== 'number') { + throw new AIServiceError(`Invalid tag at index ${index}: missing required fields`); + } + }); + + return { + tags: parsed.tags, + summary: parsed.summary || '' + }; + } catch (error) { + if (error instanceof AIServiceError) { + throw error; + } + this.logger.error('Failed to parse Claude response', { response: responseText, error: error.message }); + throw new AIServiceError(`Failed to parse Claude API response: ${error.message}`); + } + } + + /** + * Validate input + * @private + */ + _validateInput(base64Image, mediaType) { + if (!base64Image || typeof base64Image !== 'string' || base64Image.trim() === '') { + throw new ValidationError('Base64 image is required'); + } + if (!mediaType || typeof mediaType !== 'string') { + throw new ValidationError('Media type is required'); + } + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(mediaType.toLowerCase())) { + throw new ValidationError(`Unsupported media type: ${mediaType}`); + } + } +} + +module.exports = ClaudeAIProvider; + + + diff --git a/src/infrastructure/config/corsConfig.js b/src/infrastructure/config/corsConfig.js new file mode 100644 index 0000000..b465c67 --- /dev/null +++ b/src/infrastructure/config/corsConfig.js @@ -0,0 +1,14 @@ +/** + * CORS configuration + */ +const corsConfig = { + origin: process.env.ALLOWED_ORIGIN || '*', + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], + credentials: true +}; + +module.exports = corsConfig; + + + diff --git a/src/infrastructure/config/dependencyContainer.js b/src/infrastructure/config/dependencyContainer.js new file mode 100644 index 0000000..f7e142f --- /dev/null +++ b/src/infrastructure/config/dependencyContainer.js @@ -0,0 +1,94 @@ +const mysql = require('mysql2/promise'); +const ClaudeAIProvider = require('../ai/ClaudeAIProvider'); +const MySQLImageRepository = require('../repositories/MySQLImageRepository'); +const ApiKeyRepository = require('../repositories/ApiKeyRepository'); +const TagImageUseCase = require('../../application/useCases/TagImageUseCase'); +const TagBase64ImageUseCase = require('../../application/useCases/TagBase64ImageUseCase'); +const TagBatchImagesUseCase = require('../../application/useCases/TagBatchImagesUseCase'); +const TagBatchBase64ImagesUseCase = require('../../application/useCases/TagBatchBase64ImagesUseCase'); +const logger = require('../../shared/utils/logger'); + +/** + * Dependency Injection Container + */ +class DependencyContainer { + constructor() { + this._services = new Map(); + this._initialize(); + } + + /** + * Initialize all services + * @private + */ + _initialize() { + // Database connection pool + const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'property_tagging', + waitForConnections: true, + connectionLimit: 20, + queueLimit: 0, + enableKeepAlive: true, + keepAliveInitialDelay: 0 + }); + + this._services.set('pool', pool); + + // Repositories + const imageRepository = new MySQLImageRepository(pool, logger); + const apiKeyRepository = new ApiKeyRepository(pool, logger); + this._services.set('imageRepository', imageRepository); + this._services.set('apiKeyRepository', apiKeyRepository); + + // AI Provider + if (!process.env.ANTHROPIC_API_KEY) { + logger.warn('ANTHROPIC_API_KEY not set. Claude AI provider will not work.'); + } + const aiProvider = new ClaudeAIProvider(process.env.ANTHROPIC_API_KEY, logger); + this._services.set('aiProvider', aiProvider); + + // Use Cases + const tagImageUseCase = new TagImageUseCase(imageRepository, aiProvider, logger); + const tagBase64ImageUseCase = new TagBase64ImageUseCase(imageRepository, aiProvider, logger); + const tagBatchImagesUseCase = new TagBatchImagesUseCase(tagImageUseCase, logger); + const tagBatchBase64ImagesUseCase = new TagBatchBase64ImagesUseCase(tagBase64ImageUseCase, logger); + this._services.set('tagImageUseCase', tagImageUseCase); + this._services.set('tagBase64ImageUseCase', tagBase64ImageUseCase); + this._services.set('tagBatchImagesUseCase', tagBatchImagesUseCase); + this._services.set('tagBatchBase64ImagesUseCase', tagBatchBase64ImagesUseCase); + } + + /** + * Get service by name + * @param {string} serviceName - Service name + * @returns {*} Service instance + */ + get(serviceName) { + const service = this._services.get(serviceName); + if (!service) { + throw new Error(`Service '${serviceName}' not found`); + } + return service; + } + + /** + * Close all connections (graceful shutdown) + */ + async close() { + const pool = this._services.get('pool'); + if (pool) { + await pool.end(); + logger.info('Database connection pool closed'); + } + } +} + +// Singleton instance +const container = new DependencyContainer(); + +module.exports = container; + diff --git a/src/infrastructure/repositories/ApiKeyRepository.js b/src/infrastructure/repositories/ApiKeyRepository.js new file mode 100644 index 0000000..c3e94a7 --- /dev/null +++ b/src/infrastructure/repositories/ApiKeyRepository.js @@ -0,0 +1,223 @@ +const ApiKeyGenerator = require('../../shared/utils/apiKeyGenerator'); +const { AuthenticationError, AuthorizationError, ValidationError } = require('../../shared/errors/AppError'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Repository for API key management + */ +class ApiKeyRepository { + /** + * @param {Object} pool - MySQL connection pool + * @param {Object} logger - Logger instance + */ + constructor(pool, logger) { + this.pool = pool; + this.logger = logger; + } + + /** + * Validate API key + * @param {string} apiKey - API key to validate + * @returns {Promise} Key data if valid, null if invalid + */ + async validateKey(apiKey) { + const connection = await this.pool.getConnection(); + + try { + if (!apiKey || typeof apiKey !== 'string') { + throw new AuthenticationError('API key is required'); + } + + // Validate format + if (!ApiKeyGenerator.isValidFormat(apiKey)) { + throw new AuthorizationError('Invalid API key format'); + } + + // Hash the key + const keyHash = ApiKeyGenerator.hash(apiKey); + + // Query database + const [rows] = await connection.query( + `SELECT id, key_prefix, name, environment, is_active, expires_at, revoked_at + FROM api_keys + WHERE key_hash = ?`, + [keyHash] + ); + + if (rows.length === 0) { + throw new AuthorizationError('Invalid API key'); + } + + const keyData = rows[0]; + + // Check if active + if (!keyData.is_active) { + throw new AuthorizationError('API key is not active'); + } + + // Check if revoked + if (keyData.revoked_at) { + throw new AuthorizationError('API key has been revoked'); + } + + // Check if expired + if (keyData.expires_at && new Date(keyData.expires_at) < new Date()) { + throw new AuthorizationError('API key has expired'); + } + + return { + id: keyData.id, + name: keyData.name, + environment: keyData.environment + }; + } catch (error) { + if (error instanceof AuthenticationError || error instanceof AuthorizationError) { + throw error; + } + this.logger.error('Failed to validate API key', error); + throw new AuthorizationError('Failed to validate API key'); + } finally { + connection.release(); + } + } + + /** + * Create new API key + * @param {Object} data - Key data + * @param {string} data.name - Key name + * @param {string} [data.description] - Key description + * @param {string} [data.environment] - Environment (development, staging, production) + * @returns {Promise} Created key with plain text (ONLY shown on creation) + */ + async createKey(data) { + const connection = await this.pool.getConnection(); + + try { + const environment = data.environment || 'development'; + const prefix = environment === 'production' ? 'key_live_' : 'key_test_'; + + // Generate key + const plainTextKey = ApiKeyGenerator.generate(prefix); + const keyHash = ApiKeyGenerator.hash(plainTextKey); + + const keyId = uuidv4(); + + await connection.query( + `INSERT INTO api_keys (id, key_prefix, key_hash, name, description, environment) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + keyId, + prefix, + keyHash, + data.name, + data.description || null, + environment + ] + ); + + this.logger.info('API key created', { keyId, name: data.name, environment }); + + return { + id: keyId, + key: plainTextKey, // ONLY shown on creation + name: data.name, + environment, + createdAt: new Date() + }; + } catch (error) { + this.logger.error('Failed to create API key', error); + throw new Error('Failed to create API key'); + } finally { + connection.release(); + } + } + + /** + * Revoke API key + * @param {string} keyId - Key ID + * @param {string} [reason] - Revocation reason + */ + async revokeKey(keyId, reason = null) { + const connection = await this.pool.getConnection(); + + try { + await connection.query( + `UPDATE api_keys + SET is_active = FALSE, revoked_at = NOW(), revoked_reason = ? + WHERE id = ?`, + [reason, keyId] + ); + + this.logger.info('API key revoked', { keyId, reason }); + } catch (error) { + this.logger.error('Failed to revoke API key', error); + throw new Error('Failed to revoke API key'); + } finally { + connection.release(); + } + } + + /** + * Activate API key + * @param {string} keyId - Key ID + */ + async activateKey(keyId) { + const connection = await this.pool.getConnection(); + + try { + await connection.query( + `UPDATE api_keys + SET is_active = TRUE, revoked_at = NULL, revoked_reason = NULL + WHERE id = ?`, + [keyId] + ); + + this.logger.info('API key activated', { keyId }); + } catch (error) { + this.logger.error('Failed to activate API key', error); + throw new Error('Failed to activate API key'); + } finally { + connection.release(); + } + } + + /** + * Get all API keys + * @returns {Promise} List of API keys (without plain text) + */ + async getAllKeys() { + const connection = await this.pool.getConnection(); + + try { + const [rows] = await connection.query( + `SELECT id, key_prefix, name, description, environment, is_active, + created_at, expires_at, revoked_at, revoked_reason + FROM api_keys + ORDER BY created_at DESC` + ); + + return rows.map(row => ({ + id: row.id, + prefix: row.key_prefix, + name: row.name, + description: row.description, + environment: row.environment, + isActive: row.is_active, + createdAt: row.created_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + revokedReason: row.revoked_reason + })); + } catch (error) { + this.logger.error('Failed to get API keys', error); + throw new Error('Failed to get API keys'); + } finally { + connection.release(); + } + } +} + +module.exports = ApiKeyRepository; + + + diff --git a/src/infrastructure/repositories/MySQLImageRepository.js b/src/infrastructure/repositories/MySQLImageRepository.js new file mode 100644 index 0000000..ec23322 --- /dev/null +++ b/src/infrastructure/repositories/MySQLImageRepository.js @@ -0,0 +1,297 @@ +const IImageRepository = require('../../domain/interfaces/IImageRepository'); +const TaggingResult = require('../../domain/entities/TaggingResult'); +const ImageTag = require('../../domain/entities/ImageTag'); +const { NotFoundError } = require('../../shared/errors/AppError'); +const crypto = require('crypto'); +const { v4: uuidv4 } = require('uuid'); + +/** + * MySQL implementation of IImageRepository + */ +class MySQLImageRepository extends IImageRepository { + /** + * @param {Object} pool - MySQL connection pool + * @param {Object} logger - Logger instance + */ + constructor(pool, logger) { + super(); + this.pool = pool; + this.logger = logger; + } + + /** + * Save tagging result and image data + * @param {TaggingResult} taggingResult - Tagging result entity + * @param {Buffer} imageBuffer - Image buffer + * @returns {Promise} + */ + async save(taggingResult, imageBuffer) { + const connection = await this.pool.getConnection(); + + try { + await connection.beginTransaction(); + + // Calculate image hash + const imageHash = this._calculateImageHash(imageBuffer); + + // Check for duplicate before saving + const existing = await this.findByImageHash(imageHash); + if (existing) { + await connection.rollback(); + return existing; + } + + // Get image metadata (dimensions, size, etc.) + const metadata = await this._getImageMetadata(imageBuffer); + + // Insert image record + const imageId = taggingResult.imageId || uuidv4(); + await connection.query( + `INSERT INTO images (id, file_name, original_name, file_size, mime_type, width, height, image_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + imageId, + `${imageId}.jpg`, // Store as .jpg (optimized format) + metadata.originalName || 'unknown', + imageBuffer.length, + metadata.mimeType || 'image/jpeg', + metadata.width || null, + metadata.height || null, + imageHash + ] + ); + + // Insert tagging result + const resultId = uuidv4(); + await connection.query( + `INSERT INTO tagging_results (id, image_id, tags, summary, total_tags, model_version, was_duplicate) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + resultId, + imageId, + JSON.stringify(taggingResult.tags.map(tag => tag.toJSON())), + taggingResult.summary, + taggingResult.getTotalTags(), + 'claude-sonnet-4-20250514', + false + ] + ); + + await connection.commit(); + + this.logger.info('Image and tags saved', { imageId, resultId }); + + return taggingResult; + } catch (error) { + await connection.rollback(); + this.logger.error('Failed to save image and tags', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + /** + * Find tagging result by image ID + * @param {string} imageId - Image ID + * @returns {Promise} + */ + async findById(imageId) { + const connection = await this.pool.getConnection(); + + try { + const [rows] = await connection.query( + `SELECT tr.*, i.mime_type, i.width, i.height + FROM tagging_results tr + JOIN images i ON tr.image_id = i.id + WHERE tr.image_id = ? + ORDER BY tr.tagged_at DESC + LIMIT 1`, + [imageId] + ); + + if (rows.length === 0) { + return null; + } + + return this._mapRowToTaggingResult(rows[0]); + } catch (error) { + this.logger.error('Failed to find image by ID', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + /** + * Find tagging result by image hash + * @param {string} hash - SHA256 hash of image + * @returns {Promise} + */ + async findByImageHash(hash) { + const connection = await this.pool.getConnection(); + + try { + const [rows] = await connection.query( + `SELECT tr.*, i.mime_type, i.width, i.height + FROM tagging_results tr + JOIN images i ON tr.image_id = i.id + WHERE i.image_hash = ? + ORDER BY tr.tagged_at DESC + LIMIT 1`, + [hash] + ); + + if (rows.length === 0) { + return null; + } + + return this._mapRowToTaggingResult(rows[0]); + } catch (error) { + this.logger.error('Failed to find image by hash', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + /** + * Search images by tag value + * @param {string} tagValue - Tag value to search for + * @returns {Promise>} + */ + async findByTagValue(tagValue) { + const connection = await this.pool.getConnection(); + + try { + const [rows] = await connection.query( + `SELECT DISTINCT tr.*, i.mime_type, i.width, i.height + FROM tagging_results tr + JOIN images i ON tr.image_id = i.id + WHERE JSON_SEARCH(tr.tags, 'one', ?) IS NOT NULL + ORDER BY tr.tagged_at DESC`, + [tagValue] + ); + + return rows.map(row => this._mapRowToTaggingResult(row)); + } catch (error) { + this.logger.error('Failed to search by tag value', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + /** + * Get statistics about tagged images + * @returns {Promise} Statistics object + */ + async getStats() { + const connection = await this.pool.getConnection(); + + try { + const [totalImages] = await connection.query( + 'SELECT COUNT(*) as count FROM images' + ); + + const [totalTagged] = await connection.query( + 'SELECT COUNT(*) as count FROM tagging_results' + ); + + const [totalDuplicates] = await connection.query( + 'SELECT COUNT(*) as count FROM tagging_results WHERE was_duplicate = TRUE' + ); + + const [avgTags] = await connection.query( + 'SELECT AVG(total_tags) as avg FROM tagging_results' + ); + + return { + totalImages: totalImages[0].count, + totalTagged: totalTagged[0].count, + totalDuplicates: totalDuplicates[0].count, + averageTagsPerImage: avgTags[0].avg ? parseFloat(avgTags[0].avg).toFixed(2) : 0 + }; + } catch (error) { + this.logger.error('Failed to get statistics', error); + throw new Error('Database operation failed'); + } finally { + connection.release(); + } + } + + /** + * Check database connection health + * @returns {Promise} True if connected + */ + async checkHealth() { + const connection = await this.pool.getConnection(); + + try { + await connection.ping(); + return true; + } catch (error) { + this.logger.error('Database health check failed', error); + return false; + } finally { + connection.release(); + } + } + + /** + * Map database row to TaggingResult entity + * @private + */ + _mapRowToTaggingResult(row) { + try { + const tags = typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags; + + return new TaggingResult({ + imageId: row.image_id, + tags: tags, + summary: row.summary || '', + createdAt: row.tagged_at || new Date() + }); + } catch (error) { + this.logger.error('Failed to map row to TaggingResult', error); + throw new Error('Failed to parse tagging result'); + } + } + + /** + * Calculate SHA256 hash of image buffer + * @private + */ + _calculateImageHash(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } + + /** + * Get image metadata using Sharp (if available) + * @private + */ + async _getImageMetadata(imageBuffer) { + try { + const sharp = require('sharp'); + const metadata = await sharp(imageBuffer).metadata(); + return { + width: metadata.width, + height: metadata.height, + mimeType: `image/${metadata.format}`, + originalName: 'unknown' + }; + } catch (error) { + // If Sharp fails, return minimal metadata + this.logger.warn('Failed to get image metadata', error); + return { + width: null, + height: null, + mimeType: 'image/jpeg', + originalName: 'unknown' + }; + } + } +} + +module.exports = MySQLImageRepository; + diff --git a/src/presentation/controllers/ImageTaggingController.js b/src/presentation/controllers/ImageTaggingController.js new file mode 100644 index 0000000..db0ae7e --- /dev/null +++ b/src/presentation/controllers/ImageTaggingController.js @@ -0,0 +1,314 @@ +const TagImageRequestDto = require('../../application/dtos/TagImageRequestDto'); +const ImageValidator = require('../validators/imageValidator'); +const ResponseFormatter = require('../../shared/utils/responseFormatter'); +const { ValidationError } = require('../../shared/errors/AppError'); +const Joi = require('joi'); + +/** + * Image Tagging Controller + */ +class ImageTaggingController { + /** + * @param {TagImageUseCase} tagImageUseCase - Tag image use case + * @param {TagBase64ImageUseCase} tagBase64ImageUseCase - Tag base64 image use case + * @param {TagBatchImagesUseCase} tagBatchImagesUseCase - Tag batch images use case + * @param {TagBatchBase64ImagesUseCase} tagBatchBase64ImagesUseCase - Tag batch base64 images use case + * @param {IImageRepository} imageRepository - Image repository + * @param {Object} logger - Logger instance + */ + constructor(tagImageUseCase, tagBase64ImageUseCase, tagBatchImagesUseCase, tagBatchBase64ImagesUseCase, imageRepository, logger) { + this.tagImageUseCase = tagImageUseCase; + this.tagBase64ImageUseCase = tagBase64ImageUseCase; + this.tagBatchImagesUseCase = tagBatchImagesUseCase; + this.tagBatchBase64ImagesUseCase = tagBatchBase64ImagesUseCase; + this.imageRepository = imageRepository; + this.logger = logger; + } + + /** + * Tag uploaded image + */ + async tagUploadedImage(req, res, next) { + try { + // Validate upload + if (!req.file) { + throw new ValidationError('Image file is required'); + } + + await ImageValidator.validateUpload(req.file); + + // Convert format if needed + let imageBuffer = req.file.buffer; + imageBuffer = await ImageValidator.convertToClaudeSupportedFormat( + imageBuffer, + req.file.mimetype + ); + + // Optimize for AI + imageBuffer = await ImageValidator.optimizeForAI(imageBuffer); + + // Create request DTO + const requestDto = new TagImageRequestDto({ + fileBuffer: imageBuffer, + mimeType: 'image/jpeg', // Always JPEG after optimization + fileName: req.file.originalname || 'unknown' + }); + + // Execute use case + const result = await this.tagImageUseCase.execute(requestDto); + + // Format response + const message = result.isDuplicate + ? '✅ Duplicate detected - returned cached tags (no cost)' + : '✅ New image tagged successfully'; + + res.status(200).json( + ResponseFormatter.success(result, message) + ); + } catch (error) { + next(error); + } + } + + /** + * Tag base64 image + */ + async tagBase64Image(req, res, next) { + try { + // Validate input + const schema = Joi.object({ + base64Image: Joi.string().base64().required(), + mediaType: Joi.string().valid('image/jpeg', 'image/png', 'image/webp', 'image/gif').required(), + fileName: Joi.string().max(255).optional() + }); + + const { error: validationError, value } = schema.validate(req.body); + if (validationError) { + throw new ValidationError(validationError.details[0].message); + } + + // Execute use case + const result = await this.tagBase64ImageUseCase.execute( + value.base64Image, + value.mediaType, + value.fileName + ); + + // Format response + const message = result.isDuplicate + ? '✅ Duplicate detected - returned cached tags (no cost)' + : '✅ New image tagged successfully'; + + res.status(200).json( + ResponseFormatter.success(result, message) + ); + } catch (error) { + next(error); + } + } + + /** + * Search images by tag + */ + async searchByTag(req, res, next) { + try { + const tagValue = req.query.tag; + + if (!tagValue || typeof tagValue !== 'string') { + throw new ValidationError('Tag query parameter is required'); + } + + const results = await this.imageRepository.findByTagValue(tagValue); + + res.status(200).json( + ResponseFormatter.success(results, `Found ${results.length} image(s) with tag "${tagValue}"`) + ); + } catch (error) { + next(error); + } + } + + /** + * Get statistics + */ + async getStats(req, res, next) { + try { + const stats = await this.imageRepository.getStats(); + + res.status(200).json( + ResponseFormatter.success(stats, 'Statistics retrieved successfully') + ); + } catch (error) { + next(error); + } + } + + /** + * Tag multiple uploaded images (batch) + */ + async tagBatchUploadedImages(req, res, next) { + try { + if (!req.files || req.files.length === 0) { + throw new ValidationError('At least one image file is required'); + } + + if (req.files.length > 50) { + throw new ValidationError('Maximum 50 images allowed per batch request'); + } + + // Process all files + const requestDtos = []; + + for (const file of req.files) { + try { + await ImageValidator.validateUpload(file); + + // Convert format if needed + let imageBuffer = file.buffer; + imageBuffer = await ImageValidator.convertToClaudeSupportedFormat( + imageBuffer, + file.mimetype + ); + + // Optimize for AI + imageBuffer = await ImageValidator.optimizeForAI(imageBuffer); + + // Create request DTO + const requestDto = new TagImageRequestDto({ + fileBuffer: imageBuffer, + mimeType: 'image/jpeg', + fileName: file.originalname || 'unknown' + }); + + requestDtos.push(requestDto); + } catch (error) { + this.logger.warn('Skipping invalid file in batch', { + fileName: file.originalname, + error: error.message + }); + } + } + + if (requestDtos.length === 0) { + throw new ValidationError('No valid images found in batch'); + } + + // Execute batch use case + const results = await this.tagBatchImagesUseCase.execute(requestDtos); + + // Format response + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + const message = `Batch processing completed: ${successCount} succeeded, ${failureCount} failed`; + + res.status(200).json( + ResponseFormatter.success({ + total: results.length, + succeeded: successCount, + failed: failureCount, + results: results.map(r => ({ + success: r.success, + index: r.index, + data: r.data, + error: r.error || null + })) + }, message) + ); + } catch (error) { + next(error); + } + } + + /** + * Tag multiple base64 images (batch) + */ + async tagBatchBase64Images(req, res, next) { + try { + // Validate input + const schema = Joi.object({ + images: Joi.array().items( + Joi.object({ + base64Image: Joi.string().base64().required(), + mediaType: Joi.string().valid('image/jpeg', 'image/png', 'image/webp', 'image/gif').required(), + fileName: Joi.string().max(255).optional() + }) + ).min(1).max(50).required() + }); + + const { error: validationError, value } = schema.validate(req.body); + if (validationError) { + throw new ValidationError(validationError.details[0].message); + } + + // Execute batch use case + const results = await this.tagBatchBase64ImagesUseCase.execute(value.images); + + // Format response + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + const message = `Batch processing completed: ${successCount} succeeded, ${failureCount} failed`; + + res.status(200).json( + ResponseFormatter.success({ + total: results.length, + succeeded: successCount, + failed: failureCount, + results: results.map(r => ({ + success: r.success, + index: r.index, + data: r.data, + error: r.error || null + })) + }, message) + ); + } catch (error) { + next(error); + } + } + + /** + * Health check + */ + async getHealth(req, res) { + try { + // Check database connection + let dbStatus = 'unknown'; + try { + const isHealthy = await this.imageRepository.checkHealth(); + dbStatus = isHealthy ? 'connected' : 'disconnected'; + } catch (error) { + dbStatus = 'disconnected'; + } + + // Check memory usage + const memoryUsage = process.memoryUsage(); + const memoryMB = { + rss: Math.round(memoryUsage.rss / 1024 / 1024), + heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024), + heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024) + }; + + const health = { + status: dbStatus === 'connected' ? 'healthy' : 'unhealthy', + database: dbStatus, + memory: memoryMB, + uptime: process.uptime(), + timestamp: new Date().toISOString() + }; + + const statusCode = health.status === 'healthy' ? 200 : 503; + res.status(statusCode).json(health); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + error: error.message, + timestamp: new Date().toISOString() + }); + } + } +} + +module.exports = ImageTaggingController; + diff --git a/src/presentation/middleware/apiKeyAuth.js b/src/presentation/middleware/apiKeyAuth.js new file mode 100644 index 0000000..c310c5b --- /dev/null +++ b/src/presentation/middleware/apiKeyAuth.js @@ -0,0 +1,79 @@ +const { AuthenticationError, AuthorizationError } = require('../../shared/errors/AppError'); + +/** + * API Key Authentication Middleware + */ +class ApiKeyAuthMiddleware { + /** + * @param {ApiKeyRepository} apiKeyRepository - API key repository + * @param {Object} logger - Logger instance + */ + constructor(apiKeyRepository, logger) { + this.apiKeyRepository = apiKeyRepository; + this.logger = logger; + } + + /** + * Authentication middleware + */ + authenticate() { + return async (req, res, next) => { + try { + // Skip auth if SKIP_AUTH=true (development only) + if (process.env.SKIP_AUTH === 'true' && process.env.NODE_ENV !== 'production') { + this.logger.warn('Authentication skipped (SKIP_AUTH=true)', { path: req.path }); + req.apiKey = { id: 'skip', name: 'Development Skip', environment: 'development' }; + return next(); + } + + // Extract API key from header + const apiKey = this._extractApiKey(req); + + if (!apiKey) { + throw new AuthenticationError('API key required. Include X-API-Key header or Authorization: Bearer token.'); + } + + // Validate key + const keyData = await this.apiKeyRepository.validateKey(apiKey); + + // Attach key data to request + req.apiKey = keyData; + + this.logger.info('API key authenticated', { + keyId: keyData.id, + name: keyData.name, + environment: keyData.environment, + path: req.path + }); + + next(); + } catch (error) { + next(error); + } + }; + } + + /** + * Extract API key from request headers + * @private + */ + _extractApiKey(req) { + // Try X-API-Key header first + if (req.headers['x-api-key']) { + return req.headers['x-api-key']; + } + + // Try Authorization: Bearer token + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + return null; + } +} + +module.exports = ApiKeyAuthMiddleware; + + + diff --git a/src/presentation/middleware/errorHandler.js b/src/presentation/middleware/errorHandler.js new file mode 100644 index 0000000..e892192 --- /dev/null +++ b/src/presentation/middleware/errorHandler.js @@ -0,0 +1,38 @@ +const ResponseFormatter = require('../../shared/utils/responseFormatter'); +const logger = require('../../shared/utils/logger'); +const { AppError } = require('../../shared/errors/AppError'); + +/** + * Global error handler middleware + */ +const errorHandler = (err, req, res, next) => { + // Log error + logger.error('Request error', { + method: req.method, + path: req.path, + error: err, + requestId: req.id + }); + + // Handle known operational errors + if (err instanceof AppError && err.isOperational) { + return res.status(err.statusCode).json( + ResponseFormatter.error(err, err.message) + ); + } + + // Handle unknown errors + const statusCode = err.statusCode || 500; + const message = process.env.NODE_ENV === 'production' + ? 'Internal server error' + : err.message; + + res.status(statusCode).json( + ResponseFormatter.error(err, message) + ); +}; + +module.exports = errorHandler; + + + diff --git a/src/presentation/middleware/requestId.js b/src/presentation/middleware/requestId.js new file mode 100644 index 0000000..ad6dea3 --- /dev/null +++ b/src/presentation/middleware/requestId.js @@ -0,0 +1,15 @@ +const { v4: uuidv4 } = require('uuid'); + +/** + * Request ID middleware - adds unique ID to each request + */ +const requestIdMiddleware = (req, res, next) => { + req.id = req.headers['x-request-id'] || uuidv4(); + res.setHeader('X-Request-ID', req.id); + next(); +}; + +module.exports = requestIdMiddleware; + + + diff --git a/src/presentation/routes/imageRoutes.js b/src/presentation/routes/imageRoutes.js new file mode 100644 index 0000000..47e432c --- /dev/null +++ b/src/presentation/routes/imageRoutes.js @@ -0,0 +1,72 @@ +const express = require('express'); +const multer = require('multer'); + +/** + * Create image routes + * @param {ImageTaggingController} controller - Image tagging controller + * @param {ApiKeyAuthMiddleware} authMiddleware - API key auth middleware + * @returns {express.Router} + */ +const createImageRoutes = (controller, authMiddleware) => { + const router = express.Router(); + + // Configure multer for file uploads + const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 50 * 1024 * 1024 // 50MB + } + }); + + // Health check (public) + router.get('/health', (req, res) => controller.getHealth(req, res)); + + // Tag uploaded image (auth required) + router.post( + '/tag', + authMiddleware.authenticate(), + upload.single('image'), + (req, res, next) => controller.tagUploadedImage(req, res, next) + ); + + // Tag base64 image (auth required) + router.post( + '/tag-base64', + authMiddleware.authenticate(), + (req, res, next) => controller.tagBase64Image(req, res, next) + ); + + // Tag multiple uploaded images (batch, auth required) + router.post( + '/tag/batch', + authMiddleware.authenticate(), + upload.array('images', 50), + (req, res, next) => controller.tagBatchUploadedImages(req, res, next) + ); + + // Tag multiple base64 images (batch, auth required) + router.post( + '/tag-base64/batch', + authMiddleware.authenticate(), + (req, res, next) => controller.tagBatchBase64Images(req, res, next) + ); + + // Search by tag (auth required) + router.get( + '/search', + authMiddleware.authenticate(), + (req, res, next) => controller.searchByTag(req, res, next) + ); + + // Get statistics (auth required) + router.get( + '/stats', + authMiddleware.authenticate(), + (req, res, next) => controller.getStats(req, res, next) + ); + + return router; +}; + +module.exports = createImageRoutes; + diff --git a/src/presentation/validators/imageValidator.js b/src/presentation/validators/imageValidator.js new file mode 100644 index 0000000..cb68928 --- /dev/null +++ b/src/presentation/validators/imageValidator.js @@ -0,0 +1,135 @@ +const { fileTypeFromBuffer } = require('file-type'); +const sharp = require('sharp'); +const { ValidationError } = require('../../shared/errors/AppError'); + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +const MAX_DIMENSION = 15000; // pixels +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/heic', + 'image/tiff', + 'image/bmp' +]; + +/** + * Image validator utility + */ +class ImageValidator { + /** + * Validate uploaded file + * @param {Object} file - Multer file object + * @returns {Promise} + * @throws {ValidationError} If validation fails + */ + static async validateUpload(file) { + if (!file || !file.buffer) { + throw new ValidationError('File is required'); + } + + // Check file size + if (file.buffer.length > MAX_FILE_SIZE) { + throw new ValidationError(`File size exceeds maximum of ${MAX_FILE_SIZE / 1024 / 1024}MB`); + } + + // Validate actual file type using magic numbers + const fileType = await fileTypeFromBuffer(file.buffer); + if (!fileType) { + throw new ValidationError('Unable to determine file type'); + } + + // Check if MIME type is allowed + const allowedMimeTypes = [ + ...ALLOWED_MIME_TYPES, + 'image/jpg', // Accept jpg as well + 'image/heif', // HEIC variant + 'image/x-tiff' // TIFF variant + ]; + + const mimeType = fileType.mime.toLowerCase(); + if (!allowedMimeTypes.includes(mimeType) && !allowedMimeTypes.some(allowed => mimeType.includes(allowed.split('/')[1]))) { + throw new ValidationError(`Unsupported file type: ${fileType.mime}. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`); + } + + // Validate and check dimensions + try { + const metadata = await sharp(file.buffer).metadata(); + + if (metadata.width > MAX_DIMENSION || metadata.height > MAX_DIMENSION) { + throw new ValidationError(`Image dimensions exceed maximum of ${MAX_DIMENSION}x${MAX_DIMENSION} pixels`); + } + } catch (error) { + if (error instanceof ValidationError) { + throw error; + } + throw new ValidationError('Invalid image file'); + } + } + + /** + * Convert image to Claude-supported format (JPEG/PNG/WebP/GIF) + * @param {Buffer} buffer - Image buffer + * @param {string} mimeType - Original MIME type + * @returns {Promise} Converted image buffer + */ + static async convertToClaudeSupportedFormat(buffer, mimeType) { + const lowerMime = mimeType.toLowerCase(); + + // If already supported, just optimize + if (['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'].includes(lowerMime)) { + return buffer; + } + + // Convert HEIC/TIFF/BMP to JPEG + try { + const converted = await sharp(buffer) + .jpeg({ quality: 90 }) + .toBuffer(); + + return converted; + } catch (error) { + throw new ValidationError(`Failed to convert image: ${error.message}`); + } + } + + /** + * Optimize image for AI processing (resize if needed) + * @param {Buffer} buffer - Image buffer + * @returns {Promise} Optimized image buffer + */ + static async optimizeForAI(buffer) { + try { + const image = sharp(buffer); + const metadata = await image.metadata(); + + // If image is larger than 2048px, resize it + if (metadata.width > 2048 || metadata.height > 2048) { + const optimized = await image + .resize(2048, 2048, { + fit: 'inside', + withoutEnlargement: true + }) + .jpeg({ quality: 85 }) + .toBuffer(); + + return optimized; + } + + // If already small enough, just ensure it's JPEG + if (metadata.format !== 'jpeg') { + return await image + .jpeg({ quality: 85 }) + .toBuffer(); + } + + return buffer; + } catch (error) { + throw new ValidationError(`Failed to optimize image: ${error.message}`); + } + } +} + +module.exports = ImageValidator; + diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6a81665 --- /dev/null +++ b/src/server.js @@ -0,0 +1,83 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const morgan = require('morgan'); + +// Import middleware and routes +const corsConfig = require('./infrastructure/config/corsConfig'); +const container = require('./infrastructure/config/dependencyContainer'); +const createImageRoutes = require('./presentation/routes/imageRoutes'); +const ImageTaggingController = require('./presentation/controllers/ImageTaggingController'); +const ApiKeyAuthMiddleware = require('./presentation/middleware/apiKeyAuth'); +const errorHandler = require('./presentation/middleware/errorHandler'); +const requestIdMiddleware = require('./presentation/middleware/requestId'); +const logger = require('./shared/utils/logger'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(helmet()); +app.use(compression()); +app.use(cors(corsConfig)); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(requestIdMiddleware); +app.use(morgan('combined')); + +// Root +app.get('/', (req, res) => { + res.json({ + service: 'Property Image Tagging API', + version: '1.0.0', + authentication: 'Simple API Key' + }); +}); + +// Dependency injection +const tagImageUseCase = container.get('tagImageUseCase'); +const tagBase64ImageUseCase = container.get('tagBase64ImageUseCase'); +const tagBatchImagesUseCase = container.get('tagBatchImagesUseCase'); +const tagBatchBase64ImagesUseCase = container.get('tagBatchBase64ImagesUseCase'); +const imageRepository = container.get('imageRepository'); +const apiKeyRepository = container.get('apiKeyRepository'); + +const imageController = new ImageTaggingController( + tagImageUseCase, + tagBase64ImageUseCase, + tagBatchImagesUseCase, + tagBatchBase64ImagesUseCase, + imageRepository, + logger +); + +const authMiddleware = new ApiKeyAuthMiddleware(apiKeyRepository, logger); + +// Routes +const imageRoutes = createImageRoutes(imageController, authMiddleware); +app.use('/api/images', imageRoutes); + +// Error handler (last) +app.use(errorHandler); + +// Graceful shutdown +const gracefulShutdown = async (signal) => { + logger.info(`${signal} received, shutting down`); + await container.close(); + process.exit(0); +}; + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +// Start +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + }); +} + +module.exports = app; + diff --git a/src/shared/errors/AppError.js b/src/shared/errors/AppError.js new file mode 100644 index 0000000..00e540b --- /dev/null +++ b/src/shared/errors/AppError.js @@ -0,0 +1,80 @@ +/** + * Base application error class + * @class AppError + * @extends Error + */ +class AppError extends Error { + /** + * @param {string} message - Error message + * @param {number} statusCode - HTTP status code + * @param {boolean} isOperational - Whether error is operational (expected) or programming error + */ + constructor(message, statusCode = 500, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Validation error (400) + */ +class ValidationError extends AppError { + constructor(message) { + super(message, 400); + this.name = 'ValidationError'; + } +} + +/** + * AI service error (503) + */ +class AIServiceError extends AppError { + constructor(message) { + super(message, 503); + this.name = 'AIServiceError'; + } +} + +/** + * Not found error (404) + */ +class NotFoundError extends AppError { + constructor(message = 'Resource not found') { + super(message, 404); + this.name = 'NotFoundError'; + } +} + +/** + * Authentication error (401) + */ +class AuthenticationError extends AppError { + constructor(message = 'Authentication required') { + super(message, 401); + this.name = 'AuthenticationError'; + } +} + +/** + * Authorization error (403) + */ +class AuthorizationError extends AppError { + constructor(message = 'Invalid authentication credentials') { + super(message, 403); + this.name = 'AuthorizationError'; + } +} + +module.exports = { + AppError, + ValidationError, + AIServiceError, + NotFoundError, + AuthenticationError, + AuthorizationError +}; + + + diff --git a/src/shared/utils/apiKeyGenerator.js b/src/shared/utils/apiKeyGenerator.js new file mode 100644 index 0000000..a2be913 --- /dev/null +++ b/src/shared/utils/apiKeyGenerator.js @@ -0,0 +1,92 @@ +const crypto = require('crypto'); +const { ValidationError } = require('../errors/AppError'); + +/** + * API Key Generator utility + */ +class ApiKeyGenerator { + /** + * Generate a secure API key + * @param {string} prefix - Key prefix (e.g., 'key_test_' or 'key_live_') + * @returns {string} Full API key + */ + static generate(prefix = 'key_test_') { + if (!prefix || typeof prefix !== 'string') { + throw new ValidationError('Prefix must be a non-empty string'); + } + + // Generate 64 hex characters (256 bits) + const randomBytes = crypto.randomBytes(32); + const keySuffix = randomBytes.toString('hex'); + + return `${prefix}${keySuffix}`; + } + + /** + * Hash API key with SHA256 + * @param {string} apiKey - API key to hash + * @returns {string} SHA256 hash + */ + static hash(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + throw new ValidationError('API key must be a non-empty string'); + } + + return crypto.createHash('sha256').update(apiKey).digest('hex'); + } + + /** + * Mask API key for display (show only first 13 and last 4 chars) + * @param {string} apiKey - API key to mask + * @returns {string} Masked key + */ + static mask(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return '***'; + } + + if (apiKey.length <= 17) { + return '***'; + } + + const start = apiKey.substring(0, 13); + const end = apiKey.substring(apiKey.length - 4); + const middle = '*'.repeat(Math.max(0, apiKey.length - 17)); + + return `${start}${middle}${end}`; + } + + /** + * Validate API key format + * @param {string} apiKey - API key to validate + * @returns {boolean} + */ + static isValidFormat(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return false; + } + + // Format: key_(test|live)_[64 hex chars] + const pattern = /^key_(test|live)_[a-f0-9]{64}$/; + return pattern.test(apiKey); + } + + /** + * Extract prefix from API key + * @param {string} apiKey - API key + * @returns {string|null} Prefix or null if invalid + */ + static extractPrefix(apiKey) { + if (!this.isValidFormat(apiKey)) { + return null; + } + + const match = apiKey.match(/^(key_(test|live)_)/); + return match ? match[1] : null; + } +} + +module.exports = ApiKeyGenerator; + + + diff --git a/src/shared/utils/logger.js b/src/shared/utils/logger.js new file mode 100644 index 0000000..29670f4 --- /dev/null +++ b/src/shared/utils/logger.js @@ -0,0 +1,126 @@ +const winston = require('winston'); +const DailyRotateFile = require('winston-daily-rotate-file'); +const path = require('path'); +const fs = require('fs'); + +const logDir = path.join(process.cwd(), 'logs'); + +// Create logs directory if it doesn't exist +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + +// Define log format +const logFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.json() +); + +// Console format for development +const consoleFormat = winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const metaString = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''; + return `${timestamp} [${level}]: ${message} ${metaString}`; + }) +); + +// Daily rotate file transport for errors +const errorFileTransport = new DailyRotateFile({ + filename: path.join(logDir, 'error-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + level: 'error', + format: logFormat, + maxSize: '20m', + maxFiles: '14d' +}); + +// Daily rotate file transport for all logs +const combinedFileTransport = new DailyRotateFile({ + filename: path.join(logDir, 'combined-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + format: logFormat, + maxSize: '20m', + maxFiles: '14d' +}); + +// Create logger instance +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: logFormat, + defaultMeta: { service: 'property-image-tagger' }, + transports: [ + errorFileTransport, + combinedFileTransport + ], + exceptionHandlers: [ + new winston.transports.File({ filename: path.join(logDir, 'exceptions.log') }) + ], + rejectionHandlers: [ + new winston.transports.File({ filename: path.join(logDir, 'rejections.log') }) + ] +}); + +// Add console transport in development +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: consoleFormat + })); +} + +/** + * Log info message + * @param {string} message - Log message + * @param {Object} [meta] - Additional metadata + */ +const info = (message, meta = {}) => { + logger.info(message, meta); +}; + +/** + * Log error message + * @param {string} message - Log message + * @param {Error|Object} error - Error object or metadata + */ +const error = (message, error = {}) => { + if (error instanceof Error) { + logger.error(message, { + message: error.message, + stack: error.stack, + ...error + }); + } else { + logger.error(message, error); + } +}; + +/** + * Log warning message + * @param {string} message - Log message + * @param {Object} [meta] - Additional metadata + */ +const warn = (message, meta = {}) => { + logger.warn(message, meta); +}; + +/** + * Log debug message + * @param {string} message - Log message + * @param {Object} [meta] - Additional metadata + */ +const debug = (message, meta = {}) => { + logger.debug(message, meta); +}; + +module.exports = { + info, + error, + warn, + debug, + logger +}; + + + diff --git a/src/shared/utils/responseFormatter.js b/src/shared/utils/responseFormatter.js new file mode 100644 index 0000000..cddc20a --- /dev/null +++ b/src/shared/utils/responseFormatter.js @@ -0,0 +1,46 @@ +/** + * Format API responses consistently + */ +class ResponseFormatter { + /** + * Format successful response + * @param {*} data - Response data + * @param {string} message - Success message + * @returns {Object} + */ + static success(data, message = 'Success') { + return { + success: true, + message, + data, + timestamp: new Date().toISOString() + }; + } + + /** + * Format error response + * @param {Error} error - Error object + * @param {string} message - Error message (defaults to error.message) + * @returns {Object} + */ + static error(error, message = null) { + const errorMessage = message || error.message || 'An error occurred'; + const response = { + success: false, + message: errorMessage, + timestamp: new Date().toISOString() + }; + + // Only include error details in development + if (process.env.NODE_ENV === 'development' && error.stack) { + response.stack = error.stack; + } + + return response; + } +} + +module.exports = ResponseFormatter; + + +