Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const { buildAuditLogPdf } = require('./src/utils/export/auditLogPdf');
const { getRequestIp } = require('./src/utils/requestIp');
const { getRedisClient, closeRedisClient } = require('./src/config/redis');
const { createRateLimiter } = require('./middleware/rateLimiter');
const createPrivacyRoutes = require('./routes/privacy');


// Tier middleware β€” attaches req.user.tier to every request
Expand Down Expand Up @@ -245,6 +246,9 @@ function createApp(dependencies = {}) {
// Creator collaboration endpoints
app.use('/api/collaborations', createCollaborationRoutes());

// Privacy preference endpoints
app.use('/api/v1/users', createPrivacyRoutes({ database }));

// Subdomain management endpoints
app.use('/api/subdomains', createSubdomainRoutes({ database, config, subdomainService, sslCertificateService }));

Expand Down
14 changes: 14 additions & 0 deletions migrations/knex/010_add_privacy_preferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

exports.up = function(knex) {
return knex.schema.createTable('privacy_preferences', (table) => {
table.string('wallet_address').primary();
table.boolean('share_email_with_merchants').defaultTo(true);
table.boolean('allow_marketing').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
};

exports.down = function(knex) {
return knex.schema.dropTableIfExists('privacy_preferences');
};
31 changes: 31 additions & 0 deletions migrations/knex/011_create_dunning_tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@

exports.up = function(knex) {
return knex.schema
.createTable('dunning_sequences', (table) => {
table.string('id').primary();
table.string('wallet_address').notNullable();
table.string('creator_id').notNullable();
table.string('status').defaultTo('active'); // active, halted, completed
table.integer('current_day').defaultTo(1);
table.timestamp('last_notified_at').defaultTo(knex.fn.now());
table.timestamp('next_notification_at').nullable();
table.timestamp('started_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());

table.unique(['wallet_address', 'creator_id', 'status']);
})
.createTable('dunning_history', (table) => {
table.string('id').primary();
table.string('sequence_id').references('id').inTable('dunning_sequences');
table.string('event_type').notNullable(); // email_day_1, email_day_4, webhook_day_7, etc.
table.timestamp('occurred_at').defaultTo(knex.fn.now());
table.string('status').notNullable(); // success, failed
table.text('metadata_json').nullable();
});
};

exports.down = function(knex) {
return knex.schema
.dropTableIfExists('dunning_history')
.dropTableIfExists('dunning_sequences');
};
84 changes: 84 additions & 0 deletions routes/privacy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@

const express = require('express');
const router = express.Router();
const { PrivacyService } = require('../src/services/privacyService');
const { attachTier } = require('../middleware/tierAuth');

/**
* Privacy Preferences API
*/
function createPrivacyRoutes({ database }) {
const privacyService = new PrivacyService(database);

/**
* @route PATCH /api/v1/users/privacy
* @description Update user privacy preferences
* @access Authenticated
*/
router.patch('/privacy', attachTier, async (req, res) => {
try {
const walletAddress = req.user?.address;

if (!walletAddress) {
return res.status(401).json({
success: false,
error: 'Authentication required'
});
}

const { share_email_with_merchants, allow_marketing } = req.body;

const preferences = {};
if (share_email_with_merchants !== undefined) preferences.share_email_with_merchants = share_email_with_merchants;
if (allow_marketing !== undefined) preferences.allow_marketing = allow_marketing;

const updated = await privacyService.updatePreferences(walletAddress, preferences);

return res.status(200).json({
success: true,
data: updated
});
} catch (error) {
console.error('Privacy update error:', error);
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
});

/**
* @route GET /api/v1/users/privacy
* @description Get user privacy preferences
* @access Authenticated
*/
router.get('/privacy', attachTier, async (req, res) => {
try {
const walletAddress = req.user?.address;

if (!walletAddress) {
return res.status(401).json({
success: false,
error: 'Authentication required'
});
}

const preferences = await privacyService.getPreferences(walletAddress);

return res.status(200).json({
success: true,
data: preferences
});
} catch (error) {
console.error('Privacy fetch error:', error);
return res.status(500).json({
success: false,
error: 'Internal server error'
});
}
});

return router;
}

module.exports = createPrivacyRoutes;
37 changes: 37 additions & 0 deletions src/db/PostgresSubscriberDB.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Utilizes advanced indexing for <100ms fan list queries regardless of size

const { Pool } = require('pg');
const cacheManager = require('../utils/cache');

class PostgresSubscriberDB {
constructor(connectionString) {
Expand Down Expand Up @@ -332,6 +333,42 @@ class PostgresSubscriberDB {
}
}

/**
* Get MRR (Monthly Recurring Revenue) analytics for a creator
* Cached for 15 minutes to protect DB
*/
async getMRRAnalytics(creatorId) {
const cacheKey = `analytics:${creatorId}:mrr`;

return await cacheManager.wrap(cacheKey, async () => {
const client = await this.pool.connect();
try {
// Complex analytical query summing flow rates for active subscriptions
const result = await client.query(`
SELECT
SUM(CAST(cs.flow_rate AS DECIMAL)) as total_mrr,
COUNT(s.wallet_address) as active_subscribers,
cs.currency
FROM subscriptions s
JOIN creator_settings cs ON s.creator_id = cs.creator_id
WHERE s.creator_id = $1 AND s.active = 1
GROUP BY cs.currency
`, [creatorId]);

return result.rows[0] || { total_mrr: 0, active_subscribers: 0, currency: 'XLM' };
} finally {
client.release();
}
}, 900); // 15 minute TTL
}

/**
* Invalidate analytics cache for a creator
*/
async invalidateAnalytics(creatorId) {
await cacheManager.invalidateCreatorAnalytics(creatorId);
}

/**
* Close the database connection pool
*/
Expand Down
Loading
Loading