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
61 changes: 59 additions & 2 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use crate::base::errors::Error;
use crate::base::events::{
AdminTransferred, AutoshareCreated, AutoshareUpdated, ContractPaused, ContractUnpaused,
GroupActivated, GroupDeactivated, Withdrawal,
ContributorReputationUpdated, GroupActivated, GroupDeactivated, Withdrawal,
};
use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory};
use crate::base::types::{AutoShareDetails, ContributorReputation, GroupMember, PaymentHistory};
use crate::base::validation::{
validate_fee, validate_name, validate_usage_count, validate_withdraw_amount,
};
Expand All @@ -22,6 +22,8 @@ pub enum DataKey {
GroupPaymentHistory(BytesN<32>),
GroupMembers(BytesN<32>),
IsPaused,
/// Stores ContributorReputation for each contributor address.
Reputation(Address),
}

pub fn create_autoshare(
Expand Down Expand Up @@ -810,3 +812,58 @@ fn validate_members(members: &Vec<GroupMember>) -> Result<(), Error> {
}
Ok(())
}

// ============================================================================
// Reputation System
// ============================================================================

/// Returns the current reputation for a contributor.
/// Returns a default (score=0, completed_tasks=0) if no reputation exists yet.
pub fn get_contributor_reputation(env: Env, contributor: Address) -> ContributorReputation {
let key = DataKey::Reputation(contributor.clone());
env.storage()
.persistent()
.get(&key)
.unwrap_or(ContributorReputation {
contributor,
score: 0,
completed_tasks: 0,
})
}

/// Increases a contributor's reputation score by the given amount.
/// Increments completed_tasks by 1.
/// Emits a ContributorReputationUpdated event.
/// Designed to be extensible: future badge logic can be added here.
pub fn increase_contributor_reputation(
env: Env,
contributor: Address,
score_increase: u32,
) -> Result<(), Error> {
contributor.require_auth();

let key = DataKey::Reputation(contributor.clone());
let mut reputation: ContributorReputation = env
.storage()
.persistent()
.get(&key)
.unwrap_or(ContributorReputation {
contributor: contributor.clone(),
score: 0,
completed_tasks: 0,
});

reputation.score += score_increase;
reputation.completed_tasks += 1;

env.storage().persistent().set(&key, &reputation);

ContributorReputationUpdated {
contributor: contributor.clone(),
new_score: reputation.score,
completed_tasks: reputation.completed_tasks,
}
.publish(&env);

Ok(())
}
11 changes: 11 additions & 0 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,14 @@ pub struct Withdrawal {
pub recipient: Address,
pub amount: i128,
}

/// Emitted when a contributor's reputation score changes.
/// The new score reflects the updated reputation after a submission is accepted.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ContributorReputationUpdated {
#[topic]
pub contributor: Address,
pub new_score: u32,
pub completed_tasks: u32,
}
11 changes: 11 additions & 0 deletions contract/contracts/hello-world/src/base/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ pub struct PaymentHistory {
pub amount_paid: i128,
pub timestamp: u64,
}

/// Represents a contributor's reputation score.
/// The score increases when a submission is accepted.
/// Designed to be extensible for future badge logic.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContributorReputation {
pub contributor: Address,
pub score: u32,
pub completed_tasks: u32,
}
25 changes: 25 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,31 @@ impl AutoShareContract {
pub fn reduce_usage(env: Env, id: BytesN<32>, caller: Address) {
autoshare_logic::reduce_usage(env, id, caller).unwrap();
}

// ============================================================================
// Reputation System
// ============================================================================

/// Returns the current reputation for a contributor.
/// Returns a default (score=0, completed_tasks=0) if no reputation exists yet.
pub fn get_contributor_reputation(
env: Env,
contributor: Address,
) -> base::types::ContributorReputation {
autoshare_logic::get_contributor_reputation(env, contributor)
}

/// Increases a contributor's reputation score by the given amount.
/// Increments completed_tasks by 1.
/// Emits a ContributorReputationUpdated event.
/// Designed to be extensible: future badge logic can be added here.
pub fn increase_contributor_reputation(
env: Env,
contributor: Address,
score_increase: u32,
) {
autoshare_logic::increase_contributor_reputation(env, contributor, score_increase).unwrap();
}
}

