Skip to content
Merged
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
393 changes: 393 additions & 0 deletions contracts/opsce/src/provider_rating.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,393 @@
//! Provider rating module
//!
//! Implements a 1-5 star rating system that lets an asset owner rate a
//! maintenance provider after a maintenance record is marked complete.
//!
//! Acceptance criteria covered:
//! - `rate_provider(env, record_id, rating, comment)` — caller must be the
//! asset owner; rating must be 1..=5
//! - Maintains a running average rating stored on the `ProviderProfile`
//! (scaled by 100, e.g. 4.50 stars => 450)
//! - A given record may be rated only once. Re-rating returns
//! `Err(ContractError::AlreadyRated)`.
//! - `get_provider_rating(env, provider_address)` returns
//! `{ average_rating, total_reviews }`
//! - Emits a `provider_rated` event carrying the rating value and record id

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, Address, Env, String, Symbol,
};

/// Errors returned by this contract.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum ContractError {
NotInitialized = 1,
AlreadyInitialized = 2,
Unauthorized = 3,
/// Rating is outside the allowed 1..=5 range.
InvalidRating = 4,
/// The maintenance record was already rated.
AlreadyRated = 5,
/// Maintenance record id was not found.
RecordNotFound = 6,
/// Maintenance record exists but is not yet marked complete.
RecordNotComplete = 7,
/// Provider profile is not registered.
ProviderNotFound = 8,
}

/// Provider profile with cumulative rating fields.
///
/// `average_rating` is stored scaled by 100 so a rating of 4.5 stars is
/// represented as 450. This avoids floating point in the WASM contract while
/// preserving two decimal places of precision.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProviderProfile {
pub address: Address,
pub total_reviews: u32,
pub rating_sum: u32,
pub average_rating: u32,
}

/// Lightweight maintenance record needed for the rating workflow.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MaintenanceRecord {
pub record_id: u64,
pub asset_id: u64,
pub provider: Address,
pub owner: Address,
pub completed: bool,
}

/// Public read model returned by `get_provider_rating`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProviderRating {
/// Average rating scaled by 100 (e.g. 450 = 4.50 stars). Zero when no
/// reviews have been submitted yet.
pub average_rating: u32,
pub total_reviews: u32,
}

/// Persisted rating entry for a single maintenance record.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Review {
pub record_id: u64,
pub provider: Address,
pub rater: Address,
pub rating: u32,
pub comment: String,
pub timestamp: u64,
}

#[contracttype]
pub enum DataKey {
Admin,
Provider(Address),
Record(u64),
/// Marker key written when a record has been rated.
RatedRecord(u64),
/// The persisted review for a record id.
Review(u64),
}

#[contract]
pub struct OpsceContract;

