Skip to content
Open
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
25 changes: 25 additions & 0 deletions backend/db/migrations/20260429_reward_tiers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Migration: Reward Tiers and Backer Perks
-- Created at: 2026-04-29

CREATE TABLE reward_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
min_amount NUMERIC(20, 7) NOT NULL,
asset_type TEXT NOT NULL, -- Must match campaign asset
"limit" INT, -- NULL means unlimited
claimed_count INT NOT NULL DEFAULT 0,
estimated_delivery DATE,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE contribution_rewards (
contribution_id UUID PRIMARY KEY REFERENCES contributions(id) ON DELETE CASCADE,
reward_tier_id UUID NOT NULL REFERENCES reward_tiers(id) ON DELETE CASCADE,
claimed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX reward_tiers_campaign_idx ON reward_tiers (campaign_id);
CREATE INDEX reward_tiers_min_amount_idx ON reward_tiers (min_amount);
CREATE INDEX contribution_rewards_tier_idx ON contribution_rewards (reward_tier_id);
28 changes: 28 additions & 0 deletions backend/src/middleware/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,34 @@ const createCampaignValidation = [
.optional()
.isBoolean()
.withMessage('show_backer_amounts must be a boolean'),
body('reward_tiers')
.optional({ nullable: true })
.custom((value) => {
if (value == null) return true;
if (!Array.isArray(value)) throw new Error('reward_tiers must be an array');
if (value.length > 10) throw new Error('Campaigns can define at most 10 reward tiers');
for (const [index, tier] of value.entries()) {
if (!tier || typeof tier !== 'object') {
throw new Error(`Tier ${index + 1} must be an object`);
}
if (!String(tier.title || '').trim()) {
throw new Error(`Tier ${index + 1} title is required`);
}
const minAmount = Number(tier.min_amount);
if (!Number.isFinite(minAmount) || minAmount <= 0) {
throw new Error(`Tier ${index + 1} min_amount must be greater than zero`);
}
if (tier.limit !== undefined && tier.limit !== null) {
const limit = Number(tier.limit);
if (!Number.isInteger(limit) || limit < 1) {
throw new Error(`Tier ${index + 1} limit must be a positive integer`);
}
}
}
return true;
}),
body('reward_tiers.*.title').optional().customSanitizer(stripHtml),
body('reward_tiers.*.description').optional().customSanitizer(stripHtml),
];

const createCampaignUpdateValidation = [
Expand Down
202 changes: 168 additions & 34 deletions backend/src/routes/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,46 @@ function normalizeMilestonesInput(input) {
return normalized;
}

function normalizeRewardTiersInput(input, assetType) {
if (input == null) return [];
if (!Array.isArray(input)) {
throw new Error('reward_tiers must be an array');
}
if (input.length === 0) return [];
if (input.length > 10) {
throw new Error('Campaigns can define at most 10 reward tiers');
}

return input.map((tier, index) => {
const title = String(tier?.title || '').trim();
if (!title) {
throw new Error(`Tier ${index + 1} title is required`);
}

const minAmount = Number(tier?.min_amount);
if (!Number.isFinite(minAmount) || minAmount <= 0) {
throw new Error(`Tier ${index + 1} min_amount must be greater than zero`);
}

let limit = null;
if (tier.limit !== undefined && tier.limit !== null) {
limit = parseInt(tier.limit, 10);
if (isNaN(limit) || limit < 1) {
throw new Error(`Tier ${index + 1} limit must be a positive integer`);
}
}

return {
title,
description: String(tier?.description || '').trim() || null,
min_amount: minAmount.toFixed(7),
asset_type: assetType,
limit,
estimated_delivery: tier.estimated_delivery || null,
};
});
}

async function logWithdrawalEvent(client, { withdrawalRequestId, actorUserId, action, note, metadata }) {
const runner = client || db;
await runner.query(
Expand Down Expand Up @@ -503,6 +543,56 @@ router.get('/:id/stream', async (req, res) => {
});
});

// GET /campaigns/:id/tiers — list tiers with availability
router.get('/:id/tiers', async (req, res) => {
const { rows } = await db.query(
`SELECT id, campaign_id, title, description, min_amount, asset_type, "limit", claimed_count, estimated_delivery, created_at
FROM reward_tiers
WHERE campaign_id = $1
ORDER BY min_amount ASC`,
[req.params.id]
);
res.json(rows);
});

// POST /campaigns/:id/tiers — creator adds a tier post-creation
router.post('/:id/tiers', requireAuth, requireCampaignMember('owner'), async (req, res) => {
const { rows: campaignRows } = await db.query('SELECT asset_type FROM campaigns WHERE id = $1', [req.params.id]);
if (!campaignRows.length) return res.status(404).json({ error: 'Campaign not found' });

let normalized;
try {
// Normalize a single tier (passed as body) or an array
const input = Array.isArray(req.body) ? req.body : [req.body];
normalized = normalizeRewardTiersInput(input, campaignRows[0].asset_type);
} catch (err) {
return res.status(400).json({ error: err.message });
}

const client = await db.connect();
try {
await client.query('BEGIN');
const created = [];
for (const tier of normalized) {
const { rows } = await client.query(
`INSERT INTO reward_tiers
(campaign_id, title, description, min_amount, asset_type, "limit", estimated_delivery)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[req.params.id, tier.title, tier.description, tier.min_amount, tier.asset_type, tier.limit, tier.estimated_delivery]
);
created.push(rows[0]);
}
await client.query('COMMIT');
res.status(201).json(Array.isArray(req.body) ? created : created[0]);
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'Could not add reward tier' });
} finally {
client.release();
}
});

// Get live on-chain balance for a campaign
router.get('/:id/balance', async (req, res) => {
/**
Expand Down Expand Up @@ -650,40 +740,60 @@ router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (re
});

// Create campaign (authenticated)
router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, async (req, res) => {
/**
* @openapi
* /api/campaigns:
* post:
* tags: [Campaigns]
* summary: Create campaign
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title, target_amount, asset_type]
* properties:
* title: { type: string }
* description: { type: string, nullable: true }
* target_amount: { type: string }
* asset_type: { type: string }
* deadline: { type: string, nullable: true }
* milestones: { type: array, items: { type: object }, nullable: true }
* min_contribution: { type: string, nullable: true }
* max_contribution: { type: string, nullable: true }
* responses:
* 201:
* description: Created
* 401:
* description: Unauthorized
* 403:
* description: Forbidden
*/
const { title, description, target_amount, asset_type, deadline, milestones, min_contribution, max_contribution } = req.body;
router.post(
'/',
requireAuth,
requireRole('creator', 'admin'),
createCampaignValidation,
validateRequest,
async (req, res) => {
/**
* @openapi
* /api/campaigns:
* post:
* tags: [Campaigns]
* summary: Create campaign
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [title, target_amount, asset_type]
* properties:
* title: { type: string }
* description: { type: string, nullable: true }
* target_amount: { type: string }
* asset_type: { type: string }
* deadline: { type: string, nullable: true }
* milestones: { type: array, items: { type: object }, nullable: true }
* reward_tiers: { type: array, items: { type: object }, nullable: true }
* min_contribution: { type: string, nullable: true }
* max_contribution: { type: string, nullable: true }
* responses:
* 201:
* description: Created
* 401:
* description: Unauthorized
* 403:
* description: Forbidden
*/

const {
title,
description,
target_amount,
asset_type,
deadline,
milestones,
reward_tiers,
min_contribution,
max_contribution,
} = req.body

// ...rest of handler logic here

let normalizedMilestones;
try {
Expand All @@ -692,6 +802,13 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal
return res.status(400).json({ error: err.message });
}

let normalizedRewards;
try {
normalizedRewards = normalizeRewardTiersInput(reward_tiers, asset_type);
} catch (err) {
return res.status(400).json({ error: err.message });
}

// Get creator's info
const { rows: userRows } = await db.query(
'SELECT email, wallet_public_key, kyc_status FROM users WHERE id = $1',
Expand Down Expand Up @@ -754,6 +871,23 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal
);
}

for (const tier of normalizedRewards) {
await client.query(
`INSERT INTO reward_tiers
(campaign_id, title, description, min_amount, asset_type, "limit", estimated_delivery)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
campaign.id,
tier.title,
tier.description,
tier.min_amount,
tier.asset_type,
tier.limit,
tier.estimated_delivery,
]
);
}

// Soroban Initialization:
// In a real scenario, we would call the contracts here.
// milestones.initialize(creator, platform, escrow, milestones_vec)
Expand Down
10 changes: 9 additions & 1 deletion backend/src/routes/contributions.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,13 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => {
st.initiated_by_user_id, st.metadata, st.created_at, st.updated_at,
c.creator_id,
ct.id AS contribution_row_id, ct.sender_public_key, ct.amount,
ct.asset, ct.created_at AS contribution_created_at
ct.asset, ct.created_at AS contribution_created_at,
rt.id AS reward_id, rt.title AS reward_title, rt.description AS reward_description
FROM stellar_transactions st
JOIN campaigns c ON c.id = st.campaign_id
LEFT JOIN contributions ct ON ct.id = st.contribution_id
LEFT JOIN contribution_rewards cr ON cr.contribution_id = ct.id
LEFT JOIN reward_tiers rt ON rt.id = cr.reward_tier_id
WHERE st.tx_hash = $1 AND st.kind = 'contribution'`,
[txHash]
);
Expand Down Expand Up @@ -197,6 +200,11 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => {
amount: row.amount,
asset: row.asset,
created_at: row.contribution_created_at,
reward: row.reward_id ? {
id: row.reward_id,
title: row.reward_title,
description: row.reward_description,
} : null,
}
: null,
metadata: row.metadata,
Expand Down
29 changes: 29 additions & 0 deletions backend/src/services/ledgerMonitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,35 @@ async function handlePayment(campaignId, walletPublicKey, payment) {

await markContributionIndexed(client, txHash, inserted[0].id);

// Reward Tier Assignment Logic
const { rows: tiers } = await client.query(
`SELECT id, title, "limit", claimed_count
FROM reward_tiers
WHERE campaign_id = $1 AND min_amount <= $2 AND asset_type = $3
ORDER BY min_amount DESC`,
[campaignId, destinationAmount, destinationAsset]
);

let assignedTier = null;
for (const tier of tiers) {
if (tier.limit === null || tier.claimed_count < tier.limit) {
assignedTier = tier;
break;
}
}

if (assignedTier) {
await client.query(
`UPDATE reward_tiers SET claimed_count = claimed_count + 1 WHERE id = $1`,
[assignedTier.id]
);
await client.query(
`INSERT INTO contribution_rewards (contribution_id, reward_tier_id) VALUES ($1, $2)`,
[inserted[0].id, assignedTier.id]
);
console.log(`[monitor] Reward tier "${assignedTier.title}" assigned to contribution ${inserted[0].id}`);
}

if (anchorMetadata?.anchor_deposit_id) {
await client.query(
`UPDATE anchor_deposits
Expand Down
Loading