Skip to main content

Overview

Utility services provide supporting functionality for the AndanDo platform including review management, like/favorite system, and currency conversion.

ReviewService

Overview

Manages service reviews and ratings for tours.
public interface IReviewService
Location: ~/workspace/source/AndanDo/Services/Utility/ReviewService.cs

AddReviewAsync

Adds a review for a tour service.
Task AddReviewAsync(
    int serviceId,
    int userId,
    int rating,
    string comment,
    CancellationToken cancellationToken = default)
serviceId
int
required
The tour/service ID being reviewed
userId
int
required
User ID submitting the review
rating
int
required
Rating value (1-5 stars)
comment
string
required
Review comment text (max 2000 characters)
try
{
    await reviewService.AddReviewAsync(
        serviceId: 42,
        userId: 123,
        rating: 5,
        comment: "Amazing tour! The guide was knowledgeable and the views were breathtaking."
    );
    
    Console.WriteLine("Review submitted successfully");
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Validation error: {ex.Message}");
}
Validation: The method validates:
  • serviceId must be greater than 0
  • userId must be greater than 0
  • rating must be between 1 and 5
  • comment must not be empty or whitespace
Throws ArgumentException or ArgumentOutOfRangeException if validation fails.
Reviews are automatically set as visible (EsVisible = true) and timestamped with UTC time.

GetReviewsAsync

Retrieves visible reviews for a service.
Task<IReadOnlyList<ServiceReviewDto>> GetReviewsAsync(
    int serviceId,
    int top = 50,
    CancellationToken cancellationToken = default)
serviceId
int
required
The tour/service ID
top
int
default:"50"
Maximum number of reviews to return
reviews
IReadOnlyList<ServiceReviewDto>
List of reviews ordered by creation date (most recent first)
var reviews = await reviewService.GetReviewsAsync(
    serviceId: 42,
    top: 10
);

foreach (var review in reviews)
{
    var stars = new string('⭐', review.Rating);
    Console.WriteLine($"{stars} - {review.Nombre} {review.Apellido}");
    Console.WriteLine($"{review.Comentario}");
    Console.WriteLine($"Posted: {review.FechaCreacion:g}");
    Console.WriteLine();
}
Only reviews marked as visible (EsVisible = true) are returned. This allows moderation of inappropriate content.

GetOwnerRatingAsync

Calculates aggregate rating statistics for all tours owned by a user.
Task<(double? AvgRating, int RatingCount)> GetOwnerRatingAsync(
    int ownerUserId,
    CancellationToken cancellationToken = default)
ownerUserId
int
required
The owner’s user ID
avgRating
double?
Average rating across all tours (null if no ratings)
ratingCount
int
Total number of ratings received
var (avgRating, count) = await reviewService.GetOwnerRatingAsync(
    ownerUserId: 123
);

if (avgRating.HasValue)
{
    Console.WriteLine($"Host Rating: {avgRating.Value:F2} ⭐ ({count} reviews)");
}
else
{
    Console.WriteLine("No reviews yet");
}

PostLikeService

Overview

Manages user likes/favorites for tours.
public interface IPostLikeService
Location: ~/workspace/source/AndanDo/Services/Utility/PostLikeService.cs

HasUserLikedAsync

Checks if a user has liked a specific tour.
Task<bool> HasUserLikedAsync(
    int userId,
    int postId,
    CancellationToken cancellationToken = default)
userId
int
required
User ID to check
postId
int
required
Tour/post ID to check
hasLiked
bool
Returns true if user has liked the tour, false otherwise
bool hasLiked = await postLikeService.HasUserLikedAsync(
    userId: 123,
    postId: 42
);

var buttonText = hasLiked ? "❤️ Liked" : "🤍 Like";
Returns false if either userId or postId is invalid (less than or equal to 0).

AddLikeAsync

Adds a like for a tour.
Task AddLikeAsync(
    int userId,
    int postId,
    CancellationToken cancellationToken = default)
userId
int
required
User ID adding the like
postId
int
required
Tour/post ID to like
try
{
    await postLikeService.AddLikeAsync(
        userId: 123,
        postId: 42
    );
    
    Console.WriteLine("Tour added to favorites");
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Invalid input: {ex.Message}");
}
The method is idempotent - if the user has already liked the tour, it won’t create a duplicate entry.