// 3. Link the tests (Requirement: Unit Tests)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"use client";

import { motion } from "framer-motion";
import { CheckCircle2, Circle, UserRound } from "lucide-react";
import { Award, CheckCircle2, Circle, Star, UserRound } from "lucide-react";
import React from "react";

import {
calculateContributorProfileCompletion,
type ContributorProfileField,
} from "@/lib/contributor-profile";
import { getContributorReputation } from "@/lib/task-workflow";

const PROFILE_FIELDS: ContributorProfileField[] = [
{ key: "name", label: "Full name" },
Expand All @@ -19,7 +20,9 @@ const PROFILE_FIELDS: ContributorProfileField[] = [
];

export default function ContributorProfileCard() {
const [profile, setProfile] = React.useState<Record<string, string | undefined>>({
const [profile, setProfile] = React.useState<
Record<string, string | undefined>
>({
name: "Alicia Chen",
headline: "Product designer",
bio: "",
Expand All @@ -33,6 +36,11 @@ export default function ContributorProfileCard() {
[profile],
);

const reputation = React.useMemo(
() => getContributorReputation("current-user"),
[],
);

const updateField = (key: string, value: string) => {
setProfile((current) => ({ ...current, [key]: value }));
};
Expand All @@ -50,14 +58,18 @@ export default function ContributorProfileCard() {
<UserRound className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Contributor profile</h2>
<h2 className="text-lg font-semibold text-white">
Contributor profile
</h2>
<p className="text-sm text-[#5A6578]">
Finish the profile to make your work easier to discover.
</p>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-white">{completion.percentage}%</div>
<div className="text-2xl font-semibold text-white">
{completion.percentage}%
</div>
<div className="text-xs uppercase tracking-[0.2em] text-[#5A6578]">
complete
</div>
Expand All @@ -73,6 +85,32 @@ export default function ContributorProfileCard() {
</div>
</div>

{/* Reputation Section */}
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-[#2F3547] bg-[#0F172A] px-4 py-3">
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-amber-400" />
<span className="text-xs uppercase tracking-[0.15em] text-[#5A6578]">
Reputation
</span>
</div>
<div className="mt-1 text-2xl font-bold text-white">
{reputation.score}
</div>
</div>
<div className="rounded-xl border border-[#2F3547] bg-[#0F172A] px-4 py-3">
<div className="flex items-center gap-2">
<Award className="h-4 w-4 text-emerald-400" />
<span className="text-xs uppercase tracking-[0.15em] text-[#5A6578]">
Completed
</span>
</div>
<div className="mt-1 text-2xl font-bold text-white">
{reputation.completedTasks}
</div>
</div>
</div>

<div className="mt-5 grid gap-3">
{PROFILE_FIELDS.map((field) => {
const isComplete = Boolean(profile[field.key]?.trim());
Expand All @@ -95,23 +133,29 @@ export default function ContributorProfileCard() {
)}
<div className="flex-1">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium text-white">{field.label}</label>
<label className="text-sm font-medium text-white">
{field.label}
</label>
<span className="text-xs text-[#5A6578]">
{isComplete ? "Filled" : "Missing"}
</span>
</div>
{isTextArea ? (
<textarea
value={profile[field.key] ?? ""}
onChange={(event) => updateField(field.key, event.target.value)}
onChange={(event) =>
updateField(field.key, event.target.value)
}
rows={2}
className="mt-2 w-full rounded-lg border border-[#2F3547] bg-[#111827] px-3 py-2 text-sm text-white outline-none ring-0"
placeholder={`Add your ${field.label.toLowerCase()}`}
/>
) : (
<input
value={profile[field.key] ?? ""}
onChange={(event) => updateField(field.key, event.target.value)}
onChange={(event) =>
updateField(field.key, event.target.value)
}
className="mt-2 w-full rounded-lg border border-[#2F3547] bg-[#111827] px-3 py-2 text-sm text-white outline-none ring-0"
placeholder={`Add your ${field.label.toLowerCase()}`}
/>
Expand All @@ -125,7 +169,9 @@ export default function ContributorProfileCard() {

{completion.missingFields.length > 0 && (
<div className="mt-5 rounded-xl border border-amber-500/20 bg-amber-500/10 p-3">
<p className="text-sm font-medium text-amber-300">Suggested next steps</p>
<p className="text-sm font-medium text-amber-300">
Suggested next steps
</p>
<ul className="mt-2 space-y-1 text-sm text-amber-100/90">
{completion.missingFields.slice(0, 3).map((field) => (
<li key={field.key}>• Add your {field.label.toLowerCase()}</li>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/app/api/reputation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getContributorReputation } from "@/lib/task-workflow";
import { buildNoStoreJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* GET /api/reputation?contributor=<address>
*
* Returns the reputation score for a given contributor.
* If no contributor is specified, returns an error.
*/
export async function GET(request: Request) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse;
}

const { searchParams } = new URL(request.url);
const contributor = searchParams.get("contributor")?.trim();

if (!contributor) {
return buildNoStoreJson(
{ ok: false, error: "contributor query parameter is required." },
400,
rateLimitHeaders,
);
}

const reputation = getContributorReputation(contributor);

return buildNoStoreJson(
{
ok: true,
reputation,
},
200,
rateLimitHeaders,
);
}
52 changes: 52 additions & 0 deletions frontend/src/lib/task-workflow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ContributorReputation } from "@/types/reputation";
import type {
AddCommentInput,
CommentRecord,
Expand Down Expand Up @@ -385,6 +386,9 @@ export function approveSubmission(
const updatedTask: TaskRecord = { ...task, status: "completed" };
tasks.set(taskId, updatedTask);

// Increase contributor's reputation on accepted submission
increaseContributorReputation(submission.contributor);

createNotification(
{
userId: submission.contributor,
Expand Down Expand Up @@ -527,12 +531,60 @@ export function addComment(
return { ok: true, comment };
}

// ============================================================================
// Reputation System
// ============================================================================

const reputations = new Map<string, ContributorReputation>();

/** Points awarded to a contributor when their submission is accepted. */
export const REPUTATION_POINTS_PER_ACCEPTED_SUBMISSION = 10;

/**
* Returns the current reputation for a contributor.
* Returns a default (score=0, completedTasks=0) if no reputation exists yet.
*/
export function getContributorReputation(contributor: string): ContributorReputation {
return reputations.get(contributor) ?? {
contributor,
score: 0,
completedTasks: 0,
};
}

/**
* Increases a contributor's reputation score by the given amount.
* Increments completedTasks by 1.
* Designed to be extensible: future badge logic can be added here.
*/
export function increaseContributorReputation(
contributor: string,
scoreIncrease: number = REPUTATION_POINTS_PER_ACCEPTED_SUBMISSION,
): ContributorReputation {
const current = getContributorReputation(contributor);
const updated: ContributorReputation = {
contributor,
score: current.score + scoreIncrease,
completedTasks: current.completedTasks + 1,
};
reputations.set(contributor, updated);
return updated;
}

/**
* Returns the reputation for all contributors (for leaderboard/analytics).
*/
export function getAllReputations(): ContributorReputation[] {
return Array.from(reputations.values());
}

export function resetTaskWorkflowStore() {
tasks.clear();
submissions.clear();
taskSubmissions.clear();
contributorSubmissions.clear();
comments.clear();
reputations.clear();
nextTaskId = 1;
nextSubmissionId = 1;
nextCommentId = 1;
Expand Down
Loading