diff --git a/backend/src/dal/index.js b/backend/src/dal/index.js index fece83cd..cc72c9d1 100644 --- a/backend/src/dal/index.js +++ b/backend/src/dal/index.js @@ -24,6 +24,8 @@ import { createSqliteFeatureFlagRepository } from './sqliteFeatureFlagRepository import { createSqliteIdempotencyRepository } from './sqliteIdempotencyRepository.js'; import { createSqliteNotificationRepository } from './sqliteNotificationRepository.js'; import { createSqliteNotificationPreferencesRepository } from './sqliteNotificationPreferencesRepository.js'; +import { createSqliteSessionTokenRepository } from './sqliteSessionTokenRepository.js'; +import { createSqliteCampaignRewardNotificationRepository } from './sqliteCampaignRewardNotificationRepository.js'; import { runPgMigrations } from './pg/migrate.js'; import { createPgCampaignRepository } from './pg/pgCampaignRepository.js'; @@ -100,6 +102,8 @@ export async function createDal({ idempotency: createSqliteIdempotencyRepository({ db }), notifications: createSqliteNotificationRepository({ db }), notificationPreferences: createSqliteNotificationPreferencesRepository({ db }), + sessionTokens: createSqliteSessionTokenRepository({ db }), + campaignRewardNotifications: createSqliteCampaignRewardNotificationRepository({ db }), db, pgPool, }; diff --git a/backend/src/dal/sqliteCampaignRewardNotificationRepository.js b/backend/src/dal/sqliteCampaignRewardNotificationRepository.js new file mode 100644 index 00000000..78e47bb2 --- /dev/null +++ b/backend/src/dal/sqliteCampaignRewardNotificationRepository.js @@ -0,0 +1,36 @@ +import { randomUUID } from 'crypto'; + +export function createSqliteCampaignRewardNotificationRepository({ db }) { + const stmtInsert = db.prepare(` + INSERT OR IGNORE INTO campaign_reward_notifications + (id, campaign_id, user_address, notification_type) + VALUES (?, ?, ?, ?) + `); + + const stmtGetNotified = db.prepare(` + SELECT user_address FROM campaign_reward_notifications + WHERE campaign_id = ? AND notification_type = ? + `); + + const stmtHasBeenNotified = db.prepare(` + SELECT 1 FROM campaign_reward_notifications + WHERE campaign_id = ? AND user_address = ? AND notification_type = ? + `); + + return { + recordNotification(campaignId, userAddress, type = 'reward_available') { + const id = `notif_${randomUUID()}`; + stmtInsert.run(id, campaignId, userAddress, type); + }, + + hasBeenNotified(campaignId, userAddress, type = 'reward_available') { + const result = stmtHasBeenNotified.get(campaignId, userAddress, type); + return !!result; + }, + + getNotifiedUsers(campaignId, type = 'reward_available') { + const rows = stmtGetNotified.all(campaignId, type); + return rows.map((row) => row.user_address); + }, + }; +} diff --git a/backend/src/dal/sqliteSessionTokenRepository.js b/backend/src/dal/sqliteSessionTokenRepository.js new file mode 100644 index 00000000..fd7a13d9 --- /dev/null +++ b/backend/src/dal/sqliteSessionTokenRepository.js @@ -0,0 +1,84 @@ +import { randomUUID } from 'crypto'; + +export function createSqliteSessionTokenRepository({ db }) { + const stmtInsert = db.prepare(` + INSERT INTO session_tokens (id, user_address, token, expires_at) + VALUES (?, ?, ?, ?) + `); + + const stmtGetByToken = db.prepare(` + SELECT id, user_address, token, expires_at, created_at + FROM session_tokens + WHERE token = ? AND expires_at > datetime('now') + `); + + const stmtGetByUser = db.prepare(` + SELECT id, user_address, token, expires_at, created_at + FROM session_tokens + WHERE user_address = ? AND expires_at > datetime('now') + ORDER BY created_at DESC + `); + + const stmtDeleteExpired = db.prepare(` + DELETE FROM session_tokens + WHERE expires_at <= datetime('now') + `); + + const stmtDeleteByToken = db.prepare(` + DELETE FROM session_tokens + WHERE token = ? + `); + + const stmtCountExpired = db.prepare(` + SELECT COUNT(*) as count + FROM session_tokens + WHERE expires_at <= datetime('now') + `); + + return { + create(userAddress, expiresAt) { + const token = randomUUID(); + const id = `sess_${randomUUID()}`; + stmtInsert.run(id, userAddress, token, expiresAt.toISOString()); + return { id, userAddress, token, expiresAt }; + }, + + getByToken(token) { + const row = stmtGetByToken.get(token); + if (!row) return null; + return { + id: row.id, + userAddress: row.user_address, + token: row.token, + expiresAt: new Date(row.expires_at), + createdAt: new Date(row.created_at), + }; + }, + + getByUser(userAddress) { + const rows = stmtGetByUser.all(userAddress); + return rows.map((row) => ({ + id: row.id, + userAddress: row.user_address, + token: row.token, + expiresAt: new Date(row.expires_at), + createdAt: new Date(row.created_at), + })); + }, + + deleteExpired() { + const countBefore = stmtCountExpired.get().count; + stmtDeleteExpired.run(); + const countAfter = stmtCountExpired.get().count; + return countBefore - countAfter; + }, + + deleteByToken(token) { + stmtDeleteByToken.run(token); + }, + + getExpiredCount() { + return stmtCountExpired.get().count; + }, + }; +} diff --git a/backend/src/db/migrations/046_session_tokens.js b/backend/src/db/migrations/046_session_tokens.js new file mode 100644 index 00000000..cf202bc6 --- /dev/null +++ b/backend/src/db/migrations/046_session_tokens.js @@ -0,0 +1,25 @@ +export const migration = { + name: '046_session_tokens', + description: 'Add session_tokens table for temporary session token storage and cleanup', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS session_tokens ( + id TEXT PRIMARY KEY, + user_address TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_session_tokens_user + ON session_tokens(user_address); + + CREATE INDEX IF NOT EXISTS idx_session_tokens_expires + ON session_tokens(expires_at); + + CREATE INDEX IF NOT EXISTS idx_session_tokens_token + ON session_tokens(token); + `); + }, +}; diff --git a/backend/src/db/migrations/047_campaign_tenant_isolation.js b/backend/src/db/migrations/047_campaign_tenant_isolation.js new file mode 100644 index 00000000..074cdd33 --- /dev/null +++ b/backend/src/db/migrations/047_campaign_tenant_isolation.js @@ -0,0 +1,15 @@ +export const migration = { + name: '047_campaign_tenant_isolation', + description: 'Add tenant_id to campaigns for enterprise multi-tenant isolation', + up: (db) => { + db.exec(` + ALTER TABLE campaigns ADD COLUMN tenant_id TEXT; + + CREATE INDEX IF NOT EXISTS idx_campaigns_tenant_id + ON campaigns(tenant_id); + + CREATE INDEX IF NOT EXISTS idx_campaigns_tenant_id_active + ON campaigns(tenant_id, active); + `); + }, +}; diff --git a/backend/src/db/migrations/048_campaign_reward_notifications.js b/backend/src/db/migrations/048_campaign_reward_notifications.js new file mode 100644 index 00000000..fba3b93c --- /dev/null +++ b/backend/src/db/migrations/048_campaign_reward_notifications.js @@ -0,0 +1,26 @@ +export const migration = { + name: '048_campaign_reward_notifications', + description: 'Track campaign reward availability notifications to prevent duplicate sends', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS campaign_reward_notifications ( + id TEXT PRIMARY KEY, + campaign_id TEXT NOT NULL, + user_address TEXT NOT NULL, + notification_type TEXT NOT NULL DEFAULT 'reward_available', + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(campaign_id, user_address, notification_type) + ); + + CREATE INDEX IF NOT EXISTS idx_reward_notif_campaign + ON campaign_reward_notifications(campaign_id); + + CREATE INDEX IF NOT EXISTS idx_reward_notif_user + ON campaign_reward_notifications(user_address); + + CREATE INDEX IF NOT EXISTS idx_reward_notif_sent + ON campaign_reward_notifications(sent_at); + `); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 4e708170..ff484bba 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -129,6 +129,9 @@ import { createHorizonDepositWatcher } from './services/horizonDepositWatcher.js import { createOperatorDepositRoutes } from './routes/operatorDeposits.js'; import { createApiDocsRouter } from './routes/apiDocs.js'; import { createPruningJob } from './jobs/pruningJob.js'; +import { createSessionTokenCleanupJob } from './jobs/sessionTokenCleanupJob.js'; +import { createCampaignRewardNotificationJob } from './jobs/campaignRewardNotificationJob.js'; +import { setupSentry, captureException } from './services/sentryIntegration.js'; import { purgePiiForUser, purgePiiForCampaign, @@ -476,6 +479,10 @@ export async function createApp(options = {}) { }; const app = express(); + + // Initialize Sentry error monitoring (#1263) + await setupSentry(); + const metrics = { requestTotal: 0, requestErrors: 0, @@ -828,6 +835,8 @@ export async function createApp(options = {}) { ); const pruningJob = createPruningJob({ dal }); + const sessionTokenCleanupJob = createSessionTokenCleanupJob({ dal }); + const campaignRewardNotificationJob = createCampaignRewardNotificationJob({ dal, emailService }); const jobRunner = createJobRunner({ handlers: { @@ -854,6 +863,12 @@ export async function createApp(options = {}) { async storage_pruning() { await pruningJob(); }, + async session_token_cleanup() { + await sessionTokenCleanupJob(); + }, + async campaign_reward_notifications() { + await campaignRewardNotificationJob(); + }, }, logger: log, deadLetter: failedJobRepository, @@ -893,6 +908,26 @@ export async function createApp(options = {}) { setInterval(doExport, 24 * 60 * 60 * 1_000).unref?.(); } + // Hourly session token cleanup (#1262) + if (!options.disableJobs) { + const sessionTokenCleanupIntervalMs = 60 * 60 * 1000; // 1 hour + jobRunner.enqueue('session_token_cleanup', null); + setInterval( + () => jobRunner.enqueue('session_token_cleanup', null), + sessionTokenCleanupIntervalMs, + ).unref?.(); + } + + // Twice daily campaign reward notifications (#1246) + if (!options.disableJobs) { + const campaignRewardNotifIntervalMs = 12 * 60 * 60 * 1000; // 12 hours + jobRunner.enqueue('campaign_reward_notifications', null); + setInterval( + () => jobRunner.enqueue('campaign_reward_notifications', null), + campaignRewardNotifIntervalMs, + ).unref?.(); + } + // #1255 — IPFS pinning of campaign assets (enabled when PINATA_JWT is set) const ipfsPinService = /** @type {any} */ (options.ipfsPinService) ?? diff --git a/backend/src/jobs/campaignRewardNotificationJob.js b/backend/src/jobs/campaignRewardNotificationJob.js new file mode 100644 index 00000000..ae3ff1e4 --- /dev/null +++ b/backend/src/jobs/campaignRewardNotificationJob.js @@ -0,0 +1,76 @@ +import { log } from '../middleware/logger.js'; + +export function createCampaignRewardNotificationJob({ dal, emailService }) { + return async function sendRewardNotifications() { + try { + if (!dal.campaigns || !emailService || !dal.campaignRewardNotifications) { + log.warn('[campaignRewardNotif] Required services not available'); + return; + } + + const campaigns = dal.campaigns.list({ limit: 1000 }); + let totalSent = 0; + let totalSkipped = 0; + + for (const campaign of campaigns) { + if (!campaign.active || !campaign.rewardPerAction || campaign.rewardPerAction <= 0) { + continue; + } + + try { + const participantAddresses = await dal.campaigns.getParticipants(campaign.id); + + for (const address of participantAddresses) { + if (dal.campaignRewardNotifications.hasBeenNotified(campaign.id, address)) { + totalSkipped++; + continue; + } + + const userEmail = await dal.campaigns.getUserEmail(address); + if (!userEmail) { + continue; + } + + try { + await emailService.send({ + to: userEmail, + template: 'claim-rewards-v1', + data: { + name: address.slice(0, 6), + points: campaign.rewardPerAction, + claimUrl: `${process.env.APP_URL || 'https://app.trivela.com'}/campaigns/${campaign.id}/claim`, + }, + }); + + dal.campaignRewardNotifications.recordNotification(campaign.id, address); + totalSent++; + + log.debug( + { campaignId: campaign.id, address }, + '[campaignRewardNotif] Sent notification', + ); + } catch (emailErr) { + log.warn( + { campaignId: campaign.id, address, err: emailErr }, + '[campaignRewardNotif] Failed to send email', + ); + } + } + } catch (campaignErr) { + log.error( + { campaignId: campaign.id, err: campaignErr }, + '[campaignRewardNotif] Failed processing campaign', + ); + } + } + + log.info( + { sent: totalSent, skipped: totalSkipped }, + '[campaignRewardNotif] Notification job completed', + ); + } catch (error) { + log.error({ err: error }, '[campaignRewardNotif] Job failed'); + throw error; + } + }; +} diff --git a/backend/src/jobs/sessionTokenCleanupJob.js b/backend/src/jobs/sessionTokenCleanupJob.js new file mode 100644 index 00000000..01a443b2 --- /dev/null +++ b/backend/src/jobs/sessionTokenCleanupJob.js @@ -0,0 +1,27 @@ +import { log } from '../middleware/logger.js'; + +export function createSessionTokenCleanupJob({ dal }) { + return async function cleanupSessionTokens() { + try { + if (!dal.sessionTokens) { + log.warn('[sessionTokenCleanup] Session tokens repository not available'); + return; + } + + const deletedCount = dal.sessionTokens.deleteExpired(); + log.info( + `[sessionTokenCleanup] Deleted ${deletedCount} expired session tokens`, + ); + + const expiredCount = dal.sessionTokens.getExpiredCount(); + if (expiredCount > 0) { + log.warn(`[sessionTokenCleanup] ${expiredCount} additional expired tokens still exist`); + } + + log.info('[sessionTokenCleanup] Job completed successfully'); + } catch (error) { + log.error({ err: error }, '[sessionTokenCleanup] Job failed'); + throw error; + } + }; +} diff --git a/backend/src/services/sentryIntegration.js b/backend/src/services/sentryIntegration.js new file mode 100644 index 00000000..78e42d7f --- /dev/null +++ b/backend/src/services/sentryIntegration.js @@ -0,0 +1,88 @@ +import { logger } from '../lib/logger.js'; + +let sentryClient = null; + +async function initializeSentry() { + const sentryDsn = process.env.SENTRY_DSN; + const environment = process.env.NODE_ENV || 'development'; + const release = process.env.RELEASE_VERSION || 'unknown'; + + if (!sentryDsn) { + logger.info('[sentry] SENTRY_DSN not configured, monitoring disabled'); + return null; + } + + try { + const Sentry = await import('@sentry/node'); + const sourceMapSupport = await import('source-map-support'); + + sourceMapSupport.install(); + + Sentry.init({ + dsn: sentryDsn, + environment, + release, + tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE + ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) + : 0.1, + integrations: [ + new Sentry.Integrations.Http({ tracing: true }), + new Sentry.Integrations.OnUncaughtException(), + new Sentry.Integrations.OnUnhandledRejection(), + ], + beforeSend(event, hint) { + if (process.env.NODE_ENV === 'test') { + return null; + } + return event; + }, + }); + + logger.info( + { environment, release }, + '[sentry] Initialized with error monitoring enabled', + ); + return Sentry; + } catch (error) { + logger.warn({ err: error }, '[sentry] Failed to initialize Sentry'); + return null; + } +} + +export async function setupSentry() { + sentryClient = await initializeSentry(); + return sentryClient; +} + +export function getSentry() { + return sentryClient; +} + +export function captureException(error, context = {}) { + if (!sentryClient) return; + try { + sentryClient.captureException(error, { contexts: { app: context } }); + } catch (err) { + logger.error({ err }, '[sentry] Failed to capture exception'); + } +} + +export function captureMessage(message, level = 'info', context = {}) { + if (!sentryClient) return; + try { + sentryClient.captureMessage(message, level, { contexts: { app: context } }); + } catch (err) { + logger.error({ err }, '[sentry] Failed to capture message'); + } +} + +export function withErrorBoundary(fn, contextLabel = 'unknown') { + return async (...args) => { + try { + return await fn(...args); + } catch (error) { + captureException(error, { context: contextLabel }); + throw error; + } + }; +} diff --git a/backend/src/services/tenantIsolationService.js b/backend/src/services/tenantIsolationService.js new file mode 100644 index 00000000..e145c615 --- /dev/null +++ b/backend/src/services/tenantIsolationService.js @@ -0,0 +1,41 @@ +import { logger } from '../lib/logger.js'; + +export function createTenantIsolationService({ campaignRepository }) { + return { + listByTenant(tenantId, options = {}) { + const allCampaigns = campaignRepository.list({ ...options, limit: 10000 }); + return allCampaigns.filter((c) => c.tenant_id === tenantId || c.tenant_id === null); + }, + + getByTenant(campaignId, tenantId) { + const campaign = campaignRepository.get(campaignId); + if (!campaign) return null; + if (campaign.tenant_id === tenantId || campaign.tenant_id === null) { + return campaign; + } + logger.warn( + { campaignId, tenantId, actualTenant: campaign.tenant_id }, + '[tenantIsolation] Unauthorized tenant access attempt', + ); + return null; + }, + + assertTenantAccess(campaignId, tenantId) { + const campaign = this.getByTenant(campaignId, tenantId); + if (!campaign) { + throw new Error(`Campaign ${campaignId} not found or access denied for tenant ${tenantId}`); + } + return campaign; + }, + + setTenantForCampaign(campaignId, tenantId) { + const db = campaignRepository.db; + if (!db) { + logger.error('[tenantIsolation] Database instance not available'); + return; + } + const stmt = db.prepare('UPDATE campaigns SET tenant_id = ? WHERE id = ?'); + stmt.run(tenantId, campaignId); + }, + }; +}