✨ 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; }
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public class AppDatabase(
|
||||
public DbSet<SnAccountRelationship> AccountRelationships { get; set; } = null!;
|
||||
public DbSet<SnAccountStatus> AccountStatuses { get; set; } = null!;
|
||||
public DbSet<SnCheckInResult> AccountCheckInResults { get; set; } = null!;
|
||||
public DbSet<SnPresenceActivity> PresenceActivities { get; set; } = null!;
|
||||
public DbSet<SnAccountBadge> Badges { get; set; } = null!;
|
||||
public DbSet<SnActionLog> ActionLogs { get; set; } = null!;
|
||||
public DbSet<SnAbuseReport> AbuseReports { get; set; } = null!;
|
||||
|
||||
2690
DysonNetwork.Pass/Migrations/20251101092348_AddPresenceActivity.Designer.cs
generated
Normal file
2690
DysonNetwork.Pass/Migrations/20251101092348_AddPresenceActivity.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPresenceActivity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "background_id",
|
||||
table: "realms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "picture_id",
|
||||
table: "realms");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "presence_activities",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
type = table.Column<int>(type: "integer", nullable: false),
|
||||
manual_id = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
subtitle = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
caption = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||
lease_minutes = table.Column<int>(type: "integer", nullable: false),
|
||||
lease_expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_presence_activities", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_presence_activities_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_presence_activities_account_id",
|
||||
table: "presence_activities",
|
||||
column: "account_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "presence_activities");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "background_id",
|
||||
table: "realms",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "picture_id",
|
||||
table: "realms",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.9")
|
||||
.HasAnnotation("ProductVersion", "9.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -1363,6 +1363,74 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("permission_nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPresenceActivity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("caption");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant>("LeaseExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("lease_expires_at");
|
||||
|
||||
b.Property<int>("LeaseMinutes")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("lease_minutes");
|
||||
|
||||
b.Property<string>("ManualId")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("manual_id");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("subtitle");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_presence_activities");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_presence_activities_account_id");
|
||||
|
||||
b.ToTable("presence_activities", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1378,11 +1446,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<string>("BackgroundId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("background_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
@@ -1415,11 +1478,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("picture_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
@@ -2389,6 +2447,18 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPresenceActivity", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_presence_activities_accounts_account_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")
|
||||
|
||||
@@ -10,21 +10,31 @@ public enum StatusAttitude
|
||||
{
|
||||
Positive,
|
||||
Negative,
|
||||
Neutral
|
||||
Neutral,
|
||||
}
|
||||
|
||||
public class SnAccountStatus : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public StatusAttitude Attitude { get; set; }
|
||||
[NotMapped] public bool IsOnline { get; set; }
|
||||
[NotMapped] public bool IsCustomized { get; set; } = true;
|
||||
|
||||
[NotMapped]
|
||||
public bool IsOnline { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsCustomized { get; set; } = true;
|
||||
public bool IsInvisible { get; set; }
|
||||
public bool IsNotDisturb { get; set; }
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
public Instant? ClearedAt { get; set; }
|
||||
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string? AppIdentifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates this status is created based on running process or rich presence
|
||||
@@ -44,7 +54,7 @@ public class SnAccountStatus : ModelBase
|
||||
StatusAttitude.Positive => Shared.Proto.StatusAttitude.Positive,
|
||||
StatusAttitude.Negative => Shared.Proto.StatusAttitude.Negative,
|
||||
StatusAttitude.Neutral => Shared.Proto.StatusAttitude.Neutral,
|
||||
_ => Shared.Proto.StatusAttitude.Unspecified
|
||||
_ => Shared.Proto.StatusAttitude.Unspecified,
|
||||
},
|
||||
IsOnline = IsOnline,
|
||||
IsCustomized = IsCustomized,
|
||||
@@ -53,7 +63,7 @@ public class SnAccountStatus : ModelBase
|
||||
Label = Label ?? string.Empty,
|
||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(Meta),
|
||||
ClearedAt = ClearedAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString()
|
||||
AccountId = AccountId.ToString(),
|
||||
};
|
||||
|
||||
return proto;
|
||||
@@ -69,7 +79,7 @@ public class SnAccountStatus : ModelBase
|
||||
Shared.Proto.StatusAttitude.Positive => StatusAttitude.Positive,
|
||||
Shared.Proto.StatusAttitude.Negative => StatusAttitude.Negative,
|
||||
Shared.Proto.StatusAttitude.Neutral => StatusAttitude.Neutral,
|
||||
_ => StatusAttitude.Neutral
|
||||
_ => StatusAttitude.Neutral,
|
||||
},
|
||||
IsOnline = proto.IsOnline,
|
||||
IsCustomized = proto.IsCustomized,
|
||||
@@ -78,7 +88,7 @@ public class SnAccountStatus : ModelBase
|
||||
Label = proto.Label,
|
||||
Meta = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(proto.Meta),
|
||||
ClearedAt = proto.ClearedAt?.ToInstant(),
|
||||
AccountId = Guid.Parse(proto.AccountId)
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
};
|
||||
|
||||
return status;
|
||||
@@ -92,7 +102,7 @@ public enum CheckInResultLevel
|
||||
Normal,
|
||||
Better,
|
||||
Best,
|
||||
Special
|
||||
Special,
|
||||
}
|
||||
|
||||
public class SnCheckInResult : ModelBase
|
||||
@@ -101,7 +111,9 @@ public class SnCheckInResult : ModelBase
|
||||
public CheckInResultLevel Level { get; set; }
|
||||
public decimal? RewardPoints { get; set; }
|
||||
public int? RewardExperience { get; set; }
|
||||
[Column(TypeName = "jsonb")] public ICollection<CheckInFortuneTip> Tips { get; set; } = new List<CheckInFortuneTip>();
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public ICollection<CheckInFortuneTip> Tips { get; set; } = new List<CheckInFortuneTip>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
@@ -125,3 +137,38 @@ public class DailyEventResponse
|
||||
public SnCheckInResult? CheckInResult { get; set; }
|
||||
public ICollection<SnAccountStatus> Statuses { get; set; } = new List<SnAccountStatus>();
|
||||
}
|
||||
|
||||
public enum PresenceType
|
||||
{
|
||||
Unknown,
|
||||
Gaming,
|
||||
Music,
|
||||
Workout,
|
||||
}
|
||||
|
||||
public class SnPresenceActivity : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public PresenceType Type { get; set; } = PresenceType.Unknown;
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string? ManualId { get; set; }
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string? Subtitle { get; set; }
|
||||
|
||||
[MaxLength(4096)]
|
||||
public string? Caption { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
|
||||
public int LeaseMinutes { get; set; } = 5; // Lease period in minutes (1-60)
|
||||
public Instant LeaseExpiresAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
324
PRESENCE_ACTIVITY_API.md
Normal file
324
PRESENCE_ACTIVITY_API.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Presence Activity API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Presence Activity API allows users to manage their current activities (e.g., gaming, music, workouts) with automatic expiration through a lease-based system. Activities can be created, updated, and deleted, with support for flexible metadata and both system-generated and user-defined identifiers.
|
||||
|
||||
This service is handled by the DysonNetwork.Pass, when using with the gateway, replace the `/api` with the `/pass`
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Lease-Based Expiration**: Activities automatically expire within 1-60 minutes unless renewed
|
||||
- **Flexible Identity**: Support for both autogenerated GUIDs and user-defined ManualIds
|
||||
- **Extensible Metadata**: JSON-stored metadata dictionary for custom developer data
|
||||
- **Soft Deletion**: Activities are soft-deleted and filtered automatically
|
||||
- **Performance Optimized**: Cached active activities with 1-minute expiration
|
||||
- **Authentication Required**: All endpoints require valid user authentication
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Base URL: `/api/activities`
|
||||
|
||||
### Authentication
|
||||
All endpoints require `[Authorize]` header. User context is automatically extracted.
|
||||
|
||||
---
|
||||
|
||||
## Get Active Activities
|
||||
|
||||
Retrieve all currently active (non-expired) presence activities for the authenticated user.
|
||||
|
||||
**Endpoint:** `GET /api/activities`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "Gaming",
|
||||
"manualId": "game-session-1",
|
||||
"title": "Playing Cyberpunk 2077",
|
||||
"subtitle": "Night City Exploration",
|
||||
"caption": "Missions completed: 15",
|
||||
"meta": {
|
||||
"appName": "Cyberpunk 2077",
|
||||
"platform": "Steam",
|
||||
"customProperty": "additional data"
|
||||
},
|
||||
"leaseMinutes": 10,
|
||||
"leaseExpiresAt": "2024-01-15T14:30:00Z",
|
||||
"accountId": "user-guid",
|
||||
"createdAt": "2024-01-15T14:25:00Z",
|
||||
"updatedAt": "2024-01-15T14:25:00Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Common Response Codes:**
|
||||
- `200 OK` - Success, returns array of active activities
|
||||
- `401 Unauthorized` - Invalid or missing authentication
|
||||
|
||||
---
|
||||
|
||||
## Create New Activity
|
||||
|
||||
Create a new presence activity with a configurable lease period.
|
||||
|
||||
**Endpoint:** `POST /api/activities`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"type": "Gaming",
|
||||
"manualId": "my-game-session",
|
||||
"title": "Playing Cyberpunk 2077",
|
||||
"subtitle": "Night City Mission",
|
||||
"caption": "Currently exploring downtown",
|
||||
"meta": {
|
||||
"appName": "Cyberpunk 2077",
|
||||
"platform": "Steam",
|
||||
"difficulty": "Hard",
|
||||
"mods": ["mod1", "mod2"]
|
||||
},
|
||||
"leaseMinutes": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Returns the created `SnPresenceActivity` object with populated fields.
|
||||
|
||||
**Field Details:**
|
||||
- `type`: PresenceType enum (Unknown, Gaming, Music, Workout)
|
||||
- `manualId`: Optional user-defined string identifier
|
||||
- `title`, `subtitle`, `caption`: Display strings (max 4096 chars each)
|
||||
- `meta`: Optional `Dictionary<string, object>` for custom data
|
||||
- `leaseMinutes`: 1-60 minutes (default: 5)
|
||||
|
||||
**Response Codes:**
|
||||
- `200 OK` - Activity created successfully
|
||||
- `400 Bad Request` - Invalid lease minutes or malformed data
|
||||
- `401 Unauthorized` - Invalid authentication
|
||||
|
||||
---
|
||||
|
||||
## Update Activity
|
||||
|
||||
Update an existing activity using either its GUID or ManualId. Only provided fields are updated.
|
||||
|
||||
**Endpoint:** `PUT /api/activities`
|
||||
|
||||
**Query Parameters:** (one required)
|
||||
- `id` - System-generated GUID (string)
|
||||
- `manualId` - User-defined identifier (string)
|
||||
|
||||
**Request Body:** (all fields optional)
|
||||
```json
|
||||
{
|
||||
"title": "Updated: Playing Cyberpunk 2077",
|
||||
"meta": {
|
||||
"appName": "Cyberpunk 2077",
|
||||
"platform": "Steam",
|
||||
"newProperty": "updated data"
|
||||
},
|
||||
"leaseMinutes": 20
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Returns the updated `SnPresenceActivity` object.
|
||||
|
||||
**Response Codes:**
|
||||
- `200 OK` - Activity updated successfully
|
||||
- `400 Bad Request` - Missing or invalid ID parameters
|
||||
- `401 Unauthorized` - Invalid authentication
|
||||
- `404 Not Found` - Activity not found or doesn't belong to user
|
||||
|
||||
**Example cURL:**
|
||||
```bash
|
||||
# Update by ManualId
|
||||
curl -X PUT "/api/activities?manualId=my-game-session" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"leaseMinutes": 20}'
|
||||
|
||||
# Update by GUID
|
||||
curl -X PUT "/api/activities?id=550e8400-e29b-41d4-a716-446655440000" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Updated Title"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delete Activity
|
||||
|
||||
Soft-delete an activity using either GUID or ManualId.
|
||||
|
||||
**Endpoint:** `DELETE /api/activities`
|
||||
|
||||
**Query Parameters:** (one required)
|
||||
- `id` - System-generated GUID (string)
|
||||
- `manualId` - User-defined identifier (string)
|
||||
|
||||
**Request Body:** None
|
||||
|
||||
**Response:** No content (204)
|
||||
|
||||
**Response Codes:**
|
||||
- `204 No Content` - Activity deleted successfully
|
||||
- `400 Bad Request` - Missing or invalid ID parameters
|
||||
- `401 Unauthorized` - Invalid authentication
|
||||
- `404 Not Found` - Activity not found or doesn't belong to user
|
||||
|
||||
**Example cURL:**
|
||||
```bash
|
||||
# Delete by ManualId
|
||||
curl -X DELETE "/api/activities?manualId=my-game-session" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Endpoint
|
||||
|
||||
### Get Activities by Account ID
|
||||
|
||||
**Endpoint:** `GET /api/activities/{accountId:guid}`
|
||||
|
||||
For administrative or debugging purposes. Returns activities for the specified account ID, regardless of authentication.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### PresenceType Enum
|
||||
```csharp
|
||||
public enum PresenceType
|
||||
{
|
||||
Unknown,
|
||||
Gaming,
|
||||
Music,
|
||||
Workout
|
||||
}
|
||||
```
|
||||
|
||||
### SnPresenceActivity
|
||||
```csharp
|
||||
public class SnPresenceActivity : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } // System-generated GUID
|
||||
public PresenceType Type { get; set; }
|
||||
public string? ManualId { get; set; } // User-defined ID
|
||||
public string? Title { get; set; }
|
||||
public string? Subtitle { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; } // JSON metadata
|
||||
public int LeaseMinutes { get; set; } // Lease duration
|
||||
public Instant LeaseExpiresAt { get; set; } // Expiration timestamp
|
||||
|
||||
// Inherited from ModelBase
|
||||
public Guid AccountId { get; set; }
|
||||
public Instant CreatedAt { get; set; }
|
||||
public Instant UpdatedAt { get; set; }
|
||||
public Instant? DeletedAt { get; set; } // Soft deletion
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior & Constraints
|
||||
|
||||
### Lease Expiration
|
||||
- Activities automatically expire when `SystemClock.Instance.GetCurrentInstant() > LeaseExpiresAt`
|
||||
- Expiry is checked in database queries, so expired activities are filtered out of GET operations
|
||||
- Clients must periodically update/renew leases to keep activities active
|
||||
|
||||
### ID Flexibility
|
||||
- **ManualId**: User-defined string, unique within a user's activities
|
||||
- **GUID**: System-generated, always unique, returned in API responses
|
||||
- Both can be used interchangeably for updates and deletion
|
||||
|
||||
### Performance Optimizations
|
||||
- Active activities are cached for 1 minute to handle frequent updates
|
||||
- Cache is invalidated on create/update/delete operations
|
||||
- Database queries filter expired activities automatically
|
||||
|
||||
### Security
|
||||
- All operations are scoped to the authenticated user's account
|
||||
- Users can only manage their own activities
|
||||
- Invalid or expired authentication tokens return 401 Unauthorized
|
||||
|
||||
### Data Storage
|
||||
- Activities are stored in PostgreSQL with JSONB metadata support
|
||||
- Soft deletion uses timestamp rather than hard removal
|
||||
- EF Core middleware automatically handles CreatedAt/UpdatedAt timestamps
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Gaming Session Management
|
||||
```javascript
|
||||
// Start gaming session
|
||||
const activity = await fetch('/api/activities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'Gaming',
|
||||
manualId: 'game-session-1',
|
||||
title: 'Playing Cyberpunk 2077',
|
||||
meta: { appId: 'cyberpunk2077', mods: ['photorealistic'] },
|
||||
leaseMinutes: 15
|
||||
})
|
||||
});
|
||||
|
||||
// Update progress (extend lease)
|
||||
await fetch('/api/activities?manualId=game-session-1', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: 'Playing Cyberpunk 2077 - Level 25',
|
||||
leaseMinutes: 15
|
||||
})
|
||||
});
|
||||
|
||||
// End session
|
||||
await fetch('/api/activities?manualId=game-session-1', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
```
|
||||
|
||||
### Metadata Extension
|
||||
```javascript
|
||||
// Rich metadata support
|
||||
const activity = {
|
||||
type: 'Music',
|
||||
manualId: 'spotify-session',
|
||||
title: 'Listening to Electronic',
|
||||
meta: {
|
||||
spotifyTrackId: '1Je1IMUlBXcx1FzbcXRuWw',
|
||||
artist: 'Purity Ring',
|
||||
album: 'Shrines',
|
||||
duration: 240000, // milliseconds
|
||||
custom: { userRating: 5, genre: 'Electronic' }
|
||||
},
|
||||
leaseMinutes: 30
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common error responses follow REST API conventions:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Microsoft.AspNetCore.Mvc.ValidationProblemDetails",
|
||||
"title": "One or more validation errors occurred.",
|
||||
"status": 400,
|
||||
"errors": {
|
||||
"leaseMinutes": ["Lease minutes must be between 1 and 60."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Built with ASP.NET Core and Entity Framework Core
|
||||
- Uses NodaTime for precise timestamp handling
|
||||
- PostgreSQL JSONB for flexible metadata storage
|
||||
- Integration with existing authentication and caching systems
|
||||
- Follows established project patterns for soft deletion and audit trails
|
||||
Reference in New Issue
Block a user