diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index 92dccf3..6f66853 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -79,7 +79,7 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase { var userIdClaim = User.FindFirst("user_id")?.Value; long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim."); + if (userId is null) return BadRequest("Invalid or missing user_id claim."); var account = await db.Accounts .Include(e => e.Profile) @@ -103,7 +103,7 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase { var userIdClaim = User.FindFirst("user_id")?.Value; long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim."); + if (userId is null) return BadRequest("Invalid or missing user_id claim."); var account = await db.Accounts.FindAsync(userId); if (account is null) return BadRequest("Unable to get your account."); @@ -132,7 +132,7 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase { var userIdClaim = User.FindFirst("user_id")?.Value; long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return new BadRequestObjectResult("Invalid or missing user_id claim."); + if (userId is null) return BadRequest("Invalid or missing user_id claim."); var profile = await db.AccountProfiles .Where(p => p.Account.Id == userId) diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index 583b6b2..5711621 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -1,8 +1,10 @@ +using Casbin; using Microsoft.EntityFrameworkCore; +using NodaTime; namespace DysonNetwork.Sphere.Account; -public class AccountService(AppDatabase db) +public class AccountService(AppDatabase db, IEnforcer enforcer) { public async Task LookupAccount(string probe) { @@ -17,4 +19,144 @@ public class AccountService(AppDatabase db) return null; } + + public async Task HasExistingRelationship(Account userA, Account userB) + { + var count = await db.AccountRelationships + .Where(r => (r.AccountId == userA.Id && r.AccountId == userB.Id) || + (r.AccountId == userB.Id && r.AccountId == userA.Id)) + .CountAsync(); + return count > 0; + } + + public async Task GetRelationship( + Account account, + Account related, + RelationshipStatus? status, + bool ignoreExpired = false + ) + { + var now = Instant.FromDateTimeUtc(DateTime.UtcNow); + var queries = db.AccountRelationships + .Where(r => r.AccountId == account.Id && r.AccountId == related.Id); + if (ignoreExpired) queries = queries.Where(r => r.ExpiredAt > now); + if (status is not null) queries = queries.Where(r => r.Status == status); + var relationship = await queries.FirstOrDefaultAsync(); + return relationship; + } + + public async Task CreateRelationship(Account sender, Account target, RelationshipStatus status) + { + if (status == RelationshipStatus.Pending) + throw new InvalidOperationException( + "Cannot create relationship with pending status, use SendFriendRequest instead."); + if (await HasExistingRelationship(sender, target)) + throw new InvalidOperationException("Found existing relationship between you and target user."); + + var relationship = new Relationship + { + Account = sender, + AccountId = sender.Id, + Related = target, + RelatedId = target.Id, + Status = status + }; + + db.AccountRelationships.Add(relationship); + await db.SaveChangesAsync(); + await ApplyRelationshipPermissions(relationship); + + return relationship; + } + + public async Task SendFriendRequest(Account sender, Account target) + { + if (await HasExistingRelationship(sender, target)) + throw new InvalidOperationException("Found existing relationship between you and target user."); + + var relationship = new Relationship + { + Account = sender, + AccountId = sender.Id, + Related = target, + RelatedId = target.Id, + Status = RelationshipStatus.Pending, + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7)) + }; + + db.AccountRelationships.Add(relationship); + await db.SaveChangesAsync(); + + return relationship; + } + + public async Task AcceptFriendRelationship( + Relationship relationship, + RelationshipStatus status = RelationshipStatus.Friends + ) + { + if (relationship.Status == RelationshipStatus.Pending) + throw new ArgumentException("Cannot accept friend request by setting the new status to pending."); + + // Whatever the receiver decides to apply which status to the relationship, + // the sender should always see the user as a friend since the sender ask for it + relationship.Status = RelationshipStatus.Friends; + relationship.ExpiredAt = null; + db.Update(relationship); + + var relationshipBackward = new Relationship + { + Account = relationship.Related, + AccountId = relationship.RelatedId, + Related = relationship.Account, + RelatedId = relationship.AccountId, + Status = status + }; + db.AccountRelationships.Add(relationshipBackward); + + await db.SaveChangesAsync(); + + await Task.WhenAll( + ApplyRelationshipPermissions(relationship), + ApplyRelationshipPermissions(relationshipBackward) + ); + + return relationshipBackward; + } + + public async Task UpdateRelationship(Account account, Account related, RelationshipStatus status) + { + var relationship = await GetRelationship(account, related, status); + if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); + if (relationship.Status == status) return relationship; + relationship.Status = status; + db.Update(relationship); + await db.SaveChangesAsync(); + await ApplyRelationshipPermissions(relationship); + return relationship; + } + + private async Task ApplyRelationshipPermissions(Relationship relationship) + { + // Apply the relationship permissions to casbin enforcer + // domain: the user + // status is friends: all permissions are allowed by default, expect specially specified + // status is blocked: all permissions are disallowed by default, expect specially specified + // others: use the default permissions by design + + var domain = $"user:{relationship.AccountId.ToString()}"; + var target = relationship.RelatedId.ToString(); + + await enforcer.DeleteRolesForUserAsync(target, domain); + + string role = relationship.Status switch + { + RelationshipStatus.Friends => "friends", + RelationshipStatus.Blocked => "blocked", + _ => "default" // fallback role + }; + if (role == "default") return; + + await enforcer.AddRoleForUserAsync(target, role, domain); + } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/Relationship.cs b/DysonNetwork.Sphere/Account/Relationship.cs index 011f871..25dd11a 100644 --- a/DysonNetwork.Sphere/Account/Relationship.cs +++ b/DysonNetwork.Sphere/Account/Relationship.cs @@ -1,18 +1,22 @@ +using NodaTime; + namespace DysonNetwork.Sphere.Account; -public enum RelationshipType +public enum RelationshipStatus { - Friend, + Pending, + Friends, Blocked } public class Relationship : ModelBase { - public long FromAccountId { get; set; } - public Account FromAccount { get; set; } = null!; + public long AccountId { get; set; } + public Account Account { get; set; } = null!; + public long RelatedId { get; set; } + public Account Related { get; set; } = null!; - public long ToAccountId { get; set; } - public Account ToAccount { get; set; } = null!; + public Instant? ExpiredAt { get; set; } - public RelationshipType Type { get; set; } + public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending; } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/RelationshipController.cs b/DysonNetwork.Sphere/Account/RelationshipController.cs new file mode 100644 index 0000000..e3a5d60 --- /dev/null +++ b/DysonNetwork.Sphere/Account/RelationshipController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Build.Framework; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Sphere.Account; + +[ApiController] +[Route("/relationships")] +public class RelationshipController(AppDatabase db, AccountService accounts) : ControllerBase +{ + [HttpGet] + [Authorize] + public async Task>> ListRelationships([FromQuery] int offset = 0, + [FromQuery] int take = 20) + { + var userIdClaim = User.FindFirst("user_id")?.Value; + long? userId = long.TryParse(userIdClaim, out var id) ? id : null; + if (userId is null) return BadRequest("Invalid or missing user_id claim."); + + var totalCount = await db.AccountRelationships + .CountAsync(r => r.Account.Id == userId); + var relationships = await db.AccountRelationships + .Where(r => r.Account.Id == userId) + .Include(r => r.Related) + .Skip(offset) + .Take(take) + .ToListAsync(); + + Response.Headers["X-Total"] = totalCount.ToString(); + + return relationships; + } + + public class RelationshipCreateRequest + { + [Required] public long UserId { get; set; } + [Required] public RelationshipStatus Status { get; set; } + } + + [HttpPost] + [Authorize] + public async Task> CreateRelationship([FromBody] RelationshipCreateRequest request) + { + var userIdClaim = User.FindFirst("user_id")?.Value; + long? userId = long.TryParse(userIdClaim, out var id) ? id : null; + if (userId is null) return BadRequest("Invalid or missing user_id claim."); + + var currentUser = await db.Accounts.FindAsync(userId.Value); + if (currentUser is null) return BadRequest("Failed to get your current user"); + var relatedUser = await db.Accounts.FindAsync(request.UserId); + if (relatedUser is null) return BadRequest("Invalid related user"); + + try + { + var relationship = await accounts.CreateRelationship( + currentUser, relatedUser, request.Status + ); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 1d59799..3213a2a 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -53,15 +53,15 @@ public class AppDatabase( .HasForeignKey(p => p.Id); modelBuilder.Entity() - .HasKey(r => new { r.FromAccountId, r.ToAccountId }); + .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); modelBuilder.Entity() - .HasOne(r => r.FromAccount) + .HasOne(r => r.Account) .WithMany(a => a.OutgoingRelationships) - .HasForeignKey(r => r.FromAccountId); + .HasForeignKey(r => r.AccountId); modelBuilder.Entity() - .HasOne(r => r.ToAccount) + .HasOne(r => r.Related) .WithMany(a => a.IncomingRelationships) - .HasForeignKey(r => r.ToAccountId); + .HasForeignKey(r => r.RelatedId); // Automatically apply soft-delete filter to all entities inheriting BaseModel foreach (var entityType in modelBuilder.Model.GetEntityTypes()) diff --git a/DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.Designer.cs b/DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.Designer.cs similarity index 96% rename from DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.Designer.cs rename to DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.Designer.cs index c23d7c6..7729f6f 100644 --- a/DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.Designer.cs +++ b/DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.Designer.cs @@ -14,7 +14,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace DysonNetwork.Sphere.Migrations { [DbContext(typeof(AppDatabase))] - [Migration("20250415171044_AddRelationship")] + [Migration("20250417145426_AddRelationship")] partial class AddRelationship { /// @@ -226,13 +226,13 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => { - b.Property("FromAccountId") + b.Property("AccountId") .HasColumnType("bigint") - .HasColumnName("from_account_id"); + .HasColumnName("account_id"); - b.Property("ToAccountId") + b.Property("RelatedId") .HasColumnType("bigint") - .HasColumnName("to_account_id"); + .HasColumnName("related_id"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone") @@ -242,19 +242,23 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("deleted_at"); - b.Property("Type") + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Status") .HasColumnType("integer") - .HasColumnName("type"); + .HasColumnName("status"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); - b.HasKey("FromAccountId", "ToAccountId") + b.HasKey("AccountId", "RelatedId") .HasName("pk_account_relationships"); - b.HasIndex("ToAccountId") - .HasDatabaseName("ix_account_relationships_to_account_id"); + b.HasIndex("RelatedId") + .HasDatabaseName("ix_account_relationships_related_id"); b.ToTable("account_relationships", (string)null); }); @@ -514,23 +518,23 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => { - b.HasOne("DysonNetwork.Sphere.Account.Account", "FromAccount") + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") .WithMany("OutgoingRelationships") - .HasForeignKey("FromAccountId") + .HasForeignKey("AccountId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_account_relationships_accounts_from_account_id"); + .HasConstraintName("fk_account_relationships_accounts_account_id"); - b.HasOne("DysonNetwork.Sphere.Account.Account", "ToAccount") + b.HasOne("DysonNetwork.Sphere.Account.Account", "Related") .WithMany("IncomingRelationships") - .HasForeignKey("ToAccountId") + .HasForeignKey("RelatedId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_account_relationships_accounts_to_account_id"); + .HasConstraintName("fk_account_relationships_accounts_related_id"); - b.Navigation("FromAccount"); + b.Navigation("Account"); - b.Navigation("ToAccount"); + b.Navigation("Related"); }); modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => diff --git a/DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.cs b/DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.cs similarity index 73% rename from DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.cs rename to DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.cs index 8daf184..6ef0141 100644 --- a/DysonNetwork.Sphere/Migrations/20250415171044_AddRelationship.cs +++ b/DysonNetwork.Sphere/Migrations/20250417145426_AddRelationship.cs @@ -15,34 +15,35 @@ namespace DysonNetwork.Sphere.Migrations name: "account_relationships", columns: table => new { - from_account_id = table.Column(type: "bigint", nullable: false), - to_account_id = table.Column(type: "bigint", nullable: false), - type = table.Column(type: "integer", nullable: false), + account_id = table.Column(type: "bigint", nullable: false), + related_id = table.Column(type: "bigint", nullable: false), + expired_at = table.Column(type: "timestamp with time zone", nullable: true), + status = table.Column(type: "integer", nullable: false), created_at = table.Column(type: "timestamp with time zone", nullable: false), updated_at = table.Column(type: "timestamp with time zone", nullable: false), deleted_at = table.Column(type: "timestamp with time zone", nullable: true) }, constraints: table => { - table.PrimaryKey("pk_account_relationships", x => new { x.from_account_id, x.to_account_id }); + table.PrimaryKey("pk_account_relationships", x => new { x.account_id, x.related_id }); table.ForeignKey( - name: "fk_account_relationships_accounts_from_account_id", - column: x => x.from_account_id, + name: "fk_account_relationships_accounts_account_id", + column: x => x.account_id, principalTable: "accounts", principalColumn: "id", onDelete: ReferentialAction.Cascade); table.ForeignKey( - name: "fk_account_relationships_accounts_to_account_id", - column: x => x.to_account_id, + name: "fk_account_relationships_accounts_related_id", + column: x => x.related_id, principalTable: "accounts", principalColumn: "id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( - name: "ix_account_relationships_to_account_id", + name: "ix_account_relationships_related_id", table: "account_relationships", - column: "to_account_id"); + column: "related_id"); } /// diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 38784cd..8c3cf7b 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -223,13 +223,13 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => { - b.Property("FromAccountId") + b.Property("AccountId") .HasColumnType("bigint") - .HasColumnName("from_account_id"); + .HasColumnName("account_id"); - b.Property("ToAccountId") + b.Property("RelatedId") .HasColumnType("bigint") - .HasColumnName("to_account_id"); + .HasColumnName("related_id"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone") @@ -239,19 +239,23 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("deleted_at"); - b.Property("Type") + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Status") .HasColumnType("integer") - .HasColumnName("type"); + .HasColumnName("status"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); - b.HasKey("FromAccountId", "ToAccountId") + b.HasKey("AccountId", "RelatedId") .HasName("pk_account_relationships"); - b.HasIndex("ToAccountId") - .HasDatabaseName("ix_account_relationships_to_account_id"); + b.HasIndex("RelatedId") + .HasDatabaseName("ix_account_relationships_related_id"); b.ToTable("account_relationships", (string)null); }); @@ -511,23 +515,23 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => { - b.HasOne("DysonNetwork.Sphere.Account.Account", "FromAccount") + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") .WithMany("OutgoingRelationships") - .HasForeignKey("FromAccountId") + .HasForeignKey("AccountId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_account_relationships_accounts_from_account_id"); + .HasConstraintName("fk_account_relationships_accounts_account_id"); - b.HasOne("DysonNetwork.Sphere.Account.Account", "ToAccount") + b.HasOne("DysonNetwork.Sphere.Account.Account", "Related") .WithMany("IncomingRelationships") - .HasForeignKey("ToAccountId") + .HasForeignKey("RelatedId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_account_relationships_accounts_to_account_id"); + .HasConstraintName("fk_account_relationships_accounts_related_id"); - b.Navigation("FromAccount"); + b.Navigation("Account"); - b.Navigation("ToAccount"); + b.Navigation("Related"); }); modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>