✨ Refreshed account presences system
This commit is contained in:
		@@ -26,6 +26,7 @@ public class AccountEventService(
 | 
			
		||||
{
 | 
			
		||||
    private static readonly Random Random = new();
 | 
			
		||||
    private const string StatusCacheKey = "account:status:";
 | 
			
		||||
    private const string ActivityCacheKey = "account:activities:";
 | 
			
		||||
 | 
			
		||||
    private async Task<bool> GetAccountIsConnected(Guid userId)
 | 
			
		||||
    {
 | 
			
		||||
@@ -41,6 +42,12 @@ public class AccountEventService(
 | 
			
		||||
        cache.RemoveAsync(cacheKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void PurgeActivityCache(Guid userId)
 | 
			
		||||
    {
 | 
			
		||||
        var cacheKey = $"{ActivityCacheKey}{userId}";
 | 
			
		||||
        cache.RemoveAsync(cacheKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task BroadcastStatusUpdate(SnAccountStatus status)
 | 
			
		||||
    {
 | 
			
		||||
        await nats.PublishAsync(
 | 
			
		||||
@@ -434,4 +441,105 @@ public class AccountEventService(
 | 
			
		||||
            };
 | 
			
		||||
        }).ToList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<List<SnPresenceActivity>> GetActiveActivities(Guid userId)
 | 
			
		||||
    {
 | 
			
		||||
        var cacheKey = $"{ActivityCacheKey}{userId}";
 | 
			
		||||
        var cachedActivities = await cache.GetAsync<List<SnPresenceActivity>>(cacheKey);
 | 
			
		||||
        if (cachedActivities != null)
 | 
			
		||||
        {
 | 
			
		||||
            return cachedActivities;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        var activities = await db.PresenceActivities
 | 
			
		||||
            .Where(e => e.AccountId == userId && e.LeaseExpiresAt > now)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
 | 
			
		||||
        await cache.SetWithGroupsAsync(cacheKey, activities, [$"{AccountService.AccountCachePrefix}{userId}"], TimeSpan.FromMinutes(1));
 | 
			
		||||
        return activities;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<SnPresenceActivity> SetActivity(SnPresenceActivity activity, int leaseMinutes)
 | 
			
		||||
    {
 | 
			
		||||
        if (leaseMinutes < 1 || leaseMinutes > 60)
 | 
			
		||||
            throw new ArgumentException("Lease minutes must be between 1 and 60");
 | 
			
		||||
 | 
			
		||||
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
			
		||||
        activity.LeaseMinutes = leaseMinutes;
 | 
			
		||||
        activity.LeaseExpiresAt = now + Duration.FromMinutes(leaseMinutes);
 | 
			
		||||
 | 
			
		||||
        db.PresenceActivities.Add(activity);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        PurgeActivityCache(activity.AccountId);
 | 
			
		||||
 | 
			
		||||
        return activity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<SnPresenceActivity> UpdateActivity(Guid activityId, Action<SnPresenceActivity> update, int? leaseMinutes = null)
 | 
			
		||||
    {
 | 
			
		||||
        var activity = await db.PresenceActivities.FindAsync(activityId);
 | 
			
		||||
        if (activity == null)
 | 
			
		||||
            throw new KeyNotFoundException("Activity not found");
 | 
			
		||||
 | 
			
		||||
        if (leaseMinutes.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            if (leaseMinutes.Value < 1 || leaseMinutes.Value > 60)
 | 
			
		||||
                throw new ArgumentException("Lease minutes must be between 1 and 60");
 | 
			
		||||
 | 
			
		||||
            activity.LeaseMinutes = leaseMinutes.Value;
 | 
			
		||||
            activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant() + Duration.FromMinutes(leaseMinutes.Value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        update(activity);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        PurgeActivityCache(activity.AccountId);
 | 
			
		||||
 | 
			
		||||
        return activity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<SnPresenceActivity?> UpdateActivityByManualId(string manualId, Guid userId, Action<SnPresenceActivity> update, int? leaseMinutes = null)
 | 
			
		||||
    {
 | 
			
		||||
        var activity = await db.PresenceActivities.FirstOrDefaultAsync(e => e.ManualId == manualId && e.AccountId == userId);
 | 
			
		||||
        if (activity == null)
 | 
			
		||||
            return null;
 | 
			
		||||
 | 
			
		||||
        if (leaseMinutes.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            if (leaseMinutes.Value < 1 || leaseMinutes.Value > 60)
 | 
			
		||||
                throw new ArgumentException("Lease minutes must be between 1 and 60");
 | 
			
		||||
 | 
			
		||||
            activity.LeaseMinutes = leaseMinutes.Value;
 | 
			
		||||
            activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant() + Duration.FromMinutes(leaseMinutes.Value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        update(activity);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
        PurgeActivityCache(activity.AccountId);
 | 
			
		||||
 | 
			
		||||
        return activity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<bool> DeleteActivityByManualId(string manualId, Guid userId)
 | 
			
		||||
    {
 | 
			
		||||
        var activity = await db.PresenceActivities.FirstOrDefaultAsync(e => e.ManualId == manualId && e.AccountId == userId);
 | 
			
		||||
        if (activity == null) return false;
 | 
			
		||||
        db.Remove(activity);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
        PurgeActivityCache(activity.AccountId);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<bool> DeleteActivity(Guid activityId)
 | 
			
		||||
    {
 | 
			
		||||
        var activity = await db.PresenceActivities.FindAsync(activityId);
 | 
			
		||||
        if (activity == null) return false;
 | 
			
		||||
        db.Remove(activity);
 | 
			
		||||
        await db.SaveChangesAsync();
 | 
			
		||||
        PurgeActivityCache(activity.AccountId);
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										240
									
								
								DysonNetwork.Pass/Account/PresenceActivityController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										240
									
								
								DysonNetwork.Pass/Account/PresenceActivityController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,240 @@
 | 
			
		||||
using DysonNetwork.Shared.Models;
 | 
			
		||||
using Microsoft.AspNetCore.Authorization;
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Pass.Account;
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// Controller for managing user presence activities with lease-based expiration.
 | 
			
		||||
/// Supports both user-defined manual IDs and autogenerated GUIDs for activity management.
 | 
			
		||||
/// </summary>
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/activities")]
 | 
			
		||||
[Authorize]
 | 
			
		||||
public class PresenceActivityController(AppDatabase db, AccountEventService service) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Retrieves all active (non-expired) presence activities for the authenticated user.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>List of active presence activities</returns>
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    [ProducesResponseType<List<SnPresenceActivity>>(StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    public async Task<ActionResult<List<SnPresenceActivity>>> GetActivities()
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
			
		||||
        var activities = await service.GetActiveActivities(currentUser.Id);
 | 
			
		||||
        return Ok(activities);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Retrieves active presence activities for any user account (admin/debugging endpoint).
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="accountId">The account ID to fetch activities for</param>
 | 
			
		||||
    /// <returns>List of active presence activities</returns>
 | 
			
		||||
    [HttpGet("{accountId:guid}")]
 | 
			
		||||
    [ProducesResponseType<List<SnPresenceActivity>>(StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    public async Task<ActionResult<List<SnPresenceActivity>>> GetActivitiesByAccountId(Guid accountId)
 | 
			
		||||
    {
 | 
			
		||||
        var activities = await service.GetActiveActivities(accountId);
 | 
			
		||||
        return Ok(activities);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Creates a new presence activity with lease expiration.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="request">Activity creation parameters</param>
 | 
			
		||||
    /// <returns>The created activity</returns>
 | 
			
		||||
    [HttpPost]
 | 
			
		||||
    [ProducesResponseType<SnPresenceActivity>(StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    public async Task<ActionResult<SnPresenceActivity>> SetActivity(
 | 
			
		||||
        [FromBody] SetActivityRequest request
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
			
		||||
 | 
			
		||||
        var activity = new SnPresenceActivity
 | 
			
		||||
        {
 | 
			
		||||
            Type = request.Type,
 | 
			
		||||
            ManualId = request.ManualId,
 | 
			
		||||
            Title = request.Title,
 | 
			
		||||
            Subtitle = request.Subtitle,
 | 
			
		||||
            Caption = request.Caption,
 | 
			
		||||
            Meta = request.Meta,
 | 
			
		||||
            AccountId = currentUser.Id,
 | 
			
		||||
        };
 | 
			
		||||
        var result = await service.SetActivity(activity, request.LeaseMinutes);
 | 
			
		||||
        return Ok(result);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Updates an existing presence activity using either its GUID or manual ID.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="id">System-generated GUID of the activity (optional)</param>
 | 
			
		||||
    /// <param name="manualId">User-defined manual ID of the activity (optional)</param>
 | 
			
		||||
    /// <param name="request">Update parameters (only provided fields are updated)</param>
 | 
			
		||||
    /// <returns>The updated activity</returns>
 | 
			
		||||
    /// <remarks>One of 'id' or 'manualId' must be provided and non-empty.</remarks>
 | 
			
		||||
    [HttpPut]
 | 
			
		||||
    [ProducesResponseType<SnPresenceActivity>(StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    public async Task<ActionResult<SnPresenceActivity>> UpdateActivity(
 | 
			
		||||
        [FromQuery] string? id,
 | 
			
		||||
        [FromQuery] string? manualId,
 | 
			
		||||
        [FromBody] UpdateActivityRequest request
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
			
		||||
 | 
			
		||||
        var type = request.Type;
 | 
			
		||||
        var title = request.Title;
 | 
			
		||||
        var subtitle = request.Subtitle;
 | 
			
		||||
        var caption = request.Caption;
 | 
			
		||||
        var requestManualId = request.ManualId;
 | 
			
		||||
        var requestMeta = request.Meta;
 | 
			
		||||
        var leaseMinutes = request.LeaseMinutes;
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(manualId))
 | 
			
		||||
        {
 | 
			
		||||
            var result = await service.UpdateActivityByManualId(
 | 
			
		||||
                manualId,
 | 
			
		||||
                currentUser.Id,
 | 
			
		||||
                activity =>
 | 
			
		||||
                {
 | 
			
		||||
                    if (type.HasValue) activity.Type = type.Value;
 | 
			
		||||
                    if (title != null) activity.Title = title;
 | 
			
		||||
                    if (subtitle != null) activity.Subtitle = subtitle;
 | 
			
		||||
                    if (caption != null) activity.Caption = caption;
 | 
			
		||||
                    if (requestManualId != null) activity.ManualId = requestManualId;
 | 
			
		||||
                    if (requestMeta != null) activity.Meta = requestMeta;
 | 
			
		||||
                },
 | 
			
		||||
                leaseMinutes);
 | 
			
		||||
 | 
			
		||||
            if (result == null)
 | 
			
		||||
                return NotFound();
 | 
			
		||||
 | 
			
		||||
            return Ok(result);
 | 
			
		||||
        }
 | 
			
		||||
        else if (!string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out var activityGuid))
 | 
			
		||||
        {
 | 
			
		||||
            var result = await service.UpdateActivity(
 | 
			
		||||
                activityGuid,
 | 
			
		||||
                activity =>
 | 
			
		||||
                {
 | 
			
		||||
                    if (type.HasValue) activity.Type = type.Value;
 | 
			
		||||
                    if (title != null) activity.Title = title;
 | 
			
		||||
                    if (subtitle != null) activity.Subtitle = subtitle;
 | 
			
		||||
                    if (caption != null) activity.Caption = caption;
 | 
			
		||||
                    if (requestManualId != null) activity.ManualId = requestManualId;
 | 
			
		||||
                    if (requestMeta != null) activity.Meta = requestMeta;
 | 
			
		||||
                },
 | 
			
		||||
                leaseMinutes);
 | 
			
		||||
 | 
			
		||||
            return Ok(result);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            return BadRequest("Either 'id' (GUID) or 'manualId' must be provided");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Deletes a presence activity using either its GUID or manual ID.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="id">System-generated GUID of the activity (optional)</param>
 | 
			
		||||
    /// <param name="manualId">User-defined manual ID of the activity (optional)</param>
 | 
			
		||||
    /// <returns>NoContent on success</returns>
 | 
			
		||||
    /// <remarks>One of 'id' or 'manualId' must be provided and non-empty. Soft-deletes the activity.</remarks>
 | 
			
		||||
    [HttpDelete]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status204NoContent)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    public async Task<IActionResult> DeleteActivityById(
 | 
			
		||||
        [FromQuery] string? id,
 | 
			
		||||
        [FromQuery] string? manualId)
 | 
			
		||||
    {
 | 
			
		||||
        if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(manualId))
 | 
			
		||||
        {
 | 
			
		||||
            var deleted = await service.DeleteActivityByManualId(manualId, currentUser.Id);
 | 
			
		||||
            if (!deleted)
 | 
			
		||||
                return NotFound();
 | 
			
		||||
 | 
			
		||||
            return NoContent();
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out var activityGuid))
 | 
			
		||||
            {
 | 
			
		||||
                var deleted = await service.DeleteActivity(activityGuid);
 | 
			
		||||
                if (!deleted)
 | 
			
		||||
                    return NotFound();
 | 
			
		||||
 | 
			
		||||
                return NoContent();
 | 
			
		||||
            }
 | 
			
		||||
            return BadRequest("Either 'id' (GUID) or 'manualId' must be provided");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Request model for creating a new presence activity.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public class SetActivityRequest
 | 
			
		||||
    {
 | 
			
		||||
        /// <summary>The type of presence activity (e.g., Gaming, Music, Workout)</summary>
 | 
			
		||||
        public PresenceType Type { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>User-defined identifier for the activity (optional, for easy reference)</summary>
 | 
			
		||||
        public string? ManualId { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Main title of the activity</summary>
 | 
			
		||||
        public string? Title { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Secondary subtitle of the activity</summary>
 | 
			
		||||
        public string? Subtitle { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Additional caption/description</summary>
 | 
			
		||||
        public string? Caption { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Extensible metadata dictionary for custom developer data</summary>
 | 
			
		||||
        public Dictionary<string, object>? Meta { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Lease duration in minutes (1-60, default: 5)</summary>
 | 
			
		||||
        public int LeaseMinutes { get; set; } = 5;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Request model for updating an existing presence activity.
 | 
			
		||||
    /// All fields are optional and will only update if provided.
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public class UpdateActivityRequest
 | 
			
		||||
    {
 | 
			
		||||
        /// <summary>The type of presence activity (optional update)</summary>
 | 
			
		||||
        public PresenceType? Type { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>User-defined identifier update</summary>
 | 
			
		||||
        public string? ManualId { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Title update</summary>
 | 
			
		||||
        public string? Title { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Subtitle update</summary>
 | 
			
		||||
        public string? Subtitle { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Caption update</summary>
 | 
			
		||||
        public string? Caption { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Metadata update</summary>
 | 
			
		||||
        public Dictionary<string, object>? Meta { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>Lease renewal in minutes</summary>
 | 
			
		||||
        public int? LeaseMinutes { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user