#[contractimpl]
impl OpsceContract {
/// One-time initialization. Stores the admin used to register providers
/// and seed maintenance records.
pub fn init(env: Env, admin: Address) -> Result<(), ContractError> {
if env.storage().persistent().has(&DataKey::Admin) {
return Err(ContractError::AlreadyInitialized);
}
env.storage().persistent().set(&DataKey::Admin, &admin);
Ok(())
}

/// Register a new provider with a zeroed rating profile. Admin only.
pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> {
let admin: Address = env
.storage()
.persistent()
.get(&DataKey::Admin)
.ok_or(ContractError::NotInitialized)?;
admin.require_auth();

let profile = ProviderProfile {
address: provider.clone(),
total_reviews: 0,
rating_sum: 0,
average_rating: 0,
};
env.storage()
.persistent()
.set(&DataKey::Provider(provider), &profile);
Ok(())
}

/// Persist a maintenance record (already completed). Admin only.
/// In production this would be invoked by the asset-maintenance contract
/// when a record is marked complete; here it acts as the entry point that
/// makes a record eligible for rating.
pub fn record_completed_maintenance(
env: Env,
record_id: u64,
asset_id: u64,
owner: Address,
provider: Address,
) -> Result<(), ContractError> {
let admin: Address = env
.storage()
.persistent()
.get(&DataKey::Admin)
.ok_or(ContractError::NotInitialized)?;
admin.require_auth();

if !env
.storage()
.persistent()
.has(&DataKey::Provider(provider.clone()))
{
return Err(ContractError::ProviderNotFound);
}

let record = MaintenanceRecord {
record_id,
asset_id,
provider,
owner,
completed: true,
};
env.storage()
.persistent()
.set(&DataKey::Record(record_id), &record);
Ok(())
}

/// Rate a provider on a completed maintenance record.
///
/// Authorization: the asset owner stored on the record must authorize the
/// call (`require_auth`). Rating must be in the inclusive range 1..=5 and
/// each record can be rated only once.
pub fn rate_provider(
env: Env,
record_id: u64,
rating: u32,
comment: String,
) -> Result<(), ContractError> {
// Validate rating bounds first so 0 and 6+ are rejected before any
// storage access.
if rating < 1 || rating > 5 {
return Err(ContractError::InvalidRating);
}

let record: MaintenanceRecord = env
.storage()
.persistent()
.get(&DataKey::Record(record_id))
.ok_or(ContractError::RecordNotFound)?;

if !record.completed {
return Err(ContractError::RecordNotComplete);
}

// Caller must be the asset owner stored on the record.
record.owner.require_auth();

if env
.storage()
.persistent()
.has(&DataKey::RatedRecord(record_id))
{
return Err(ContractError::AlreadyRated);
}

let mut profile: ProviderProfile = env
.storage()
.persistent()
.get(&DataKey::Provider(record.provider.clone()))
.ok_or(ContractError::ProviderNotFound)?;

// Update the running totals and average (scaled by 100).
profile.rating_sum = profile.rating_sum.saturating_add(rating);
profile.total_reviews = profile.total_reviews.saturating_add(1);
profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews;

env.storage()
.persistent()
.set(&DataKey::Provider(record.provider.clone()), &profile);

// Mark this record as rated and persist the review entry.
env.storage()
.persistent()
.set(&DataKey::RatedRecord(record_id), &true);

let review = Review {
record_id,
provider: record.provider.clone(),
rater: record.owner.clone(),
rating,
comment,
timestamp: env.ledger().timestamp(),
};
env.storage()
.persistent()
.set(&DataKey::Review(record_id), &review);

// Emit the `provider_rated` event with the rating value and record id.
let topic = Symbol::new(&env, "provider_rated");
env.events()
.publish((topic, record.provider), (rating, record_id));

Ok(())
}

/// Returns the current `{ average_rating, total_reviews }` for a provider.
/// Unknown providers return zero values rather than an error so callers
/// can use this as a cheap query.
pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating {
match env
.storage()
.persistent()
.get::<_, ProviderProfile>(&DataKey::Provider(provider_address))
{
Some(p) => ProviderRating {
average_rating: p.average_rating,
total_reviews: p.total_reviews,
},
None => ProviderRating {
average_rating: 0,
total_reviews: 0,
},
}
}

/// Fetch the persisted review for a record, if any.
pub fn get_review(env: Env, record_id: u64) -> Option<Review> {
env.storage().persistent().get(&DataKey::Review(record_id))
}
}

#[cfg(test)]
mod tests {
extern crate std;

use super::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{Address, Env, String};

struct Fixture {
env: Env,
client: OpsceContractClient<'static>,
owner: Address,
provider: Address,
record_id: u64,
}

fn setup() -> Fixture {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(OpsceContract, ());
let client = OpsceContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let provider = Address::generate(&env);
let owner = Address::generate(&env);

client.init(&admin);
client.register_provider(&provider);

let record_id: u64 = 1;
client.record_completed_maintenance(&record_id, &101u64, &owner, &provider);

Fixture {
env,
client,
owner,
provider,
record_id,
}
}

#[test]
fn rate_provider_valid_rating_updates_running_average() {
let fx = setup();

// First rating: 5 stars.
fx.client
.rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great"));

let rating = fx.client.get_provider_rating(&fx.provider);
assert_eq!(rating.total_reviews, 1);
assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100

// Second rating on a different record: 4 stars => running avg 4.5.
let record_id2: u64 = 2;
fx.client
.record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider);
fx.client
.rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good"));

let rating = fx.client.get_provider_rating(&fx.provider);
assert_eq!(rating.total_reviews, 2);
assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450
}

#[test]
fn rate_provider_duplicate_returns_already_rated() {
let fx = setup();

fx.client
.rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great"));

// try_ returns the error variant without panicking on contract errors.
let result = fx.client.try_rate_provider(
&fx.record_id,
&4u32,
&String::from_str(&fx.env, "Again"),
);
assert_eq!(result, Err(Ok(ContractError::AlreadyRated)));

// The provider's average must remain at the first rating only.
let rating = fx.client.get_provider_rating(&fx.provider);
assert_eq!(rating.total_reviews, 1);
assert_eq!(rating.average_rating, 500);
}

#[test]
fn rate_provider_rejects_rating_below_one() {
let fx = setup();

let result = fx
.client
.try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, ""));
assert_eq!(result, Err(Ok(ContractError::InvalidRating)));

// No state change occurred.
let rating = fx.client.get_provider_rating(&fx.provider);
assert_eq!(rating.total_reviews, 0);
assert_eq!(rating.average_rating, 0);
}

#[test]
fn rate_provider_rejects_rating_above_five() {
let fx = setup();

let result = fx
.client
.try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, ""));
assert_eq!(result, Err(Ok(ContractError::InvalidRating)));

let rating = fx.client.get_provider_rating(&fx.provider);
assert_eq!(rating.total_reviews, 0);
assert_eq!(rating.average_rating, 0);
}
}
Loading