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 backend/src/dal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
Expand Down
36 changes: 36 additions & 0 deletions backend/src/dal/sqliteCampaignRewardNotificationRepository.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
}
84 changes: 84 additions & 0 deletions backend/src/dal/sqliteSessionTokenRepository.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
}
25 changes: 25 additions & 0 deletions backend/src/db/migrations/046_session_tokens.js
Original file line number Diff line number Diff line change
@@ -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);
`);
},
};
15 changes: 15 additions & 0 deletions backend/src/db/migrations/047_campaign_tenant_isolation.js
Original file line number Diff line number Diff line change
@@ -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);
`);
},
};
26 changes: 26 additions & 0 deletions backend/src/db/migrations/048_campaign_reward_notifications.js
Original file line number Diff line number Diff line change
@@ -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);
`);
},
};
35 changes: 35 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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) ??
Expand Down
76 changes: 76 additions & 0 deletions backend/src/jobs/campaignRewardNotificationJob.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
27 changes: 27 additions & 0 deletions backend/src/jobs/sessionTokenCleanupJob.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
Loading
Loading