diff --git a/.env.example b/.env.example index 0c79967..3c42293 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,21 @@ RABBITMQ_NOTIFICATION_QUEUE=substream_notifications_queue RABBITMQ_EMAIL_QUEUE=substream_emails_queue RABBITMQ_LEADERBOARD_QUEUE=substream_leaderboard_queue +# Social Token Gating Configuration +SOCIAL_TOKEN_ENABLED=true +SOCIAL_TOKEN_CACHE_TTL=300 +SOCIAL_TOKEN_REVERIFICATION_INTERVAL=60000 +SOCIAL_TOKEN_CACHE_PREFIX=social_token: +STELLAR_MAX_RETRIES=3 +STELLAR_RETRY_DELAY=1000 + +# Creator Collaboration Revenue Attribution Configuration +COLLABORATION_ENABLED=true +COLLABORATION_DEFAULT_SPLIT_RATIO=0.5 +COLLABORATION_MIN_WATCH_TIME_SECONDS=30 +COLLABORATION_CACHE_TTL=3600 +COLLABORATION_CACHE_PREFIX=collaboration: + # Global Stats Caching Configuration GLOBAL_STATS_REFRESH_INTERVAL=60000 GLOBAL_STATS_INITIAL_DELAY=5000 diff --git a/docs/COLLABORATION_REVENUE_ATTRIBUTION.md b/docs/COLLABORATION_REVENUE_ATTRIBUTION.md new file mode 100644 index 0000000..6b8a040 --- /dev/null +++ b/docs/COLLABORATION_REVENUE_ATTRIBUTION.md @@ -0,0 +1,830 @@ +# Creator Collaboration Revenue Attribution API + +## Overview + +The Creator Collaboration Revenue Attribution system enables creators to collaborate on content and fairly share the revenue generated through "Drips" (streaming payments). When two or more creators co-author content, the system temporarily overrides the default subscription split for the duration of that specific content, tracks exactly how many seconds were watched, and ensures the payout logic in the smart contract matches the offline attribution during withdrawal cycles. + +## Features + +- **Co-authored Content Support**: Multiple creators can collaborate on a single piece of content +- **Custom Split Ratios**: Flexible revenue sharing agreements between collaborators +- **Precise Watch Time Tracking**: Accurate second-by-second tracking for collaborative content +- **Temporary Split Overrides**: Collaboration-specific revenue splits that don't affect other content +- **Smart Contract Integration**: Payout data formatted for Stellar smart contract verification +- **Revenue Attribution Verification**: Ensures offline calculations match on-chain payouts +- **Comprehensive Analytics**: Detailed collaboration statistics and performance metrics + +## Architecture + +### Core Components + +1. **CollaborationRevenueService** (`services/collaborationRevenueService.js`) + - Collaboration creation and management + - Revenue split calculation and attribution + - Watch time tracking and aggregation + - Smart contract payout data generation + +2. **CollaborationWatchTimeMiddleware** (`middleware/collaborationWatchTime.js`) + - Real-time watch time tracking for collaborative content + - Session management and cleanup + - WebSocket integration for live updates + - Performance monitoring and statistics + +3. **Collaboration API** (`routes/collaborations.js`) + - Complete REST API for collaboration management + - Watch time recording and attribution + - Revenue calculation and statistics + - Smart contract integration endpoints + +### Database Schema + +- `content_collaborations`: Collaboration metadata and tracking +- `collaboration_participants`: Creator participation and split ratios +- `collaboration_watch_logs`: Individual user watch time records +- `revenue_attribution_logs`: Historical attribution calculations +- `content`: Extended with collaboration flags and references + +## Collaboration Model + +### Revenue Split Logic + +``` +Total Revenue × Split Ratio = Attributed Revenue + +Example: +- Total Revenue: 100 XLM +- Primary Creator: 60% (0.6) → 60 XLM +- Collaborator A: 25% (0.25) → 25 XLM +- Collaborator B: 15% (0.15) → 15 XLM +``` + +### Watch Time Tracking + +- **Minimum Threshold**: 30 seconds minimum watch time to count +- **User-level Tracking**: Each user's watch time tracked independently +- **Session Management**: Real-time session tracking with automatic cleanup +- **Aggregation**: Watch time aggregated per participant for revenue calculation + +### Smart Contract Integration + +The system generates payout data that matches smart contract requirements: + +```javascript +{ + collaborationId: "collab_1234567890", + contentId: "content-abc123", + primaryCreator: "GABC...XYZ", + participants: [ + { + creatorAddress: "GABC...XYZ", + splitRatio: 0.6, + watchSeconds: 3600, + attributedRevenue: 60.0 + }, + // ... other participants + ], + calculatedAt: "2024-03-15T12:00:00.000Z" +} +``` + +## API Endpoints + +### Collaboration Management + +#### Create Collaboration (Creator Only) +```http +POST /api/collaborations +``` + +**Request Body:** +```json +{ + "contentId": "content-123", + "collaboratorAddresses": [ + "GDEF...XYZ", + "GHI...XYZ" + ], + "splitRatios": { + "GABC...XYZ": 0.6, + "GDEF...XYZ": 0.25, + "GHI...XYZ": 0.15 + }, + "status": "active" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "collaboration": { + "id": "collab_abc123", + "contentId": "content-123", + "primaryCreatorAddress": "GABC...XYZ", + "status": "active", + "totalWatchSeconds": 0, + "participants": [ + { + "creatorAddress": "GABC...XYZ", + "splitRatio": 0.6, + "role": "primary", + "watchSeconds": 0 + }, + { + "creatorAddress": "GDEF...XYZ", + "splitRatio": 0.25, + "role": "collaborator", + "watchSeconds": 0 + } + ], + "totalParticipants": 3 + }, + "message": "Collaboration created successfully" + } +} +``` + +#### Get Collaboration Details +```http +GET /api/collaborations/:collaborationId +``` + +#### Update Collaboration Status (Primary Creator Only) +```http +PATCH /api/collaborations/:collaborationId/status +``` + +**Request Body:** +```json +{ + "status": "completed" +} +``` + +### Watch Time Tracking + +#### Record Watch Time +```http +POST /api/collaborations/watch-time +``` + +**Request Body:** +```json +{ + "contentId": "content-123", + "watchSeconds": 120 +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "contentId": "content-123", + "userAddress": "GXYZ...ABC", + "watchSeconds": 120, + "result": { + "isCollaborative": true, + "recorded": true, + "collaborationId": "collab_abc123", + "watchSeconds": 120, + "totalWatchSeconds": 1500 + } + } +} +``` + +### Revenue Attribution + +#### Calculate Revenue Attribution +```http +GET /api/collaborations/:collaborationId/attribution +``` + +**Query Parameters:** +- `startTime`: Period start (ISO date) +- `endTime`: Period end (ISO date) +- `totalRevenue`: Total revenue to attribute + +**Response:** +```json +{ + "success": true, + "data": { + "attribution": { + "collaborationId": "collab_abc123", + "contentId": "content-123", + "totalRevenue": 100.0, + "currency": "XLM", + "totalWatchSeconds": 7200, + "totalAttributedRevenue": 100.0, + "totalAttributedWatchTime": 7200, + "attribution": [ + { + "creatorAddress": "GABC...XYZ", + "role": "primary", + "watchSeconds": 4320, + "watchTimeShare": 0.6, + "splitRatio": 0.6, + "revenueShare": 0.6, + "attributedRevenue": 60.0, + "currency": "XLM" + }, + { + "creatorAddress": "GDEF...XYZ", + "role": "collaborator", + "watchSeconds": 1800, + "watchTimeShare": 0.25, + "splitRatio": 0.25, + "revenueShare": 0.25, + "attributedRevenue": 25.0, + "currency": "XLM" + } + ], + "calculatedAt": "2024-03-15T12:00:00.000Z" + } + } +} +``` + +#### Get Content Attribution +```http +GET /api/collaborations/content/:contentId/attribution +``` + +### Analytics & Statistics + +#### Get Creator Statistics (Creator Only) +```http +GET /api/collaborations/stats +``` + +**Query Parameters:** +- `status`: Filter by collaboration status (default: active) +- `startTime`: Period start +- `endTime`: Period end + +**Response:** +```json +{ + "success": true, + "data": { + "creatorAddress": "GABC...XYZ", + "totalCollaborations": 5, + "totalWatchSeconds": 15000, + "totalRevenue": 250.0, + "collaboratorCount": 8, + "topCollaborators": [ + { + "creatorAddress": "GDEF...XYZ", + "collaborationCount": 3, + "totalWatchSeconds": 8000, + "avgSplitRatio": 0.3 + } + ], + "period": { + "startTime": "2024-02-15T00:00:00.000Z", + "endTime": "2024-03-15T00:00:00.000Z", + "status": "active" + } + } +} +``` + +### Smart Contract Integration + +#### Get Payout Data for Smart Contract (Primary Creator Only) +```http +GET /api/collaborations/:collaborationId/payout-data +``` + +**Response:** +```json +{ + "success": true, + "data": { + "payoutData": { + "collaborationId": "collab_abc123", + "contentId": "content-123", + "primaryCreator": "GABC...XYZ", + "participants": [ + { + "creatorAddress": "GABC...XYZ", + "splitRatio": 0.6, + "watchSeconds": 4320, + "attributedRevenue": 60.0 + }, + { + "creatorAddress": "GDEF...XYZ", + "splitRatio": 0.25, + "watchSeconds": 1800, + "attributedRevenue": 25.0 + } + ], + "totalWatchSeconds": 7200, + "calculatedAt": "2024-03-15T12:00:00.000Z", + "signature": null + } + } +} +``` + +#### Verify Payout Attribution (Primary Creator Only) +```http +POST /api/collaborations/:collaborationId/verify-payout +``` + +**Request Body:** +```json +{ + "contractPayout": { + "collaborationId": "collab_abc123", + "participants": [ + { + "creatorAddress": "GABC...XYZ", + "splitRatio": 0.6, + "watchSeconds": 4320, + "attributedRevenue": 60.0 + } + ], + "totalWatchSeconds": 7200 + } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "verification": { + "collaborationId": "collab_abc123", + "matches": true, + "discrepancies": [], + "verifiedAt": "2024-03-15T12:00:00.000Z" + } + } +} +``` + +## Integration with CDN Token System + +The collaboration system integrates seamlessly with the existing CDN token infrastructure: + +### Enhanced Token Request + +When requesting a CDN token for collaborative content: + +```javascript +// Standard CDN token request +const tokenResponse = await fetch('/api/cdn/token', { + method: 'POST', + headers: { 'Authorization': 'Bearer USER_JWT' }, + body: JSON.stringify({ + walletAddress: 'GXYZ...ABC', + creatorAddress: 'GABC...XYZ', + contentId: 'collaborative-content-123', + segmentPath: 'video/segment1.ts' + }) +}); +``` + +The system automatically detects collaborative content and prepares for watch time tracking. + +### Watch Time Recording + +After token issuance, watch time is automatically tracked: + +```javascript +// Watch time is recorded automatically during content playback +// The middleware detects collaborative content and tracks watch time +// No additional API calls required from the client +``` + +## WebSocket Integration + +For real-time watch time tracking and updates: + +```javascript +// Connect to collaboration WebSocket +const ws = new WebSocket('wss://api.substream.protocol/collaborations/ws'); + +// Start watch time session +ws.send(JSON.stringify({ + type: 'start_session', + sessionId: 'watch_1234567890', + contentId: 'collaborative-content-123', + userAddress: 'GXYZ...ABC' +})); + +// Record watch time increments +ws.send(JSON.stringify({ + type: 'watch_time_increment', + sessionId: 'watch_1234567890', + seconds: 30 +})); + +// Get session info +ws.send(JSON.stringify({ + type: 'get_session_info', + sessionId: 'watch_1234567890' +})); +``` + +### WebSocket Events + +- **session_started**: Watch time session initiated +- **watch_time_recorded**: Watch time increment recorded +- **session_info**: Current session information +- **session_ended**: Session completed and recorded +- **error**: Session-related errors + +## Configuration + +### Environment Variables + +```bash +# Creator Collaboration Configuration +COLLABORATION_ENABLED=true # Enable/disable collaboration system +COLLABORATION_DEFAULT_SPLIT_RATIO=0.5 # Default 50/50 split +COLLABORATION_MIN_WATCH_TIME_SECONDS=30 # Minimum watch time to count +COLLABORATION_CACHE_TTL=3600 # Cache TTL in seconds (1 hour) +COLLABORATION_CACHE_PREFIX=collaboration: # Redis key prefix +``` + +### Split Ratio Configuration + +Default split ratios can be customized: + +```javascript +// Equal split (default) +const splitRatios = { + 'GABC...XYZ': 0.5, + 'GDEF...XYZ': 0.5 +}; + +// Weighted split +const splitRatios = { + 'GABC...XYZ': 0.6, // Primary creator gets 60% + 'GDEF...XYZ': 0.25, // First collaborator gets 25% + 'GHI...XYZ': 0.15 // Second collaborator gets 15% +}; +``` + +## Use Cases + +### Basic Collaboration + +```javascript +// Creator sets up collaboration +const collaboration = await fetch('/api/collaborations', { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_JWT' }, + body: JSON.stringify({ + contentId: 'interview-episode-1', + collaboratorAddresses: ['GDEF...XYZ'], + splitRatios: { + 'GABC...XYZ': 0.7, // Interviewer gets 70% + 'GDEF...XYZ': 0.3 // Guest gets 30% + } + }) +}); +``` + +### Multi-Creator Collaboration + +```javascript +// Multiple creators collaborate +const multiCollab = await fetch('/api/collaborations', { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_JWT' }, + body: JSON.stringify({ + contentId: 'panel-discussion-123', + collaboratorAddresses: ['GDEF...XYZ', 'GHI...XYZ', 'GJKL...MNO'], + splitRatios: { + 'GABC...XYZ': 0.4, // Host gets 40% + 'GDEF...XYZ': 0.2, // Panelist 1 gets 20% + 'GHI...XYZ': 0.2, // Panelist 2 gets 20% + 'GJKL...MNO': 0.2 // Panelist 3 gets 20% + } + }) +}); +``` + +### Revenue Attribution + +```javascript +// Calculate revenue attribution for a period +const attribution = await fetch('/api/collaborations/collab_123/attribution?startTime=2024-03-01&endTime=2024-03-31&totalRevenue=500', { + headers: { 'Authorization': 'Bearer CREATOR_JWT' } +}); + +const result = await attribution.json(); +console.log('Revenue attribution:', result.data.attribution); + +// Get smart contract payout data +const payoutData = await fetch('/api/collaborations/collab_123/payout-data', { + headers: { 'Authorization': 'Bearer CREATOR_JWT' } +}); + +const payout = await payoutData.json(); +console.log('Smart contract data:', payout.data.payoutData); +``` + +### Verification Process + +```javascript +// Verify smart contract payout matches offline calculation +const verification = await fetch('/api/collaborations/collab_123/verify-payout', { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_JWT' }, + body: JSON.stringify({ + contractPayout: smartContractResult + }) +}); + +const result = await verification.json(); + +if (result.data.verification.matches) { + console.log('Payout verification successful'); +} else { + console.log('Discrepancies found:', result.data.verification.discrepancies); +} +``` + +## Performance Considerations + +### Watch Time Tracking + +- **Session Management**: In-memory tracking with Redis persistence +- **Batch Processing**: Watch time aggregated in batches to reduce database load +- **Minimum Threshold**: 30-second minimum prevents spam tracking +- **Automatic Cleanup**: Expired sessions automatically cleaned up + +### Caching Strategy + +- **Collaboration Data**: 1-hour cache for collaboration metadata +- **Revenue Attribution**: Cached calculations for repeated requests +- **User Sessions**: In-memory session tracking for performance + +### Database Optimization + +```sql +-- Optimized indexes for performance +CREATE INDEX idx_collaborations_content ON content_collaborations(content_id); +CREATE INDEX idx_participants_collaboration ON collaboration_participants(collaboration_id, creator_address); +CREATE INDEX idx_watch_logs_collaboration ON collaboration_watch_logs(collaboration_id, user_address); +CREATE INDEX idx_watch_logs_time ON collaboration_watch_logs(last_watched_at); +``` + +## Smart Contract Integration + +### Payout Data Format + +The system generates payout data compatible with Stellar smart contracts: + +```javascript +const payoutData = { + collaborationId: "collab_abc123", + contentId: "content-xyz", + primaryCreator: "GABC...XYZ", + participants: [ + { + creatorAddress: "GABC...XYZ", + splitRatio: 0.6, + watchSeconds: 3600, + attributedRevenue: 60.0 + } + ], + totalWatchSeconds: 7200, + calculatedAt: "2024-03-15T12:00:00.000Z" +}; +``` + +### Verification Process + +1. **Offline Calculation**: Backend calculates revenue attribution +2. **Smart Contract Payout**: On-chain distribution based on calculation +3. **Verification**: Backend verifies on-chain payout matches offline calculation +4. **Discrepancy Handling**: Any mismatches logged and flagged for review + +### Withdrawal Cycle Integration + +```javascript +// During withdrawal cycle +const collaborationData = await getActiveCollaborations(); +const payoutCalculations = []; + +for (const collaboration of collaborationData) { + const attribution = await calculateRevenueAttribution(collaboration.id); + const payoutData = formatForSmartContract(attribution); + payoutCalculations.push(payoutData); +} + +// Send to smart contract +const txResult = await executePayouts(payoutCalculations); + +// Verify results +for (const payout of payoutCalculations) { + await verifyPayoutAttribution(payout.collaborationId, txResult); +} +``` + +## Analytics and Reporting + +### Collaboration Metrics + +- **Total Collaborations**: Number of collaborative content pieces +- **Revenue Generated**: Total revenue from collaborative content +- **Watch Time Distribution**: How watch time is distributed among collaborators +- **Top Collaborators**: Most frequent collaboration partners +- **Split Ratio Analysis**: Most common split ratios and their effectiveness + +### Performance Tracking + +```javascript +// Get collaboration statistics +const stats = await fetch('/api/collaborations/stats', { + headers: { 'Authorization': 'Bearer CREATOR_JWT' } +}); + +const result = await stats.json(); +console.log('Collaboration performance:', result.data); + +// Analyze top collaborators +result.data.topCollaborators.forEach(collaborator => { + console.log(`Collaborated with ${collaborator.creatorAddress} ${collaborator.collaborationCount} times`); + console.log(`Average split ratio: ${collaborator.avgSplitRatio}`); + console.log(`Total watch time: ${collaborator.totalWatchSeconds} seconds`); +}); +``` + +## Troubleshooting + +### Common Issues + +#### Collaboration Not Found + +```bash +# Check if collaboration exists for content +curl "https://api.substream.protocol/api/collaborations/content/content-123" \ + -H "Authorization: Bearer TOKEN" +``` + +#### Watch Time Not Recording + +```javascript +// Verify content is collaborative +const collaboration = await getCollaborationForContent(contentId); +if (!collaboration) { + console.log('Content is not collaborative'); +} + +// Check minimum watch time threshold +if (watchSeconds < 30) { + console.log('Watch time below minimum threshold'); +} +``` + +#### Revenue Attribution Mismatch + +```javascript +// Verify payout calculation +const verification = await verifyPayoutAttribution(collaborationId, contractPayout); + +if (!verification.matches) { + console.log('Discrepancies:', verification.discrepancies); + + // Check specific fields + verification.discrepancies.forEach(discrepancy => { + console.log(`${discrepancy.field}: offline=${discrepancy.offline}, contract=${discrepancy.contract}`); + }); +} +``` + +### Debug Mode + +```bash +# Enable debug logging +DEBUG=collaboration:* npm start +``` + +## Future Enhancements + +Planned improvements: + +1. **Dynamic Split Ratios**: Split ratios that change based on contribution metrics +2. **Multi-Content Collaborations**: Collaborations spanning multiple content pieces +3. **Revenue Pooling**: Shared revenue pools for creator groups +4. **Automated Split Suggestions**: AI-powered split ratio recommendations +5. **Cross-Platform Collaboration**: Support for collaborations across platforms +6. **Advanced Analytics**: Machine learning for collaboration success prediction +7. **Smart Contract Templates**: Pre-built contract templates for common collaboration types +8. **Real-time Revenue Tracking**: Live revenue attribution during content consumption + +## API Examples + +### Complete Collaboration Workflow + +```javascript +// 1. Create collaboration +const collaboration = await fetch('/api/collaborations', { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_JWT' }, + body: JSON.stringify({ + contentId: 'podcast-episode-45', + collaboratorAddresses: ['GDEF...XYZ', 'GHI...XYZ'], + splitRatios: { + 'GABC...XYZ': 0.5, // Host + 'GDEF...XYZ': 0.3, // Co-host + 'GHI...XYZ': 0.2 // Guest + } + }) +}); + +const collabResult = await collaboration.json(); +console.log('Collaboration created:', collabResult.data.collaboration.id); + +// 2. Content consumption (automatic watch time tracking) +// Users watch the collaborative content through the CDN +// Watch time is automatically recorded by the middleware + +// 3. Calculate revenue attribution +const attribution = await fetch(`/api/collaborations/${collabResult.data.collaboration.id}/attribution?totalRevenue=200`, { + headers: { 'Authorization': 'Bearer CREATOR_JWT' } +}); + +const attrResult = await attribution.json(); +console.log('Revenue attribution:', attrResult.data.attribution); + +// 4. Get smart contract payout data +const payoutData = await fetch(`/api/collaborations/${collabResult.data.collaboration.id}/payout-data`, { + headers: { 'Authorization': 'Bearer CREATOR_JWT' } +}); + +const payoutResult = await payoutData.json(); +console.log('Smart contract data:', payoutResult.data.payoutData); + +// 5. Execute smart contract payout (integration point) +const contractResult = await executeSmartContractPayout(payoutResult.data.payoutData); + +// 6. Verify payout matches offline calculation +const verification = await fetch(`/api/collaborations/${collabResult.data.collaboration.id}/verify-payout`, { + method: 'POST', + headers: { 'Authorization': 'Bearer CREATOR_JWT' }, + body: JSON.stringify({ + contractPayout: contractResult + }) +}); + +const verifyResult = await verification.json(); +console.log('Verification result:', verifyResult.data.verification); +``` + +### Real-time Watch Time Tracking + +```javascript +// WebSocket connection for real-time tracking +const ws = new WebSocket('wss://api.substream.protocol/collaborations/ws'); + +ws.onopen = () => { + // Start watching collaborative content + ws.send(JSON.stringify({ + type: 'start_session', + sessionId: 'watch_session_123', + contentId: 'collaborative-content-456', + userAddress: 'GXYZ...ABC' + })); +}; + +// Simulate watch time increments during playback +setInterval(() => { + ws.send(JSON.stringify({ + type: 'watch_time_increment', + sessionId: 'watch_session_123', + seconds: 10 // 10 seconds of watch time + })); +}, 10000); // Every 10 seconds + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'watch_time_recorded': + console.log(`Watch time: ${data.totalWatchTime} seconds`); + break; + case 'session_ended': + console.log('Session completed and recorded'); + break; + case 'error': + console.error('Session error:', data.message); + break; + } +}; +``` + +--- + +This Creator Collaboration Revenue Attribution system enables fair and transparent revenue sharing for collaborative content, ensuring that all creators receive their proper share based on agreed-upon split ratios and actual watch time contributions. diff --git a/index.js b/index.js index 7e2f0b2..50d9f2f 100644 --- a/index.js +++ b/index.js @@ -30,13 +30,13 @@ const VideoProcessingWorker = require('./src/services/videoProcessingWorker'); const { BackgroundWorkerService } = require('./src/services/backgroundWorkerService'); const { GlobalStatsService } = require('./src/services/globalStatsService'); const GlobalStatsWorker = require('./src/services/globalStatsWorker'); -const SocialTokenGatingService = require('./services/socialTokenGatingService'); -const SocialTokenGatingMiddleware = require('./middleware/socialTokenGating'); +const CollaborationRevenueService = require('./services/collaborationRevenueService'); +const CollaborationWatchTimeMiddleware = require('./middleware/collaborationWatchTime'); const createVideoRoutes = require('./routes/video'); const createGlobalStatsRouter = require('./routes/globalStats'); const createDeviceRoutes = require('./routes/device'); const createSwaggerRoutes = require('./routes/swagger'); -const createSocialTokenRoutes = require('./routes/socialToken'); +const createCollaborationRoutes = require('./routes/collaborations'); const { buildAuditLogCsv } = require('./src/utils/export/auditLogCsv'); const { buildAuditLogPdf } = require('./src/utils/export/auditLogPdf'); const { getRequestIp } = require('./src/utils/requestIp'); @@ -148,14 +148,13 @@ function createApp(dependencies = {}) { const videoWorker = dependencies.videoWorker || new VideoProcessingWorker(config, database); - // Initialize leaderboard service and worker - const redisClient = getRedisClient(); - const leaderboardService = dependencies.leaderboardService || new EngagementLeaderboardService(config, database, redisClient); - const leaderboardWorker = dependencies.leaderboardWorker || new LeaderboardWorker(config, database, redisClient, leaderboardService); - // Initialize social token gating service and middleware - const socialTokenService = dependencies.socialTokenService || new SocialTokenGatingService(config, database, redisClient); - const socialTokenMiddleware = dependencies.socialTokenMiddleware || new SocialTokenGatingMiddleware(socialTokenService, database, redisClient); + const socialTokenService = dependencies.socialTokenService || new SocialTokenGatingService(config, database, getRedisClient()); + const socialTokenMiddleware = dependencies.socialTokenMiddleware || new SocialTokenGatingMiddleware(socialTokenService, database, getRedisClient()); + + // Initialize collaboration revenue service and watch time middleware + const collaborationService = dependencies.collaborationService || new CollaborationRevenueService(config, database, getRedisClient()); + const collaborationWatchTimeMiddleware = dependencies.collaborationWatchTimeMiddleware || new CollaborationWatchTimeMiddleware(collaborationService, database); // Initialize global stats service and worker const globalStatsService = dependencies.globalStatsService || new GlobalStatsService(database); @@ -186,6 +185,8 @@ function createApp(dependencies = {}) { app.set('socialTokenMiddleware', socialTokenMiddleware); app.set('subdomainService', subdomainService); app.set('sslCertificateService', sslCertificateService); + app.set('collaborationService', collaborationService); + app.set('collaborationWatchTimeMiddleware', collaborationWatchTimeMiddleware); // Initialize and start predictive churn analysis worker const { PredictiveChurnAnalysisWorker } = require('./src/services/predictiveChurnAnalysisWorker'); @@ -218,9 +219,15 @@ function createApp(dependencies = {}) { // Payouts API app.use('/api/payouts', require('./routes/payouts')); + // Social token gating endpoints + app.use('/api/social-token', createSocialTokenRoutes()); + // Global stats endpoints app.use('/api/global-stats', createGlobalStatsRouter({ database, globalStatsService })); + // Creator collaboration endpoints + app.use('/api/collaborations', createCollaborationRoutes()); + // Subdomain management endpoints app.use('/api/subdomains', createSubdomainRoutes({ database, config, subdomainService, sslCertificateService })); diff --git a/middleware/collaborationWatchTime.js b/middleware/collaborationWatchTime.js new file mode 100644 index 0000000..1e51de3 --- /dev/null +++ b/middleware/collaborationWatchTime.js @@ -0,0 +1,429 @@ +const { logger } = require('../utils/logger'); + +/** + * Collaboration Watch Time Tracking Middleware + * Tracks watch time for collaborative content and triggers revenue attribution + */ +class CollaborationWatchTimeMiddleware { + constructor(collaborationService, database) { + this.collaborationService = collaborationService; + this.database = database; + + // Configuration + this.minWatchTimeSeconds = 30; // Minimum 30 seconds to count + this.sessionTimeout = 300000; // 5 minutes session timeout + this.activeSessions = new Map(); // In-memory session tracking + } + + /** + * Express middleware for tracking watch time + * @param {object} options Middleware options + * @returns {Function} Express middleware function + */ + trackWatchTime(options = {}) { + return async (req, res, next) => { + try { + // Check if content is collaborative + const collaboration = await this.collaborationService.getCollaborationForContent(req.body?.contentId || req.query?.contentId); + + if (!collaboration) { + // Not collaborative content, proceed normally + return next(); + } + + // Generate session ID + const sessionId = this.generateSessionId(); + const startTime = Date.now(); + + // Store session in memory + this.activeSessions.set(sessionId, { + collaborationId: collaboration.id, + contentId: collaboration.content_id, + userAddress: req.user.address, + startTime, + lastActivity: startTime, + totalWatchTime: 0, + isActive: true + }); + + // Add session info to request + req.collaborationSession = { + sessionId, + collaborationId: collaboration.id, + isCollaborative: true, + participants: collaboration.participants, + startTime + }; + + // Set up cleanup on response finish + const originalSend = res.send; + res.send = function(data) { + // End session when response is sent + endSession(sessionId); + originalSend.call(res, data); + }; + + // Set up cleanup on connection close + req.on('close', () => { + endSession(sessionId); + }); + + // Set up session timeout + const timeoutId = setTimeout(() => { + endSession(sessionId); + }, this.sessionTimeout); + + // Store timeout ID for cleanup + this.activeSessions.get(sessionId).timeoutId = timeoutId; + + logger.debug('Collaboration watch time session started', { + sessionId, + collaborationId: collaboration.id, + contentId: collaboration.content_id, + userAddress: req.user.address + }); + + next(); + } catch (error) { + logger.error('Collaboration watch time middleware error', { + error: error.message, + contentId: req.body?.contentId || req.query?.contentId, + userAddress: req.user?.address + }); + next(); + } + }; + } + + /** + * Record watch time increment + * @param {string} sessionId Session ID + * @param {number} seconds Number of seconds to add + * @returns {Promise} Updated session info + */ + async recordWatchTimeIncrement(sessionId, seconds) { + try { + const session = this.activeSessions.get(sessionId); + + if (!session || !session.isActive) { + return { recorded: false, reason: 'Session not found or inactive' }; + } + + // Update session + session.totalWatchTime += seconds; + session.lastActivity = Date.now(); + + // Reset timeout + if (session.timeoutId) { + clearTimeout(session.timeoutId); + } + session.timeoutId = setTimeout(() => { + endSession(sessionId); + }, this.sessionTimeout); + + logger.debug('Watch time increment recorded', { + sessionId, + seconds, + totalWatchTime: session.totalWatchTime + }); + + return { + recorded: true, + sessionId, + seconds, + totalWatchTime: session.totalWatchTime + }; + } catch (error) { + logger.error('Failed to record watch time increment', { + error: error.message, + sessionId, + seconds + }); + return { recorded: false, error: error.message }; + } + } + + /** + * End watch time session and record to database + * @param {string} sessionId Session ID + * @returns {Promise} Whether session was ended successfully + */ + async endSession(sessionId) { + try { + const session = this.activeSessions.get(sessionId); + + if (!session) { + return false; + } + + // Mark as inactive to prevent duplicate processing + session.isActive = false; + + // Clear timeout + if (session.timeoutId) { + clearTimeout(sessionId); + } + + // Remove from memory + this.activeSessions.delete(sessionId); + + // Record watch time if above minimum threshold + if (session.totalWatchTime >= this.minWatchTimeSeconds) { + await this.collaborationService.recordWatchTime( + session.contentId, + session.userAddress, + session.totalWatchTime + ); + + logger.info('Collaboration watch time session recorded', { + sessionId, + collaborationId: session.collaborationId, + contentId: session.contentId, + userAddress: session.userAddress, + watchSeconds: session.totalWatchTime, + sessionDuration: Date.now() - session.startTime + }); + } else { + logger.debug('Watch time below minimum threshold, not recorded', { + sessionId, + collaborationId: session.collaborationId, + contentId: session.contentId, + userAddress: session.userAddress, + watchSeconds: session.totalWatchTime, + minimumThreshold: this.minWatchTimeSeconds + }); + } + + return true; + } catch (error) { + logger.error('Failed to end collaboration session', { + error: error.message, + sessionId + }); + return false; + } + } + + /** + * Get active session count + * @returns {number} Number of active sessions + */ + getActiveSessionCount() { + return this.activeSessions.size; + } + + /** + * Get session information + * @param {string} sessionId Session ID + * @returns {object|null} Session information + */ + getSessionInfo(sessionId) { + const session = this.activeSessions.get(sessionId); + + if (!session) { + return null; + } + + return { + sessionId, + collaborationId: session.collaborationId, + contentId: session.contentId, + userAddress: session.userAddress, + startTime: session.startTime, + lastActivity: session.lastActivity, + totalWatchTime: session.totalWatchTime, + sessionDuration: Date.now() - session.startTime, + isActive: session.isActive + }; + } + + /** + * Clean up expired sessions + * @returns {number} Number of sessions cleaned up + */ + cleanupExpiredSessions() { + try { + const now = Date.now(); + const expiredSessions = []; + + for (const [sessionId, session] of this.activeSessions.entries()) { + const age = now - session.lastActivity; + + if (age > this.sessionTimeout || !session.isActive) { + expiredSessions.push(sessionId); + + // Clear timeout + if (session.timeoutId) { + clearTimeout(session.timeoutId); + } + + // Remove from memory + this.activeSessions.delete(sessionId); + + // Record watch time if above minimum threshold + if (session.totalWatchTime >= this.minWatchTimeSeconds) { + this.collaborationService.recordWatchTime( + session.contentId, + session.userAddress, + session.totalWatchTime + ).catch(error => { + logger.error('Failed to record watch time during cleanup', { + error: error.message, + sessionId + }); + }); + } + } + } + + if (expiredSessions.length > 0) { + logger.info('Expired collaboration sessions cleaned up', { + cleanedCount: expiredSessions.length, + totalActive: this.activeSessions.size + }); + } + + return expiredSessions.length; + } catch (error) { + logger.error('Failed to cleanup expired sessions', { + error: error.message + }); + return 0; + } + } + + /** + * Get collaboration statistics + * @returns {object} Statistics + */ + getStatistics() { + try { + const activeSessions = this.activeSessions.size; + const totalWatchTime = Array.from(this.activeSessions.values()) + .reduce((sum, session) => sum + session.totalWatchTime, 0); + + const sessionsByCollaboration = {}; + for (const session of this.activeSessions.values()) { + const collabId = session.collaborationId; + if (!sessionsByCollaboration[collabId]) { + sessionsByCollaboration[collabId] = { + collaborationId: collabId, + contentId: session.contentId, + activeSessions: 0, + totalWatchTime: 0 + }; + } + sessionsByCollaboration[collabId].activeSessions++; + sessionsByCollaboration[collabId].totalWatchTime += session.totalWatchTime; + } + + return { + activeSessions, + totalWatchTime, + averageWatchTime: activeSessions > 0 ? totalWatchTime / activeSessions : 0, + sessionsByCollaboration: Object.values(sessionsByCollaboration), + generatedAt: new Date().toISOString() + }; + } catch (error) { + logger.error('Failed to get collaboration statistics', { + error: error.message + }); + return null; + } + } + + /** + * Generate unique session ID + * @returns {string} Session ID + */ + generateSessionId() { + return `watch_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; + } + + /** + * Start automatic cleanup interval + */ + startCleanupInterval() { + // Clean up expired sessions every 5 minutes + setInterval(() => { + this.cleanupExpiredSessions(); + }, 300000); + } + + /** + * Create watch time tracking endpoint for WebSocket or real-time updates + * @param {object} socket WebSocket connection + * @param {object} data Connection data + */ + handleWebSocketConnection(socket, data) { + const { sessionId, contentId, userAddress } = data; + + if (!sessionId || !contentId || !userAddress) { + socket.emit('error', { message: 'Invalid connection data' }); + return; + } + + // Verify session exists and is collaborative + const session = this.activeSessions.get(sessionId); + if (!session) { + socket.emit('error', { message: 'Session not found' }); + return; + } + + // Verify user matches session + if (session.userAddress !== userAddress) { + socket.emit('error', { message: 'User address mismatch' }); + return; + } + + // Store socket reference + session.socket = socket; + + // Set up message handlers + socket.on('watch_time_increment', async (data) => { + try { + const { seconds } = data; + + if (typeof seconds !== 'number' || seconds <= 0) { + socket.emit('error', { message: 'Invalid watch time increment' }); + return; + } + + const result = await this.recordWatchTimeIncrement(sessionId, seconds); + + socket.emit('watch_time_recorded', { + sessionId, + seconds, + totalWatchTime: result.totalWatchTime, + recorded: result.recorded + }); + } catch (error) { + logger.error('WebSocket watch time increment error', { + error: error.message, + sessionId + }); + socket.emit('error', { message: 'Failed to record watch time' }); + } + }); + + socket.on('get_session_info', () => { + const sessionInfo = this.getSessionInfo(sessionId); + socket.emit('session_info', sessionInfo); + }); + + socket.on('disconnect', () => { + // Remove socket reference + if (session.socket === socket) { + session.socket = null; + } + }); + + logger.info('Collaboration WebSocket connected', { + sessionId, + contentId, + userAddress + }); + } +} + +module.exports = CollaborationWatchTimeMiddleware; diff --git a/migrations/knex/009_add_collaboration_tables.js b/migrations/knex/009_add_collaboration_tables.js new file mode 100644 index 0000000..0699184 --- /dev/null +++ b/migrations/knex/009_add_collaboration_tables.js @@ -0,0 +1,118 @@ +exports.up = function(knex) { + return knex.schema + // Content collaborations table + .createTable('content_collaborations', function(table) { + table.string('id').primary().defaultTo(knex.raw('lower(hex(randomblob(16)))')); + table.string('content_id').notNullable().references('id').inTable('content').onDelete('CASCADE'); + table.string('primary_creator_address').notNullable().index(); + table.enum('status', ['active', 'inactive', 'completed']).defaultTo('active').index(); + table.integer('total_watch_seconds').defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index(['primary_creator_address', 'status']); + table.index(['content_id']); + table.index(['status', 'created_at']); + }) + + // Collaboration participants table + .createTable('collaboration_participants', function(table) { + table.string('collaboration_id').notNullable().references('id').inTable('content_collaborations').onDelete('CASCADE'); + table.string('creator_address').notNullable().index(); + table.decimal('split_ratio', 5, 4).notNullable(); // Split ratio (0.0000 to 1.0000) + table.enum('role', ['primary', 'collaborator']).notNullable().defaultTo('collaborator'); + table.integer('watch_seconds').defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index(['collaboration_id', 'creator_address']); + table.index(['creator_address', 'role']); + table.index(['collaboration_id', 'role']); + + // Unique constraint: one participant per address per collaboration + table.unique(['collaboration_id', 'creator_address']); + }) + + // Collaboration watch logs table + .createTable('collaboration_watch_logs', function(table) { + table.string('collaboration_id').notNullable().references('id').inTable('content_collaborations').onDelete('CASCADE'); + table.string('user_address').notNullable().index(); + table.integer('watch_seconds').notNullable(); + table.timestamp('first_watched_at').defaultTo(knex.fn.now()); + table.timestamp('last_watched_at').defaultTo(knex.fn.now()); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index(['collaboration_id', 'user_address']); + table.index(['user_address', 'last_watched_at']); + table.index(['collaboration_id', 'last_watched_at']); + + // Unique constraint: one record per user per collaboration + table.unique(['collaboration_id', 'user_address']); + }) + + // Revenue attribution logs table + .createTable('revenue_attribution_logs', function(table) { + table.string('id').primary().defaultTo(knex.raw('lower(hex(randomblob(16)))')); + table.string('collaboration_id').notNullable().references('id').inTable('content_collaborations').onDelete('CASCADE'); + table.string('period_start').notNullable(); + table.string('period_end').notNullable(); + table.decimal('total_revenue', 20, 8).notNullable(); + table.string('currency').notNullable().defaultTo('XLM'); + table.integer('total_watch_seconds').notNullable(); + table.json('attribution_data').notNullable(); // Detailed attribution breakdown + table.string('verification_status').defaultTo('pending'); // pending, verified, failed + table.text('verification_details'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('verified_at'); + + // Indexes for performance + table.index(['collaboration_id', 'period_start']); + table.index(['verification_status']); + table.index(['created_at']); + }) + + // Update content table to support collaborations + .table('content', function(table) { + table.boolean('is_collaborative').defaultTo(false); + table.string('collaboration_id').references('id').inTable('content_collaborations').onDelete('SET NULL'); + table.timestamp('collaboration_updated_at'); + + // Add index for collaborative content queries + table.index(['is_collaborative']); + table.index(['collaboration_id']); + }) + + // Update creators table to track collaboration stats + .table('creators', function(table) { + table.integer('total_collaborations').defaultTo(0); + table.integer('collaborator_count').defaultTo(0); + table.decimal('total_collaboration_revenue', 20, 8).defaultTo(0); + table.timestamp('collaboration_stats_updated_at'); + + // Add index for collaboration stats + table.index(['total_collaborations']); + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('revenue_attribution_logs') + .dropTableIfExists('collaboration_watch_logs') + .dropTableIfExists('collaboration_participants') + .dropTableIfExists('content_collaborations') + .table('content', function(table) { + table.dropColumn('is_collaborative'); + table.dropColumn('collaboration_id'); + table.dropColumn('collaboration_updated_at'); + }) + .table('creators', function(table) { + table.dropColumn('total_collaborations'); + table.dropColumn('collaborator_count'); + table.dropColumn('total_collaboration_revenue'); + table.dropColumn('collaboration_stats_updated_at'); + }); +}; diff --git a/routes/collaborations.js b/routes/collaborations.js new file mode 100644 index 0000000..22fddc0 --- /dev/null +++ b/routes/collaborations.js @@ -0,0 +1,719 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateToken, requireCreator } = require('../middleware/auth'); +const { logger } = require('../utils/logger'); + +/** + * Creator Collaboration Revenue Attribution API Routes + * Provides endpoints for managing co-authored content and revenue sharing + */ + +/** + * Create a new collaboration (creator only) + * POST /api/collaborations + */ +router.post('/', authenticateToken, requireCreator, async (req, res) => { + try { + const { + contentId, + collaboratorAddresses, + splitRatios, + status = 'active' + } = req.body; + + // Validate required fields + if (!contentId || !collaboratorAddresses || !Array.isArray(collaboratorAddresses)) { + return res.status(400).json({ + success: false, + error: 'contentId and collaboratorAddresses array are required', + code: 'MISSING_REQUIRED_FIELDS' + }); + } + + if (collaboratorAddresses.length === 0) { + return res.status(400).json({ + success: false, + error: 'At least one collaborator is required', + code: 'NO_COLLABORATORS' + }); + } + + // Validate split ratios if provided + if (splitRatios) { + const totalSplit = Object.values(splitRatios).reduce((sum, ratio) => sum + ratio, 0); + if (totalSplit > 1) { + return res.status(400).json({ + success: false, + error: 'Total split ratios cannot exceed 1.0 (100%)', + code: 'INVALID_SPLIT_TOTAL' + }); + } + + // Validate all addresses in split ratios are valid + for (const [address, ratio] of Object.entries(splitRatios)) { + if (!address.match(/^G[A-Z0-9]{55}$/)) { + return res.status(400).json({ + success: false, + error: `Invalid Stellar address format: ${address}`, + code: 'INVALID_ADDRESS' + }); + } + } + } + + // Validate collaborator addresses + for (const address of collaboratorAddresses) { + if (!address.match(/^G[A-Z0-9]{55}$/)) { + return res.status(400).json({ + success: false, + error: `Invalid Stellar address format: ${address}`, + code: 'INVALID_ADDRESS' + }); + } + + // Ensure no duplicates + if (collaboratorAddresses.filter(a => a === address).length > 1) { + return res.status(400).json({ + success: false, + error: `Duplicate collaborator address: ${address}`, + code: 'DUPLICATE_COLLABORATOR' + }); + } + } + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Create collaboration + const collaboration = await collaborationService.createCollaboration({ + contentId, + primaryCreatorAddress: req.user.address, + collaboratorAddresses, + splitRatios, + status + }); + + res.status(201).json({ + success: true, + data: { + collaboration, + message: 'Collaboration created successfully' + } + }); + + } catch (error) { + logger.error('Create collaboration error', { + error: error.message, + creatorAddress: req.user.address, + contentId: req.body.contentId + }); + + if (error.message.includes('owner can only create')) { + return res.status(403).json({ + success: false, + error: error.message, + code: 'NOT_CONTENT_OWNER' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to create collaboration', + code: 'COLLABORATION_CREATE_FAILED' + }); + } +}); + +/** + * Get collaboration by ID + * GET /api/collaborations/:collaborationId + */ +router.get('/:collaborationId', authenticateToken, async (req, res) => { + try { + const { collaborationId } = req.params; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const collaboration = await collaborationService.getCollaboration(collaborationId); + + // Verify user is part of this collaboration + const isParticipant = collaboration.participants.some( + p => p.creator_address === req.user.address + ); + + if (!isParticipant) { + return res.status(403).json({ + success: false, + error: 'Access denied - not a collaboration participant', + code: 'ACCESS_DENIED' + }); + } + + res.json({ + success: true, + data: { collaboration } + }); + + } catch (error) { + logger.error('Get collaboration error', { + error: error.message, + collaborationId: req.params.collaborationId, + userAddress: req.user.address + }); + + if (error.message.includes('not found')) { + return res.status(404).json({ + success: false, + error: 'Collaboration not found', + code: 'COLLABORATION_NOT_FOUND' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to get collaboration', + code: 'COLLABORATION_GET_FAILED' + }); + } +}); + +/** + * Update collaboration status (primary creator only) + * PATCH /api/collaborations/:collaborationId/status + */ +router.patch('/:collaborationId/status', authenticateToken, async (req, res) => { + try { + const { collaborationId } = req.params; + const { status } = req.body; + + if (!status || !['active', 'inactive', 'completed'].includes(status)) { + return res.status(400).json({ + success: false, + error: 'Valid status required: active, inactive, or completed', + code: 'INVALID_STATUS' + }); + } + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify user is primary creator + const collaboration = await collaborationService.getCollaboration(collaborationId); + + if (collaboration.primary_creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'Only primary creator can update collaboration status', + code: 'NOT_PRIMARY_CREATOR' + }); + } + + const updated = await collaborationService.updateCollaborationStatus(collaborationId, status); + + if (!updated) { + return res.status(404).json({ + success: false, + error: 'Collaboration not found', + code: 'COLLABORATION_NOT_FOUND' + }); + } + + const updatedCollaboration = await collaborationService.getCollaboration(collaborationId); + + res.json({ + success: true, + data: { + collaboration: updatedCollaboration, + message: 'Collaboration status updated successfully' + } + }); + + } catch (error) { + logger.error('Update collaboration status error', { + error: error.message, + collaborationId: req.params.collaborationId, + userAddress: req.user.address, + status: req.body.status + }); + + res.status(500).json({ + success: false, + error: 'Failed to update collaboration status', + code: 'STATUS_UPDATE_FAILED' + }); + } +}); + +/** + * Record watch time for collaborative content + * POST /api/collaborations/watch-time + */ +router.post('/watch-time', authenticateToken, async (req, res) => { + try { + const { contentId, watchSeconds } = req.body; + + if (!contentId || !watchSeconds) { + return res.status(400).json({ + success: false, + error: 'contentId and watchSeconds are required', + code: 'MISSING_REQUIRED_FIELDS' + }); + } + + const watchTime = parseInt(watchSeconds); + if (isNaN(watchTime) || watchTime <= 0) { + return res.status(400).json({ + success: false, + error: 'watchSeconds must be a positive integer', + code: 'INVALID_WATCH_TIME' + }); + } + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Record watch time + const result = await collaborationService.recordWatchTime( + contentId, + req.user.address, + watchTime + ); + + res.json({ + success: true, + data: { + contentId, + userAddress: req.user.address, + watchSeconds, + result + } + }); + + } catch (error) { + logger.error('Record watch time error', { + error: error.message, + contentId: req.body.contentId, + userAddress: req.user.address, + watchSeconds: req.body.watchSeconds + }); + + res.status(500).json({ + success: false, + error: 'Failed to record watch time', + code: 'WATCH_TIME_RECORD_FAILED' + }); + } +}); + +/** + * Get revenue attribution for collaboration + * GET /api/collaborations/:collaborationId/attribution + */ +router.get('/:collaborationId/attribution', authenticateToken, async (req, res) => { + try { + const { collaborationId } = req.params; + const { startTime, endTime, totalRevenue } = req.query; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify user is part of this collaboration + const collaboration = await collaborationService.getCollaboration(collaborationId); + const isParticipant = collaboration.participants.some( + p => p.creator_address === req.user.address + ); + + if (!isParticipant) { + return res.status(403).json({ + success: false, + error: 'Access denied - not a collaboration participant', + code: 'ACCESS_DENIED' + }); + } + + // Parse optional parameters + const attributionParams = { + collaborationId, + startTime: startTime ? new Date(startTime) : null, + endTime: endTime ? new Date(endTime) : null, + totalRevenue: totalRevenue ? parseFloat(totalRevenue) : 0 + }; + + const attribution = await collaborationService.calculateRevenueAttribution(attributionParams); + + res.json({ + success: true, + data: { + attribution + } + }); + + } catch (error) { + logger.error('Get revenue attribution error', { + error: error.message, + collaborationId: req.params.collaborationId, + userAddress: req.user.address + }); + + if (error.message.includes('not found')) { + return res.status(404).json({ + success: false, + error: 'Collaboration not found', + code: 'COLLABORATION_NOT_FOUND' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to get revenue attribution', + code: 'ATTRIBUTION_GET_FAILED' + }); + } +}); + +/** + * Get revenue attribution for content + * GET /api/collaborations/content/:contentId/attribution + */ +router.get('/content/:contentId/attribution', authenticateToken, async (req, res) => { + try { + const { contentId } = req.params; + const { startTime, endTime, totalRevenue } = req.query; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Parse optional parameters + const period = { + startTime: startTime ? new Date(startTime) : null, + endTime: endTime ? new Date(endTime) : null, + totalRevenue: totalRevenue ? parseFloat(totalRevenue) : 0 + }; + + const attribution = await collaborationService.getContentRevenueAttribution(contentId, period); + + if (!attribution) { + return res.status(404).json({ + success: false, + error: 'No collaboration found for this content', + code: 'NO_COLLABORATION_FOUND' + }); + } + + // Verify user is part of this collaboration + const isParticipant = attribution.attribution.some( + p => p.creatorAddress === req.user.address + ); + + if (!isParticipant) { + return res.status(403).json({ + success: false, + error: 'Access denied - not a collaboration participant', + code: 'ACCESS_DENIED' + }); + } + + res.json({ + success: true, + data: { attribution } + }); + + } catch (error) { + logger.error('Get content attribution error', { + error: error.message, + contentId: req.params.contentId, + userAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to get content attribution', + code: 'CONTENT_ATTRIBUTION_GET_FAILED' + }); + } +}); + +/** + * Get creator collaboration statistics (creator only) + * GET /api/collaborations/stats + */ +router.get('/stats', authenticateToken, requireCreator, async (req, res) => { + try { + const creatorAddress = req.user.address; + const { + status = 'active', + startTime, + endTime + } = req.query; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const filters = { + status, + startTime: startTime ? new Date(startTime) : null, + endTime: endTime ? new Date(endTime) : null + }; + + const stats = await collaborationService.getCreatorCollaborationStats(creatorAddress, filters); + + if (!stats) { + return res.status(404).json({ + success: false, + error: 'No collaboration statistics available', + code: 'NO_STATS_FOUND' + }); + } + + res.json({ + success: true, + data: stats + }); + + } catch (error) { + logger.error('Get creator collaboration stats error', { + error: error.message, + creatorAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to get collaboration statistics', + code: 'STATS_GET_FAILED' + }); + } +}); + +/** + * Get smart contract payout data for collaboration + * GET /api/collaborations/:collaborationId/payout-data + */ +router.get('/:collaborationId/payout-data', authenticateToken, async (req, res) => { + try { + const { collaborationId } = req.params; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify user is primary creator + const collaboration = await collaborationService.getCollaboration(collaborationId); + + if (collaboration.primary_creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'Only primary creator can access payout data', + code: 'NOT_PRIMARY_CREATOR' + }); + } + + const payoutData = await collaborationService.getSmartContractPayoutData(collaborationId); + + res.json({ + success: true, + data: { + payoutData + } + }); + + } catch (error) { + logger.error('Get payout data error', { + error: error.message, + collaborationId: req.params.collaborationId, + userAddress: req.user.address + }); + + if (error.message.includes('not found')) { + return res.status(404).json({ + success: false, + error: 'Collaboration not found', + code: 'COLLABORATION_NOT_FOUND' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to get payout data', + code: 'PAYOUT_DATA_GET_FAILED' + }); + } +}); + +/** + * Verify payout attribution against smart contract (primary creator only) + * POST /api/collaborations/:collaborationId/verify-payout + */ +router.post('/:collaborationId/verify-payout', authenticateToken, async (req, res) => { + try { + const { collaborationId } = req.params; + const { contractPayout } = req.body; + + if (!contractPayout) { + return res.status(400).json({ + success: false, + error: 'contractPayout is required', + code: 'MISSING_CONTRACT_PAYOUT' + }); + } + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + // Verify user is primary creator + const collaboration = await collaborationService.getCollaboration(collaborationId); + + if (collaboration.primary_creator_address !== req.user.address) { + return res.status(403).json({ + success: false, + error: 'Only primary creator can verify payout attribution', + code: 'NOT_PRIMARY_CREATOR' + }); + } + + const verification = await collaborationService.verifyPayoutAttribution(collaborationId, contractPayout); + + res.json({ + success: true, + data: { + verification + } + }); + + } catch (error) { + logger.error('Verify payout attribution error', { + error: error.message, + collaborationId: req.params.collaborationId, + userAddress: req.user.address + }); + + if (error.message.includes('not found')) { + return res.status(404).json({ + success: false, + error: 'Collaboration not found', + code: 'COLLABORATION_NOT_FOUND' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to verify payout attribution', + code: 'PAYOUT_VERIFICATION_FAILED' + }); + } +}); + +/** + * Get collaboration for content (internal use) + * GET /api/collaborations/content/:contentId + */ +router.get('/content/:contentId', authenticateToken, async (req, res) => { + try { + const { contentId } = req.params; + + const collaborationService = req.app.get('collaborationService'); + if (!collaborationService) { + return res.status(503).json({ + success: false, + error: 'Collaboration service not available', + code: 'SERVICE_UNAVAILABLE' + }); + } + + const collaboration = await collaborationService.getCollaborationForContent(contentId); + + if (!collaboration) { + return res.status(404).json({ + success: false, + error: 'No collaboration found for this content', + code: 'NO_COLLABORATION_FOUND' + }); + } + + // Verify user is part of this collaboration + const isParticipant = collaboration.participants.some( + p => p.creator_address === req.user.address + ); + + if (!isParticipant) { + return res.status(403).json({ + success: false, + error: 'Access denied - not a collaboration participant', + code: 'ACCESS_DENIED' + }); + } + + res.json({ + success: true, + data: { collaboration } + }); + + } catch (error) { + logger.error('Get content collaboration error', { + error: error.message, + contentId: req.params.contentId, + userAddress: req.user.address + }); + + res.status(500).json({ + success: false, + error: 'Failed to get content collaboration', + code: 'CONTENT_COLLABORATION_GET_FAILED' + }); + } +}); + +module.exports = router; diff --git a/services/collaborationRevenueService.js b/services/collaborationRevenueService.js new file mode 100644 index 0000000..d6b2ab8 --- /dev/null +++ b/services/collaborationRevenueService.js @@ -0,0 +1,857 @@ +const { logger } = require('../utils/logger'); + +/** + * Creator Collaboration Revenue Attribution Service + * Handles revenue sharing for co-authored content with precise watch time tracking + */ +class CollaborationRevenueService { + constructor(config, database, redisClient) { + this.config = config; + this.database = database; + this.redis = redisClient; + + // Configuration + this.defaultSplitRatio = config.collaboration?.defaultSplitRatio || 0.5; // 50/50 default + this.minWatchTimeSeconds = config.collaboration?.minWatchTimeSeconds || 30; + this.cacheTTL = config.collaboration?.cacheTTL || 3600; // 1 hour cache + this.prefix = config.collaboration?.cachePrefix || 'collaboration:'; + } + + /** + * Create a collaboration for content + * @param {object} collaborationData Collaboration details + * @returns {Promise} Created collaboration + */ + async createCollaboration(collaborationData) { + try { + const { + contentId, + primaryCreatorAddress, + collaboratorAddresses, + splitRatios, + status = 'active', + metadata = {} + } = collaborationData; + + // Validate primary creator owns the content + const content = this.database.db.prepare(` + SELECT creator_address FROM content WHERE id = ? + `).get(contentId); + + if (!content || content.creator_address !== primaryCreatorAddress) { + throw new Error('Only content owner can create collaborations'); + } + + // Validate collaborators + if (!collaboratorAddresses || collaboratorAddresses.length === 0) { + throw new Error('At least one collaborator is required'); + } + + // Validate split ratios + const totalSplit = Object.values(splitRatios || {}).reduce((sum, ratio) => sum + ratio, 0); + if (totalSplit > 1) { + throw new Error('Total split ratios cannot exceed 1.0 (100%)'); + } + + // Generate unique collaboration ID + const collaborationId = this.generateCollaborationId(); + + // Store collaboration + const insertQuery = ` + INSERT INTO content_collaborations ( + id, content_id, primary_creator_address, status, + total_watch_seconds, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + this.database.db.prepare(insertQuery).run( + collaborationId, + contentId, + primaryCreatorAddress, + status, + 0, // total_watch_seconds + new Date().toISOString(), + new Date().toISOString() + ); + + // Add collaborators + const collaboratorInsert = ` + INSERT INTO collaboration_participants ( + collaboration_id, creator_address, split_ratio, role, + watch_seconds, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + // Add primary creator + this.database.db.prepare(collaboratorInsert).run( + collaborationId, + primaryCreatorAddress, + splitRatios?.[primaryCreatorAddress] || (1 - (totalSplit || 0)), + 'primary', + 0, // watch_seconds + new Date().toISOString(), + new Date().toISOString() + ); + + // Add collaborators + for (const collaboratorAddress of collaboratorAddresses) { + const splitRatio = splitRatios?.[collaboratorAddress] || + ((1 - (splitRatios?.[primaryCreatorAddress] || (1 - totalSplit))) / collaboratorAddresses.length); + + this.database.db.prepare(collaboratorInsert).run( + collaborationId, + collaboratorAddress, + splitRatio, + 'collaborator', + 0, // watch_seconds + new Date().toISOString(), + new Date().toISOString() + ); + } + + // Update content to mark as collaborative + this.database.db.prepare(` + UPDATE content + SET is_collaborative = 1, collaboration_id = ?, updated_at = ? + WHERE id = ? + `).run(collaborationId, new Date().toISOString(), contentId); + + const collaboration = await this.getCollaboration(collaborationId); + + logger.info('Collaboration created', { + collaborationId, + contentId, + primaryCreatorAddress, + collaboratorCount: collaboratorAddresses.length + }); + + return collaboration; + } catch (error) { + logger.error('Failed to create collaboration', { + error: error.message, + collaborationData + }); + throw error; + } + } + + /** + * Get collaboration by ID + * @param {string} collaborationId Collaboration ID + * @returns {Promise} Collaboration details + */ + async getCollaboration(collaborationId) { + try { + // Get collaboration details + const collaborationQuery = ` + SELECT + id, content_id, primary_creator_address, status, + total_watch_seconds, created_at, updated_at + FROM content_collaborations + WHERE id = ? + `; + + const collaboration = this.database.db.prepare(collaborationQuery).get(collaborationId); + + if (!collaboration) { + throw new Error('Collaboration not found'); + } + + // Get participants + const participantsQuery = ` + SELECT + creator_address, split_ratio, role, watch_seconds, + created_at, updated_at + FROM collaboration_participants + WHERE collaboration_id = ? + ORDER BY role DESC, created_at ASC + `; + + const participants = this.database.db.prepare(participantsQuery).all(collaborationId); + + return { + ...collaboration, + participants, + totalParticipants: participants.length + }; + } catch (error) { + logger.error('Failed to get collaboration', { + error: error.message, + collaborationId + }); + throw error; + } + } + + /** + * Get collaboration for content + * @param {string} contentId Content ID + * @returns {Promise} Collaboration details + */ + async getCollaborationForContent(contentId) { + try { + const cacheKey = this.getCollaborationCacheKey(contentId); + + // Try cache first + const cached = await this.redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + const query = ` + SELECT id FROM content_collaborations + WHERE content_id = ? AND status = 'active' + `; + + const result = this.database.db.prepare(query).get(contentId); + + if (!result) { + return null; + } + + const collaboration = await this.getCollaboration(result.id); + + // Cache the result + await this.redis.setex(cacheKey, this.cacheTTL, JSON.stringify(collaboration)); + + return collaboration; + } catch (error) { + logger.error('Failed to get collaboration for content', { + error: error.message, + contentId + }); + return null; + } + } + + /** + * Record watch time for collaborative content + * @param {string} contentId Content ID + * @param {string} userAddress User wallet address + * @param {number} watchSeconds Number of seconds watched + * @returns {Promise} Watch time recording result + */ + async recordWatchTime(contentId, userAddress, watchSeconds) { + try { + // Get collaboration for content + const collaboration = await this.getCollaborationForContent(contentId); + + if (!collaboration) { + return { isCollaborative: false }; + } + + // Validate watch time + if (watchSeconds < this.minWatchTimeSeconds) { + return { + isCollaborative: true, + recorded: false, + reason: 'Watch time below minimum threshold', + minimumSeconds: this.minWatchTimeSeconds + }; + } + + // Check if this user already watched this content + const existingWatchQuery = ` + SELECT watch_seconds FROM collaboration_watch_logs + WHERE collaboration_id = ? AND user_address = ? + `; + + const existingWatch = this.database.db.prepare(existingWatchQuery).get( + collaboration.id, + userAddress + ); + + let totalWatchSeconds = watchSeconds; + + if (existingWatch) { + // Update existing record + totalWatchSeconds = Math.max(existingWatch.watch_seconds, watchSeconds); + + this.database.db.prepare(` + UPDATE collaboration_watch_logs + SET watch_seconds = ?, last_watched_at = ?, updated_at = ? + WHERE collaboration_id = ? AND user_address = ? + `).run( + totalWatchSeconds, + new Date().toISOString(), + new Date().toISOString(), + collaboration.id, + userAddress + ); + } else { + // Insert new record + this.database.db.prepare(` + INSERT INTO collaboration_watch_logs ( + collaboration_id, user_address, watch_seconds, + first_watched_at, last_watched_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + collaboration.id, + userAddress, + totalWatchSeconds, + new Date().toISOString(), + new Date().toISOString(), + new Date().toISOString(), + new Date().toISOString() + ); + } + + // Update collaboration total watch time + const newTotalWatchSeconds = collaboration.total_watch_seconds + (totalWatchSeconds - (existingWatch?.watch_seconds || 0)); + + this.database.db.prepare(` + UPDATE content_collaborations + SET total_watch_seconds = ?, updated_at = ? + WHERE id = ? + `).run( + newTotalWatchSeconds, + new Date().toISOString(), + collaboration.id + ); + + // Update participant watch times + await this.updateParticipantWatchTimes(collaboration.id); + + // Invalidate cache + await this.redis.del(this.getCollaborationCacheKey(contentId)); + + logger.debug('Watch time recorded for collaboration', { + collaborationId: collaboration.id, + contentId, + userAddress, + watchSeconds, + totalWatchSeconds + }); + + return { + isCollaborative: true, + recorded: true, + collaborationId: collaboration.id, + watchSeconds: totalWatchSeconds, + totalWatchSeconds: newTotalWatchSeconds + }; + } catch (error) { + logger.error('Failed to record watch time', { + error: error.message, + contentId, + userAddress, + watchSeconds + }); + throw error; + } + } + + /** + * Update participant watch times + * @param {string} collaborationId Collaboration ID + */ + async updateParticipantWatchTimes(collaborationId) { + try { + // Calculate watch time distribution among participants + const watchDistributionQuery = ` + SELECT + cp.creator_address, + cp.split_ratio, + COALESCE(SUM(cwl.watch_seconds), 0) as total_watch_seconds + FROM collaboration_participants cp + LEFT JOIN collaboration_watch_logs cwl ON cp.creator_address = cwl.user_address + WHERE cp.collaboration_id = ? + GROUP BY cp.creator_address, cp.split_ratio + `; + + const distribution = this.database.db.prepare(watchDistributionQuery).all(collaborationId); + + // Update each participant's watch time + for (const participant of distribution) { + this.database.db.prepare(` + UPDATE collaboration_participants + SET watch_seconds = ?, updated_at = ? + WHERE collaboration_id = ? AND creator_address = ? + `).run( + participant.total_watch_seconds, + new Date().toISOString(), + collaborationId, + participant.creator_address + ); + } + } catch (error) { + logger.error('Failed to update participant watch times', { + error: error.message, + collaborationId + }); + } + } + + /** + * Calculate revenue attribution for a time period + * @param {object} attributionParams Attribution parameters + * @returns {Promise} Attribution results + */ + async calculateRevenueAttribution(attributionParams) { + try { + const { + collaborationId, + startTime, + endTime, + totalRevenue, + currency = 'XLM' + } = attributionParams; + + const collaboration = await this.getCollaboration(collaborationId); + + if (!collaboration) { + throw new Error('Collaboration not found'); + } + + // Get watch time within period + const watchTimeQuery = ` + SELECT + creator_address, + split_ratio, + watch_seconds + FROM collaboration_participants + WHERE collaboration_id = ? + `; + + const participants = this.database.db.prepare(watchTimeQuery).all(collaborationId); + + // Calculate total watch time + const totalWatchSeconds = participants.reduce((sum, p) => sum + (p.watch_seconds || 0), 0); + + if (totalWatchSeconds === 0) { + return { + collaborationId, + totalRevenue, + currency, + totalWatchSeconds: 0, + attribution: [] + }; + } + + // Calculate revenue attribution + const attribution = participants.map(participant => { + const watchTimeShare = (participant.watch_seconds || 0) / totalWatchSeconds; + const revenueShare = participant.split_ratio || (1 / participants.length); + const attributedRevenue = totalRevenue * revenueShare; + + return { + creatorAddress: participant.creator_address, + role: participant.role, + watchSeconds: participant.watch_seconds || 0, + watchTimeShare: watchTimeShare, + splitRatio: participant.split_ratio, + revenueShare, + attributedRevenue, + currency + }; + }); + + // Verify attribution totals + const totalAttributedRevenue = attribution.reduce((sum, a) => sum + a.attributedRevenue, 0); + const totalAttributedWatchTime = attribution.reduce((sum, a) => sum + a.watchSeconds, 0); + + logger.info('Revenue attribution calculated', { + collaborationId, + totalRevenue, + currency, + totalWatchSeconds, + totalAttributedRevenue, + participantCount: attribution.length + }); + + return { + collaborationId, + contentId: collaboration.content_id, + totalRevenue, + currency, + totalWatchSeconds, + totalAttributedRevenue, + totalAttributedWatchTime, + attribution, + calculatedAt: new Date().toISOString() + }; + } catch (error) { + logger.error('Failed to calculate revenue attribution', { + error: error.message, + attributionParams + }); + throw error; + } + } + + /** + * Get revenue attribution for content + * @param {string} contentId Content ID + * @param {object} period Period parameters + * @returns {Promise} Attribution results + */ + async getContentRevenueAttribution(contentId, period = {}) { + try { + const collaboration = await this.getCollaborationForContent(contentId); + + if (!collaboration) { + return null; + } + + // Default to last 30 days if no period specified + const endTime = period.endTime || new Date(); + const startTime = period.startTime || new Date(endTime.getTime() - (30 * 24 * 60 * 60 * 1000)); + + // For now, we'll use the total watch time as a proxy for revenue calculation + // In a real implementation, this would integrate with actual revenue data + const totalRevenue = period.totalRevenue || 0; // Would come from payment processing + + return await this.calculateRevenueAttribution({ + collaborationId: collaboration.id, + startTime, + endTime, + totalRevenue + }); + } catch (error) { + logger.error('Failed to get content revenue attribution', { + error: error.message, + contentId, + period + }); + return null; + } + } + + /** + * Update collaboration status + * @param {string} collaborationId Collaboration ID + * @param {string} status New status + * @returns {Promise} Whether update was successful + */ + async updateCollaborationStatus(collaborationId, status) { + try { + const result = this.database.db.prepare(` + UPDATE content_collaborations + SET status = ?, updated_at = ? + WHERE id = ? + `).run(status, new Date().toISOString(), collaborationId); + + const updated = result.changes > 0; + + if (updated) { + logger.info('Collaboration status updated', { + collaborationId, + status + }); + } + + return updated; + } catch (error) { + logger.error('Failed to update collaboration status', { + error: error.message, + collaborationId, + status + }); + return false; + } + } + + /** + * Get collaboration statistics for a creator + * @param {string} creatorAddress Creator wallet address + * @param {object} filters Filter options + * @returns {Promise} Statistics + */ + async getCreatorCollaborationStats(creatorAddress, filters = {}) { + try { + const { + status = 'active', + startTime, + endTime + } = filters; + + // Get collaborations where creator is primary or participant + const collaborationsQuery = ` + SELECT DISTINCT cc.id, cc.content_id, cc.status, cc.created_at + FROM content_collaborations cc + LEFT JOIN collaboration_participants cp ON cc.id = cp.collaboration_id + WHERE (cc.primary_creator_address = ? OR cp.creator_address = ?) + AND cc.status = COALESCE(?, cc.status) + AND cc.created_at >= COALESCE(?, cc.created_at) + AND cc.created_at <= COALESCE(?, cc.created_at) + `; + + const collaborations = this.database.db.prepare(collaborationsQuery).all( + creatorAddress, + creatorAddress, + status, + startTime, + endTime + ); + + if (collaborations.length === 0) { + return { + creatorAddress, + totalCollaborations: 0, + totalWatchSeconds: 0, + totalRevenue: 0, + collaboratorCount: 0, + topCollaborators: [] + }; + } + + // Calculate statistics + let totalWatchSeconds = 0; + let collaboratorSet = new Set(); + let totalRevenue = 0; + + for (const collaboration of collaborations) { + const collabData = await this.getCollaboration(collaboration.id); + totalWatchSeconds += collabData.total_watch_seconds || 0; + + // Add collaborators to set (excluding primary creator) + collabData.participants.forEach(p => { + if (p.creator_address !== creatorAddress) { + collaboratorSet.add(p.creator_address); + } + }); + + // Calculate revenue (would come from actual payment data) + // For now, using a simple calculation based on watch time + const estimatedRevenue = (collabData.total_watchSeconds || 0) * 0.001; // Example rate + totalRevenue += estimatedRevenue; + } + + // Get top collaborators + const topCollaboratorsQuery = ` + SELECT + cp.creator_address, + COUNT(*) as collaboration_count, + SUM(cp.watch_seconds) as total_watch_seconds, + AVG(cp.split_ratio) as avg_split_ratio + FROM collaboration_participants cp + JOIN content_collaborations cc ON cp.collaboration_id = cc.id + WHERE cc.primary_creator_address = ? AND cp.creator_address != ? + AND cc.status = 'active' + GROUP BY cp.creator_address + ORDER BY collaboration_count DESC, total_watch_seconds DESC + LIMIT 10 + `; + + const topCollaborators = this.database.db.prepare(topCollaboratorsQuery).all( + creatorAddress, + creatorAddress + ); + + return { + creatorAddress, + totalCollaborations: collaborations.length, + totalWatchSeconds, + totalRevenue, + collaboratorCount: collaboratorSet.size, + topCollaborators: topCollaborators.map(c => ({ + creatorAddress: c.creator_address, + collaborationCount: c.collaboration_count, + totalWatchSeconds: c.total_watch_seconds || 0, + avgSplitRatio: c.avg_split_ratio || 0 + })), + period: { + startTime, + endTime, + status + } + }; + } catch (error) { + logger.error('Failed to get creator collaboration stats', { + error: error.message, + creatorAddress, + filters + }); + return null; + } + } + + /** + * Generate collaboration ID + * @returns {string} Unique collaboration ID + */ + generateCollaborationId() { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 15); + return `collab_${timestamp}_${random}`; + } + + /** + * Get collaboration cache key + * @param {string} contentId Content ID + * @returns {string} Cache key + */ + getCollaborationCacheKey(contentId) { + return `${this.prefix}content:${contentId}`; + } + + /** + * Invalidate collaboration cache + * @param {string} contentId Content ID + */ + async invalidateCollaborationCache(contentId) { + try { + await this.redis.del(this.getCollaborationCacheKey(contentId)); + } catch (error) { + logger.error('Failed to invalidate collaboration cache', { + error: error.message, + contentId + }); + } + } + + /** + * Get smart contract payout data for collaboration + * @param {string} collaborationId Collaboration ID + * @returns {Promise} Payout data for smart contract + */ + async getSmartContractPayoutData(collaborationId) { + try { + const collaboration = await this.getCollaboration(collaborationId); + + if (!collaboration) { + throw new Error('Collaboration not found'); + } + + // Calculate final attribution for the period + const attribution = await this.calculateRevenueAttribution({ + collaborationId, + startTime: collaboration.created_at, + endTime: new Date(), + totalRevenue: 0 // Smart contract will provide actual revenue + }); + + // Format for smart contract + const payoutData = { + collaborationId: collaboration.id, + contentId: collaboration.content_id, + primaryCreator: collaboration.primary_creator_address, + participants: attribution.attribution.map(p => ({ + creatorAddress: p.creatorAddress, + splitRatio: p.splitRatio, + watchSeconds: p.watchSeconds, + attributedRevenue: p.attributedRevenue + })), + totalWatchSeconds: attribution.totalAttributedWatchTime, + calculatedAt: attribution.calculatedAt, + signature: null // Would be signed by primary creator + }; + + return payoutData; + } catch (error) { + logger.error('Failed to get smart contract payout data', { + error: error.message, + collaborationId + }); + throw error; + } + } + + /** + * Verify smart contract payout matches offline attribution + * @param {string} collaborationId Collaboration ID + * @param {object} contractPayout Smart contract payout data + * @returns {Promise} Verification result + */ + async verifyPayoutAttribution(collaborationId, contractPayout) { + try { + const offlineAttribution = await this.getSmartContractPayoutData(collaborationId); + + // Compare key metrics + const verification = { + collaborationId, + matches: true, + discrepancies: [], + verifiedAt: new Date().toISOString() + }; + + // Verify total watch seconds + if (Math.abs(offlineAttribution.totalWatchSeconds - contractPayout.totalWatchSeconds) > 60) { + verification.matches = false; + verification.discrepancies.push({ + field: 'totalWatchSeconds', + offline: offlineAttribution.totalWatchSeconds, + contract: contractPayout.totalWatchSeconds, + difference: Math.abs(offlineAttribution.totalWatchSeconds - contractPayout.totalWatchSeconds) + }); + } + + // Verify participant attribution + const offlineParticipants = offlineAttribution.participants.reduce((map, p) => { + map[p.creatorAddress] = p; + return map; + }, {}); + + const contractParticipants = contractPayout.participants.reduce((map, p) => { + map[p.creatorAddress] = p; + return map; + }, {}); + + for (const [address, offlineData] of Object.entries(offlineParticipants)) { + const contractData = contractParticipants[address]; + + if (!contractData) { + verification.matches = false; + verification.discrepancies.push({ + field: 'missing_participant', + address, + offline: offlineData + }); + continue; + } + + // Verify split ratio + if (Math.abs(offlineData.splitRatio - contractData.splitRatio) > 0.01) { + verification.matches = false; + verification.discrepancies.push({ + field: 'split_ratio', + address, + offline: offlineData.splitRatio, + contract: contractData.splitRatio, + difference: Math.abs(offlineData.splitRatio - contractData.splitRatio) + }); + } + + // Verify watch seconds (allow small tolerance) + if (Math.abs(offlineData.watchSeconds - contractData.watchSeconds) > 30) { + verification.matches = false; + verification.discrepancies.push({ + field: 'watch_seconds', + address, + offline: offlineData.watchSeconds, + contract: contractData.watchSeconds, + difference: Math.abs(offlineData.watchSeconds - contractData.watchSeconds) + }); + } + } + + // Check for extra participants in contract + for (const [address, contractData] of Object.entries(contractParticipants)) { + if (!offlineParticipants[address]) { + verification.matches = false; + verification.discrepancies.push({ + field: 'extra_participant', + address, + contract: contractData + }); + } + } + + logger.info('Payout attribution verification completed', { + collaborationId, + matches: verification.matches, + discrepancyCount: verification.discrepancies.length + }); + + return verification; + } catch (error) { + logger.error('Failed to verify payout attribution', { + error: error.message, + collaborationId + }); + throw error; + } + } +} + +module.exports = CollaborationRevenueService;