Relationships controllers

This commit is contained in:
LittleSheep 2025-04-17 23:54:35 +08:00
parent cec8c3af81
commit f9701764f3
8 changed files with 282 additions and 61 deletions

View File

@ -79,7 +79,7 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase
{ {
var userIdClaim = User.FindFirst("user_id")?.Value; var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null; 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 var account = await db.Accounts
.Include(e => e.Profile) .Include(e => e.Profile)
@ -103,7 +103,7 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase
{ {
var userIdClaim = User.FindFirst("user_id")?.Value; var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null; 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); var account = await db.Accounts.FindAsync(userId);
if (account is null) return BadRequest("Unable to get your account."); 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; var userIdClaim = User.FindFirst("user_id")?.Value;
long? userId = long.TryParse(userIdClaim, out var id) ? id : null; 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 var profile = await db.AccountProfiles
.Where(p => p.Account.Id == userId) .Where(p => p.Account.Id == userId)

View File

@ -1,8 +1,10 @@
using Casbin;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
public class AccountService(AppDatabase db) public class AccountService(AppDatabase db, IEnforcer enforcer)
{ {
public async Task<Account?> LookupAccount(string probe) public async Task<Account?> LookupAccount(string probe)
{ {
@ -17,4 +19,144 @@ public class AccountService(AppDatabase db)
return null; return null;
} }
public async Task<bool> 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<Relationship?> 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<Relationship> 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<Relationship> 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<Relationship> 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<Relationship> 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);
}
} }

View File

@ -1,18 +1,22 @@
using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
public enum RelationshipType public enum RelationshipStatus
{ {
Friend, Pending,
Friends,
Blocked Blocked
} }
public class Relationship : ModelBase public class Relationship : ModelBase
{ {
public long FromAccountId { get; set; } public long AccountId { get; set; }
public Account FromAccount { get; set; } = null!; public Account Account { get; set; } = null!;
public long RelatedId { get; set; }
public Account Related { get; set; } = null!;
public long ToAccountId { get; set; } public Instant? ExpiredAt { get; set; }
public Account ToAccount { get; set; } = null!;
public RelationshipType Type { get; set; } public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
} }

View File

@ -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<ActionResult<List<Relationship>>> 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<ActionResult<Relationship>> 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);
}
}
}

View File

@ -53,15 +53,15 @@ public class AppDatabase(
.HasForeignKey<Account.Profile>(p => p.Id); .HasForeignKey<Account.Profile>(p => p.Id);
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Account.Relationship>()
.HasKey(r => new { r.FromAccountId, r.ToAccountId }); .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Account.Relationship>()
.HasOne(r => r.FromAccount) .HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships) .WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.FromAccountId); .HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Account.Relationship>() modelBuilder.Entity<Account.Relationship>()
.HasOne(r => r.ToAccount) .HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships) .WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.ToAccountId); .HasForeignKey(r => r.RelatedId);
// Automatically apply soft-delete filter to all entities inheriting BaseModel // Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes()) foreach (var entityType in modelBuilder.Model.GetEntityTypes())

View File

@ -14,7 +14,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DysonNetwork.Sphere.Migrations namespace DysonNetwork.Sphere.Migrations
{ {
[DbContext(typeof(AppDatabase))] [DbContext(typeof(AppDatabase))]
[Migration("20250415171044_AddRelationship")] [Migration("20250417145426_AddRelationship")]
partial class AddRelationship partial class AddRelationship
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -226,13 +226,13 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b =>
{ {
b.Property<long>("FromAccountId") b.Property<long>("AccountId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("from_account_id"); .HasColumnName("account_id");
b.Property<long>("ToAccountId") b.Property<long>("RelatedId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("to_account_id"); .HasColumnName("related_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@ -242,19 +242,23 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<int>("Type") b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<int>("Status")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("type"); .HasColumnName("status");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.HasKey("FromAccountId", "ToAccountId") b.HasKey("AccountId", "RelatedId")
.HasName("pk_account_relationships"); .HasName("pk_account_relationships");
b.HasIndex("ToAccountId") b.HasIndex("RelatedId")
.HasDatabaseName("ix_account_relationships_to_account_id"); .HasDatabaseName("ix_account_relationships_related_id");
b.ToTable("account_relationships", (string)null); b.ToTable("account_relationships", (string)null);
}); });
@ -514,23 +518,23 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Account.Account", "FromAccount") b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany("OutgoingRelationships") .WithMany("OutgoingRelationships")
.HasForeignKey("FromAccountId") .HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .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") .WithMany("IncomingRelationships")
.HasForeignKey("ToAccountId") .HasForeignKey("RelatedId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .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 => modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>

View File

@ -15,34 +15,35 @@ namespace DysonNetwork.Sphere.Migrations
name: "account_relationships", name: "account_relationships",
columns: table => new columns: table => new
{ {
from_account_id = table.Column<long>(type: "bigint", nullable: false), account_id = table.Column<long>(type: "bigint", nullable: false),
to_account_id = table.Column<long>(type: "bigint", nullable: false), related_id = table.Column<long>(type: "bigint", nullable: false),
type = table.Column<int>(type: "integer", nullable: false), expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
status = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", 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), updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
}, },
constraints: table => 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( table.ForeignKey(
name: "fk_account_relationships_accounts_from_account_id", name: "fk_account_relationships_accounts_account_id",
column: x => x.from_account_id, column: x => x.account_id,
principalTable: "accounts", principalTable: "accounts",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
table.ForeignKey( table.ForeignKey(
name: "fk_account_relationships_accounts_to_account_id", name: "fk_account_relationships_accounts_related_id",
column: x => x.to_account_id, column: x => x.related_id,
principalTable: "accounts", principalTable: "accounts",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_account_relationships_to_account_id", name: "ix_account_relationships_related_id",
table: "account_relationships", table: "account_relationships",
column: "to_account_id"); column: "related_id");
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -223,13 +223,13 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b =>
{ {
b.Property<long>("FromAccountId") b.Property<long>("AccountId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("from_account_id"); .HasColumnName("account_id");
b.Property<long>("ToAccountId") b.Property<long>("RelatedId")
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("to_account_id"); .HasColumnName("related_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@ -239,19 +239,23 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<int>("Type") b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<int>("Status")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("type"); .HasColumnName("status");
b.Property<Instant>("UpdatedAt") b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.HasKey("FromAccountId", "ToAccountId") b.HasKey("AccountId", "RelatedId")
.HasName("pk_account_relationships"); .HasName("pk_account_relationships");
b.HasIndex("ToAccountId") b.HasIndex("RelatedId")
.HasDatabaseName("ix_account_relationships_to_account_id"); .HasDatabaseName("ix_account_relationships_related_id");
b.ToTable("account_relationships", (string)null); b.ToTable("account_relationships", (string)null);
}); });
@ -511,23 +515,23 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Account.Account", "FromAccount") b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany("OutgoingRelationships") .WithMany("OutgoingRelationships")
.HasForeignKey("FromAccountId") .HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .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") .WithMany("IncomingRelationships")
.HasForeignKey("ToAccountId") .HasForeignKey("RelatedId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .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 => modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>