diff --git a/backend/src/routes/contribution-export.routes.ts b/backend/src/routes/contribution-export.routes.ts new file mode 100644 index 00000000..f9ddade6 --- /dev/null +++ b/backend/src/routes/contribution-export.routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { exportAsJSON, exportAsCSV } from '../services/contributionExportService.js'; + +const router = Router(); + +// GET /export/contributions/:userId/json +router.get('/contributions/:userId/json', (req, res) => { + const { userId } = req.params; + const data = exportAsJSON(userId); + res.json(data); +}); + +// GET /export/contributions/:userId/csv +router.get('/contributions/:userId/csv', (req, res) => { + const { userId } = req.params; + const csv = exportAsCSV(userId); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="contributions-${userId}.csv"`); + res.send(csv); +}); + +export default router; diff --git a/backend/src/services/contributionExportService.ts b/backend/src/services/contributionExportService.ts new file mode 100644 index 00000000..268d96f7 --- /dev/null +++ b/backend/src/services/contributionExportService.ts @@ -0,0 +1,38 @@ +interface Contribution { + id: string; + repo: string; + type: string; + date: string; + status: string; +} + +interface ContributionExportData { + userId: string; + exportedAt: string; + contributions: Contribution[]; +} + +function getMockContributions(): Contribution[] { + return [ + { id: 'c1', repo: 'stellar/stellar-sdk', type: 'pull_request', date: '2024-03-01', status: 'merged' }, + { id: 'c2', repo: 'web3-student-lab/frontend', type: 'issue', date: '2024-03-15', status: 'closed' }, + { id: 'c3', repo: 'soroban-examples/tokens', type: 'pull_request', date: '2024-04-10', status: 'open' }, + ]; +} + +export function exportAsJSON(userId: string): ContributionExportData { + return { + userId, + exportedAt: new Date().toISOString(), + contributions: getMockContributions(), + }; +} + +export function exportAsCSV(userId: string): string { + const contributions = getMockContributions(); + const header = 'id,repo,type,date,status'; + const rows = contributions.map( + (c) => `${c.id},${c.repo},${c.type},${c.date},${c.status}` + ); + return [header, ...rows].join('\n'); +} diff --git a/backend/tests/contributionExport.test.ts b/backend/tests/contributionExport.test.ts new file mode 100644 index 00000000..cfca8631 --- /dev/null +++ b/backend/tests/contributionExport.test.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import express from 'express'; +import { exportAsJSON, exportAsCSV } from '../src/services/contributionExportService.js'; +import contributionExportRouter from '../src/routes/contribution-export.routes.js'; + +describe('contributionExportService', () => { + describe('exportAsJSON', () => { + it('returns the correct data shape', () => { + const result = exportAsJSON('user-42'); + expect(result.userId).toBe('user-42'); + expect(typeof result.exportedAt).toBe('string'); + expect(new Date(result.exportedAt).toISOString()).toBe(result.exportedAt); + expect(Array.isArray(result.contributions)).toBe(true); + expect(result.contributions).toHaveLength(3); + const [first] = result.contributions; + expect(first).toHaveProperty('id'); + expect(first).toHaveProperty('repo'); + expect(first).toHaveProperty('type'); + expect(first).toHaveProperty('date'); + expect(first).toHaveProperty('status'); + }); + }); + + describe('exportAsCSV', () => { + it('returns a string with CSV headers', () => { + const result = exportAsCSV('user-42'); + expect(typeof result).toBe('string'); + const lines = result.split('\n'); + expect(lines[0]).toBe('id,repo,type,date,status'); + expect(lines).toHaveLength(4); // header + 3 rows + }); + }); +}); + +describe('contribution export routes', () => { + const app = express(); + app.use('/export', contributionExportRouter); + + it('GET /export/contributions/:userId/json returns 200 with JSON content type', async () => { + const res = await request(app).get('/export/contributions/user-1/json'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body.userId).toBe('user-1'); + }); + + it('GET /export/contributions/:userId/csv returns 200 with CSV content type', async () => { + const res = await request(app).get('/export/contributions/user-1/csv'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/csv/); + expect(res.headers['content-disposition']).toMatch(/attachment/); + expect(res.text).toMatch(/^id,repo,type,date,status/); + }); +}); diff --git a/contracts/src/referral_program.rs b/contracts/src/referral_program.rs new file mode 100644 index 00000000..8edd0dff --- /dev/null +++ b/contracts/src/referral_program.rs @@ -0,0 +1,103 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Referrer(Address), + ReferralCount(Address), + Rewarded(Address, Address), +} + +#[contract] +pub struct ReferralProgramContract; + +#[contractimpl] +impl ReferralProgramContract { + /// Register a referral relationship. Panics on self-referral or duplicate. + pub fn register_referral(env: Env, referrer: Address, referee: Address) { + if referrer == referee { + panic!("self-referral not allowed"); + } + let key = DataKey::Referrer(referee.clone()); + if env.storage().instance().has(&key) { + panic!("referee already has a referrer"); + } + env.storage().instance().set(&key, &referrer.clone()); + + let count_key = DataKey::ReferralCount(referrer.clone()); + let count: u32 = env.storage().instance().get(&count_key).unwrap_or(0); + env.storage().instance().set(&count_key, &(count + 1)); + } + + /// Returns the number of successful referrals made by `referrer`. + pub fn get_referral_count(env: Env, referrer: Address) -> u32 { + env.storage() + .instance() + .get(&DataKey::ReferralCount(referrer)) + .unwrap_or(0) + } + + /// Returns the referrer of `referee`, if any. + pub fn get_referrer(env: Env, referee: Address) -> Option
{ + env.storage() + .instance() + .get(&DataKey::Referrer(referee)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, ReferralProgramContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(ReferralProgramContract, ()); + let client = ReferralProgramContractClient::new(&env, &id); + (env, client) + } + + #[test] + fn register_referral_stores_data() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + + client.register_referral(&referrer, &referee); + + assert_eq!(client.get_referrer(&referee), Some(referrer)); + } + + #[test] + fn get_referral_count_increments() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee1 = Address::generate(&env); + let referee2 = Address::generate(&env); + + assert_eq!(client.get_referral_count(&referrer), 0); + client.register_referral(&referrer, &referee1); + assert_eq!(client.get_referral_count(&referrer), 1); + client.register_referral(&referrer, &referee2); + assert_eq!(client.get_referral_count(&referrer), 2); + } + + #[test] + #[should_panic] + fn self_referral_panics() { + let (env, client) = setup(); + let user = Address::generate(&env); + client.register_referral(&user, &user); + } + + #[test] + #[should_panic] + fn duplicate_referral_panics() { + let (env, client) = setup(); + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + client.register_referral(&referrer, &referee); + client.register_referral(&referrer, &referee); + } +} diff --git a/frontend/src/app/playground/page.tsx b/frontend/src/app/playground/page.tsx index 187ba7cf..19dda102 100644 --- a/frontend/src/app/playground/page.tsx +++ b/frontend/src/app/playground/page.tsx @@ -383,6 +383,7 @@ export default function PlaygroundPage() {
This playground now includes the educational notarization and payment gateway modules.
Learners can inspect hash timestamping, escrowed payment processing, refunds, and
diff --git a/frontend/src/app/roadmap/page.tsx b/frontend/src/app/roadmap/page.tsx
index 01f9e7cf..3a100c7c 100644
--- a/frontend/src/app/roadmap/page.tsx
+++ b/frontend/src/app/roadmap/page.tsx
@@ -89,6 +89,7 @@ export default function RoadmapPage() {
>
Retry Connection
+
+ Contract Search
+
+ {filtered.length === 0 ? (
+
+