RemoveLikeAsync

Removes a like from a tour.
Task RemoveLikeAsync(
    int userId,
    int postId,
    CancellationToken cancellationToken = default)
userId
int
required
User ID removing the like
postId
int
required
Tour/post ID to unlike
public async Task ToggleLikeAsync(int userId, int postId)
{
    bool hasLiked = await postLikeService.HasUserLikedAsync(userId, postId);
    
    if (hasLiked)
    {
        await postLikeService.RemoveLikeAsync(userId, postId);
        Console.WriteLine("Removed from favorites");
    }
    else
    {
        await postLikeService.AddLikeAsync(userId, postId);
        Console.WriteLine("Added to favorites");
    }
}

GetUserLikedToursAsync

Retrieves all tours liked by a user.
Task<IReadOnlyList<LikedTourSummaryDto>> GetUserLikedToursAsync(
    int userId,
    CancellationToken cancellationToken = default)
userId
int
required
User ID
likedTours
IReadOnlyList<LikedTourSummaryDto>
List of liked tours with summary information
var favorites = await postLikeService.GetUserLikedToursAsync(
    userId: 123
);

Console.WriteLine($"You have {favorites.Count} favorite tours:");

foreach (var tour in favorites)
{
    Console.WriteLine($"- {tour.Title} ({tour.LocationLabel})");
    if (tour.FromPrice.HasValue)
    {
        Console.WriteLine($"  From {tour.FromPrice.Value:C} {tour.CurrencyCode}");
    }
}
Results are ordered by liked date (most recent first) if the CreatedAt or FechaLike column exists, otherwise ordered by title.

GetOwnerLikeStatsAsync

Retrieves like statistics for all tours owned by a user.
Task<(int TotalLikes, int RecentLikes)> GetOwnerLikeStatsAsync(
    int ownerUserId,
    int recentDays = 7,
    CancellationToken cancellationToken = default)
ownerUserId
int
required
Owner’s user ID
recentDays
int
default:"7"
Number of days to consider as “recent”
totalLikes
int
Total number of likes across all owner’s tours
recentLikes
int
Number of likes received in the recent period
var (total, recent) = await postLikeService.GetOwnerLikeStatsAsync(
    ownerUserId: 123,
    recentDays: 30
);

Console.WriteLine($"Total Favorites: {total}");
Console.WriteLine($"New in last 30 days: {recent}");
Recent likes count requires the CreatedAt column in the PostLikes table. If the column doesn’t exist, recentLikes will be 0.

CurrencyConversionService

Overview

Provides real-time currency conversion using the ExchangeRate API.
public interface ICurrencyConversionService
Location: ~/workspace/source/AndanDo/Services/Utility/CurrencyConversionService.cs

ConvertAsync

Converts an amount from one currency to another.
Task<decimal> ConvertAsync(
    decimal amount,
    string fromCurrency,
    string toCurrency,
    CancellationToken cancellationToken = default)
amount
decimal
required
Amount to convert (must be non-negative)
fromCurrency
string
required
Source currency code (e.g., “USD”, “EUR”)
toCurrency
string
required
Target currency code
convertedAmount
decimal
Converted amount in target currency
// Convert 100 USD to DOP
var dopAmount = await currencyService.ConvertAsync(
    amount: 100m,
    fromCurrency: "USD",
    toCurrency: "DOP"
);

Console.WriteLine($"$100 USD = ${dopAmount:F2} DOP");
API: Uses the free ExchangeRate-API service at https://open.er-api.com/v6/latest/No API key required for basic usage.
If source and target currencies are the same, the method returns the original amount without making an API call.
Validation: The method validates:
  • amount must be non-negative
  • fromCurrency must not be empty
  • toCurrency must not be empty
  • Target currency must exist in API response
Throws ArgumentOutOfRangeException, ArgumentException, or InvalidOperationException if validation fails.

Error Handling

public async Task<decimal?> SafeConvertAsync(
    decimal amount,
    string fromCurrency,
    string toCurrency)
{
    try
    {
        return await currencyService.ConvertAsync(
            amount,
            fromCurrency,
            toCurrency
        );
    }
    catch (ArgumentOutOfRangeException ex)
    {
        Console.WriteLine($"Invalid amount: {ex.Message}");
        return null;
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine($"Invalid currency code: {ex.Message}");
        return null;
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"Conversion failed: {ex.Message}");
        return null;
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"Network error: {ex.Message}");
        return null;
    }
}

