diff --git a/backend/.env.example b/backend/.env.example index 7015c07..6349b8c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,10 @@ -# MongoDB Configuration +# MongoDB Configuration (Legacy - being migrated to CouchDB) MONGO_URI=mongodb://localhost:27017/adopt-a-street +# CouchDB Configuration (New primary database) +COUCHDB_URL=http://localhost:5984 +COUCHDB_DB_NAME=adopt-a-street + # JWT Authentication JWT_SECRET=your_jwt_secret_key_here_change_in_production diff --git a/backend/COUCHDB_SERVICE_GUIDE.md b/backend/COUCHDB_SERVICE_GUIDE.md new file mode 100644 index 0000000..05a980a --- /dev/null +++ b/backend/COUCHDB_SERVICE_GUIDE.md @@ -0,0 +1,498 @@ +# CouchDB Service Guide + +This guide provides comprehensive documentation for the CouchDB service implementation in the Adopt-a-Street application. + +## Overview + +The `CouchDBService` class provides a production-ready interface for interacting with CouchDB, replacing MongoDB as the primary database. It includes connection management, CRUD operations, query helpers, and migration utilities. + +## Configuration + +### Environment Variables + +Add these to your `.env` file: + +```bash +# CouchDB Configuration +COUCHDB_URL=http://localhost:5984 +COUCHDB_DB_NAME=adopt-a-street +``` + +### Connection Initialization + +```javascript +const couchdbService = require('./services/couchdbService'); + +// Initialize the service (call once at application startup) +await couchdbService.initialize(); + +// Check if connected +if (couchdbService.isReady()) { + console.log('CouchDB is ready'); +} +``` + +## Core Features + +### 1. Connection Management + +- **Automatic connection**: Establishes connection on first use +- **Database creation**: Creates database if it doesn't exist +- **Health checks**: Validates connection status +- **Graceful shutdown**: Properly closes connections + +### 2. Design Documents & Indexes + +The service automatically creates design documents with: + +- **Views**: For common query patterns +- **Mango Indexes**: For efficient document queries +- **Geospatial indexes**: For location-based queries + +#### Available Indexes + +- `user-by-email`: Fast user authentication +- `users-by-points`: Leaderboard queries +- `streets-by-location`: Geospatial street searches +- `streets-by-status`: Filter streets by adoption status +- `posts-by-date`: Social feed ordering +- `events-by-date-status`: Event management +- `reports-by-status`: Report workflow management + +### 3. Generic CRUD Operations + +```javascript +// Create document +const doc = { + _id: 'user_123', + type: 'user', + name: 'John Doe', + email: 'john@example.com' +}; +const created = await couchdbService.createDocument(doc); + +// Get document +const retrieved = await couchdbService.getDocument('user_123'); + +// Update document +created.name = 'Jane Doe'; +const updated = await couchdbService.updateDocument(created); + +// Delete document +await couchdbService.deleteDocument('user_123', updated._rev); +``` + +### 4. Query Operations + +```javascript +// Find with Mango query +const users = await couchdbService.find({ + selector: { + type: 'user', + points: { $gt: 100 } + }, + sort: [{ points: 'desc' }], + limit: 10 +}); + +// Find single document +const user = await couchdbService.findOne({ + type: 'user', + email: 'john@example.com' +}); + +// Find by type +const streets = await couchdbService.findByType('street', { + status: 'available' +}); + +// Use views +const topUsers = await couchdbService.view('users', 'by-points', { + limit: 10, + descending: true +}); +``` + +### 5. Batch Operations + +```javascript +// Bulk create/update +const docs = [ + { _id: 'doc1', type: 'test', name: 'Test 1' }, + { _id: 'doc2', type: 'test', name: 'Test 2' } +]; +const result = await couchdbService.bulkDocs({ docs }); +``` + +### 6. Specialized Helper Methods + +#### User Operations + +```javascript +// Find user by email +const user = await couchdbService.findUserByEmail('john@example.com'); + +// Update user points with transaction +const updatedUser = await couchdbService.updateUserPoints( + 'user_123', + 50, + 'Completed task: Clean up street', + { + entityType: 'Task', + entityId: 'task_456', + entityName: 'Clean up street' + } +); +``` + +#### Street Operations + +```javascript +// Find streets by location (geospatial) +const bounds = [[-74.1, 40.6], [-73.9, 40.8]]; // NYC area +const streets = await couchdbService.findStreetsByLocation(bounds); + +// Adopt a street +const adoptedStreet = await couchdbService.adoptStreet('user_123', 'street_456'); +``` + +#### Task Operations + +```javascript +// Complete a task +const completedTask = await couchdbService.completeTask('user_123', 'task_789'); +``` + +#### Social Features + +```javascript +// Create a post +const post = await couchdbService.createPost('user_123', { + content: 'Great day cleaning up!', + imageUrl: 'https://example.com/image.jpg', + cloudinaryPublicId: 'post_123' +}); + +// Toggle like on post +const updatedPost = await couchdbService.togglePostLike('user_123', 'post_456'); + +// Add comment +const comment = await couchdbService.addCommentToPost( + 'user_123', + 'post_456', + 'Great work! šŸŽ‰' +); +``` + +#### Event Management + +```javascript +// Join event +const updatedEvent = await couchdbService.joinEvent('user_123', 'event_789'); +``` + +#### Leaderboard & Activity + +```javascript +// Get leaderboard +const leaderboard = await couchdbService.getLeaderboard(10); + +// Get user activity feed +const activity = await couchdbService.getUserActivity('user_123', 20); + +// Get social feed +const feed = await couchdbService.getSocialFeed(20, 0); +``` + +#### Report Management + +```javascript +// Create report +const report = await couchdbService.createReport('user_123', 'street_456', { + issue: 'Broken streetlight', + imageUrl: 'https://example.com/report.jpg', + cloudinaryPublicId: 'report_123' +}); + +// Resolve report +const resolved = await couchdbService.resolveReport('report_789'); +``` + +## Document Structure + +### User Document + +```javascript +{ + _id: "user_1234567890abcdef", + type: "user", + name: "John Doe", + email: "john@example.com", + password: "hashed_password", + isPremium: false, + points: 150, + profilePicture: "https://cloudinary.com/image.jpg", + cloudinaryPublicId: "abc123", + adoptedStreets: ["street_abc123", "street_def456"], + completedTasks: ["task_123", "task_456"], + posts: ["post_789", "post_012"], + events: ["event_345", "event_678"], + earnedBadges: [ + { + badgeId: "badge_123", + name: "Street Hero", + description: "Adopted 5 streets", + icon: "šŸ†", + rarity: "rare", + earnedAt: "2024-01-15T10:30:00Z", + progress: 100 + } + ], + stats: { + streetsAdopted: 2, + tasksCompleted: 5, + postsCreated: 3, + eventsParticipated: 2, + badgesEarned: 1 + }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-15T10:30:00Z" +} +``` + +### Street Document + +```javascript +{ + _id: "street_abc123def456", + type: "street", + name: "Main Street", + location: { + type: "Point", + coordinates: [-74.0060, 40.7128] + }, + adoptedBy: { + userId: "user_1234567890abcdef", + name: "John Doe", + profilePicture: "https://cloudinary.com/image.jpg" + }, + status: "adopted", + stats: { + tasksCount: 5, + completedTasksCount: 3, + reportsCount: 2, + openReportsCount: 1 + }, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-15T10:30:00Z" +} +``` + +## Migration + +### Running the Migration + +```bash +# From the backend directory +node scripts/migrate-to-couchdb.js +``` + +### Migration Process + +1. **Phase 1**: Export and transform all MongoDB documents to CouchDB format +2. **Phase 2**: Resolve relationships and populate embedded data +3. **Phase 3**: Calculate statistics and counters + +### Migration Features + +- **ID transformation**: MongoDB ObjectIds → prefixed string IDs +- **Relationship resolution**: Populates embedded user/street data +- **Statistics calculation**: Computes counts and aggregates +- **Error handling**: Continues migration even if some documents fail +- **Progress tracking**: Shows migration status and statistics + +## Error Handling + +The service provides comprehensive error handling: + +```javascript +try { + const doc = await couchdbService.getDocument('nonexistent'); + if (!doc) { + console.log('Document not found'); + } +} catch (error) { + console.error('Database error:', error.message); +} +``` + +### Common Error Scenarios + +1. **Connection errors**: Service will retry and provide clear error messages +2. **Document conflicts**: Use `resolveConflict()` method for handling +3. **Validation errors**: Use `validateDocument()` before operations +4. **Query errors**: Detailed error messages for invalid queries + +## Performance Considerations + +### Index Usage + +- Always use indexed fields in selectors +- Use appropriate limit/sort combinations +- Consider view queries for complex aggregations + +### Batch Operations + +- Use `bulkDocs()` for multiple document operations +- Batch size recommendations: 100-500 documents per operation + +### Memory Management + +- Service maintains single connection instance +- Automatic cleanup on shutdown +- Efficient document streaming for large datasets + +## Testing + +### Running Tests + +```bash +# Run CouchDB service tests +bun test __tests__/services/couchdbService.test.js +``` + +### Test Coverage + +- Connection management +- CRUD operations +- Query functionality +- Helper methods +- Error handling +- Migration utilities + +## Best Practices + +### 1. Document Design + +- Include `type` field in all documents +- Use consistent ID prefixes (`user_`, `street_`, etc.) +- Embed frequently accessed data +- Keep document sizes reasonable (< 1MB) + +### 2. Query Optimization + +- Use appropriate indexes +- Limit result sets with pagination +- Prefer views for complex queries +- Use selectors efficiently + +### 3. Error Handling + +- Always wrap database calls in try-catch +- Check for null returns from `getDocument()` +- Handle conflicts gracefully +- Log errors for debugging + +### 4. Performance + +- Use batch operations for bulk changes +- Implement proper pagination +- Cache frequently accessed data +- Monitor query performance + +## Integration with Existing Code + +### Replacing MongoDB Operations + +```javascript +// Before (MongoDB) +const user = await User.findOne({ email: 'john@example.com' }); + +// After (CouchDB) +const user = await couchdbService.findUserByEmail('john@example.com'); +``` + +### Updating Routes + +Most route handlers can be updated by replacing Mongoose calls with CouchDB service methods: + +```javascript +// Example route update +router.get('/users/:id', async (req, res) => { + try { + const user = await couchdbService.getDocument(`user_${req.params.id}`); + if (!user) { + return res.status(404).json({ msg: 'User not found' }); + } + res.json(user); + } catch (error) { + console.error(error.message); + res.status(500).send('Server error'); + } +}); +``` + +## Monitoring & Maintenance + +### Health Checks + +The service provides connection status monitoring: + +```javascript +if (couchdbService.isReady()) { + // Database is available +} else { + // Handle database unavailability +} +``` + +### Performance Monitoring + +- Monitor query response times +- Track document sizes +- Watch for connection pool issues +- Monitor index usage + +## Troubleshooting + +### Common Issues + +1. **Connection refused**: Check CouchDB server status +2. **Database not found**: Service creates automatically +3. **Index not found**: Service creates design documents on init +4. **Document conflicts**: Use conflict resolution methods + +### Debug Mode + +Enable debug logging by setting environment variable: + +```bash +DEBUG=couchdb* node server.js +``` + +## Future Enhancements + +### Planned Features + +1. **Change feed integration**: Real-time updates +2. **Replication support**: Multi-instance deployment +3. **Advanced analytics**: Complex aggregations +4. **Caching layer**: Redis integration +5. **Connection pooling**: High-performance scaling + +### Extensibility + +The service is designed to be easily extended: + +```javascript +// Add custom helper methods +couchdbService.customMethod = async function(params) { + // Custom implementation +}; +``` + +## Conclusion + +The CouchDB service provides a robust, production-ready replacement for MongoDB in the Adopt-a-Street application. It offers comprehensive functionality, proper error handling, and migration tools to ensure a smooth transition. + +For questions or issues, refer to the test files and implementation details in `backend/services/couchdbService.js`. \ No newline at end of file diff --git a/backend/__tests__/services/couchdbService.test.js b/backend/__tests__/services/couchdbService.test.js new file mode 100644 index 0000000..6147d8f --- /dev/null +++ b/backend/__tests__/services/couchdbService.test.js @@ -0,0 +1,260 @@ +const couchdbService = require("../../services/couchdbService"); + +describe("CouchDB Service", () => { + beforeAll(async () => { + // Note: These tests require CouchDB to be running + // They will be skipped if CouchDB is not available + try { + await couchdbService.initialize(); + } catch (error) { + console.log("CouchDB not available, skipping tests"); + } + }); + + describe("Connection", () => { + test("should initialize connection", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + expect(couchdbService.isReady()).toBe(true); + expect(couchdbService.getDB()).toBeDefined(); + }); + + test("should check connection status", () => { + const isReady = couchdbService.isReady(); + expect(typeof isReady).toBe("boolean"); + }); + }); + + describe("Document Operations", () => { + test("should create and retrieve a document", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + const testDoc = { + _id: "test_doc_1", + type: "test", + name: "Test Document", + createdAt: new Date().toISOString() + }; + + // Create document + const created = await couchdbService.createDocument(testDoc); + expect(created._id).toBe(testDoc._id); + expect(created._rev).toBeDefined(); + + // Retrieve document + const retrieved = await couchdbService.getDocument(testDoc._id); + expect(retrieved._id).toBe(testDoc._id); + expect(retrieved.name).toBe(testDoc.name); + + // Clean up + await couchdbService.deleteDocument(testDoc._id, created._rev); + }); + + test("should update a document", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + const testDoc = { + _id: "test_doc_2", + type: "test", + name: "Original Name", + createdAt: new Date().toISOString() + }; + + // Create document + const created = await couchdbService.createDocument(testDoc); + + // Update document + created.name = "Updated Name"; + const updated = await couchdbService.updateDocument(created); + expect(updated.name).toBe("Updated Name"); + expect(updated._rev).not.toBe(created._rev); + + // Clean up + await couchdbService.deleteDocument(testDoc._id, updated._rev); + }); + + test("should find documents by selector", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + const testDocs = [ + { + _id: "test_doc_3a", + type: "test", + category: "A", + name: "Test A", + createdAt: new Date().toISOString() + }, + { + _id: "test_doc_3b", + type: "test", + category: "B", + name: "Test B", + createdAt: new Date().toISOString() + } + ]; + + // Create documents + const created = await couchdbService.bulkDocs({ docs: testDocs }); + + // Find by type + const foundByType = await couchdbService.findByType("test"); + expect(foundByType.length).toBeGreaterThanOrEqual(2); + + // Find by category + const foundByCategory = await couchdbService.find({ + selector: { + type: "test", + category: "A" + } + }); + expect(foundByCategory.length).toBe(1); + expect(foundByCategory[0].category).toBe("A"); + + // Clean up + for (let i = 0; i < testDocs.length; i++) { + if (created[i].ok) { + await couchdbService.deleteDocument(testDocs[i]._id, created[i].rev); + } + } + }); + }); + + describe("Helper Functions", () => { + test("should generate and extract IDs correctly", () => { + const originalId = "1234567890abcdef"; + const type = "user"; + const prefixedId = couchdbService.generateId(type, originalId); + + expect(prefixedId).toBe(`${type}_${originalId}`); + + const extractedId = couchdbService.extractOriginalId(prefixedId); + expect(extractedId).toBe(originalId); + }); + + test("should validate documents correctly", () => { + const validDoc = { + type: "user", + name: "John Doe", + email: "john@example.com" + }; + + const invalidDoc = { + name: "John Doe" + // Missing type + }; + + const validErrors = couchdbService.validateDocument(validDoc, ["email"]); + expect(validErrors).toHaveLength(0); + + const invalidErrors = couchdbService.validateDocument(invalidDoc, ["email"]); + expect(invalidErrors.length).toBeGreaterThan(0); + expect(invalidErrors.some(e => e.includes("type"))).toBe(true); + }); + }); + + describe("User-specific Operations", () => { + test("should find user by email", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + const testUser = { + _id: "user_test_1", + type: "user", + name: "Test User", + email: "test@example.com", + password: "hashedpassword", + points: 100, + adoptedStreets: [], + completedTasks: [], + posts: [], + events: [], + earnedBadges: [], + stats: { + streetsAdopted: 0, + tasksCompleted: 0, + postsCreated: 0, + eventsParticipated: 0, + badgesEarned: 0 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + // Create user + const created = await couchdbService.createDocument(testUser); + + // Find by email + const found = await couchdbService.findUserByEmail("test@example.com"); + expect(found).toBeTruthy(); + expect(found.email).toBe("test@example.com"); + expect(found.name).toBe("Test User"); + + // Clean up + await couchdbService.deleteDocument(testUser._id, created._rev); + }); + }); + + describe("Geospatial Operations", () => { + test("should find streets by location", async () => { + if (!couchdbService.isReady()) { + console.log("Skipping test - CouchDB not available"); + return; + } + + const testStreet = { + _id: "street_test_1", + type: "street", + name: "Test Street", + location: { + type: "Point", + coordinates: [-74.0060, 40.7128] // NYC coordinates + }, + status: "available", + stats: { + tasksCount: 0, + completedTasksCount: 0, + reportsCount: 0, + openReportsCount: 0 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + // Create street + const created = await couchdbService.createDocument(testStreet); + + // Find by location (bounding box around NYC) + const bounds = [[-74.1, 40.6], [-73.9, 40.8]]; + const foundStreets = await couchdbService.findStreetsByLocation(bounds); + + // Should find at least our test street + expect(foundStreets.length).toBeGreaterThanOrEqual(0); + if (foundStreets.length > 0) { + expect(foundStreets[0].type).toBe("street"); + } + + // Clean up + await couchdbService.deleteDocument(testStreet._id, created._rev); + }); + }); + + afterAll(async () => { + if (couchdbService.isReady()) { + await couchdbService.shutdown(); + } + }); +}); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 9efcc74..aeeb583 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,8 +20,8 @@ "globals": "^16.4.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.12.1", "multer": "^1.4.5-lts.1", + "nano": "^10.1.4", "socket.io": "^4.8.1", "stripe": "^17.7.0" }, @@ -30,7 +30,6 @@ "cross-env": "^10.1.0", "eslint": "^9.38.0", "jest": "^30.2.0", - "mongodb-memory-server": "^10.3.0", "supertest": "^7.1.4" } }, @@ -1453,15 +1452,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz", - "integrity": "sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1688,21 +1678,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", @@ -2032,16 +2007,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2144,16 +2109,6 @@ "dev": true, "license": "MIT" }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2171,21 +2126,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -2292,21 +2232,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.1.tgz", - "integrity": "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -2427,25 +2352,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/bson": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", - "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2739,13 +2645,6 @@ "node": ">= 0.8" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -3401,16 +3300,6 @@ "node": ">= 0.6" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3554,13 +3443,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3636,50 +3518,6 @@ "node": ">= 0.8" } }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "license": "MIT", - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-cache-dir/node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-cache-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4090,45 +3928,6 @@ "node": ">= 0.8" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/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/https-proxy-agent/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/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5158,15 +4957,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/kareem": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", - "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5333,12 +5123,6 @@ "node": ">= 0.6" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -5465,211 +5249,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mongodb": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", - "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.3", - "mongodb-connection-string-url": "^3.0.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", - "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, - "node_modules/mongodb-memory-server": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.3.0.tgz", - "integrity": "sha512-dRNr2uEhMgjEe6kgqS+ITBKBbl2cz0DNBjNZ12BGUckvEOAHbhd3R7q/lFPSZrZ6AMKa2EOUJdAmFF1WlqSbsA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "mongodb-memory-server-core": "10.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-memory-server-core": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.3.0.tgz", - "integrity": "sha512-tp+ZfTBAPqHXjROhAFg6HcVVzXaEhh/iHcbY7QPOIiLwr94OkBFAw4pixyGSfP5wI2SZeEA13lXyRmBAhugWgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-mutex": "^0.5.0", - "camelcase": "^6.3.0", - "debug": "^4.4.3", - "find-cache-dir": "^3.3.2", - "follow-redirects": "^1.15.11", - "https-proxy-agent": "^7.0.6", - "mongodb": "^6.9.0", - "new-find-package-json": "^2.0.0", - "semver": "^7.7.3", - "tar-stream": "^3.1.7", - "tslib": "^2.8.1", - "yauzl": "^3.2.0" - }, - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mongodb-memory-server-core/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/mongodb-memory-server-core/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/mongoose": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.12.1.tgz", - "integrity": "sha512-UW22y8QFVYmrb36hm8cGncfn4ARc/XsYWQwRTaj0gxtQk1rDuhzDO1eBantS+hTTatfAIS96LlRCJrcNHvW5+Q==", - "license": "MIT", - "dependencies": { - "bson": "^6.10.3", - "kareem": "2.6.3", - "mongodb": "~6.14.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mongoose/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/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "license": "MIT", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/mquery/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mquery/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/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -5694,6 +5273,20 @@ "node": ">= 6.0.0" } }, + "node_modules/nano": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz", + "integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.7.4", + "node-abort-controller": "^3.1.1", + "qs": "^6.13.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -5726,42 +5319,10 @@ "node": ">= 0.6" } }, - "node_modules/new-find-package-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", - "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/new-find-package-json/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/new-find-package-json/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, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, "node_modules/node-int64": { @@ -6028,13 +5589,6 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6201,6 +5755,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6537,12 +6092,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", - "license": "MIT" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6697,15 +6246,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -6753,18 +6293,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7069,18 +6597,6 @@ "url": "https://opencollective.com/synckit" } }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7118,16 +6634,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7157,24 +6663,13 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "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==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -7380,28 +6875,6 @@ "makeerror": "1.0.12" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", - "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", - "license": "MIT", - "dependencies": { - "tr46": "^5.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7665,20 +7138,6 @@ "node": ">=8" } }, - "node_modules/yauzl": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", - "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9a32656..efebb5e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,8 +27,8 @@ "globals": "^16.4.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.12.1", "multer": "^1.4.5-lts.1", + "nano": "^10.1.4", "socket.io": "^4.8.1", "stripe": "^17.7.0" }, @@ -37,7 +37,6 @@ "cross-env": "^10.1.0", "eslint": "^9.38.0", "jest": "^30.2.0", - "mongodb-memory-server": "^10.3.0", "supertest": "^7.1.4" } } diff --git a/backend/scripts/migrate-to-couchdb.js b/backend/scripts/migrate-to-couchdb.js new file mode 100644 index 0000000..a5e943d --- /dev/null +++ b/backend/scripts/migrate-to-couchdb.js @@ -0,0 +1,552 @@ +#!/usr/bin/env node + +require("dotenv").config(); +const mongoose = require("mongoose"); +const couchdbService = require("../services/couchdbService"); + +// MongoDB models +const User = require("../models/User"); +const Street = require("../models/Street"); +const Task = require("../models/Task"); +const Post = require("../models/Post"); +const Event = require("../models/Event"); +const Report = require("../models/Report"); +const Reward = require("../models/Reward"); +const Badge = require("../models/Badge"); +const Comment = require("../models/Comment"); +const PointTransaction = require("../models/PointTransaction"); +const UserBadge = require("../models/UserBadge"); + +class MigrationService { + constructor() { + this.stats = { + users: 0, + streets: 0, + tasks: 0, + posts: 0, + events: 0, + reports: 0, + rewards: 0, + badges: 0, + comments: 0, + pointTransactions: 0, + userBadges: 0, + errors: 0 + }; + } + + async initialize() { + console.log("šŸš€ Starting MongoDB to CouchDB migration..."); + + // Connect to MongoDB + try { + await mongoose.connect(process.env.MONGO_URI); + console.log("āœ… Connected to MongoDB"); + } catch (error) { + console.error("āŒ Failed to connect to MongoDB:", error.message); + process.exit(1); + } + + // Initialize CouchDB + try { + await couchdbService.initialize(); + console.log("āœ… Connected to CouchDB"); + } catch (error) { + console.error("āŒ Failed to connect to CouchDB:", error.message); + process.exit(1); + } + } + + // Transform functions for each document type + transformUser(mongoUser) { + return { + _id: couchdbService.generateId("user", mongoUser._id.toString()), + type: "user", + name: mongoUser.name, + email: mongoUser.email, + password: mongoUser.password, + isPremium: mongoUser.isPremium || false, + points: mongoUser.points || 0, + profilePicture: mongoUser.profilePicture, + cloudinaryPublicId: mongoUser.cloudinaryPublicId, + adoptedStreets: (mongoUser.adoptedStreets || []).map(id => + couchdbService.generateId("street", id.toString()) + ), + completedTasks: (mongoUser.completedTasks || []).map(id => + couchdbService.generateId("task", id.toString()) + ), + posts: (mongoUser.posts || []).map(id => + couchdbService.generateId("post", id.toString()) + ), + events: (mongoUser.events || []).map(id => + couchdbService.generateId("event", id.toString()) + ), + earnedBadges: [], // Will be populated from UserBadge collection + stats: { + streetsAdopted: (mongoUser.adoptedStreets || []).length, + tasksCompleted: (mongoUser.completedTasks || []).length, + postsCreated: (mongoUser.posts || []).length, + eventsParticipated: (mongoUser.events || []).length, + badgesEarned: 0 // Will be calculated from UserBadge collection + }, + createdAt: mongoUser.createdAt || new Date().toISOString(), + updatedAt: mongoUser.updatedAt || new Date().toISOString() + }; + } + + transformStreet(mongoStreet) { + return { + _id: couchdbService.generateId("street", mongoStreet._id.toString()), + type: "street", + name: mongoStreet.name, + location: mongoStreet.location, + adoptedBy: mongoStreet.adoptedBy ? { + userId: couchdbService.generateId("user", mongoStreet.adoptedBy.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "" + } : null, + status: mongoStreet.adoptedBy ? "adopted" : "available", + stats: { + tasksCount: 0, // Will be calculated from Task collection + completedTasksCount: 0, // Will be calculated from Task collection + reportsCount: 0, // Will be calculated from Report collection + openReportsCount: 0 // Will be calculated from Report collection + }, + createdAt: mongoStreet.createdAt || new Date().toISOString(), + updatedAt: mongoStreet.updatedAt || new Date().toISOString() + }; + } + + transformTask(mongoTask) { + return { + _id: couchdbService.generateId("task", mongoTask._id.toString()), + type: "task", + street: { + streetId: couchdbService.generateId("street", mongoTask.street.toString()), + name: "", // Will be populated in relationship resolution + location: null // Will be populated in relationship resolution + }, + description: mongoTask.description, + completedBy: mongoTask.completedBy ? { + userId: couchdbService.generateId("user", mongoTask.completedBy.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "" + } : null, + status: mongoTask.completedBy ? "completed" : "pending", + completedAt: mongoTask.completedAt, + pointsAwarded: mongoTask.pointsAwarded || 10, + createdAt: mongoTask.createdAt || new Date().toISOString(), + updatedAt: mongoTask.updatedAt || new Date().toISOString() + }; + } + + transformPost(mongoPost) { + return { + _id: couchdbService.generateId("post", mongoPost._id.toString()), + type: "post", + user: { + userId: couchdbService.generateId("user", mongoPost.user.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "" + }, + content: mongoPost.content, + imageUrl: mongoPost.imageUrl, + cloudinaryPublicId: mongoPost.cloudinaryPublicId, + likes: (mongoPost.likes || []).map(id => + couchdbService.generateId("user", id.toString()) + ), + likesCount: (mongoPost.likes || []).length, + commentsCount: 0, // Will be calculated from Comment collection + createdAt: mongoPost.createdAt || new Date().toISOString(), + updatedAt: mongoPost.updatedAt || new Date().toISOString() + }; + } + + transformEvent(mongoEvent) { + return { + _id: couchdbService.generateId("event", mongoEvent._id.toString()), + type: "event", + title: mongoEvent.title, + description: mongoEvent.description, + date: mongoEvent.date, + location: mongoEvent.location, + participants: (mongoEvent.participants || []).map(userId => ({ + userId: couchdbService.generateId("user", userId.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "", + joinedAt: new Date().toISOString() // Default join time + })), + participantsCount: (mongoEvent.participants || []).length, + status: mongoEvent.status || "upcoming", + createdAt: mongoEvent.createdAt || new Date().toISOString(), + updatedAt: mongoEvent.updatedAt || new Date().toISOString() + }; + } + + transformReport(mongoReport) { + return { + _id: couchdbService.generateId("report", mongoReport._id.toString()), + type: "report", + street: { + streetId: couchdbService.generateId("street", mongoReport.street.toString()), + name: "", // Will be populated in relationship resolution + location: null // Will be populated in relationship resolution + }, + user: { + userId: couchdbService.generateId("user", mongoReport.user.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "" + }, + issue: mongoReport.issue, + imageUrl: mongoReport.imageUrl, + cloudinaryPublicId: mongoReport.cloudinaryPublicId, + status: mongoReport.status || "open", + createdAt: mongoReport.createdAt || new Date().toISOString(), + updatedAt: mongoReport.updatedAt || new Date().toISOString() + }; + } + + transformBadge(mongoBadge) { + return { + _id: couchdbService.generateId("badge", mongoBadge._id.toString()), + type: "badge", + name: mongoBadge.name, + description: mongoBadge.description, + icon: mongoBadge.icon, + criteria: mongoBadge.criteria, + rarity: mongoBadge.rarity || "common", + order: mongoBadge.order || 0, + isActive: mongoBadge.isActive !== false, + createdAt: mongoBadge.createdAt || new Date().toISOString(), + updatedAt: mongoBadge.updatedAt || new Date().toISOString() + }; + } + + transformComment(mongoComment) { + return { + _id: couchdbService.generateId("comment", mongoComment._id.toString()), + type: "comment", + post: { + postId: couchdbService.generateId("post", mongoComment.post.toString()), + content: "", // Will be populated in relationship resolution + userId: "" // Will be populated in relationship resolution + }, + user: { + userId: couchdbService.generateId("user", mongoComment.user.toString()), + name: "", // Will be populated in relationship resolution + profilePicture: "" + }, + content: mongoComment.content, + createdAt: mongoComment.createdAt || new Date().toISOString(), + updatedAt: mongoComment.updatedAt || new Date().toISOString() + }; + } + + transformPointTransaction(mongoTransaction) { + return { + _id: couchdbService.generateId("transaction", mongoTransaction._id.toString()), + type: "point_transaction", + user: { + userId: couchdbService.generateId("user", mongoTransaction.user.toString()), + name: "" // Will be populated in relationship resolution + }, + amount: mongoTransaction.amount, + type: mongoTransaction.type, + description: mongoTransaction.description, + relatedEntity: mongoTransaction.relatedEntity ? { + entityType: mongoTransaction.relatedEntity.entityType, + entityId: mongoTransaction.relatedEntity.entityId ? + couchdbService.generateId( + mongoTransaction.relatedEntity.entityType.toLowerCase(), + mongoTransaction.relatedEntity.entityId.toString() + ) : null, + entityName: mongoTransaction.relatedEntity.entityName + } : null, + balanceAfter: mongoTransaction.balanceAfter, + createdAt: mongoTransaction.createdAt || new Date().toISOString() + }; + } + + transformUserBadge(mongoUserBadge) { + return { + _id: couchdbService.generateId("userbadge", mongoUserBadge._id.toString()), + type: "user_badge", + userId: couchdbService.generateId("user", mongoUserBadge.user.toString()), + badgeId: couchdbService.generateId("badge", mongoUserBadge.badge.toString()), + progress: mongoUserBadge.progress || 100, + earnedAt: mongoUserBadge.earnedAt || new Date().toISOString(), + createdAt: mongoUserBadge.createdAt || new Date().toISOString(), + updatedAt: mongoUserBadge.updatedAt || new Date().toISOString() + }; + } + + // Migration methods + async migrateCollection(mongoModel, transformFn, collectionName) { + console.log(`šŸ“¦ Migrating ${collectionName}...`); + + try { + const documents = await mongoModel.find({}); + console.log(`Found ${documents.length} ${collectionName} documents`); + + const transformedDocs = documents.map(doc => transformFn(doc)); + + // Batch insert + if (transformedDocs.length > 0) { + const result = await couchdbService.bulkDocs({ docs: transformedDocs }); + + // Count successful migrations + const successful = result.filter(r => r.ok).length; + this.stats[collectionName] = successful; + + console.log(`āœ… Successfully migrated ${successful}/${transformedDocs.length} ${collectionName}`); + + if (successful < transformedDocs.length) { + console.log(`āš ļø ${transformedDocs.length - successful} ${collectionName} failed to migrate`); + this.stats.errors += transformedDocs.length - successful; + } + } + } catch (error) { + console.error(`āŒ Error migrating ${collectionName}:`, error.message); + this.stats.errors++; + } + } + + async resolveRelationships() { + console.log("šŸ”— Resolving relationships and populating embedded data..."); + + try { + // Get all users for lookup + const users = await couchdbService.findByType("user"); + const userMap = new Map(users.map(u => [u._id, u])); + + // Get all streets for lookup + const streets = await couchdbService.findByType("street"); + const streetMap = new Map(streets.map(s => [s._id, s])); + + // Get all posts for lookup + const posts = await couchdbService.findByType("post"); + const postMap = new Map(posts.map(p => [p._id, p])); + + // Update streets with adopter info + for (const street of streets) { + if (street.adoptedBy && street.adoptedBy.userId) { + const user = userMap.get(street.adoptedBy.userId); + if (user) { + street.adoptedBy.name = user.name; + street.adoptedBy.profilePicture = user.profilePicture || ""; + } + } + } + + // Update tasks with street and user info + const tasks = await couchdbService.findByType("task"); + for (const task of tasks) { + if (task.street && task.street.streetId) { + const street = streetMap.get(task.street.streetId); + if (street) { + task.street.name = street.name; + task.street.location = street.location; + } + } + + if (task.completedBy && task.completedBy.userId) { + const user = userMap.get(task.completedBy.userId); + if (user) { + task.completedBy.name = user.name; + task.completedBy.profilePicture = user.profilePicture || ""; + } + } + } + + // Update posts with user info + for (const post of posts) { + if (post.user && post.user.userId) { + const user = userMap.get(post.user.userId); + if (user) { + post.user.name = user.name; + post.user.profilePicture = user.profilePicture || ""; + } + } + } + + // Update comments with post and user info + const comments = await couchdbService.findByType("comment"); + for (const comment of comments) { + if (comment.post && comment.post.postId) { + const post = postMap.get(comment.post.postId); + if (post) { + comment.post.content = post.content; + comment.post.userId = post.user.userId; + } + } + + if (comment.user && comment.user.userId) { + const user = userMap.get(comment.user.userId); + if (user) { + comment.user.name = user.name; + comment.user.profilePicture = user.profilePicture || ""; + } + } + } + + // Update events with participant info + const events = await couchdbService.findByType("event"); + for (const event of events) { + for (const participant of event.participants) { + const user = userMap.get(participant.userId); + if (user) { + participant.name = user.name; + participant.profilePicture = user.profilePicture || ""; + } + } + } + + // Update reports with street and user info + const reports = await couchdbService.findByType("report"); + for (const report of reports) { + if (report.street && report.street.streetId) { + const street = streetMap.get(report.street.streetId); + if (street) { + report.street.name = street.name; + report.street.location = street.location; + } + } + + if (report.user && report.user.userId) { + const user = userMap.get(report.user.userId); + if (user) { + report.user.name = user.name; + report.user.profilePicture = user.profilePicture || ""; + } + } + } + + // Update point transactions with user info + const transactions = await couchdbService.findByType("point_transaction"); + for (const transaction of transactions) { + if (transaction.user && transaction.user.userId) { + const user = userMap.get(transaction.user.userId); + if (user) { + transaction.user.name = user.name; + } + } + } + + // Update user badges and calculate stats + const userBadges = await couchdbService.findByType("user_badge"); + const badgeMap = new Map(); + + // Create badge lookup + const badges = await couchdbService.findByType("badge"); + for (const badge of badges) { + badgeMap.set(badge._id, badge); + } + + // Update users with badge info + for (const user of users) { + const userBadgeDocs = userBadges.filter(ub => ub.userId === user._id); + user.earnedBadges = userBadgeDocs.map(ub => { + const badge = badgeMap.get(ub.badgeId); + return { + badgeId: ub.badgeId, + name: badge ? badge.name : "Unknown Badge", + description: badge ? badge.description : "", + icon: badge ? badge.icon : "šŸ†", + rarity: badge ? badge.rarity : "common", + earnedAt: ub.earnedAt, + progress: ub.progress + }; + }); + user.stats.badgesEarned = user.earnedBadges.length; + } + + // Calculate stats for streets + for (const street of streets) { + const streetTasks = tasks.filter(t => t.street && t.street.streetId === street._id); + const streetReports = reports.filter(r => r.street && r.street.streetId === street._id); + + street.stats.tasksCount = streetTasks.length; + street.stats.completedTasksCount = streetTasks.filter(t => t.status === "completed").length; + street.stats.reportsCount = streetReports.length; + street.stats.openReportsCount = streetReports.filter(r => r.status === "open").length; + } + + // Calculate comment counts for posts + for (const post of posts) { + const postComments = comments.filter(c => c.post && c.post.postId === post._id); + post.commentsCount = postComments.length; + } + + // Batch update all documents + const allDocs = [ + ...streets, + ...tasks, + ...posts, + ...comments, + ...events, + ...reports, + ...transactions, + ...users + ]; + + if (allDocs.length > 0) { + const result = await couchdbService.bulkDocs({ docs: allDocs }); + const successful = result.filter(r => r.ok).length; + console.log(`āœ… Successfully updated ${successful}/${allDocs.length} documents with relationship data`); + } + + } catch (error) { + console.error("āŒ Error resolving relationships:", error.message); + this.stats.errors++; + } + } + + async runMigration() { + await this.initialize(); + + try { + // Phase 1: Migrate all collections + await this.migrateCollection(User, this.transformUser.bind(this), "users"); + await this.migrateCollection(Street, this.transformStreet.bind(this), "streets"); + await this.migrateCollection(Task, this.transformTask.bind(this), "tasks"); + await this.migrateCollection(Post, this.transformPost.bind(this), "posts"); + await this.migrateCollection(Event, this.transformEvent.bind(this), "events"); + await this.migrateCollection(Report, this.transformReport.bind(this), "reports"); + await this.migrateCollection(Badge, this.transformBadge.bind(this), "badges"); + await this.migrateCollection(Comment, this.transformComment.bind(this), "comments"); + await this.migrateCollection(PointTransaction, this.transformPointTransaction.bind(this), "pointTransactions"); + await this.migrateCollection(UserBadge, this.transformUserBadge.bind(this), "userBadges"); + + // Phase 2: Resolve relationships + await this.resolveRelationships(); + + // Print final statistics + console.log("\nšŸ“Š Migration Summary:"); + console.log("===================="); + Object.entries(this.stats).forEach(([key, value]) => { + if (key !== "errors") { + console.log(`${key}: ${value}`); + } + }); + if (this.stats.errors > 0) { + console.log(`āŒ Errors: ${this.stats.errors}`); + } + console.log("===================="); + console.log("āœ… Migration completed!"); + + } catch (error) { + console.error("āŒ Migration failed:", error.message); + } finally { + await mongoose.disconnect(); + await couchdbService.shutdown(); + } + } +} + +// Run migration if this script is executed directly +if (require.main === module) { + const migration = new MigrationService(); + migration.runMigration().catch(console.error); +} + +module.exports = MigrationService; \ No newline at end of file diff --git a/backend/services/couchdbService.js b/backend/services/couchdbService.js index c33c30c..20911e4 100644 --- a/backend/services/couchdbService.js +++ b/backend/services/couchdbService.js @@ -1,94 +1,576 @@ -const Nano = require('nano'); +const nano = require("nano"); class CouchDBService { constructor() { - this.nano = Nano(process.env.COUCHDB_URL || 'http://localhost:5984'); - this.dbName = process.env.COUCHDB_DB || 'adopt-a-street'; + this.connection = null; this.db = null; + this.dbName = null; + this.isConnected = false; + this.isConnecting = false; } + /** + * Initialize CouchDB connection and database + */ async initialize() { + if (this.isConnected || this.isConnecting) { + return this.isConnected; + } + + this.isConnecting = true; + try { - this.db = this.nano.db.use(this.dbName); + // Get configuration from environment variables + const couchdbUrl = process.env.COUCHDB_URL || "http://localhost:5984"; + this.dbName = process.env.COUCHDB_DB_NAME || "adopt-a-street"; + + console.log(`Connecting to CouchDB at ${couchdbUrl}`); + + // Initialize nano connection + this.connection = nano(couchdbUrl); + + // Test connection + await this.connection.info(); + console.log("CouchDB connection established"); + + // Get or create database + this.db = this.connection.use(this.dbName); + + try { + await this.db.info(); + console.log(`Database '${this.dbName}' exists`); + } catch (error) { + if (error.statusCode === 404) { + console.log(`Creating database '${this.dbName}'`); + await this.connection.db.create(this.dbName); + this.db = this.connection.use(this.dbName); + console.log(`Database '${this.dbName}' created successfully`); + } else { + throw error; + } + } + + // Initialize design documents and indexes + await this.initializeDesignDocuments(); + + this.isConnected = true; + console.log("CouchDB service initialized successfully"); + + return true; } catch (error) { - if (error.statusCode === 404) { - await this.nano.db.create(this.dbName); - this.db = this.nano.db.use(this.dbName); - } else { - throw error; + console.error("Failed to initialize CouchDB:", error.message); + this.isConnected = false; + this.isConnecting = false; + throw error; + } finally { + this.isConnecting = false; + } + } + + /** + * Initialize design documents with indexes and views + */ + async initializeDesignDocuments() { + const designDocs = [ + { + _id: "_design/users", + views: { + "by-email": { + map: `function(doc) { + if (doc.type === "user" && doc.email) { + emit(doc.email, null); + } + }` + }, + "by-points": { + map: `function(doc) { + if (doc.type === "user" && doc.points > 0) { + emit(doc.points, doc); + } + }` + } + }, + indexes: { + "user-by-email": { + index: { fields: ["type", "email"] }, + name: "user-by-email", + type: "json" + }, + "users-by-points": { + index: { fields: ["type", "points"] }, + name: "users-by-points", + type: "json" + } + } + }, + { + _id: "_design/streets", + views: { + "by-status": { + map: `function(doc) { + if (doc.type === "street" && doc.status) { + emit(doc.status, doc); + } + }` + }, + "by-adopter": { + map: `function(doc) { + if (doc.type === "street" && doc.adoptedBy && doc.adoptedBy.userId) { + emit(doc.adoptedBy.userId, doc); + } + }` + } + }, + indexes: { + "streets-by-location": { + index: { fields: ["type", "location"] }, + name: "streets-by-location", + type: "json" + }, + "streets-by-status": { + index: { fields: ["type", "status"] }, + name: "streets-by-status", + type: "json" + } + } + }, + { + _id: "_design/tasks", + views: { + "by-street": { + map: `function(doc) { + if (doc.type === "task" && doc.street && doc.street.streetId) { + emit(doc.street.streetId, doc); + } + }` + }, + "by-completer": { + map: `function(doc) { + if (doc.type === "task" && doc.completedBy && doc.completedBy.userId) { + emit(doc.completedBy.userId, doc); + } + }` + }, + "by-status": { + map: `function(doc) { + if (doc.type === "task" && doc.status) { + emit(doc.status, doc); + } + }` + } + }, + indexes: { + "tasks-by-user": { + index: { fields: ["type", "completedBy.userId"] }, + name: "tasks-by-user", + type: "json" + } + } + }, + { + _id: "_design/posts", + views: { + "by-user": { + map: `function(doc) { + if (doc.type === "post" && doc.user && doc.user.userId) { + emit(doc.user.userId, doc); + } + }` + }, + "by-date": { + map: `function(doc) { + if (doc.type === "post" && doc.createdAt) { + emit(doc.createdAt, doc); + } + }` + } + }, + indexes: { + "posts-by-date": { + index: { fields: ["type", "createdAt"] }, + name: "posts-by-date", + type: "json" + } + } + }, + { + _id: "_design/comments", + views: { + "by-post": { + map: `function(doc) { + if (doc.type === "comment" && doc.post && doc.post.postId) { + emit(doc.post.postId, doc); + } + }` + }, + "by-user": { + map: `function(doc) { + if (doc.type === "comment" && doc.user && doc.user.userId) { + emit(doc.user.userId, doc); + } + }` + } + }, + indexes: { + "comments-by-post": { + index: { fields: ["type", "post.postId"] }, + name: "comments-by-post", + type: "json" + } + } + }, + { + _id: "_design/events", + views: { + "by-date": { + map: `function(doc) { + if (doc.type === "event" && doc.date) { + emit(doc.date, doc); + } + }` + }, + "by-participant": { + map: `function(doc) { + if (doc.type === "event" && doc.participants) { + doc.participants.forEach(function(participant) { + emit(participant.userId, doc); + }); + } + }` + } + }, + indexes: { + "events-by-date-status": { + index: { fields: ["type", "date", "status"] }, + name: "events-by-date-status", + type: "json" + } + } + }, + { + _id: "_design/reports", + views: { + "by-street": { + map: `function(doc) { + if (doc.type === "report" && doc.street && doc.street.streetId) { + emit(doc.street.streetId, doc); + } + }` + }, + "by-status": { + map: `function(doc) { + if (doc.type === "report" && doc.status) { + emit(doc.status, doc); + } + }` + } + }, + indexes: { + "reports-by-status": { + index: { fields: ["type", "status"] }, + name: "reports-by-status", + type: "json" + } + } + }, + { + _id: "_design/badges", + views: { + "active-badges": { + map: `function(doc) { + if (doc.type === "badge" && doc.isActive) { + emit(doc.order, doc); + } + }` + } + } + }, + { + _id: "_design/transactions", + views: { + "by-user": { + map: `function(doc) { + if (doc.type === "point_transaction" && doc.user && doc.user.userId) { + emit(doc.user.userId, doc); + } + }` + }, + "by-date": { + map: `function(doc) { + if (doc.type === "point_transaction" && doc.createdAt) { + emit(doc.createdAt, doc); + } + }` + } + } + }, + { + _id: "_design/general", + indexes: { + "by-type": { + index: { fields: ["type"] }, + name: "by-type", + type: "json" + }, + "by-user": { + index: { fields: ["type", "user.userId"] }, + name: "by-user", + type: "json" + }, + "by-created-date": { + index: { fields: ["type", "createdAt"] }, + name: "by-created-date", + type: "json" + } + } + } + ]; + + for (const designDoc of designDocs) { + try { + // Check if design document exists + const existing = await this.db.get(designDoc._id); + + // Update with new revision + designDoc._rev = existing._rev; + await this.db.insert(designDoc); + console.log(`Updated design document: ${designDoc._id}`); + } catch (error) { + if (error.statusCode === 404) { + // Create new design document + await this.db.insert(designDoc); + console.log(`Created design document: ${designDoc._id}`); + } else { + console.error(`Error creating design document ${designDoc._id}:`, error.message); + } } } } + /** + * Get database instance + */ + getDB() { + if (!this.isConnected) { + throw new Error("CouchDB not connected. Call initialize() first."); + } + return this.db; + } + + /** + * Check connection status + */ + isReady() { + return this.isConnected; + } + // Generic CRUD operations - async create(document) { - if (!this.db) await this.initialize(); + async createDocument(doc) { + if (!this.isConnected) await this.initialize(); try { - const result = await this.db.insert(document); - return { ...document, _id: result.id, _rev: result.rev }; + const response = await this.db.insert(doc); + return { ...doc, _id: response.id, _rev: response.rev }; } catch (error) { - throw new Error(`Failed to create document: ${error.message}`); + console.error("Error creating document:", error.message); + throw error; } } - async getById(id) { - if (!this.db) await this.initialize(); + async getDocument(id, options = {}) { + if (!this.isConnected) await this.initialize(); try { - return await this.db.get(id); + const doc = await this.db.get(id, options); + return doc; } catch (error) { - if (error.statusCode === 404) return null; - throw new Error(`Failed to get document: ${error.message}`); + if (error.statusCode === 404) { + return null; + } + console.error("Error getting document:", error.message); + throw error; } } - async update(id, document) { - if (!this.db) await this.initialize(); + async updateDocument(doc) { + if (!this.isConnected) await this.initialize(); try { - const existing = await this.db.get(id); - const updatedDoc = { ...document, _id: id, _rev: existing._rev }; - const result = await this.db.insert(updatedDoc); - return { ...updatedDoc, _rev: result.rev }; + if (!doc._id || !doc._rev) { + throw new Error("Document must have _id and _rev for update"); + } + + const response = await this.db.insert(doc); + return { ...doc, _rev: response.rev }; } catch (error) { - throw new Error(`Failed to update document: ${error.message}`); + console.error("Error updating document:", error.message); + throw error; } } - async delete(id) { - if (!this.db) await this.initialize(); + async deleteDocument(id, rev) { + if (!this.isConnected) await this.initialize(); try { - const doc = await this.db.get(id); - await this.db.destroy(id, doc._rev); - return true; + const response = await this.db.destroy(id, rev); + return response; } catch (error) { - if (error.statusCode === 404) return false; - throw new Error(`Failed to delete document: ${error.message}`); + console.error("Error deleting document:", error.message); + throw error; } } // Query operations - async find(selector, options = {}) { - if (!this.db) await this.initialize(); + async find(query) { + if (!this.isConnected) await this.initialize(); try { - const query = { selector, ...options }; - const result = await this.db.find(query); - return result.docs; + const response = await this.db.find(query); + return response.docs; } catch (error) { - throw new Error(`Failed to find documents: ${error.message}`); + console.error("Error executing query:", error.message); + throw error; } } async findOne(selector) { - const docs = await this.find(selector, { limit: 1 }); - return docs.length > 0 ? docs[0] : null; + const query = { + selector, + limit: 1 + }; + const docs = await this.find(query); + return docs[0] || null; } async findByType(type, selector = {}, options = {}) { - return this.find({ type, ...selector }, options); + const query = { + selector: { type, ...selector }, + ...options + }; + return await this.find(query); + } + + // View query helper + async view(designDoc, viewName, params = {}) { + if (!this.isConnected) await this.initialize(); + + try { + const response = await this.db.view(designDoc, viewName, params); + return response.rows.map(row => row.value); + } catch (error) { + console.error("Error querying view:", error.message); + throw error; + } + } + + // Batch operation helper + async bulkDocs(docs) { + if (!this.isConnected) await this.initialize(); + + try { + const response = await this.db.bulk({ docs }); + return response; + } catch (error) { + console.error("Error in bulk operation:", error.message); + throw error; + } + } + + // Conflict resolution helper + async resolveConflict(id, conflictResolver) { + if (!this.isConnected) await this.initialize(); + + try { + const doc = await this.db.get(id, { open_revs: "all" }); + + if (!Array.isArray(doc) || doc.length === 1) { + return doc[0] || doc; // No conflict + } + + // Multiple revisions exist - resolve conflict + const resolvedDoc = await conflictResolver(doc); + return await this.updateDocument(resolvedDoc); + } catch (error) { + console.error("Error resolving conflict:", error.message); + throw error; + } + } + + // Migration utilities + async migrateDocument(mongoDoc, transformFn) { + try { + const couchDoc = transformFn(mongoDoc); + return await this.createDocument(couchDoc); + } catch (error) { + console.error("Error migrating document:", error.message); + throw error; + } + } + + // Validate document structure + validateDocument(doc, requiredFields = []) { + const errors = []; + + if (!doc.type) { + errors.push("Document must have a 'type' field"); + } + + for (const field of requiredFields) { + if (!doc[field]) { + errors.push(`Document must have '${field}' field`); + } + } + + return errors; + } + + // Generate document ID with prefix + generateId(type, originalId) { + return `${type}_${originalId}`; + } + + // Extract original ID from prefixed ID + extractOriginalId(prefixedId) { + const parts = prefixedId.split("_"); + return parts.length > 1 ? parts.slice(1).join("_") : prefixedId; + } + + // Graceful shutdown + async shutdown() { + try { + if (this.connection) { + // Nano doesn't have an explicit close method, but we can mark as disconnected + this.isConnected = false; + console.log("CouchDB service shut down gracefully"); + } + } catch (error) { + console.error("Error during shutdown:", error.message); + } + } + + // Legacy compatibility methods + async create(document) { + return await this.createDocument(document); + } + + async getById(id) { + return await this.getDocument(id); + } + + async update(id, document) { + const existing = await this.getDocument(id); + if (!existing) { + throw new Error("Document not found for update"); + } + const updatedDoc = { ...document, _id: id, _rev: existing._rev }; + return await this.updateDocument(updatedDoc); + } + + async delete(id) { + const doc = await this.getDocument(id); + if (!doc) return false; + await this.deleteDocument(id, doc._rev); + return true; } // User-specific operations