diff --git a/contracts/restaurant_registry/src/lib.rs b/contracts/restaurant_registry/src/lib.rs index b3aee88..77e2ff0 100644 --- a/contracts/restaurant_registry/src/lib.rs +++ b/contracts/restaurant_registry/src/lib.rs @@ -5,13 +5,13 @@ //! used as a foreign key in the Order and Payment contracts. //! //! ## Roles -//! - **Admin** – contract deployer; can deactivate any restaurant. +//! - **Admin** – contract deployer; can suspend/unsuspend any restaurant. //! - **Owner** – the wallet that registered a restaurant; can update its -//! own restaurant metadata and toggle its active flag. +//! own restaurant metadata and permanently close it. #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Symbol}; // --------------------------------------------------------------------------- // Storage types @@ -33,6 +33,10 @@ pub struct Restaurant { pub is_active: bool, /// Ledger timestamp of registration. pub created_at: u64, + /// Address that performed the last deactivation (None if currently active). + pub deactivated_by: Option
, + /// Ledger timestamp of the last deactivation (None if currently active). + pub deactivated_at: Option, } /// Storage key discriminants. @@ -71,7 +75,7 @@ impl RestaurantRegistry { } env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Count, &0u64); - env.storage().instance().extend_ttl(17_280, 17_280); // ~1 day + env.storage().instance().extend_ttl(17_280, 17_280); } // ----------------------------------------------------------------------- @@ -106,9 +110,11 @@ impl RestaurantRegistry { slug: slug.clone(), is_active: true, created_at: env.ledger().timestamp(), + deactivated_by: None, + deactivated_at: None, }; - let ttl: u32 = 2_073_600; // ~120 days on Stellar + let ttl: u32 = 2_073_600; env.storage() .persistent() .set(&DataKey::Restaurant(id), &restaurant); @@ -126,7 +132,6 @@ impl RestaurantRegistry { env.storage().instance().set(&DataKey::Count, &id); env.storage().instance().extend_ttl(17_280, 17_280); - // Emit: (topic1, topic2) -> (id, owner) env.events().publish( (symbol_short!("register"), symbol_short!("rest")), (id, owner, name), @@ -175,10 +180,13 @@ impl RestaurantRegistry { ); } - /// Activate or deactivate a restaurant. + /// Permanently close a restaurant. Only the restaurant's own owner may call this. /// - /// Only the owner or admin may change the active flag. - pub fn set_active(env: Env, caller: Address, restaurant_id: u64, active: bool) { + /// This action is irreversible — the admin cannot reopen a voluntarily closed restaurant. + /// + /// # Panics + /// - If `caller` is not the restaurant owner. + pub fn owner_close_restaurant(env: Env, caller: Address, restaurant_id: u64) { caller.require_auth(); let mut restaurant: Restaurant = env @@ -187,12 +195,56 @@ impl RestaurantRegistry { .get(&DataKey::Restaurant(restaurant_id)) .unwrap_or_else(|| panic!("restaurant not found")); + if caller != restaurant.owner { + panic!("unauthorized: only the restaurant owner can close their restaurant"); + } + + let now = env.ledger().timestamp(); + restaurant.is_active = false; + restaurant.deactivated_by = Some(caller.clone()); + restaurant.deactivated_at = Some(now); + + let ttl: u32 = 2_073_600; + env.storage() + .persistent() + .set(&DataKey::Restaurant(restaurant_id), &restaurant); + env.storage() + .persistent() + .extend_ttl(&DataKey::Restaurant(restaurant_id), ttl, ttl); + + env.events().publish( + (symbol_short!("rest"), symbol_short!("close")), + (restaurant_id, caller, now), + ); + } + + /// Suspend a restaurant (recoverable). Only the contract admin may call this. + /// + /// # Panics + /// - If `caller` is not the contract admin. + pub fn admin_suspend_restaurant( + env: Env, + caller: Address, + restaurant_id: u64, + reason: Symbol, + ) { + caller.require_auth(); + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); - if caller != restaurant.owner && caller != admin { - panic!("unauthorized"); + if caller != admin { + panic!("unauthorized: only the admin can suspend a restaurant"); } - restaurant.is_active = active; + let mut restaurant: Restaurant = env + .storage() + .persistent() + .get(&DataKey::Restaurant(restaurant_id)) + .unwrap_or_else(|| panic!("restaurant not found")); + + let now = env.ledger().timestamp(); + restaurant.is_active = false; + restaurant.deactivated_by = Some(caller.clone()); + restaurant.deactivated_at = Some(now); let ttl: u32 = 2_073_600; env.storage() @@ -203,8 +255,45 @@ impl RestaurantRegistry { .extend_ttl(&DataKey::Restaurant(restaurant_id), ttl, ttl); env.events().publish( - (symbol_short!("setactive"), symbol_short!("rest")), - (restaurant_id, active), + (symbol_short!("rest"), symbol_short!("suspend")), + (restaurant_id, caller, now, reason), + ); + } + + /// Lift a suspension placed by the admin. Only the contract admin may call this. + /// + /// # Panics + /// - If `caller` is not the contract admin. + pub fn admin_unsuspend_restaurant(env: Env, caller: Address, restaurant_id: u64) { + caller.require_auth(); + + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != admin { + panic!("unauthorized: only the admin can unsuspend a restaurant"); + } + + let mut restaurant: Restaurant = env + .storage() + .persistent() + .get(&DataKey::Restaurant(restaurant_id)) + .unwrap_or_else(|| panic!("restaurant not found")); + + let now = env.ledger().timestamp(); + restaurant.is_active = true; + restaurant.deactivated_by = None; + restaurant.deactivated_at = None; + + let ttl: u32 = 2_073_600; + env.storage() + .persistent() + .set(&DataKey::Restaurant(restaurant_id), &restaurant); + env.storage() + .persistent() + .extend_ttl(&DataKey::Restaurant(restaurant_id), ttl, ttl); + + env.events().publish( + (symbol_short!("rest"), symbol_short!("unsuspnd")), + (restaurant_id, caller, now), ); } @@ -257,6 +346,17 @@ mod test { (env, client) } + fn register_one(env: &Env, client: &RestaurantRegistryClient, admin: &Address) -> (Address, u64) { + let owner = Address::generate(env); + client.initialize(admin); + let id = client.register_restaurant( + &owner, + &String::from_str(env, "Mama's Kitchen"), + &String::from_str(env, "mamas-kitchen"), + ); + (owner, id) + } + #[test] fn test_register_and_get() { let (env, client) = setup(); @@ -276,6 +376,8 @@ mod test { assert_eq!(rest.owner, owner); assert_eq!(rest.name, String::from_str(&env, "Mama's Kitchen")); assert!(rest.is_active); + assert!(rest.deactivated_by.is_none()); + assert!(rest.deactivated_at.is_none()); } #[test] @@ -302,24 +404,77 @@ mod test { assert_eq!(rest.name, String::from_str(&env, "New Name")); } + // ----------------------------------------------------------------------- + // owner_close_restaurant + // ----------------------------------------------------------------------- + #[test] - fn test_deactivate_restaurant() { + fn test_owner_can_close_own_restaurant() { let (env, client) = setup(); let admin = Address::generate(&env); - let owner = Address::generate(&env); + let (owner, id) = register_one(&env, &client, &admin); - client.initialize(&admin); - let id = client.register_restaurant( - &owner, - &String::from_str(&env, "Test Rest"), - &String::from_str(&env, "test-rest"), - ); + client.owner_close_restaurant(&owner, &id); + + let rest = client.get_restaurant(&id); + assert!(!rest.is_active); + assert_eq!(rest.deactivated_by, Some(owner)); + assert!(rest.deactivated_at.is_some()); + } + + #[test] + #[should_panic(expected = "unauthorized: only the restaurant owner can close their restaurant")] + fn test_admin_cannot_call_owner_close() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let (_owner, id) = register_one(&env, &client, &admin); + + // Admin tries to use the owner-only function — must panic + client.owner_close_restaurant(&admin, &id); + } + + // ----------------------------------------------------------------------- + // admin_suspend_restaurant / admin_unsuspend_restaurant + // ----------------------------------------------------------------------- + + #[test] + fn test_admin_can_suspend_and_unsuspend() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let (_owner, id) = register_one(&env, &client, &admin); + + let reason = Symbol::new(&env, "policy"); + client.admin_suspend_restaurant(&admin, &id, &reason); - client.set_active(&admin, &id, &false); let rest = client.get_restaurant(&id); assert!(!rest.is_active); + assert_eq!(rest.deactivated_by, Some(admin.clone())); + assert!(rest.deactivated_at.is_some()); + + client.admin_unsuspend_restaurant(&admin, &id); + + let rest = client.get_restaurant(&id); + assert!(rest.is_active); + assert!(rest.deactivated_by.is_none()); + assert!(rest.deactivated_at.is_none()); } + #[test] + #[should_panic(expected = "unauthorized: only the admin can suspend a restaurant")] + fn test_owner_cannot_call_admin_suspend() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let (owner, id) = register_one(&env, &client, &admin); + + let reason = Symbol::new(&env, "policy"); + // Owner tries to use the admin-only suspend function — must panic + client.admin_suspend_restaurant(&owner, &id, &reason); + } + + // ----------------------------------------------------------------------- + // Legacy / other + // ----------------------------------------------------------------------- + #[test] #[should_panic(expected = "already initialized")] fn test_double_init_panics() {