Caching Recommendations

Performance: Currency rates change frequently but not constantly. Consider implementing caching:
  • Cache rates for 1-6 hours
  • Use IMemoryCache or distributed cache
  • Refresh cache in background
  • Provide stale data if API is unavailable
public class CachedCurrencyService
{
    private readonly ICurrencyConversionService _currencyService;
    private readonly IMemoryCache _cache;
    private const int CacheMinutes = 60;
    
    public async Task<decimal> ConvertWithCacheAsync(
        decimal amount,
        string fromCurrency,
        string toCurrency)
    {
        var cacheKey = $"rate_{fromCurrency}_{toCurrency}";
        
        if (!_cache.TryGetValue<decimal>(cacheKey, out var rate))
        {
            // Get rate by converting 1 unit
            rate = await _currencyService.ConvertAsync(
                1m,
                fromCurrency,
                toCurrency
            );
            
            _cache.Set(cacheKey, rate, TimeSpan.FromMinutes(CacheMinutes));
        }
        
        return amount * rate;
    }
}

Common Use Cases

Display Tour with Reviews and Likes

public async Task<TourCardViewModel> GetTourCardAsync(
    int tourId,
    int? currentUserId = null)
{
    var tour = await tourService.GetTourFullByIdAsync(tourId);
    if (tour == null) return null;
    
    // Get reviews
    var reviews = await reviewService.GetReviewsAsync(tourId, top: 5);
    var avgRating = reviews.Any() 
        ? reviews.Average(r => r.Rating) 
        : (double?)null;
    
    // Get like status
    bool isLiked = false;
    if (currentUserId.HasValue)
    {
        isLiked = await postLikeService.HasUserLikedAsync(
            currentUserId.Value,
            tourId
        );
    }
    
    // Convert price if needed
    decimal displayPrice = tour.TicketTypes.FirstOrDefault()?.Price ?? 0;
    string displayCurrency = "USD";
    
    if (userPreferredCurrency != "USD")
    {
        displayPrice = await currencyService.ConvertAsync(
            displayPrice,
            "USD",
            userPreferredCurrency
        );
        displayCurrency = userPreferredCurrency;
    }
    
    return new TourCardViewModel
    {
        Tour = tour,
        AverageRating = avgRating,
        ReviewCount = reviews.Count,
        IsLiked = isLiked,
        DisplayPrice = displayPrice,
        DisplayCurrency = displayCurrency
    };
}

Host Dashboard Stats

public async Task<HostDashboardViewModel> GetHostStatsAsync(int userId)
{
    var (totalTours, recentTours) = await tourService.GetOwnerTourStatsAsync(
        userId,
        recentDays: 7
    );
    
    var (totalLikes, recentLikes) = await postLikeService.GetOwnerLikeStatsAsync(
        userId,
        recentDays: 7
    );
    
    var (avgRating, ratingCount) = await reviewService.GetOwnerRatingAsync(userId);
    
    var earnings = await tourService.GetOwnerEarningsAsync(userId);
    
    return new HostDashboardViewModel
    {
        TotalTours = totalTours,
        NewToursThisWeek = recentTours,
        TotalFavorites = totalLikes,
        NewFavoritesThisWeek = recentLikes,
        AverageRating = avgRating ?? 0,
        TotalReviews = ratingCount,
        TotalEarnings = earnings
    };
}

Configuration

// Register utility services
services.AddScoped<IReviewService, ReviewService>();
services.AddScoped<IPostLikeService, PostLikeService>();

// Currency service needs HttpClient
services.AddHttpClient<ICurrencyConversionService, CurrencyConversionService>();

Best Practices

Review Moderation: Reviews are auto-visible by default. Implement a moderation system to flag and hide inappropriate content.
Rate Limiting: The currency conversion service makes external API calls. Implement rate limiting and caching to avoid hitting API quotas.
Data Integrity: When deleting tours, ensure reviews and likes are also cleaned up. The TourService.DeleteTourAsync method handles this automatically.

Build docs developers (and LLMs) love